summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJuan J. Martínez <jjm@usebox.net>2021-07-20 19:24:39 +0000
committerJuan J. Martinez <jjm@usebox.net>2021-07-22 19:49:53 +0100
commita13c87ea9b0fae2a38adead85cb97e6c5e31c6d8 (patch)
treee60a35310a2a9f120a5c3e769ea862e950d322f5
parentab4a5268bd2971a364bf026aede2cb50c885a03d (diff)
parentc760d7c9bd14d7e56ae1553a0b3c4a1258448686 (diff)
downloadspacebeans-a13c87ea9b0fae2a38adead85cb97e6c5e31c6d8.tar.gz
spacebeans-a13c87ea9b0fae2a38adead85cb97e6c5e31c6d8.zip
Merge branch 'cgi-suport-wip' into 'master'
CGI support See merge request reidrac/spacebeans!1
-rw-r--r--.gitlab-ci.yml7
-rw-r--r--README.md42
-rw-r--r--server/src/net/usebox/gemini/server/Response.scala85
-rw-r--r--server/src/net/usebox/gemini/server/Server.scala63
-rw-r--r--server/src/net/usebox/gemini/server/ServiceConf.scala72
-rwxr-xr-xserver/test/resources/dir/bad-cgi4
-rwxr-xr-xserver/test/resources/dir/bad-response3
-rwxr-xr-xserver/test/resources/dir/cgi4
-rwxr-xr-xserver/test/resources/dir/sub/cgi6
-rw-r--r--server/test/resources/dir/sub/empty0
-rw-r--r--server/test/src/ServerSpec.scala425
-rw-r--r--server/test/src/ServiceConfSpec.scala135
-rw-r--r--spacebeans.conf.example2
13 files changed, 696 insertions, 152 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 93ee0b5..740120c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,16 +1,19 @@
image: openjdk:8
+variables:
+ MILL_CLI: "-D coursier.cache=$CI_PROJECT_DIR/.cache -j 0"
+
cache:
paths:
- .cache/
test:
script:
- - ./mill -D ivy.default.ivy.user.dir=.cache server.test
+ - ./mill $MILL_CLI server.test
package:
script:
- - ./mill -D ivy.default.ivy.user.dir=.cache server.assembly
+ - ./mill $MILL_CLI server.assembly
artifacts:
paths:
- out/server/assembly/dest/*.jar
diff --git a/README.md b/README.md
index 4fcb6a6..319a78c 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,48 @@ 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.
+## CGI support
+
+Classic CGI is supported via the `allow-cgi` flag in the `directories` directive.
+
+The flag only affects to the specified directory.
+
+The environment variables provided to the CGIs are:
+
+| Variable | Example |
+| --- | --- |
+| REMOTE_ADDR | `127.0.0.1` |
+| SERVER_PORT | `1965` |
+| GEMINI_URL | `gemini://localhost/mycgi/example/path?test=1` |
+| REMOTE_HOST | `127.0.0.1` |
+| SERVER_PROTOCOL | `GEMINI` |
+| SERVER_SOFTWARE | `spacebeans/v1.1.3` |
+| SCRIPT_NAME | `mycgi` |
+| SERVER_NAME | `localhost` |
+| PATH_INFO | `/example/path` |
+| GATEWAY_INTERFACE | `CGI/1.1` |
+| QUERY_STRING | `test=1` |
+
+These are described in [RFC-3875](https://datatracker.ietf.org/doc/html/rfc3875).
+
+The server expects:
+
+- the CGI must terminate with exit code `0` (success)
+- the response will be in CGI's standard output
+- it **must** be a valid Gemini response
+
+An example of a CGI script written in Python that outputs the environment
+variables could be:
+
+```python
+#!/usr/bin/env python3
+
+import os
+
+print("20 text/gemini\r\n")
+print('\n'.join([k + "=" + v for k, v in os.environ.items()]))
+```
+
## Development
Requirements:
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)
}
diff --git a/server/test/resources/dir/bad-cgi b/server/test/resources/dir/bad-cgi
new file mode 100755
index 0000000..0ec31e2
--- /dev/null
+++ b/server/test/resources/dir/bad-cgi
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+echo -e "20 text/gemini\r\n"
+exit 1
diff --git a/server/test/resources/dir/bad-response b/server/test/resources/dir/bad-response
new file mode 100755
index 0000000..c9bedb5
--- /dev/null
+++ b/server/test/resources/dir/bad-response
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+echo "20 text/gemini"
diff --git a/server/test/resources/dir/cgi b/server/test/resources/dir/cgi
new file mode 100755
index 0000000..05a3e53
--- /dev/null
+++ b/server/test/resources/dir/cgi
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+echo -e "20 text/gemini\r\n"
+env
diff --git a/server/test/resources/dir/sub/cgi b/server/test/resources/dir/sub/cgi
new file mode 100755
index 0000000..05ba716
--- /dev/null
+++ b/server/test/resources/dir/sub/cgi
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+echo -e "20 text/gemini\r\n"
+echo "SHOULD NOT RUN"
+exit 1
+
diff --git a/server/test/resources/dir/sub/empty b/server/test/resources/dir/sub/empty
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/test/resources/dir/sub/empty
diff --git a/server/test/src/ServerSpec.scala b/server/test/src/ServerSpec.scala
index 05e1580..7a2b501 100644
--- a/server/test/src/ServerSpec.scala
+++ b/server/test/src/ServerSpec.scala
@@ -12,12 +12,6 @@ class ServerSpec extends AnyFlatSpec with Matchers {
def getPath(value: String) = FileSystems.getDefault().getPath(value)
- def getPath(root: String, dir: String) =
- FileSystems
- .getDefault()
- .getPath(root, dir)
- .normalize()
-
behavior of "validPath"
it should "return true for the emtpy path" in {
@@ -135,107 +129,110 @@ class ServerSpec extends AnyFlatSpec with Matchers {
behavior of "handleReq"
it should "return bad request on URLs with no scheme" in {
- Server(TestData.conf).handleReq("//localhost/") should matchPattern {
- case _: BadRequest =>
- }
+ Server(TestData.conf)
+ .handleReq("//localhost/", "127.0.0.1") should be(a[BadRequest])
}
it should "return proxy request refused on port mismatch" in {
Server(TestData.conf)
- .handleReq("gemini://localhost:8080/") should matchPattern {
- case _: ProxyRequestRefused =>
- }
+ .handleReq("gemini://localhost:8080/", "127.0.0.1") should be(
+ a[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 =>
- }
+ .handleReq("gemini://localhost/", "127.0.0.1") should be(
+ a[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 =>
- }
+ .handleReq("gemini://localhost:8080/", "127.0.0.1") should be(a[Success])
}
it should "return proxy request refused when the vhost is not found" in {
Server(TestData.conf)
- .handleReq("gemini://otherhost/") should matchPattern {
- case _: ProxyRequestRefused =>
- }
+ .handleReq("gemini://otherhost/", "127.0.0.1") should be(
+ a[ProxyRequestRefused]
+ )
}
it should "return bad request when user info is present" in {
Server(TestData.conf)
- .handleReq("gemini://user@localhost/") should matchPattern {
- case _: BadRequest =>
- }
+ .handleReq("gemini://user@localhost/", "127.0.0.1") should be(
+ a[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 =>
- }
+ .handleReq("gemini://localhost/../../", "127.0.0.1") should be(
+ a[BadRequest]
+ )
}
it should "return bad request for invalid URLs" in {
Server(TestData.conf)
- .handleReq("gemini://localhost/ invalid") should matchPattern {
- case _: BadRequest =>
- }
+ .handleReq(
+ "gemini://localhost/ invalid",
+ "127.0.0.1"
+ ) should be(a[BadRequest])
}
it should "redirect to normalize the URL" in {
Server(TestData.conf)
- .handleReq("gemini://localhost/./") should matchPattern {
- case _: PermanentRedirect =>
- }
+ .handleReq("gemini://localhost/./", "127.0.0.1") should be(
+ a[PermanentRedirect]
+ )
}
it should "return not found if the path doesn't exist" in {
Server(TestData.conf)
- .handleReq("gemini://localhost/doesnotexist") should matchPattern {
- case _: NotFound =>
- }
+ .handleReq(
+ "gemini://localhost/doesnotexist",
+ "127.0.0.1"
+ ) should be(a[NotFound])
}
it should "return not found if a dot file" in {
Server(TestData.conf)
- .handleReq("gemini://localhost/.dotfile") should matchPattern {
- case _: NotFound =>
- }
+ .handleReq(
+ "gemini://localhost/.dotfile",
+ "127.0.0.1"
+ ) should be(a[NotFound])
}
it should "return success on reading file" in {
Server(TestData.conf)
- .handleReq("gemini://localhost/index.gmi") should matchPattern {
+ .handleReq(
+ "gemini://localhost/index.gmi",
+ "127.0.0.1"
+ ) 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 =>
- }
+ .handleReq("gemini://localhost/dir", "127.0.0.1") should be(
+ a[PermanentRedirect]
+ )
}
it should "return an existing index file when requesting a directory" in {
Server(TestData.conf)
- .handleReq("gemini://localhost/") should matchPattern {
+ .handleReq("gemini://localhost/", "127.0.0.1") should matchPattern {
case Success(_, "text/gemini", Some(_), 25L) =>
}
}
it should "return proxy request refused for non gemini schemes" in {
Server(TestData.conf)
- .handleReq("https://localhost/") should matchPattern {
- case _: ProxyRequestRefused =>
- }
+ .handleReq("https://localhost/", "127.0.0.1") should be(
+ a[ProxyRequestRefused]
+ )
}
it should "include gemini params for gemini MIME type" in {
@@ -243,7 +240,10 @@ class ServerSpec extends AnyFlatSpec with Matchers {
TestData.conf.copy(virtualHosts =
List(TestData.conf.virtualHosts(0).copy(geminiParams = Some("test")))
)
- ).handleReq("gemini://localhost/index.gmi") should matchPattern {
+ ).handleReq(
+ "gemini://localhost/index.gmi",
+ "127.0.0.1"
+ ) should matchPattern {
case Success(_, "text/gemini; test", Some(_), 25L) =>
}
}
@@ -252,55 +252,80 @@ class ServerSpec extends AnyFlatSpec with Matchers {
it should "return a directory listing if is enabled and no index" in {
Server(TestData.conf)
- .handleReq("gemini://localhost/dir/") should matchPattern {
- case _: DirListing =>
- }
+ .handleReq("gemini://localhost/dir/", "127.0.0.1") should be(
+ a[DirListing]
+ )
}
it should "return a directory listing, directory listing flags: vhost flag false, directories flag true" in {
Server(
- TestData.conf.copy(virtualHosts =
- List(
- TestData.conf
- .virtualHosts(0)
- .copy(
- directoryListing = false,
- directories = List(
- Directory(
- getPath(getClass.getResource("/").getPath(), "dir/")
- .toString(),
- directoryListing = Some(true)
+ ServiceConf.initConf(
+ TestData.conf.copy(virtualHosts =
+ List(
+ TestData.conf
+ .virtualHosts(0)
+ .copy(
+ directoryListing = false,
+ directories = List(
+ Directory(
+ "dir/",
+ directoryListing = Some(true),
+ allowCgi = None
+ )
)
)
- )
+ )
)
)
- ).handleReq("gemini://localhost/dir/") should matchPattern {
- case _: DirListing =>
- }
+ ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[DirListing])
}
it should "return not found with no index, directory listing flags: vhost flag true, directories flag false" in {
Server(
- TestData.conf.copy(virtualHosts =
- List(
- TestData.conf
- .virtualHosts(0)
- .copy(
- directoryListing = true,
- directories = List(
- Directory(
- getPath(getClass.getResource("/").getPath(), "dir/")
- .toString(),
- directoryListing = Some(false)
+ ServiceConf.initConf(
+ TestData.conf.copy(virtualHosts =
+ List(
+ TestData.conf
+ .virtualHosts(0)
+ .copy(
+ directoryListing = true,
+ directories = List(
+ Directory(
+ "dir/",
+ directoryListing = Some(false),
+ allowCgi = None
+ )
)
)
- )
+ )
)
)
- ).handleReq("gemini://localhost/dir/") should matchPattern {
- case _: NotFound =>
- }
+ ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound])
+ }
+
+ it should "not apply directory listing override to subdirectories" in {
+ Server(
+ ServiceConf.initConf(
+ TestData.conf.copy(virtualHosts =
+ List(
+ TestData.conf
+ .virtualHosts(0)
+ .copy(
+ directoryListing = false,
+ directories = List(
+ Directory(
+ "dir/",
+ directoryListing = Some(true),
+ allowCgi = None
+ )
+ )
+ )
+ )
+ )
+ )
+ ).handleReq("gemini://localhost/dir/sub/", "127.0.0.1") should be(
+ a[NotFound]
+ )
}
it should "return not found if directory listing is not enabled and no index" in {
@@ -308,16 +333,15 @@ class ServerSpec extends AnyFlatSpec with Matchers {
TestData.conf.copy(virtualHosts =
List(TestData.conf.virtualHosts(0).copy(directoryListing = false))
)
- ).handleReq("gemini://localhost/dir/") should matchPattern {
- case _: NotFound =>
- }
+ ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound])
}
behavior of "handleReq, user directories"
it should "return success on reading file" in {
Server(TestData.confUserDir).handleReq(
- "gemini://localhost/~username/index.gmi"
+ "gemini://localhost/~username/index.gmi",
+ "127.0.0.1"
) should matchPattern {
case Success(_, "text/gemini", Some(_), 38L) =>
}
@@ -325,15 +349,15 @@ class ServerSpec extends AnyFlatSpec with Matchers {
it should "return redirect accessing the user directory without ending slash" in {
Server(TestData.confUserDir).handleReq(
- "gemini://localhost/~username"
- ) should matchPattern {
- case _: PermanentRedirect =>
- }
+ "gemini://localhost/~username",
+ "127.0.0.1"
+ ) should be(a[PermanentRedirect])
}
it should "return success accessing the user directory index" in {
Server(TestData.confUserDir).handleReq(
- "gemini://localhost/~username/"
+ "gemini://localhost/~username/",
+ "127.0.0.1"
) should matchPattern {
case Success(_, "text/gemini", Some(_), 38L) =>
}
@@ -341,32 +365,28 @@ class ServerSpec extends AnyFlatSpec with Matchers {
it should "return bad request trying to exit the root directory" in {
Server(TestData.confUserDir).handleReq(
- "gemini://localhost/~username/../../"
- ) should matchPattern {
- case _: BadRequest =>
- }
+ "gemini://localhost/~username/../../",
+ "127.0.0.1"
+ ) should be(a[BadRequest])
}
it should "return redirect to the virtual host root when leaving the user dir" in {
Server(TestData.confUserDir).handleReq(
- "gemini://localhost/~username/../"
- ) should matchPattern {
- case _: PermanentRedirect =>
- }
+ "gemini://localhost/~username/../",
+ "127.0.0.1"
+ ) should be(a[PermanentRedirect])
}
it should "not translate root if used an invalid user pattern" in {
Server(TestData.confUserDir).handleReq(
- "gemini://localhost/~username../"
- ) should matchPattern {
- case _: NotFound =>
- }
+ "gemini://localhost/~username../",
+ "127.0.0.1"
+ ) should be(a[NotFound])
Server(TestData.confUserDir).handleReq(
- "gemini://localhost/~0invalid/"
- ) should matchPattern {
- case _: NotFound =>
- }
+ "gemini://localhost/~0invalid/",
+ "127.0.0.1"
+ ) should be(a[NotFound])
}
it should "not translate root if no user directory path was provided" in {
@@ -379,21 +399,187 @@ class ServerSpec extends AnyFlatSpec with Matchers {
)
)
).handleReq(
- "gemini://localhost/~username/"
+ "gemini://localhost/~username/",
+ "127.0.0.1"
+ ) should be(a[NotFound])
+ }
+
+ it should "not execute a CGI if the target resource is not executable" in {
+ Server(TestData.cgiConf).handleReq(
+ "gemini://localhost/dir/file.txt",
+ "127.0.0.1"
+ ) should matchPattern {
+ case Success(_, "text/plain", Some(_), 5L) =>
+ }
+ }
+
+ it should "not execute a CGI if the target resource is a directory" in {
+ Server(TestData.cgiConf).handleReq(
+ "gemini://localhost/dir/sub/",
+ "127.0.0.1"
+ ) should be(a[DirListing])
+ }
+
+ it should "not apply allow CGI to subdirectories" in {
+ Server(TestData.cgiConf).handleReq(
+ "gemini://localhost/dir/sub/cgi",
+ "127.0.0.1"
+ ) should matchPattern {
+ case Success(_, "text/plain", Some(_), 72) =>
+ }
+ }
+
+ it should "execute a CGI" in {
+ val cgi = Server(TestData.cgiConf)
+ .handleReq(
+ "gemini://localhost/dir/cgi",
+ "127.0.0.1"
+ )
+ .asInstanceOf[Cgi]
+
+ cgi.status should be(20)
+ cgi.meta should be("text/gemini")
+ cgi.body should include("GATEWAY_INTERFACE=CGI/1.1")
+ }
+
+ it should "execute a CGI: empty parameters, host and port" in {
+ Server(TestData.cgiConf).handleReq(
+ "gemini://localhost/dir/cgi",
+ "127.0.0.1"
+ ) should matchPattern {
+ case Cgi(
+ _,
+ _,
+ "",
+ "",
+ "cgi",
+ TestData.host,
+ TestData.portStr,
+ _
+ ) =>
+ }
+ }
+
+ it should "execute a CGI: query string" in {
+ Server(TestData.cgiConf).handleReq(
+ "gemini://localhost/dir/cgi?query&string",
+ "127.0.0.1"
+ ) should matchPattern {
+ case Cgi(
+ _,
+ _,
+ "query&string",
+ "",
+ "cgi",
+ TestData.host,
+ TestData.portStr,
+ _
+ ) =>
+ }
+ }
+
+ it should "execute a CGI: path info" in {
+ Server(TestData.cgiConf).handleReq(
+ "gemini://localhost/dir/cgi/path/info",
+ "127.0.0.1"
+ ) should matchPattern {
+ case Cgi(
+ _,
+ _,
+ "",
+ "/path/info",
+ "cgi",
+ TestData.host,
+ TestData.portStr,
+ _
+ ) =>
+ }
+ }
+
+ it should "execute a CGI: query string and path info" in {
+ Server(TestData.cgiConf).handleReq(
+ "gemini://localhost/dir/cgi/path/info?query=string",
+ "127.0.0.1"
) should matchPattern {
- case _: NotFound =>
+ case Cgi(
+ _,
+ _,
+ "query=string",
+ "/path/info",
+ "cgi",
+ TestData.host,
+ TestData.portStr,
+ _
+ ) =>
}
}
+ it should "not execute an executable if allow CGI is off" in {
+ Server(TestData.conf)
+ .handleReq(
+ "gemini://localhost/dir/cgi",
+ "127.0.0.1"
+ ) should matchPattern {
+ case Success(_, "text/plain", Some(_), _) =>
+ }
+ }
+
+ it should "response with an error if the CGI exits with non 0" in {
+ val bad = Server(TestData.cgiConf)
+ .handleReq(
+ "gemini://localhost/dir/bad-cgi",
+ "127.0.0.1"
+ )
+ .asInstanceOf[Cgi]
+
+ val meta = "Error executing CGI"
+ bad.status should be(50)
+ bad.meta should be(meta)
+ bad.body should include(meta)
+ }
+
+ it should "return a response with an error if the CGI exits with non 0" in {
+ val bad = Server(TestData.cgiConf)
+ .handleReq(
+ "gemini://localhost/dir/bad-cgi",
+ "127.0.0.1"
+ )
+ .asInstanceOf[Cgi]
+
+ val meta = "Error executing CGI"
+ bad.status should be(50)
+ bad.meta should be(meta)
+ bad.body should include(meta)
+ }
+
+ it should "return a response with an error if the CGI response is invalid" in {
+ val bad = Server(TestData.cgiConf)
+ .handleReq(
+ "gemini://localhost/dir/bad-response",
+ "127.0.0.1"
+ )
+ .asInstanceOf[Cgi]
+
+ val meta = "Invalid response from CGI"
+ bad.status should be(40)
+ bad.meta should be(meta)
+ bad.body should include(meta)
+ }
+
object TestData {
+
+ val host = "localhost"
+ val port = 1965
+ val portStr = port.toString()
+
val conf = ServiceConf(
address = "127.0.0.1",
- port = 1965,
+ port = port,
defaultMimeType = "text/plain",
idleTimeout = 10.seconds,
virtualHosts = List(
VirtualHost(
- host = "localhost",
+ host = host,
root = getClass.getResource("/").getPath()
)
),
@@ -402,6 +588,25 @@ class ServerSpec extends AnyFlatSpec with Matchers {
enabledCipherSuites = Nil
)
+ val cgiConf = ServiceConf.initConf(
+ conf.copy(virtualHosts =
+ List(
+ conf
+ .virtualHosts(0)
+ .copy(
+ directoryListing = true,
+ directories = List(
+ Directory(
+ "dir/",
+ directoryListing = Some(false),
+ allowCgi = Some(true)
+ )
+ )
+ )
+ )
+ )
+ )
+
val confUserDir = conf.copy(virtualHosts =
List(
conf
diff --git a/server/test/src/ServiceConfSpec.scala b/server/test/src/ServiceConfSpec.scala
new file mode 100644
index 0000000..d886b4f
--- /dev/null
+++ b/server/test/src/ServiceConfSpec.scala
@@ -0,0 +1,135 @@
+package net.usebox.gemini.server
+
+import java.nio.file.FileSystems
+
+import scala.concurrent.duration._
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class ServiceConfSpec extends AnyFlatSpec with Matchers {
+
+ def getPath(value: String) =
+ FileSystems.getDefault().getPath(getClass.getResource("/").getPath(), value)
+
+ behavior of "getDirectoryListing"
+
+ it should "resolve directory listing using vhost conf if no directory override" in {
+ val vh = TestData.conf.virtualHosts.head
+ vh.getDirectoryListing(getPath("/dir")) shouldBe vh.directoryListing
+ }
+
+ it should "resolve directory listing using directory override" in {
+ val vh = TestData.conf.virtualHosts.head.copy(
+ directoryListing = false,
+ directories = List(
+ Directory(
+ getPath("dir").toString(),
+ directoryListing = Some(true),
+ None
+ )
+ )
+ )
+ vh.getDirectoryListing(getPath("dir")) shouldBe true
+ }
+
+ it should "ignore non matching directories resolving directory listing" in {
+ val vh = TestData.conf.virtualHosts.head.copy(
+ directoryListing = false,
+ directories = List(
+ Directory(
+ getPath("no-match").toString(),
+ directoryListing = Some(true),
+ allowCgi = None
+ )
+ )
+ )
+ vh.getDirectoryListing(getPath("dir")) shouldBe false
+ }
+
+ behavior of "getCgi"
+
+ it should "return None as allow CGI is off by default" in {
+ val vh = TestData.conf.virtualHosts.head
+ vh.getCgi(getPath("dir/cgi")) shouldBe None
+ }
+
+ it should "set allow CGI via directory override" in {
+ List(true, false).foreach { value =>
+ val vh = TestData.conf.virtualHosts.head.copy(
+ directories = List(
+ Directory(
+ getPath("dir").toString(),
+ directoryListing = None,
+ allowCgi = Some(value)
+ )
+ )
+ )
+ vh.getCgi(getPath("dir/cgi")) should matchPattern {
+ case Some(_) if value =>
+ case None if !value =>
+ }
+ }
+ }
+
+ it should "return the CGI path minus path info" in {
+ val vh = TestData.conf.virtualHosts.head.copy(
+ directories = List(
+ Directory(
+ getPath("dir").toString(),
+ directoryListing = None,
+ allowCgi = Some(true)
+ )
+ )
+ )
+ vh.getCgi(getPath("dir/cgi/path/info")) shouldBe Some(
+ getPath("dir/cgi")
+ )
+ }
+
+ it should "not return the CGI path if is exactly the CGI dir" in {
+ val vh = TestData.conf.virtualHosts.head.copy(
+ directories = List(
+ Directory(
+ getPath("dir").toString(),
+ directoryListing = None,
+ allowCgi = Some(true)
+ )
+ )
+ )
+ vh.getCgi(getPath("dir")) shouldBe None
+ vh.getCgi(getPath("dir/")) shouldBe None
+ }
+
+ it should "not return the CGI path if allow CGI is false" in {
+ val vh = TestData.conf.virtualHosts.head.copy(
+ directories = List(
+ Directory(
+ getPath("dir").toString(),
+ directoryListing = None,
+ allowCgi = Some(false)
+ )
+ )
+ )
+ vh.getCgi(getPath("dir/cgi")) shouldBe None
+ vh.getCgi(getPath("dir/cgit/with/path")) shouldBe None
+ }
+
+ 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
+ )
+ }
+}
diff --git a/spacebeans.conf.example b/spacebeans.conf.example
index 96e9d08..9e92d45 100644
--- a/spacebeans.conf.example
+++ b/spacebeans.conf.example
@@ -33,7 +33,7 @@ virtual-hosts = [
// important: directory's path is relative to the root
//
// directories = [
- // { path = "relative/path/", directory-listing = true }
+ // { path = "relative/path/", directory-listing = true, allow-cgi = false }
// ]
// user directory support