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 /server/src/net/usebox/gemini | |
download | spacebeans-26f9cb8e66e836607851aab623223aef478f3b27.tar.gz spacebeans-26f9cb8e66e836607851aab623223aef478f3b27.zip |
Initial public dump
Diffstat (limited to 'server/src/net/usebox/gemini')
-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 |
6 files changed, 739 insertions, 0 deletions
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) + } +} |