aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJuan J. Martinez <jjm@usebox.net>2021-02-25 22:08:51 +0000
committerJuan J. Martinez <jjm@usebox.net>2021-02-25 22:11:22 +0000
commit26f9cb8e66e836607851aab623223aef478f3b27 (patch)
tree3e2c5449c8a7a80b912641da1b144d5169aab912
downloadspacebeans-26f9cb8e66e836607851aab623223aef478f3b27.tar.gz
spacebeans-26f9cb8e66e836607851aab623223aef478f3b27.zip
Initial public dump
-rw-r--r--.gitignore6
-rw-r--r--.scalafmt.conf1
-rw-r--r--CHAMGES.md7
-rw-r--r--COPYING20
-rw-r--r--README.md103
-rw-r--r--build.sc46
-rw-r--r--server/resources/application.conf8
-rw-r--r--server/resources/logback.xml11
-rw-r--r--server/src/net/usebox/gemini/server/Response.scala135
-rw-r--r--server/src/net/usebox/gemini/server/Server.scala271
-rw-r--r--server/src/net/usebox/gemini/server/ServerApp.scala62
-rw-r--r--server/src/net/usebox/gemini/server/ServiceConf.scala38
-rw-r--r--server/src/net/usebox/gemini/server/TLSUtils.scala214
-rw-r--r--server/src/net/usebox/gemini/server/URIUtils.scala19
-rw-r--r--server/test/resources/.dotfile0
-rw-r--r--server/test/resources/dir/file.txt1
-rw-r--r--server/test/resources/index.gmi4
-rw-r--r--server/test/resources/logback-test.xml11
-rw-r--r--server/test/src/ServerSpec.scala271
-rw-r--r--spacebeans.conf.example51
20 files changed, 1279 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..05ab01b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.bloop/
+.metals/
+*~
+*.swp
+out/
+
diff --git a/.scalafmt.conf b/.scalafmt.conf
new file mode 100644
index 0000000..dd18323
--- /dev/null
+++ b/.scalafmt.conf
@@ -0,0 +1 @@
+version = 2.5.2
diff --git a/CHAMGES.md b/CHAMGES.md
new file mode 100644
index 0000000..3bcf10a
--- /dev/null
+++ b/CHAMGES.md
@@ -0,0 +1,7 @@
+
+# What's new?
+
+## Release 1.0.0 - 2021-??-??
+
+ - TODO
+
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..d6788f5
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,20 @@
+SpaceBeans Gemini Server
+Copyright (C) 2021 by Juan J. Martinez <jjm@usebox.net>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0ac4e49
--- /dev/null
+++ b/README.md
@@ -0,0 +1,103 @@
+# SpaceBeans Gemini Server
+
+This is an experimental server for [Gemini](https://gemini.circumlunar.space/) protocol.
+
+It is built using [Scala](https://www.scala-lang.org/) and [Akka Streams](https://doc.akka.io/docs/akka/current/stream/index.html). The name tries to link the Gemini *theme* with the fact that the
+server runs on the Java Virtual Machine.
+
+Some of the **SpaceBeans** features:
+
+ - Static files, including optional directory listings
+ - IPv4 and IPv6
+ - Configurable MIME types, or a built-in resolver
+ - Virtual hosting, with SNI support
+ - User provided certificates or auto-generated in memory (for development)
+ - Configurable SSL engine (e.g. TLSv1.2 and/or TLSv1.3), with configurable ciphers
+
+Check [CHANGES](CHANGES.md) to see what's new in the latest release.
+
+## How to run it
+
+Download [the `jar` distribution file](releases/) and install Java Runtime Environment 8 (or
+later; [openJRE](https://adoptopenjdk.net/) recommended).
+
+You can run the service with:
+```
+java -jar spacebeans-VERSION.jar spacebeans.conf
+```
+
+You can also run the server with `--help` flag for CLI help.
+
+Please check [the example configuration file](spacebeans.conf.example) for instructions on
+how to configure the service.
+
+### Running it as a service
+
+TODO: instructions with systemd or similar.
+
+## On security
+
+You should evaluate your security requirements when running **SpaceBeans**.
+
+In this section *TOFU* refers to "Trust On First Use".
+
+### Auto-generated self-signed certificate
+
+This is the easiest option, no need to store securely the certificate. The
+downside is that you get a new certificate every time you start the service,
+and that's bad for TOFU validation.
+
+This is recommended **only for development** and not for a service facing the
+Internet.
+
+Comment out the `key-store` section on your virtual host and you are done.
+
+### Self-signed certificate
+
+You can generate a self signed certificate using Java's `keytool`:
+```
+keytool -genkey -keyalg RSA -alias ALIAS -keystore keystore.jks -storepass SECRET -validity 36500 -keysize 2048
+```
+
+When entering the certificate details, use the domain name as `CN`.
+
+In the configuration file provide the path to the keystore, the alias and the
+secret used when generating the certificate.
+
+This is the recommended TOFU-compatible way of managing certificates. The
+certificate **should be set to expire way in the future** because changing
+certificates doesn't play well with TOFU validation.
+
+### Import a CA signed certificate
+
+The certificate has to be converted and imported into a JKS keystore to be
+used by the server.
+
+For example:
+```
+keytool -import -alias ALIAS -keystore keystore.jks -file cert.pem -storepass SECRET -noprompt
+```
+
+Answer "yes" when asked if the certificate should be trusted.
+
+In the configuration file provide the path to the keystore, the alias and the
+secret used when importing the certificate.
+
+CA signed certificates don't play well with TOFU, but if the client is properly
+validating the certificate, this is perfectly safe.
+
+## Development
+
+Requirements:
+
+ - JDK 8 (or later; openjdk 8 or 11 recommended)
+ - [Mill](http://www.lihaoyi.com/mill/) for building
+
+Run the server with `mill server.run` and the tests with `mill server.test`.
+
+## License
+
+Copyright (C) 2021 Juan J. Martinez <jjm@usebox.net>
+This software is distributed under MIT license, unless stated otherwise.
+
+See COPYING file.
diff --git a/build.sc b/build.sc
new file mode 100644
index 0000000..69cc6f7
--- /dev/null
+++ b/build.sc
@@ -0,0 +1,46 @@
+import mill._
+import mill.scalalib._
+import scalafmt._
+
+object server extends ScalaModule with ScalafmtModule {
+ def scalaVersion = "2.13.5"
+
+ def scalacOptions = Seq(
+ // features
+ "-encoding", "utf-8",
+ "-explaintypes",
+ "-language:higherKinds",
+ // warnings
+ "-deprecation",
+ "-Xlint:unused",
+ "-unchecked",
+ )
+
+ def ivyDeps = Agg(
+ ivy"com.github.pureconfig::pureconfig:0.14.0",
+ ivy"com.monovore::decline:1.3.0",
+ ivy"org.log4s::log4s:1.8.2",
+ ivy"ch.qos.logback:logback-classic:1.2.3",
+ ivy"com.typesafe.akka::akka-stream:2.6.12",
+ ivy"org.bouncycastle:bcprov-jdk15to18:1.68"
+ )
+
+ override def compile = T {
+ reformat().apply()
+ super.compile()
+ }
+
+ object test extends Tests with ScalafmtModule {
+ def ivyDeps = Agg(ivy"org.scalatest::scalatest:3.2.2")
+ def testFrameworks = Seq("org.scalatest.tools.Framework")
+
+ override def compile = T {
+ reformat().apply()
+ super.compile()
+ }
+
+ def testOnly(args: String*) = T.command {
+ super.runMain("org.scalatest.run", args: _*)
+ }
+ }
+}
diff --git a/server/resources/application.conf b/server/resources/application.conf
new file mode 100644
index 0000000..d509a7d
--- /dev/null
+++ b/server/resources/application.conf
@@ -0,0 +1,8 @@
+sb-blocking-dispatcher {
+ type = Dispatcher
+ executor = "thread-pool-executor"
+ thread-pool-executor {
+ fixed-pool-size = 16
+ }
+ throughput = 1
+}
diff --git a/server/resources/logback.xml b/server/resources/logback.xml
new file mode 100644
index 0000000..fbff415
--- /dev/null
+++ b/server/resources/logback.xml
@@ -0,0 +1,11 @@
+<configuration>
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%date [%level] %logger{15} - %message%n%xException{10}</pattern>
+ </encoder>
+ </appender>
+ <logger name="net.usebox.gemini.server" level="INFO" />
+ <root level="WARN">
+ <appender-ref ref="STDOUT" />
+ </root>
+</configuration>
diff --git a/server/src/net/usebox/gemini/server/Response.scala b/server/src/net/usebox/gemini/server/Response.scala
new file mode 100644
index 0000000..6c243b2
--- /dev/null
+++ b/server/src/net/usebox/gemini/server/Response.scala
@@ -0,0 +1,135 @@
+package net.usebox.gemini.server
+
+import java.nio.file.Path
+
+import akka.stream.ActorAttributes
+import akka.stream.scaladsl.{Source, FileIO}
+import akka.util.ByteString
+
+import URIUtils._
+
+sealed trait Response {
+ def req: String
+ def status: Int
+ def meta: String
+ def bodyPath: Option[Path]
+ def bodySize: Long
+
+ def toSource =
+ Source.single(ByteString(s"${status} ${meta}\r\n")) ++ (bodyPath match {
+ case None => Source.empty
+ case Some(path) =>
+ FileIO
+ .fromPath(path)
+ .withAttributes(
+ ActorAttributes.dispatcher("sb-blocking-dispatcher")
+ )
+ })
+}
+
+sealed trait NoContentResponse extends Response {
+ var bodyPath: Option[Path] = None
+ var bodySize: Long = 0
+}
+
+case class Success(
+ req: String,
+ meta: String = "Success",
+ bodyPath: Option[Path] = None,
+ bodySize: Long = 0
+) extends Response {
+ val status: Int = 20
+}
+
+case class DirListing(
+ req: String,
+ meta: String = "Success",
+ uriPath: String,
+ bodyPath: Option[Path] = None
+) extends Response {
+ val status: Int = 20
+
+ val body: String = bodyPath.fold("") { path =>
+ (List(s"# Index of ${uriPath}\n") ++
+ (if (uriPath != "/") List(s"=> ../ ..") else Nil)
+ ++
+ path
+ .toFile()
+ .listFiles()
+ .toList
+ .sortBy {
+ case f if f.isDirectory() => 0
+ case f if f.isFile() => 1
+ case _ => 2
+ }
+ .flatMap {
+ case f if !f.canRead() || f.getName().startsWith(".") => None
+ case f if f.isDirectory() =>
+ Some(s"=> ${f.getName().encode()}/ ${f.getName()}/")
+ case f => Some(s"=> ${f.getName().encode()} ${f.getName()}")
+ }).mkString("\n") + "\n"
+ }
+
+ def bodySize: Long = body.size
+
+ override def toSource =
+ Source.single(ByteString(s"${status} ${meta}\r\n")) ++ Source.single(
+ ByteString(body)
+ )
+}
+
+case class TempRedirect(
+ req: String,
+ meta: String = "Redirect - temporary"
+) extends NoContentResponse {
+ val status: Int = 30
+}
+
+case class PermanentRedirect(
+ req: String,
+ meta: String = "Redirect - permanent"
+) extends NoContentResponse {
+ val status: Int = 31
+}
+
+case class TempFailure(
+ req: String,
+ meta: String = "Temporary failure"
+) extends NoContentResponse {
+ val status: Int = 40
+}
+
+case class NotAvailable(
+ req: String,
+ meta: String = "Server not available"
+) extends NoContentResponse {
+ val status: Int = 41
+}
+
+case class PermanentFailure(
+ req: String,
+ meta: String = "Permanent failure"
+) extends NoContentResponse {
+ val status: Int = 50
+}
+
+case class NotFound(
+ req: String,
+ meta: String = "Not found"
+) extends NoContentResponse {
+ val status: Int = 51
+}
+
+case class ProxyRequestRefused(
+ req: String,
+ meta: String = "Proxy request refused"
+) extends NoContentResponse {
+ val status: Int = 53
+}
+
+case class BadRequest(
+ req: String,
+ meta: String = "Bad request"
+) extends NoContentResponse {
+ val status: Int = 59
+}
diff --git a/server/src/net/usebox/gemini/server/Server.scala b/server/src/net/usebox/gemini/server/Server.scala
new file mode 100644
index 0000000..c2fcee3
--- /dev/null
+++ b/server/src/net/usebox/gemini/server/Server.scala
@@ -0,0 +1,271 @@
+package net.usebox.gemini.server
+
+import java.nio.charset.Charset
+import javax.net.ssl.SSLEngine
+import java.net.URI
+import java.nio.file.Path
+import java.nio.file.Files
+
+import scala.util.{Try, Success => TrySuccess}
+
+import org.log4s._
+
+import akka.stream._
+import akka.stream.scaladsl._
+import akka.actor.ActorSystem
+import akka.util.ByteString
+
+import URIUtils._
+
+case class Server(conf: ServiceConf) {
+
+ implicit val system = ActorSystem("space-beans")
+ implicit val ec = system.dispatcher
+
+ private[this] val logger = getLogger
+
+ val defPort = 1965
+ val maxReqLen = 1024
+
+ val mimeTypes = conf.mimeTypes
+ val defaultMimeType = conf.defaultMimeType
+ val vHosts = conf.virtualHosts
+
+ val charsetDecoder = Charset.forName("utf-8").newDecoder()
+ def decodeUTF8(value: ByteString): Either[Throwable, String] =
+ Try(charsetDecoder.decode(value.toByteBuffer).toString()).toEither
+
+ def validPath(path: String): Boolean =
+ !path
+ .split('/')
+ .drop(1)
+ .foldLeft(List(0)) {
+ case (acc, "..") => acc.appended(acc.last - 1)
+ case (acc, ".") => acc.appended(acc.last)
+ case (acc, _) => acc.appended(acc.last + 1)
+ }
+ .exists(_ < 0)
+
+ def guessMimeType(path: Path, params: Option[String]): String =
+ mimeTypes.fold {
+ List(".gmi", ".gemini")
+ .find(path.toString().endsWith(_))
+ .fold {
+ Try(Files.probeContentType(path)).toOption match {
+ case Some(mime) if mime != null => mime
+ case _ => defaultMimeType
+ }
+ }(_ => "text/gemini")
+ } { types =>
+ types
+ .find {
+ case (t, exts) => exts.exists(path.toString().endsWith(_))
+ }
+ .fold(defaultMimeType) { case (t, _) => t }
+ } match {
+ case mime @ "text/gemini" =>
+ params.fold(mime)(p => s"$mime; ${p.stripMargin(';').trim()}")
+ case mime => mime
+ }
+
+ def handleReq(req: String): Response =
+ (for {
+ uri <- Try(URI.create(req)).toEither
+ resp <- Try(
+ (
+ uri.getScheme(),
+ uri.getHost(),
+ uri.getPath().decode(),
+ vHosts.find(_.host == uri.getHost())
+ ) match {
+ case (null, _, _, _) =>
+ logger.debug(s"no scheme")
+ BadRequest(req)
+ case _ if uri.getPort() != -1 && uri.getPort() != conf.port =>
+ logger.debug(s"invalid port, is a proxy request")
+ ProxyRequestRefused(req)
+ case _ if uri.getPort() == -1 && conf.port != defPort =>
+ logger.debug(
+ s"default port but non default was configured, is a proxy request"
+ )
+ ProxyRequestRefused(req)
+ case ("gemini", host, _, None) =>
+ logger.debug(s"vhost $host not found in $vHosts")
+ ProxyRequestRefused(req)
+ case ("gemini", host, _, _) if uri.getUserInfo() != null =>
+ logger.debug(s"user info present")
+ BadRequest(req, "Userinfo component is not allowed")
+ case ("gemini", _, path, _) if !validPath(path) =>
+ logger.debug("invalid path, out of root")
+ BadRequest(req)
+ case ("gemini", _, _, _) if uri.normalize() != uri =>
+ logger.debug("redirect to normalize uri")
+ PermanentRedirect(req, uri.normalize().toString())
+ case ("gemini", host, path, Some(vhost)) =>
+ val resource = Path.of(vhost.root, path).normalize()
+
+ logger.debug(s"requesting: '$resource'")
+
+ resource.toFile() match {
+ case path if !path.exists() =>
+ logger.debug("no resource")
+ NotFound(req)
+ case path if path.exists() && !path.canRead() =>
+ logger.debug("no read permissions")
+ NotFound(req)
+ case file if file.getName().startsWith(".") =>
+ logger.debug("dot file, ignored request")
+ NotFound(req)
+ case file if file.isFile() =>
+ Success(
+ req,
+ meta = guessMimeType(resource, vhost.geminiParams),
+ bodySize = file.length(),
+ bodyPath = Some(resource)
+ )
+ case dir
+ if dir.isDirectory() && !path.isEmpty() && !path
+ .endsWith("/") =>
+ logger.debug("redirect directory")
+ PermanentRedirect(req, uri.toString() + "/")
+ case dir if dir.isDirectory() =>
+ val dirFilePath = resource.resolve(vhost.indexFile)
+ val dirFile = dirFilePath.toFile()
+
+ if (dirFile.isFile() && dirFile.canRead()) {
+ logger.debug(s"serving index file: $dirFilePath")
+ Success(
+ req,
+ meta = guessMimeType(dirFilePath, vhost.geminiParams),
+ bodySize = dirFile.length(),
+ bodyPath = Some(dirFilePath)
+ )
+ } else if (vhost.directoryListing) {
+ logger.debug("directory listing")
+ DirListing(
+ req,
+ meta = "text/gemini",
+ bodyPath = Some(resource),
+ uriPath = path
+ )
+ } else
+ NotFound(req)
+ case _ =>
+ logger.debug("default: other resource type")
+ NotFound(req)
+ }
+ case (scheme, _, _, _) =>
+ logger.debug(s"scheme $scheme not allowed")
+ ProxyRequestRefused(req)
+ }
+ ).toEither
+ } yield resp) match {
+ case Left(error: IllegalArgumentException) =>
+ logger.debug(s"invalid request: ${error.getMessage()}")
+ BadRequest(req)
+ case Left(error) =>
+ logger.error(error)("Internal server error")
+ PermanentFailure(req, "Internal server error")
+
+ case Right(resp) => resp
+ }
+
+ def serve = {
+ val certs = vHosts.map { vhost =>
+ vhost.keyStore.fold(
+ (
+ vhost.host,
+ TLSUtils.genSelfSignedCert(vhost.host, conf.genCertValidFor)
+ )
+ ) {
+ case KeyStore(path, alias, password) =>
+ TLSUtils
+ .loadCert(path, alias, password)
+ .fold(
+ err => {
+ logger
+ .error(err)(s"Failed to load $alias cert from keystore $path")
+ system.terminate()
+ throw err
+ },
+ r => (vhost.host, r)
+ )
+ }
+ }.toMap
+
+ val sslContext = TLSUtils.genSSLContext(certs)
+
+ certs.foreach {
+ case (host, (cert, _)) =>
+ logger.info(s"Certificate for ${host} - serial-no: ${cert
+ .getSerialNumber()}, final-date: ${cert.getNotAfter()}")
+ }
+
+ def createSSLEngine: SSLEngine = {
+ val engine = sslContext.createSSLEngine()
+
+ engine.setUseClientMode(false)
+ engine.setEnabledCipherSuites(conf.enabledCipherSuites.toArray)
+ engine.setEnabledProtocols(conf.enabledProtocols.toArray)
+
+ engine
+ }
+
+ Tcp()
+ .bindWithTls(
+ conf.address,
+ conf.port,
+ () => createSSLEngine,
+ backlog = 100,
+ options = Nil,
+ idleTimeout = conf.idleTimeout,
+ verifySession = _ => TrySuccess(()),
+ closing = TLSClosing.ignoreCancel
+ )
+ .runForeach { connection =>
+ logger.debug(s"new connection ${connection.remoteAddress}")
+
+ val handler = Flow[ByteString]
+ .watchTermination() { (_, f) =>
+ f.onComplete {
+ _.toEither.swap.map(error =>
+ logger.warn(s"stream terminated: ${error.getMessage()}")
+ )
+ }
+ }
+ .via(
+ Framing
+ .delimiter(
+ ByteString("\r\n"),
+ maximumFrameLength = maxReqLen + 1,
+ allowTruncation = true
+ )
+ )
+ .prefixAndTail(1)
+ .map {
+ case (Seq(req), tail) =>
+ tail.run()
+ decodeUTF8(req) match {
+ case Left(error) =>
+ logger.debug(s"invalid UTF-8 encoding: ${error.getMessage()}")
+ BadRequest(req.utf8String)
+ case Right(reqStr) =>
+ if (req.size > maxReqLen)
+ BadRequest(reqStr.take(1024) + "{...}")
+ else
+ handleReq(reqStr)
+ }
+ }
+ .take(1)
+ .wireTap(resp =>
+ logger.info(
+ s"""${connection.remoteAddress
+ .getHostName()} "${resp.req}" ${resp.status} ${resp.bodySize}"""
+ )
+ )
+ .flatMapConcat(_.toSource)
+
+ connection.handleWith(handler)
+ }
+ }
+}
diff --git a/server/src/net/usebox/gemini/server/ServerApp.scala b/server/src/net/usebox/gemini/server/ServerApp.scala
new file mode 100644
index 0000000..b8bc84a
--- /dev/null
+++ b/server/src/net/usebox/gemini/server/ServerApp.scala
@@ -0,0 +1,62 @@
+package net.usebox.gemini.server
+
+import com.monovore.decline._
+
+import cats.implicits._
+
+import org.log4s._
+
+object ServerApp {
+ private val logger = getLogger
+
+ val appName = "SpaceBeans Gemini Server"
+ val version = "0.1.0"
+ val defConfFile = "/etc/spacebeans.conf"
+
+ case class ServerOpts(
+ version: Boolean,
+ confFile: String
+ )
+
+ val opts: Command[ServerOpts] =
+ Command(
+ name = "server",
+ header = appName
+ ) {
+ (
+ Opts
+ .flag("version", "Display the version and exit.")
+ .orFalse,
+ Opts
+ .option[String](
+ "conf",
+ s"Configuration file (default: $defConfFile).",
+ short = "c"
+ )
+ .withDefault(defConfFile)
+ ).mapN { (version, confFile) =>
+ ServerOpts(version, confFile)
+ }
+ }
+
+ def main(args: Array[String]): Unit =
+ opts.parse(args.toIndexedSeq) match {
+ case Left(help) =>
+ println(help)
+ case Right(ServerOpts(true, _)) =>
+ println(version)
+ case Right(ServerOpts(_, confFile)) =>
+ ServiceConf.load(confFile) match {
+ case Left(error) =>
+ logger
+ .error(
+ s"Error reading $confFile: $error"
+ )
+ case Right(conf) =>
+ logger.info(
+ s"Starting $appName $version, listening on ${conf.address}:${conf.port}"
+ )
+ Server(conf).serve
+ }
+ }
+}
diff --git a/server/src/net/usebox/gemini/server/ServiceConf.scala b/server/src/net/usebox/gemini/server/ServiceConf.scala
new file mode 100644
index 0000000..9c0f8e8
--- /dev/null
+++ b/server/src/net/usebox/gemini/server/ServiceConf.scala
@@ -0,0 +1,38 @@
+package net.usebox.gemini.server
+
+import pureconfig._
+import pureconfig.generic.semiauto._
+
+import scala.concurrent.duration.FiniteDuration
+
+case class KeyStore(path: String, alias: String, password: String)
+
+case class VirtualHost(
+ host: String,
+ root: String,
+ keyStore: Option[KeyStore] = None,
+ indexFile: String = "index.gmi",
+ directoryListing: Boolean = true,
+ geminiParams: Option[String] = None
+)
+
+case class ServiceConf(
+ address: String,
+ port: Int,
+ idleTimeout: FiniteDuration,
+ defaultMimeType: String,
+ mimeTypes: Option[Map[String, List[String]]] = None,
+ virtualHosts: List[VirtualHost],
+ genCertValidFor: FiniteDuration,
+ enabledProtocols: List[String],
+ enabledCipherSuites: List[String]
+)
+
+object ServiceConf {
+
+ implicit val keyStoreReader = deriveReader[KeyStore]
+ implicit val virtualHostReader = deriveReader[VirtualHost]
+ implicit val serviceConfReader = deriveReader[ServiceConf]
+
+ def load(confFile: String) = ConfigSource.file(confFile).load[ServiceConf]
+}
diff --git a/server/src/net/usebox/gemini/server/TLSUtils.scala b/server/src/net/usebox/gemini/server/TLSUtils.scala
new file mode 100644
index 0000000..03e2216
--- /dev/null
+++ b/server/src/net/usebox/gemini/server/TLSUtils.scala
@@ -0,0 +1,214 @@
+package net.usebox.gemini.server
+
+import java.io.FileInputStream
+import java.time.Instant
+import java.math.BigInteger
+import java.util.Date
+import java.security.cert.X509Certificate
+import java.security.{
+ Security,
+ KeyStore,
+ SecureRandom,
+ PrivateKey,
+ KeyPairGenerator,
+ Principal
+}
+import java.security.KeyStore.PrivateKeyEntry
+import java.net.Socket
+import javax.net.ssl.{
+ SSLContext,
+ TrustManagerFactory,
+ KeyManagerFactory,
+ X509ExtendedKeyManager,
+ SSLEngine,
+ ExtendedSSLSession,
+ StandardConstants,
+ SNIHostName
+}
+
+import scala.concurrent.duration.FiniteDuration
+import scala.jdk.CollectionConverters._
+import scala.util.Try
+
+import org.bouncycastle.jce.provider.BouncyCastleProvider
+import org.bouncycastle.x509.X509V3CertificateGenerator
+import org.bouncycastle.jce.X509Principal
+
+import org.log4s._
+
+object TLSUtils {
+
+ private[this] val logger = getLogger
+
+ Security.addProvider(new BouncyCastleProvider())
+
+ // https://github.com/grahamedgecombe/netty-sni-example/
+ class SniKeyManager(keyManager: X509ExtendedKeyManager, defaultAlias: String)
+ extends X509ExtendedKeyManager {
+ override def getClientAliases(
+ keyType: String,
+ issuers: Array[Principal]
+ ): Array[String] = throw new UnsupportedOperationException()
+
+ override def chooseClientAlias(
+ keyType: Array[String],
+ issuers: Array[Principal],
+ socker: Socket
+ ): String = throw new UnsupportedOperationException()
+
+ override def chooseEngineClientAlias(
+ keyType: Array[String],
+ issuers: Array[Principal],
+ engine: SSLEngine
+ ): String = throw new UnsupportedOperationException()
+
+ override def getServerAliases(
+ keyType: String,
+ issuers: Array[Principal]
+ ): Array[String] = keyManager.getServerAliases(keyType, issuers)
+
+ override def chooseServerAlias(
+ keyType: String,
+ issuers: Array[Principal],
+ socket: Socket
+ ): String = keyManager.chooseServerAlias(keyType, issuers, socket)
+
+ override def chooseEngineServerAlias(
+ keyType: String,
+ issuers: Array[Principal],
+ engine: SSLEngine
+ ): String =
+ engine
+ .getHandshakeSession()
+ .asInstanceOf[ExtendedSSLSession]
+ .getRequestedServerNames()
+ .asScala
+ .collectFirst {
+ case n: SNIHostName
+ if n.getType() == StandardConstants.SNI_HOST_NAME =>
+ n.getAsciiName()
+ } match {
+ case Some(hostname)
+ if hostname != null && getCertificateChain(
+ hostname
+ ) != null && getPrivateKey(hostname) != null =>
+ hostname
+ case _ => defaultAlias
+ }
+
+ override def getCertificateChain(alias: String): Array[X509Certificate] =
+ keyManager.getCertificateChain(alias)
+
+ override def getPrivateKey(alias: String): PrivateKey =
+ keyManager.getPrivateKey(alias)
+ }
+
+ object SniKeyManager {
+ def apply(
+ keyManagerFactory: KeyManagerFactory,
+ defaultAlias: String
+ ): Either[Throwable, SniKeyManager] =
+ keyManagerFactory.getKeyManagers.find(
+ _.isInstanceOf[X509ExtendedKeyManager]
+ ) match {
+ case Some(km: X509ExtendedKeyManager) =>
+ Right(new SniKeyManager(km, defaultAlias))
+ case _ =>
+ Left(
+ new RuntimeException(
+ "No X509ExtendedKeyManager: SNI support is not available"
+ )
+ )
+ }
+ }
+
+ def loadCert(
+ path: String,
+ alias: String,
+ password: String
+ ): Either[Throwable, (X509Certificate, PrivateKey)] =
+ Try {
+ val fis = new FileInputStream(path)
+ val ks = KeyStore.getInstance("JKS")
+ ks.load(fis, password.toCharArray())
+ fis.close()
+
+ (
+ ks.getCertificate(alias).asInstanceOf[X509Certificate],
+ ks.getEntry(
+ alias,
+ new KeyStore.PasswordProtection(password.toCharArray())
+ )
+ .asInstanceOf[PrivateKeyEntry]
+ .getPrivateKey()
+ )
+ }.toEither
+
+ def genSelfSignedCert(
+ host: String,
+ validFor: FiniteDuration
+ ): (X509Certificate, PrivateKey) = {
+
+ val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
+ keyPairGenerator.initialize(2048)
+ val kp = keyPairGenerator.generateKeyPair()
+
+ val v3CertGen = new X509V3CertificateGenerator()
+ v3CertGen.setSerialNumber(
+ BigInteger.valueOf(new SecureRandom().nextInt()).abs()
+ )
+ v3CertGen.setIssuerDN(
+ new X509Principal("CN=" + host + ", OU=None, O=None L=None, C=None")
+ )
+ v3CertGen.setNotBefore(
+ Date.from(Instant.now().minusSeconds(60 * 60))
+ )
+ v3CertGen.setNotAfter(
+ Date.from(Instant.now().plusSeconds(validFor.toSeconds))
+ )
+ v3CertGen.setSubjectDN(
+ new X509Principal("CN=" + host + ", OU=None, O=None L=None, C=None")
+ )
+
+ v3CertGen.setPublicKey(kp.getPublic())
+ v3CertGen.setSignatureAlgorithm("SHA256WithRSAEncryption")
+
+ (v3CertGen.generateX509Certificate(kp.getPrivate()), kp.getPrivate())
+ }
+
+ def genSSLContext(
+ certs: Map[String, (X509Certificate, PrivateKey)]
+ ): SSLContext = {
+
+ val ks = KeyStore.getInstance("JKS")
+ ks.load(null, "secret".toCharArray())
+ certs.foreach {
+ case (hostname, (cert, pk)) =>
+ ks.setKeyEntry(
+ hostname,
+ pk,
+ "secret".toCharArray(),
+ List(cert).toArray
+ )
+ }
+
+ val tmFac = TrustManagerFactory.getInstance("SunX509")
+ tmFac.init(ks)
+
+ val kmFac = KeyManagerFactory.getInstance("SunX509")
+ kmFac.init(ks, "secret".toCharArray())
+
+ val sniKeyManager = SniKeyManager(kmFac, "localhost").left.map { err =>
+ logger.warn(err)(s"Failed to init SNI")
+ }
+
+ val ctx = SSLContext.getInstance("TLSv1.2")
+ ctx.init(
+ sniKeyManager.fold(_ => kmFac.getKeyManagers, km => Array(km)),
+ tmFac.getTrustManagers,
+ new SecureRandom
+ )
+ ctx
+ }
+
+}
diff --git a/server/src/net/usebox/gemini/server/URIUtils.scala b/server/src/net/usebox/gemini/server/URIUtils.scala
new file mode 100644
index 0000000..63c1534
--- /dev/null
+++ b/server/src/net/usebox/gemini/server/URIUtils.scala
@@ -0,0 +1,19 @@
+package net.usebox.gemini.server
+
+import java.nio.charset.StandardCharsets
+import java.net.{URLEncoder, URLDecoder}
+
+import scala.util.Try
+
+object URIUtils {
+ // FIXME: decoding/encoding errors
+ implicit class StringOps(s: String) {
+ def encode(): String =
+ Try(URLEncoder.encode(s, StandardCharsets.UTF_8.toString())).toOption
+ .getOrElse(s)
+
+ def decode(): String =
+ Try(URLDecoder.decode(s, StandardCharsets.UTF_8.name())).toOption
+ .getOrElse(s)
+ }
+}
diff --git a/server/test/resources/.dotfile b/server/test/resources/.dotfile
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/test/resources/.dotfile
diff --git a/server/test/resources/dir/file.txt b/server/test/resources/dir/file.txt
new file mode 100644
index 0000000..3de705a
--- /dev/null
+++ b/server/test/resources/dir/file.txt
@@ -0,0 +1 @@
+Text
diff --git a/server/test/resources/index.gmi b/server/test/resources/index.gmi
new file mode 100644
index 0000000..d5e86d7
--- /dev/null
+++ b/server/test/resources/index.gmi
@@ -0,0 +1,4 @@
+# Test page
+
+Text body.
+
diff --git a/server/test/resources/logback-test.xml b/server/test/resources/logback-test.xml
new file mode 100644
index 0000000..1474587
--- /dev/null
+++ b/server/test/resources/logback-test.xml
@@ -0,0 +1,11 @@
+<configuration>
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%date [%level] %logger{15} - %message%n%xException{10}</pattern>
+ </encoder>
+ </appender>
+ <logger name="net.usebox.gemini.server" level="DEBUG" />
+ <root level="WARN">
+ <appender-ref ref="STDOUT" />
+ </root>
+</configuration>
diff --git a/server/test/src/ServerSpec.scala b/server/test/src/ServerSpec.scala
new file mode 100644
index 0000000..40a2818
--- /dev/null
+++ b/server/test/src/ServerSpec.scala
@@ -0,0 +1,271 @@
+package net.usebox.gemini.server
+
+import java.nio.file.Path
+
+import scala.concurrent.duration._
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import akka.util.ByteString
+
+class ServerSpec extends AnyFlatSpec with Matchers {
+
+ behavior of "validPath"
+
+ it should "return true for the emtpy path" in {
+ Server(TestData.conf).validPath("") shouldBe true
+ }
+
+ it should "return true for valid paths" in {
+ List("/", "/file", "/./", "/.", "/dir/", "/dir/../").foreach { p =>
+ Server(TestData.conf).validPath(p) shouldBe true
+ }
+ }
+
+ it should "return false for invalid paths" in {
+ List("/../", "/..", "/dir/../..", "/dir/../..", "/./../", "/./dir/.././../")
+ .foreach { p =>
+ Server(TestData.conf).validPath(p) shouldBe false
+ }
+ }
+
+ behavior of "guessMimeType using the internal resolver"
+
+ it should "resolve a known MIME type" in {
+ Server(TestData.conf)
+ .guessMimeType(Path.of("file.html"), None) shouldBe "text/html"
+ }
+
+ it should "resolve de default MIME type for unknown types" in {
+ Server(TestData.conf)
+ .guessMimeType(
+ Path.of("unknow"),
+ None
+ ) shouldBe TestData.conf.defaultMimeType
+ }
+
+ it should "resolve gemini MIME type" in {
+ Server(TestData.conf)
+ .guessMimeType(Path.of("file.gmi"), None) shouldBe "text/gemini"
+ Server(TestData.conf)
+ .guessMimeType(Path.of("file.gemini"), None) shouldBe "text/gemini"
+ }
+
+ it should "resolve gemini MIME type, including parameters" in {
+ Server(TestData.conf)
+ .guessMimeType(
+ Path.of("file.gmi"),
+ Some("param")
+ ) shouldBe "text/gemini; param"
+ Server(TestData.conf)
+ .guessMimeType(
+ Path.of("file.gemini"),
+ Some("param")
+ ) shouldBe "text/gemini; param"
+ }
+
+ it should "gemini MIME type parameters are sanitized" in {
+ Server(TestData.conf)
+ .guessMimeType(
+ Path.of("file.gmi"),
+ Some(" ; param")
+ ) shouldBe "text/gemini; param"
+ }
+
+ behavior of "guessMimeType using the configured types"
+
+ it should "resolve a known MIME type" in {
+ Server(TestData.conf.copy(mimeTypes = TestData.mimeTypes))
+ .guessMimeType(Path.of("file.gmi"), None) shouldBe "config"
+ }
+
+ it should "include parameters for text/gemini MIME types" in {
+ Server(
+ TestData.conf.copy(mimeTypes = Some(Map("text/gemini" -> List(".gmi"))))
+ ).guessMimeType(
+ Path.of("file.gmi"),
+ Some("param")
+ ) shouldBe "text/gemini; param"
+ }
+
+ it should "resolve de default MIME type for unknown types" in {
+ Server(TestData.conf.copy(mimeTypes = TestData.mimeTypes))
+ .guessMimeType(
+ Path.of("unknow"),
+ None
+ ) shouldBe TestData.conf.defaultMimeType
+ }
+
+ behavior of "decodeUTF8"
+
+ it should "return right on valid UTF-8 codes" in {
+ Server(TestData.conf)
+ .decodeUTF8(ByteString("vĂ¡lid UTF-8")) shouldBe Symbol(
+ "right"
+ )
+ }
+
+ it should "return left on invalid UTF-8 codes" in {
+ Server(TestData.conf)
+ .decodeUTF8(ByteString(Array(0xc3.toByte, 0x28.toByte))) shouldBe Symbol(
+ "left"
+ )
+ }
+
+ behavior of "handleReq"
+
+ it should "return bad request on URLs with no scheme" in {
+ Server(TestData.conf).handleReq("//localhost/") should matchPattern {
+ case _: BadRequest =>
+ }
+ }
+
+ it should "return proxy request refused on port mismatch" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost:8080/") should matchPattern {
+ case _: ProxyRequestRefused =>
+ }
+ }
+
+ it should "return proxy request refused when port not provided and configured port is not default" in {
+ Server(TestData.conf.copy(port = 8080))
+ .handleReq("gemini://localhost/") should matchPattern {
+ case _: ProxyRequestRefused =>
+ }
+ }
+
+ it should "return success when port is provided and matches configured port (not default)" in {
+ Server(TestData.conf.copy(port = 8080))
+ .handleReq("gemini://localhost:8080/") should matchPattern {
+ case _: Success =>
+ }
+ }
+
+ it should "return proxy request refused when the vhost is not found" in {
+ Server(TestData.conf)
+ .handleReq("gemini://otherhost/") should matchPattern {
+ case _: ProxyRequestRefused =>
+ }
+ }
+
+ it should "return bad request when user info is present" in {
+ Server(TestData.conf)
+ .handleReq("gemini://user@localhost/") should matchPattern {
+ case _: BadRequest =>
+ }
+ }
+
+ it should "return bad request when the path is out of root dir" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/../../") should matchPattern {
+ case _: BadRequest =>
+ }
+ }
+
+ it should "return bad request for invalid URLs" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/ invalid") should matchPattern {
+ case _: BadRequest =>
+ }
+ }
+
+ it should "redirect to normalize the URL" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/./") should matchPattern {
+ case _: PermanentRedirect =>
+ }
+ }
+
+ it should "return not found if the path doesn't exist" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/doesnotexist") should matchPattern {
+ case _: NotFound =>
+ }
+ }
+
+ it should "return not found if a dot file" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/.dotfile") should matchPattern {
+ case _: NotFound =>
+ }
+ }
+
+ it should "return success on reading file" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/index.gmi") should matchPattern {
+ case Success(_, "text/gemini", Some(_), 25L) =>
+ }
+ }
+
+ it should "redirect and normalize request on a directory" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/dir") should matchPattern {
+ case _: PermanentRedirect =>
+ }
+ }
+
+ it should "return an existing index file when requesting a directory" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/") should matchPattern {
+ case Success(_, "text/gemini", Some(_), 25L) =>
+ }
+ }
+
+ it should "return a directory listing if is enabled and no index" in {
+ Server(TestData.conf)
+ .handleReq("gemini://localhost/dir/") should matchPattern {
+ case _: DirListing =>
+ }
+ }
+
+ it should "return not found if directory listing is nt enabled and no index" in {
+ Server(
+ TestData.conf.copy(virtualHosts =
+ List(TestData.conf.virtualHosts(0).copy(directoryListing = false))
+ )
+ ).handleReq("gemini://localhost/dir/") should matchPattern {
+ case _: NotFound =>
+ }
+ }
+
+ it should "return proxy request refused for non gemini schemes" in {
+ Server(TestData.conf)
+ .handleReq("https://localhost/") should matchPattern {
+ case _: ProxyRequestRefused =>
+ }
+ }
+
+ it should "include gemini params for gemini MIME type" in {
+ Server(
+ TestData.conf.copy(virtualHosts =
+ List(TestData.conf.virtualHosts(0).copy(geminiParams = Some("test")))
+ )
+ ).handleReq("gemini://localhost/index.gmi") should matchPattern {
+ case Success(_, "text/gemini; test", Some(_), 25L) =>
+ }
+ }
+
+ object TestData {
+ val conf = ServiceConf(
+ address = "127.0.0.1",
+ port = 1965,
+ defaultMimeType = "text/plain",
+ idleTimeout = 10.seconds,
+ virtualHosts = List(
+ VirtualHost(
+ host = "localhost",
+ root = getClass.getResource("/").getPath()
+ )
+ ),
+ genCertValidFor = 1.day,
+ enabledProtocols = Nil,
+ enabledCipherSuites = Nil
+ )
+
+ val mimeTypes = Some(
+ Map(
+ "config" -> List(".gmi", ".gemini")
+ )
+ )
+ }
+}
diff --git a/spacebeans.conf.example b/spacebeans.conf.example
new file mode 100644
index 0000000..e2d586b
--- /dev/null
+++ b/spacebeans.conf.example
@@ -0,0 +1,51 @@
+// SpaceBeans gemini server configuration
+
+// listening address/port
+address = "127.0.0.1"
+port = 1965
+
+// how long until an idle connection is closed
+idle-timeout = "10 seconds"
+
+// default MIME type if detection fails
+default-mime-type = "text/plain"
+
+// by default a built-in resolver is used;
+// use this to define your own MIME types
+// mime-types = {
+// "text/gemini": [".gmi", ".gemini"]
+// }
+
+// hosts configuration
+virtual-hosts = [
+ {
+ host = "localhost"
+ root = "/var/gemini/localhost/"
+ index-file = "index.gmi"
+
+ directory-listing = true
+
+ // optional parameters for text/gemini
+ // gemini-params = "charset=utf-8; lang=en"
+
+ // comment out to use an auto-generated self-signed certificate
+ key-store {
+ path = "/path/to/keystore.jks"
+ alias = "localhost"
+ password = "secret"
+ }
+ }
+]
+
+// SSL support
+gen-cert-valid-for = "365 days"
+enabled-protocols = [ "TLSv1.2", "TLSv1.3" ]
+enabled-cipher-suites = [
+ "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
+ "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
+ "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
+ "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
+ "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384",
+ "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256"
+]
+