diff options
author | Juan J. MartÃnez <jjm@usebox.net> | 2021-07-20 19:24:39 +0000 |
---|---|---|
committer | Juan J. Martinez <jjm@usebox.net> | 2021-07-22 19:49:53 +0100 |
commit | a13c87ea9b0fae2a38adead85cb97e6c5e31c6d8 (patch) | |
tree | e60a35310a2a9f120a5c3e769ea862e950d322f5 /server/src/net/usebox | |
parent | ab4a5268bd2971a364bf026aede2cb50c885a03d (diff) | |
parent | c760d7c9bd14d7e56ae1553a0b3c4a1258448686 (diff) | |
download | spacebeans-a13c87ea9b0fae2a38adead85cb97e6c5e31c6d8.tar.gz spacebeans-a13c87ea9b0fae2a38adead85cb97e6c5e31c6d8.zip |
Merge branch 'cgi-suport-wip' into 'master'
CGI support
See merge request reidrac/spacebeans!1
Diffstat (limited to 'server/src/net/usebox')
-rw-r--r-- | server/src/net/usebox/gemini/server/Response.scala | 85 | ||||
-rw-r--r-- | server/src/net/usebox/gemini/server/Server.scala | 63 | ||||
-rw-r--r-- | server/src/net/usebox/gemini/server/ServiceConf.scala | 72 |
3 files changed, 181 insertions, 39 deletions
diff --git a/server/src/net/usebox/gemini/server/Response.scala b/server/src/net/usebox/gemini/server/Response.scala index 6c243b2..a6993c4 100644 --- a/server/src/net/usebox/gemini/server/Response.scala +++ b/server/src/net/usebox/gemini/server/Response.scala @@ -2,6 +2,11 @@ package net.usebox.gemini.server import java.nio.file.Path +import scala.sys.process._ +import scala.util.Try + +import org.log4s._ + import akka.stream.ActorAttributes import akka.stream.scaladsl.{Source, FileIO} import akka.util.ByteString @@ -78,6 +83,86 @@ case class DirListing( ) } +case class Cgi( + req: String, + filename: String, + queryString: String, + pathInfo: String, + scriptName: String, + host: String, + port: String, + remoteAddr: String +) extends Response { + + private[this] val logger = getLogger + + val bodyPath: Option[Path] = None + + val responseRe = "([0-9]{2}) (.*)".r + + val env = Map( + "GATEWAY_INTERFACE" -> "CGI/1.1", + "SERVER_SOFTWARE" -> s"${BuildInfo.name}/${BuildInfo.version}", + "SERVER_PROTOCOL" -> "GEMINI", + "GEMINI_URL" -> req, + "SCRIPT_NAME" -> scriptName, + "PATH_INFO" -> pathInfo, + "QUERY_STRING" -> queryString, + "SERVER_NAME" -> host, + "SERVER_PORT" -> port, + "REMOTE_ADDR" -> remoteAddr, + "REMOTE_HOST" -> remoteAddr + ).toSeq + + val (status: Int, meta: String, body: String) = { + val output = new java.io.ByteArrayOutputStream + Try { + val jpb = new java.lang.ProcessBuilder(filename) + jpb.environment.clear() + env.foreach { case (k, v) => jpb.environment.put(k, v) } + + val exit = (Process(jpb) #> output).! + output.close() + + exit + }.toEither match { + case Right(0) => + val body = output.toString("UTF-8") + body.split("\r\n").headOption match { + case Some(req @ responseRe(status, meta)) + if req.length <= Server.maxReqLen => + (status.toInt, meta, body) + case _ => + logger.warn(s"$scriptName: invalid CGI response") + respError(40, "Invalid response from CGI") + } + + case Right(exit) => + logger.warn(s"$scriptName: failed to execute CGI (exit: $exit)") + respError(50, s"Error executing CGI") + + case Left(error) => + logger.warn( + s"$scriptName: failed to execute CGI (${error.getMessage()})" + ) + respError(50, s"Error executing CGI") + } + } + + def respError( + status: Int, + meta: String + ): (Int, String, String) = { + // response should fit in 1024 bytes: XX META\r\n + val limited = meta.substring(0, Math.min(meta.length, Server.maxReqLen - 5)) + (status, meta, s"$status $limited\r\n") + } + + def bodySize: Long = body.size + + override def toSource = Source.single(ByteString(body)) +} + case class TempRedirect( req: String, meta: String = "Redirect - temporary" diff --git a/server/src/net/usebox/gemini/server/Server.scala b/server/src/net/usebox/gemini/server/Server.scala index a250158..3c78721 100644 --- a/server/src/net/usebox/gemini/server/Server.scala +++ b/server/src/net/usebox/gemini/server/Server.scala @@ -23,9 +23,6 @@ case class Server(conf: ServiceConf) { private[this] val logger = getLogger - val defPort = 1965 - val maxReqLen = 1024 - val mimeTypes = conf.mimeTypes val defaultMimeType = conf.defaultMimeType val vHosts = conf.virtualHosts @@ -67,7 +64,7 @@ case class Server(conf: ServiceConf) { case mime => mime } - def handleReq(req: String): Response = + def handleReq(req: String, remoteAddr: String): Response = (for { uri <- Try(URI.create(req)).toEither resp <- Try( @@ -83,7 +80,7 @@ case class Server(conf: ServiceConf) { 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 => + case _ if uri.getPort() == -1 && conf.port != Server.defPort => logger.debug( s"default port but non default was configured, is a proxy request" ) @@ -107,10 +104,41 @@ case class Server(conf: ServiceConf) { .getDefault() .getPath(root, path) .normalize() + val cgi = vhost.getCgi(resource) - logger.debug(s"requesting: '$resource'") + logger.debug(s"requesting: '$resource', cgi is '$cgi'") resource.toFile() match { + case file + if cgi + .map(_.toFile()) + .map(f => f.isFile() && f.canExecute()) + .getOrElse(false) => + logger.debug("is cgi, will execute") + + val cgiFile = cgi.get + val queryString = + if (uri.getQuery() == null) "" else uri.getQuery() + val pathInfo = + if (cgiFile.compareTo(resource) == 0) "" + else + "/" + resource + .subpath( + cgiFile.getNameCount(), + resource.getNameCount() + ) + .toString() + + Cgi( + req, + filename = cgiFile.toString(), + queryString = queryString, + pathInfo = pathInfo, + scriptName = cgiFile.getFileName().toString(), + host = vhost.host, + port = conf.port.toString(), + remoteAddr = remoteAddr + ) case path if !path.exists() => logger.debug("no resource") NotFound(req) @@ -227,15 +255,15 @@ case class Server(conf: ServiceConf) { closing = TLSClosing.ignoreCancel ) .runForeach { connection => - val remoteHost = connection.remoteAddress.getHostString() - logger.debug(s"new connection $remoteHost") + val remoteAddr = connection.remoteAddress.getHostString() + logger.debug(s"new connection $remoteAddr") val handler = Flow[ByteString] .watchTermination() { (_, f) => f.onComplete { _.toEither.swap.map(error => logger.warn( - s"$remoteHost - stream terminated: ${error.getMessage()}" + s"$remoteAddr - stream terminated: ${error.getMessage()}" ) ) } @@ -244,7 +272,7 @@ case class Server(conf: ServiceConf) { Framing .delimiter( ByteString("\r\n"), - maximumFrameLength = maxReqLen + 1, + maximumFrameLength = Server.maxReqLen + 1, allowTruncation = true ) ) @@ -257,16 +285,16 @@ case class Server(conf: ServiceConf) { logger.debug(s"invalid UTF-8 encoding: ${error.getMessage()}") BadRequest(req.utf8String) case Right(reqStr) => - if (req.size > maxReqLen) + if (req.size > Server.maxReqLen) BadRequest(reqStr.take(1024) + "{...}") else - handleReq(reqStr) + handleReq(reqStr, remoteAddr) } } .take(1) .wireTap(resp => logger.info( - s"""$remoteHost "${resp.req}" ${resp.status} ${resp.bodySize}""" + s"""$remoteAddr "${resp.req}" ${resp.status} ${resp.bodySize}""" ) ) .flatMapConcat(_.toSource) @@ -275,3 +303,12 @@ case class Server(conf: ServiceConf) { } } } + +object Server { + + /** Maximum request length in bytes. */ + val maxReqLen = 1024 + + /** Default port. */ + val defPort = 1965 +} diff --git a/server/src/net/usebox/gemini/server/ServiceConf.scala b/server/src/net/usebox/gemini/server/ServiceConf.scala index 74ed548..dc28650 100644 --- a/server/src/net/usebox/gemini/server/ServiceConf.scala +++ b/server/src/net/usebox/gemini/server/ServiceConf.scala @@ -11,7 +11,11 @@ import org.log4s._ case class KeyStore(path: String, alias: String, password: String) -case class Directory(path: String, directoryListing: Option[Boolean]) +case class Directory( + path: String, + directoryListing: Option[Boolean], + allowCgi: Option[Boolean] +) case class VirtualHost( host: String, @@ -34,9 +38,24 @@ object VirtualHost { def getDirectoryListing(path: Path): Boolean = vhost.directories .find(_.path == path.toString()) - .fold(vhost.directoryListing)(loc => - loc.directoryListing.getOrElse(vhost.directoryListing) + .flatMap(_.directoryListing) + .getOrElse(vhost.directoryListing) + + def getCgi(path: Path): Option[Path] = + vhost.directories + .find(d => + path.startsWith( + d.path + ) && path.toString != d.path && d.allowCgi == Some(true) ) + .collect { + case d => + val dp = + FileSystems.getDefault().getPath(d.path).normalize() + FileSystems + .getDefault() + .getPath(d.path, path.getName(dp.getNameCount()).toString()) + } def getRoot(path: String): (String, String) = path match { @@ -75,32 +94,33 @@ object ServiceConf { import VirtualHost.userTag - def load(confFile: String) = - ConfigSource.file(confFile).load[ServiceConf].map { conf => - conf.copy(virtualHosts = conf.virtualHosts.map { vhost => - if ( - vhost.userDirectories && !vhost.userDirectoryPath - .fold(false)(dir => dir.contains(userTag)) + def initConf(conf: ServiceConf): ServiceConf = + conf.copy(virtualHosts = conf.virtualHosts.map { vhost => + if ( + vhost.userDirectories && !vhost.userDirectoryPath + .fold(false)(dir => dir.contains(userTag)) + ) + logger.warn( + s"In virtual host '${vhost.host}': user-directories is enabled but $userTag not found in user-directory-path" ) - logger.warn( - s"In virtual host '${vhost.host}': user-directories is enabled but $userTag not found in user-directory-path" - ) - vhost.copy(directories = vhost.directories.map { dir => - val path = - FileSystems - .getDefault() - .getPath(vhost.root, dir.path) - .normalize() + vhost.copy(directories = vhost.directories.map { dir => + val path = + FileSystems + .getDefault() + .getPath(vhost.root, dir.path) + .normalize() - if (!path.toFile().isDirectory()) - logger.warn( - s"In virtual host '${vhost.host}': directory entry '${dir.path}' is not a directory" - ) + if (!path.toFile().isDirectory()) + logger.warn( + s"In virtual host '${vhost.host}': directory entry '${dir.path}' is not a directory" + ) - dir - .copy(path = path.toString()) - }) + dir + .copy(path = path.toString()) }) - } + }) + + def load(confFile: String) = + ConfigSource.file(confFile).load[ServiceConf].map(initConf) } |