diff options
author | Juan J. Martinez <jjm@usebox.net> | 2021-02-25 22:08:51 +0000 |
---|---|---|
committer | Juan J. Martinez <jjm@usebox.net> | 2021-02-25 22:11:22 +0000 |
commit | 26f9cb8e66e836607851aab623223aef478f3b27 (patch) | |
tree | 3e2c5449c8a7a80b912641da1b144d5169aab912 | |
download | spacebeans-26f9cb8e66e836607851aab623223aef478f3b27.tar.gz spacebeans-26f9cb8e66e836607851aab623223aef478f3b27.zip |
Initial public dump
-rw-r--r-- | .gitignore | 6 | ||||
-rw-r--r-- | .scalafmt.conf | 1 | ||||
-rw-r--r-- | CHAMGES.md | 7 | ||||
-rw-r--r-- | COPYING | 20 | ||||
-rw-r--r-- | README.md | 103 | ||||
-rw-r--r-- | build.sc | 46 | ||||
-rw-r--r-- | server/resources/application.conf | 8 | ||||
-rw-r--r-- | server/resources/logback.xml | 11 | ||||
-rw-r--r-- | server/src/net/usebox/gemini/server/Response.scala | 135 | ||||
-rw-r--r-- | server/src/net/usebox/gemini/server/Server.scala | 271 | ||||
-rw-r--r-- | server/src/net/usebox/gemini/server/ServerApp.scala | 62 | ||||
-rw-r--r-- | server/src/net/usebox/gemini/server/ServiceConf.scala | 38 | ||||
-rw-r--r-- | server/src/net/usebox/gemini/server/TLSUtils.scala | 214 | ||||
-rw-r--r-- | server/src/net/usebox/gemini/server/URIUtils.scala | 19 | ||||
-rw-r--r-- | server/test/resources/.dotfile | 0 | ||||
-rw-r--r-- | server/test/resources/dir/file.txt | 1 | ||||
-rw-r--r-- | server/test/resources/index.gmi | 4 | ||||
-rw-r--r-- | server/test/resources/logback-test.xml | 11 | ||||
-rw-r--r-- | server/test/src/ServerSpec.scala | 271 | ||||
-rw-r--r-- | spacebeans.conf.example | 51 |
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 + @@ -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" +] + |