Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

20 changed files with 319 additions and 1250 deletions

View File

@ -1,4 +1,4 @@
Copyright (c) 2019-23 Solderpunk <solderpunk@posteo.net>. All rights reserved. Copyright (c) 2019 Solderpunk <solderpunk@sdf.org> . All rights reserved.
Redistribution and use in source and binary forms, with or without modification, Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met: are permitted provided that the following conditions are met:

125
README.md
View File

@ -59,12 +59,6 @@ Molly Brown only has a single dependency beyond the Go standard
library, which is [this TOML parsing library, which is [this TOML parsing
library](https://github.com/BurntSushi/toml). library](https://github.com/BurntSushi/toml).
The OpenBSD implementation also uses the [golang.org/x/sys/unix
package](https://godoc.org/golang.org/x/sys/unix) to provide the
[pledge(2)](https://man.openbsd.org/pledge.2) and
[unveil(2)](https://man.openbsd.org/unveil.2) system calls to provide
additional security features.
## Installation ## Installation
The easiest way for now to install Molly Brown is to use the standard The easiest way for now to install Molly Brown is to use the standard
@ -107,16 +101,6 @@ command line option to tell Molly Brown where to find it.
### Running ### Running
The `molly-brown` executable recognises the following command line
switches:
* `-c`: Used to specify a config file.
* `-C`: Used to specify a directory to chroot to (unix only).
* `-u`: Used to specify the name of an unprivileged user which
Molly Brown should switch to running as if started as
root or run as a setuid executable (unix only).
* `-v`: Print version number and exit.
Molly Brown does not handle details like daemonising itself, changing Molly Brown does not handle details like daemonising itself, changing
the user it runs as, etc. You will need to take care of these tasks the user it runs as, etc. You will need to take care of these tasks
by, e.g. integrating Molly Brown with your operating system's init by, e.g. integrating Molly Brown with your operating system's init
@ -181,23 +165,6 @@ You can start your `mollybrownd` daemon with `rcctl`:
rcctl start mollybrownd rcctl start mollybrownd
``` ```
#### FreeBSD
An example FreeBSD rc script is in
`contrib/init/molly-brown.freebsd.example`.
Copy rc script to `/etc/rc.d/molly`, and add `molly_enable="YES"`
to `/etc/rc.conf` to enable the service.
Make sure the `daemon` user has access to config locations in
`molly.conf` like `CertPath`, `KeyPath`, `DocBase`, etc.
Start `molly` with,
```
service molly start
```
## Configuration Options ## Configuration Options
The following sections detail all the options which can be set in The following sections detail all the options which can be set in
@ -231,14 +198,10 @@ examples of the appropriate syntax.
`/var/gemini/users/gus/` to `/home/gus/public_gemini/` if you want. `/var/gemini/users/gus/` to `/home/gus/public_gemini/` if you want.
* `AccessLog`: Path to access log file (default value `access.log`, * `AccessLog`: Path to access log file (default value `access.log`,
i.e. in the current wrorking directory). Note that all intermediate i.e. in the current wrorking directory). Note that all intermediate
directories must exist, Molly Brown won't create them for you. Set directories must exist, Molly Brown won't create them for you.
to `-` for logging to `stdout`, or to an empty string to disable * `ErrorLog`: Path to error log file (default value `error.log`, i.e.
access logging. in the current wrorking directory). Note that all intermediate
* `ErrorLog`: Path to error log file. If set to an empty string (the directories must exist, Molly Brown won't create them for you.
default), Molly Brown will log errors to stderr (where they are
easily captured by systemd or similar init systems). If set to a
file, note that all intermediate directories must exist, Molly Brown
won't create them for you.
* `GeminiExt`: Files with this extension will be served with a MIME * `GeminiExt`: Files with this extension will be served with a MIME
type of `text/gemini` (default value `gmi`). type of `text/gemini` (default value `gmi`).
* `MimeOverrides`: In this section of the config file, keys are path * `MimeOverrides`: In this section of the config file, keys are path
@ -247,8 +210,6 @@ examples of the appropriate syntax.
will be used instead of one inferred from the filename extension. will be used instead of one inferred from the filename extension.
* `DefaultLang`: If this option is set, it will be served as the * `DefaultLang`: If this option is set, it will be served as the
`lang` parameter of the MIME type for all `text/gemini` content. `lang` parameter of the MIME type for all `text/gemini` content.
* `DefaultEncoding`: If this option is set, it will be served as the
`charset` parameter of the MIME type for all `text/gemini` content.
### Directory listings ### Directory listings
@ -262,15 +223,9 @@ instead of the default "Directory listing" title.
The following options allow users to configure various aspects of the The following options allow users to configure various aspects of the
directory listing: directory listing:
* `DirectoryListing` (boolean): if true, enable directory listing; if false,
return 51 Not found (default value true)
* `DirectorySort`: A string specifying how to sort files in * `DirectorySort`: A string specifying how to sort files in
automatically generated directory listings. Must be one of "Name", automatically generated directory listings. Must be one of "Name",
"Size" or "Time" (default value "Name"). "Size" or "Time" (default value "Name").
* `DirectorySubdirsFirst` (boolean): if true, list subdirectories of
the directory being listed before files. Subdirs and files will be
sorted within their respective categories according to
`DirectorySort` (default value false).
* `DirectoryReverse` (boolean): if true, automatically generated * `DirectoryReverse` (boolean): if true, automatically generated
directory listings will list files in descending order of whatever directory listings will list files in descending order of whatever
`DirectorySort` is set to (default value false). `DirectorySort` is set to (default value false).
@ -311,51 +266,17 @@ a request URL includes components after the path to an executable
executable (e.g. `/var/gemini/cgi-bin/scripty.py`) while the variable executable (e.g. `/var/gemini/cgi-bin/scripty.py`) while the variable
`PATH_INFO` will contain the remainder (e.g. `foo/bar/baz`). `PATH_INFO` will contain the remainder (e.g. `foo/bar/baz`).
Molly Brown itself tries very hard to avoid being tricked into serving It is very important to be aware that programs written in Go are
content that isn't supposed to be served, but it is completely unable *unable* to reliably change their UID once started, due to how
to impose any control over what CGI processes can or can't go after goroutines are implemented on unix systems. As an unavoidable
they are started! Where possible, Molly Brown will use the operating consequence of this, CGI processes started by Molly Brown are run as
system's security features to reduce risk, but it is your the same user as the server process. This means CGI processes
responsibility to understand what it can and cannot do and weigh the necessarily have read and write access to the server logs and to the
risks accordingly: TLS private key. There is no way to work around this. As such you
must be extremely careful about only running trustworthy CGI
When compiled on GNU/Linux with Go version 1.16 or later, or on any applications, ideally only applications you have carefully written
other unix operating system with any version of Go, Molly Brown will yourself. Allowing untrusted users to upload arbitrary executable
use the setuid() system call as follows. When the compiled files into a CGI path is a serious security vulnerability.
`molly-brown` executable has its SETUID bit set, so that it starts
with the privileges of the user who owns the binary, it will change
the effective UID back to the real UID before it begins accepting
network connections. This way, config files, log files and TLS keys
can be set readable by the user who owns the binary, but not readable
by the user who runs the binary. CGI processes will then be unable to
read any of those sensitive files. If the binary is not SETUID but is
run by the superuser/root, then Molly will change its UID to that of
the `nobody` user (or any other user specified with the `-u` option)
before accepting network connections, so CGI processes will again not
be able to read sensitive files. Note that while these measures can
protect Molly's own sensitive files from CGI processes, CGI processes
may still be able to read other sensitive files anywhere else on the
system. Consider chroot()-ing Molly Brown into a small corner of the
filesystem (see discussion of the `-C` option at the start of the
Running section) to reduce this risk.
When compiled on GNU/Linux with Go versions 1.15 or earlier, Molly
Brown is completley unable to reliably change its UID due to the way
early implementations of goroutines interacted with the setuid()
system call. In this situation, Molly Brown will refuse to run as
superuser/root. It will run as any other user, but CGI processes will
necessary run as the same user as the server and so unavoidably will
have access to sensitive files. You should proceed with extreme
caution and only use carefully vetted CGI programs. Consider using
systemd's ability to chroot a non-privileged process at the moment of
startup to at least confine the risk to Molly Brown's sensitive files
and not the entire system's.
Molly Brown will compile on non-unix operating systems and is known to
run on Plan9, for example, but no special security measures are taken
on these non-unix platforms. It is your responsibility to understand
the risks. If you are aware of security measures for these systems
which can be implemented in Go, patches are extremely welcome.
SCGI applications must be started separately (i.e. Molly Brown expects SCGI applications must be started separately (i.e. Molly Brown expects
them to already be running and will not attempt to start them itself), them to already be running and will not attempt to start them itself),
@ -373,8 +294,7 @@ startup, database connection etc. on each request).
single non-separator character and `*` matches a sequence of them - single non-separator character and `*` matches a sequence of them -
if wildcards are used, the path should *not* end in a trailing slash if wildcards are used, the path should *not* end in a trailing slash
- this appears to be a peculiarity of the Go standard library's - this appears to be a peculiarity of the Go standard library's
`filepath.Glob` function. Any non-absolute paths will be resolved `filepath.Glob` function.
relative to `DocBase`.
* `SCGIPaths`: In this section of the config file, keys are URL path * `SCGIPaths`: In this section of the config file, keys are URL path
prefixes and values are filesystem paths to unix domain sockets. prefixes and values are filesystem paths to unix domain sockets.
Any request for a URL whose path begins with one of the specified Any request for a URL whose path begins with one of the specified
@ -384,16 +304,7 @@ startup, database connection etc. on each request).
SCGI applications are responsible for generating their own response SCGI applications are responsible for generating their own response
headers. headers.
### TLS options ### Certificate zones
* `AllowTLS12` (boolean): if true, Molly Brown will accept connections
from clients using TLS version 1.2 or later (1.2 is the bare minimum
allowed by the Gemini spec). If set to false, Molly Brown will
instead require TLS version 1.3 or later - 1.2 to 1.3 was a big
change and drastic simplification of the TLS spec which discarded a
wide range of old and insecure configurations. (default value `true`)
#### Certificate zones
Molly Brown allows you to use client certificates to restrict access Molly Brown allows you to use client certificates to restrict access
to certain resources (which may be static or dynamic). The overall to certain resources (which may be static or dynamic). The overall
@ -439,9 +350,7 @@ other settings in `.molly` files will be ignored:
* `CertificateZones` * `CertificateZones`
* `DefaultLang` * `DefaultLang`
* `DefaultEncoding`
* `DirectorySort` * `DirectorySort`
* `DirectorySubdirsFirst`
* `DirectoryReverse` * `DirectoryReverse`
* `DirectoryTitles` * `DirectoryTitles`
* `GeminiExt` * `GeminiExt`

View File

@ -10,24 +10,24 @@ import (
"time" "time"
) )
func enforceCertificateValidity(clientCerts []*x509.Certificate, conn net.Conn, logEntry *LogEntry) { func enforceCertificateValidity(clientCerts []*x509.Certificate, conn net.Conn, log *LogEntry) {
// This will fail if any of multiple certs are invalid // This will fail if any of multiple certs are invalid
// Maybe we should just require one valid? // Maybe we should just require one valid?
now := time.Now() now := time.Now()
for _, cert := range clientCerts { for _, cert := range clientCerts {
if now.Before(cert.NotBefore) { if now.Before(cert.NotBefore) {
conn.Write([]byte("64 Client certificate not yet valid!\r\n")) conn.Write([]byte("64 Client certificate not yet valid!\r\n"))
logEntry.Status = 64 log.Status = 64
return return
} else if now.After(cert.NotAfter) { } else if now.After(cert.NotAfter) {
conn.Write([]byte("65 Client certificate has expired!\r\n")) conn.Write([]byte("65 Client certificate has expired!\r\n"))
logEntry.Status = 65 log.Status = 65
return return
} }
} }
} }
func handleCertificateZones(URL *url.URL, clientCerts []*x509.Certificate, config UserConfig, conn net.Conn, logEntry *LogEntry) { func handleCertificateZones(URL *url.URL, clientCerts []*x509.Certificate, config Config, conn net.Conn, log *LogEntry) {
authorised := true authorised := true
for zone, allowedFingerprints := range config.CertificateZones { for zone, allowedFingerprints := range config.CertificateZones {
matched, err := regexp.Match(zone, []byte(URL.Path)) matched, err := regexp.Match(zone, []byte(URL.Path))
@ -47,10 +47,10 @@ func handleCertificateZones(URL *url.URL, clientCerts []*x509.Certificate, confi
if !authorised { if !authorised {
if len(clientCerts) > 0 { if len(clientCerts) > 0 {
conn.Write([]byte("61 Provided certificate not authorised for this resource\r\n")) conn.Write([]byte("61 Provided certificate not authorised for this resource\r\n"))
logEntry.Status = 61 log.Status = 61
} else { } else {
conn.Write([]byte("60 A pre-authorised certificate is required to access this resource\r\n")) conn.Write([]byte("60 A pre-authorised certificate is required to access this resource\r\n"))
logEntry.Status = 60 log.Status = 60
} }
return return
} }

272
config.go
View File

@ -2,137 +2,84 @@ package main
import ( import (
"errors" "errors"
"github.com/BurntSushi/toml"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "github.com/BurntSushi/toml"
) )
type SysConfig struct { type Config struct {
Port int Port int
Hostname string Hostname string
CertPath string CertPath string
KeyPath string KeyPath string
AccessLog string DocBase string
ErrorLog string HomeDocBase string
DocBase string GeminiExt string
HomeDocBase string DefaultLang string
CGIPaths []string AccessLog string
SCGIPaths map[string]string ErrorLog string
ReadMollyFiles bool ReadMollyFiles bool
AllowTLS12 bool TempRedirects map[string]string
RateLimitEnable bool PermRedirects map[string]string
RateLimitAverage int MimeOverrides map[string]string
RateLimitSoft int CGIPaths []string
RateLimitHard int SCGIPaths map[string]string
CertificateZones map[string][]string
DirectorySort string
DirectoryReverse bool
DirectoryTitles bool
} }
type UserConfig struct { type MollyFile struct {
GeminiExt string GeminiExt string
DefaultLang string TempRedirects map[string]string
DefaultEncoding string PermRedirects map[string]string
TempRedirects map[string]string MimeOverrides map[string]string
PermRedirects map[string]string CertificateZones map[string][]string
MimeOverrides map[string]string DefaultLang string
CertificateZones map[string][]string DirectorySort string
DirectoryListing bool DirectoryReverse bool
DirectorySort string DirectoryTitles bool
DirectorySubdirsFirst bool
DirectoryReverse bool
DirectoryTitles bool
} }
func getConfig(filename string) (SysConfig, UserConfig, error) { func getConfig(filename string) (Config, error) {
var sysConfig SysConfig var config Config
var userConfig UserConfig
// Defaults // Defaults
sysConfig.Port = 1965 config.Port = 1965
sysConfig.Hostname = "localhost" config.Hostname = "localhost"
sysConfig.CertPath = "cert.pem" config.CertPath = "cert.pem"
sysConfig.KeyPath = "key.pem" config.KeyPath = "key.pem"
sysConfig.AccessLog = "access.log" config.DocBase = "/var/gemini/"
sysConfig.ErrorLog = "" config.HomeDocBase = "users"
sysConfig.DocBase = "/var/gemini/" config.GeminiExt = "gmi"
sysConfig.HomeDocBase = "users" config.DefaultLang = ""
sysConfig.CGIPaths = make([]string, 0) config.AccessLog = "access.log"
sysConfig.SCGIPaths = make(map[string]string) config.ErrorLog = "error.log"
sysConfig.ReadMollyFiles = false config.TempRedirects = make(map[string]string)
sysConfig.AllowTLS12 = true config.PermRedirects = make(map[string]string)
sysConfig.RateLimitEnable = false config.CGIPaths = make([]string, 0)
sysConfig.RateLimitAverage = 1 config.SCGIPaths = make(map[string]string)
sysConfig.RateLimitSoft = 10 config.DirectorySort = "Name"
sysConfig.RateLimitHard = 50
userConfig.GeminiExt = "gmi"
userConfig.DefaultLang = ""
userConfig.DefaultEncoding = ""
userConfig.TempRedirects = make(map[string]string)
userConfig.PermRedirects = make(map[string]string)
userConfig.DirectoryListing = true
userConfig.DirectorySort = "Name"
userConfig.DirectorySubdirsFirst = false
// Return defaults if no filename given // Return defaults if no filename given
if filename == "" { if filename == "" {
return sysConfig, userConfig, nil return config, nil
} }
// Attempt to overwrite defaults from file // Attempt to overwrite defaults from file
sysConfig, err := readSysConfig(filename, sysConfig)
if err != nil {
return sysConfig, userConfig, err
}
userConfig, err = readUserConfig(filename, userConfig, true)
if err != nil {
return sysConfig, userConfig, err
}
return sysConfig, userConfig, nil
}
func readSysConfig(filename string, config SysConfig) (SysConfig, error) {
_, err := toml.DecodeFile(filename, &config) _, err := toml.DecodeFile(filename, &config)
if err != nil { if err != nil {
return config, err return config, err
} }
// Force hostname to lowercase // Validate pseudo-enums
config.Hostname = strings.ToLower(config.Hostname) switch config.DirectorySort {
case "Name", "Size", "Time":
// Absolutise paths default:
config.DocBase, err = filepath.Abs(config.DocBase) return config, errors.New("Invalid DirectorySort value.")
if err != nil {
return config, err
}
config.CertPath, err = filepath.Abs(config.CertPath)
if err != nil {
return config, err
}
config.KeyPath, err = filepath.Abs(config.KeyPath)
if err != nil {
return config, err
}
if config.AccessLog != "" && config.AccessLog != "-" {
config.AccessLog, err = filepath.Abs(config.AccessLog)
if err != nil {
return config, err
}
}
if config.ErrorLog != "" {
config.ErrorLog, err = filepath.Abs(config.ErrorLog)
if err != nil {
return config, err
}
}
// Absolutise CGI paths
for index, cgiPath := range config.CGIPaths {
if !filepath.IsAbs(cgiPath) {
config.CGIPaths[index] = filepath.Join(config.DocBase, cgiPath)
}
} }
// Expand CGI paths // Expand CGI paths
@ -146,71 +93,44 @@ func readSysConfig(filename string, config SysConfig) (SysConfig, error) {
} }
config.CGIPaths = cgiPaths config.CGIPaths = cgiPaths
// Absolutise SCGI paths
for index, scgiPath := range config.SCGIPaths {
config.SCGIPaths[index], err = filepath.Abs( scgiPath)
if err != nil {
return config, err
}
}
return config, nil return config, nil
} }
func readUserConfig(filename string, config UserConfig, requireValid bool) (UserConfig, error) { func parseMollyFiles(path string, config *Config, errorLog *log.Logger) {
_, err := toml.DecodeFile(filename, &config)
if err != nil {
return config, err
}
// Validate pseudo-enums
if requireValid {
switch config.DirectorySort {
case "Name", "Size", "Time":
default:
return config, errors.New("Invalid DirectorySort value.")
}
}
// Validate redirects
for key, value := range config.TempRedirects {
if strings.Contains(value, "://") && !strings.HasPrefix(value, "gemini://") {
if requireValid {
return config, errors.New("Invalid cross-protocol redirect to " + value)
} else {
log.Println("Ignoring cross-protocol redirect to " + value + " in .molly file " + filename)
delete(config.TempRedirects, key)
}
}
}
for key, value := range config.PermRedirects {
if strings.Contains(value, "://") && !strings.HasPrefix(value, "gemini://") {
if requireValid {
return config, errors.New("Invalid cross-protocol redirect to " + value)
} else {
log.Println("Ignoring cross-protocol redirect to " + value + " in .molly file " + filename)
delete(config.PermRedirects, key)
}
}
}
return config, nil
}
func parseMollyFiles(path string, docBase string, config UserConfig) UserConfig {
// Replace config variables which use pointers with new ones, // Replace config variables which use pointers with new ones,
// so that changes made here aren't reflected everywhere. // so that changes made here aren't reflected everywhere.
config.TempRedirects = make(map[string]string) newTempRedirects := make(map[string]string)
config.PermRedirects = make(map[string]string) for key, value := range config.TempRedirects {
config.MimeOverrides = make(map[string]string) newTempRedirects[key] = value
config.CertificateZones = make(map[string][]string) }
config.TempRedirects = newTempRedirects
newPermRedirects := make(map[string]string)
for key, value := range config.PermRedirects {
newPermRedirects[key] = value
}
config.PermRedirects = newPermRedirects
newMimeOverrides := make(map[string]string)
for key, value := range config.MimeOverrides {
newMimeOverrides[key] = value
}
config.MimeOverrides = newMimeOverrides
newCertificateZones := make(map[string][]string)
for key, value := range config.CertificateZones {
newCertificateZones[key] = value
}
config.CertificateZones = newCertificateZones
// Initialise MollyFile using main Config
var mollyFile MollyFile
mollyFile.GeminiExt = config.GeminiExt
mollyFile.DefaultLang = config.DefaultLang
mollyFile.DirectorySort = config.DirectorySort
mollyFile.DirectoryReverse = config.DirectoryReverse
mollyFile.DirectoryTitles = config.DirectoryTitles
// Build list of directories to check // Build list of directories to check
var dirs []string var dirs []string
dirs = append(dirs, path) dirs = append(dirs, path)
for { for {
if path == filepath.Clean(docBase) { if path == filepath.Clean(config.DocBase) {
break break
} }
subpath := filepath.Dir(path) subpath := filepath.Dir(path)
@ -232,12 +152,28 @@ func parseMollyFiles(path string, docBase string, config UserConfig) UserConfig
continue continue
} }
// If the file exists and we can read it, try to parse it // If the file exists and we can read it, try to parse it
config, err = readUserConfig(mollyPath, config, false) _, err = toml.DecodeFile(mollyPath, &mollyFile)
if err != nil { if err != nil {
log.Println("Error parsing .molly file " + mollyPath + ": " + err.Error()) errorLog.Println("Error parsing .molly file " + mollyPath + ": " + err.Error())
continue continue
} }
// Overwrite main Config using MollyFile
config.GeminiExt = mollyFile.GeminiExt
config.DefaultLang = mollyFile.DefaultLang
config.DirectorySort = mollyFile.DirectorySort
config.DirectoryReverse = mollyFile.DirectoryReverse
config.DirectoryTitles = mollyFile.DirectoryTitles
for key, value := range mollyFile.TempRedirects {
config.TempRedirects[key] = value
}
for key, value := range mollyFile.PermRedirects {
config.PermRedirects[key] = value
}
for key, value := range mollyFile.MimeOverrides {
config.MimeOverrides[key] = value
}
for key, value := range mollyFile.CertificateZones {
config.CertificateZones[key] = value
}
} }
return config
} }

View File

@ -1,47 +0,0 @@
#!/bin/sh
#
# $FreeBSD$
#
# PROVIDE: molly
# REQUIRE: networking
# KEYWORD: shutdown
. /etc/rc.subr
name="molly"
desc="Gemini Protocol daemon"
rcvar="molly_enable"
command="/usr/local/sbin/molly-brown"
command_args="-c /etc/molly.conf"
molly_brown_user="daemon"
pidfile="/var/run/${name}.pid"
required_files="/etc/molly.conf"
start_cmd="molly_start"
stop_cmd="molly_stop"
status_cmd="molly_status"
molly_start() {
/usr/sbin/daemon -P ${pidfile} -r -f -u $molly_brown_user $command
}
molly_stop() {
if [ -e "${pidfile}" ]; then
kill -s TERM `cat ${pidfile}`
else
echo "${name} is not running"
fi
}
molly_status() {
if [ -e "${pidfile}" ]; then
echo "${name} is running as pid `cat ${pidfile}`"
else
echo "${name} is not running"
fi
}
load_rc_config $name
run_rc_command "$1"

View File

@ -11,7 +11,7 @@ import (
"strings" "strings"
) )
func generateDirectoryListing(URL *url.URL, path string, config UserConfig) (string, error) { func generateDirectoryListing(URL *url.URL, path string, config Config) (string, error) {
var listing string var listing string
files, err := ioutil.ReadDir(path) files, err := ioutil.ReadDir(path)
if err != nil { if err != nil {
@ -36,7 +36,7 @@ func generateDirectoryListing(URL *url.URL, path string, config UserConfig) (str
up := filepath.Dir(URL.Path) up := filepath.Dir(URL.Path)
listing += fmt.Sprintf("=> %s %s\n", up, "..") listing += fmt.Sprintf("=> %s %s\n", up, "..")
} }
// Sort files by criteria first // Sort files
sort.SliceStable(files, func(i, j int) bool { sort.SliceStable(files, func(i, j int) bool {
if config.DirectoryReverse { if config.DirectoryReverse {
i, j = j, i i, j = j, i
@ -50,17 +50,6 @@ func generateDirectoryListing(URL *url.URL, path string, config UserConfig) (str
} }
return false // Should not happen return false // Should not happen
}) })
// Sort directories before file
if config.DirectorySubdirsFirst {
sort.SliceStable(files, func(i, j int) bool {
// If i is a dir and j is a file, i < j
if files[i].IsDir() && !files[j].IsDir() {
return true
} else {
return false
}
})
}
// Format lines // Format lines
for _, file := range files { for _, file := range files {
// Skip dotfiles // Skip dotfiles
@ -82,7 +71,7 @@ func generateDirectoryListing(URL *url.URL, path string, config UserConfig) (str
return listing, nil return listing, nil
} }
func generatePrettyFileLabel(info os.FileInfo, path string, config UserConfig) string { func generatePrettyFileLabel(info os.FileInfo, path string, config Config) string {
var size string var size string
if info.IsDir() { if info.IsDir() {
size = " " size = " "

View File

@ -15,7 +15,7 @@ import (
"time" "time"
) )
func handleCGI(config SysConfig, path string, cgiPath string, URL *url.URL, logEntry *LogEntry, conn net.Conn) { func handleCGI(config Config, path string, cgiPath string, URL *url.URL, log *LogEntry, errorLog *log.Logger, conn net.Conn) {
// Find the shortest leading part of path which maps to an executable file. // Find the shortest leading part of path which maps to an executable file.
// Call this part scriptPath, and everything after it pathInfo. // Call this part scriptPath, and everything after it pathInfo.
components := strings.Split(path, "/") components := strings.Split(path, "/")
@ -58,50 +58,42 @@ func handleCGI(config SysConfig, path string, cgiPath string, URL *url.URL, logE
response, err := cmd.Output() response, err := cmd.Output()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
log.Println("Terminating CGI process " + path + " due to exceeding 10 second runtime limit.") errorLog.Println("Terminating CGI process " + path + " due to exceeding 10 second runtime limit.")
conn.Write([]byte("42 CGI process timed out!\r\n")) conn.Write([]byte("42 CGI process timed out!\r\n"))
logEntry.Status = 42 log.Status = 42
return return
} }
if err != nil { if err != nil {
log.Println("Error running CGI program " + path + ": " + err.Error()) errorLog.Println("Error running CGI program " + path + ": " + err.Error())
if err, ok := err.(*exec.ExitError); ok { if err, ok := err.(*exec.ExitError); ok {
log.Println("↳ stderr output: " + string(err.Stderr)) errorLog.Println("↳ stderr output: " + string(err.Stderr))
} }
conn.Write([]byte("42 CGI error!\r\n")) conn.Write([]byte("42 CGI error!\r\n"))
logEntry.Status = 42 log.Status = 42
return return
} }
// Extract response header // Extract response header
responseString := string(response) header, _, err := bufio.NewReader(strings.NewReader(string(response))).ReadLine()
if len(responseString) == 0 { status, err2 := strconv.Atoi(strings.Fields(string(header))[0])
log.Println("Received no response from CGI process " + path) if err != nil || err2 != nil {
errorLog.Println("Unable to parse first line of output from CGI process " + path + " as valid Gemini response header. Line was: " + string(header))
conn.Write([]byte("42 CGI error!\r\n")) conn.Write([]byte("42 CGI error!\r\n"))
logEntry.Status = 42 log.Status = 42
return return
} }
header, _, _ := bufio.NewReader(strings.NewReader(string(response))).ReadLine() log.Status = status
status, err := strconv.Atoi(strings.Fields(string(header))[0])
if err != nil {
log.Println("Unable to parse first line of output from CGI process " + path + " as valid Gemini response header. Line was: " + string(header))
conn.Write([]byte("42 CGI error!\r\n"))
logEntry.Status = 42
return
}
logEntry.Status = status
// Write response // Write response
conn.Write(response) conn.Write(response)
} }
func handleSCGI(URL *url.URL, scgiPath string, scgiSocket string, config SysConfig, logEntry *LogEntry, conn net.Conn) { func handleSCGI(URL *url.URL, scgiPath string, scgiSocket string, config Config, log *LogEntry, errorLog *log.Logger, conn net.Conn) {
// Connect to socket // Connect to socket
socket, err := net.Dial("unix", scgiSocket) socket, err := net.Dial("unix", scgiSocket)
if err != nil { if err != nil {
log.Println("Error connecting to SCGI socket " + scgiSocket + ": " + err.Error()) errorLog.Println("Error connecting to SCGI socket " + scgiSocket + ": " + err.Error())
conn.Write([]byte("42 Error connecting to SCGI service!\r\n")) conn.Write([]byte("42 Error connecting to SCGI service!\r\n"))
logEntry.Status = 42 log.Status = 42
return return
} }
defer socket.Close() defer socket.Close()
@ -131,9 +123,9 @@ func handleSCGI(URL *url.URL, scgiPath string, scgiSocket string, config SysConf
break break
} else if !first { } else if !first {
// Err // Err
log.Println("Error reading from SCGI socket " + scgiSocket + ": " + err.Error()) errorLog.Println("Error reading from SCGI socket " + scgiSocket + ": " + err.Error())
conn.Write([]byte("42 Error reading from SCGI service!\r\n")) conn.Write([]byte("42 Error reading from SCGI service!\r\n"))
logEntry.Status = 42 log.Status = 42
return return
} else { } else {
break break
@ -146,17 +138,17 @@ func handleSCGI(URL *url.URL, scgiPath string, scgiSocket string, config SysConf
status, err := strconv.Atoi(strings.Fields(lines[0])[0]) status, err := strconv.Atoi(strings.Fields(lines[0])[0])
if err != nil { if err != nil {
conn.Write([]byte("42 CGI error!\r\n")) conn.Write([]byte("42 CGI error!\r\n"))
logEntry.Status = 42 log.Status = 42
return return
} }
logEntry.Status = status log.Status = status
} }
// Send to client // Send to client
conn.Write(buffer[:n]) conn.Write(buffer[:n])
} }
} }
func prepareCGIVariables(config SysConfig, URL *url.URL, conn net.Conn, script_path string, path_info string) map[string]string { func prepareCGIVariables(config Config, URL *url.URL, conn net.Conn, script_path string, path_info string) map[string]string {
vars := prepareGatewayVariables(config, URL, conn) vars := prepareGatewayVariables(config, URL, conn)
vars["GATEWAY_INTERFACE"] = "CGI/1.1" vars["GATEWAY_INTERFACE"] = "CGI/1.1"
vars["SCRIPT_PATH"] = script_path vars["SCRIPT_PATH"] = script_path
@ -164,7 +156,7 @@ func prepareCGIVariables(config SysConfig, URL *url.URL, conn net.Conn, script_p
return vars return vars
} }
func prepareSCGIVariables(config SysConfig, URL *url.URL, scgiPath string, conn net.Conn) map[string]string { func prepareSCGIVariables(config Config, URL *url.URL, scgiPath string, conn net.Conn) map[string]string {
vars := prepareGatewayVariables(config, URL, conn) vars := prepareGatewayVariables(config, URL, conn)
vars["SCGI"] = "1" vars["SCGI"] = "1"
vars["CONTENT_LENGTH"] = "0" vars["CONTENT_LENGTH"] = "0"
@ -173,7 +165,7 @@ func prepareSCGIVariables(config SysConfig, URL *url.URL, scgiPath string, conn
return vars return vars
} }
func prepareGatewayVariables(config SysConfig, URL *url.URL, conn net.Conn) map[string]string { func prepareGatewayVariables(config Config, URL *url.URL, conn net.Conn) map[string]string {
vars := make(map[string]string) vars := make(map[string]string)
vars["QUERY_STRING"] = URL.RawQuery vars["QUERY_STRING"] = URL.RawQuery
vars["REQUEST_METHOD"] = "" vars["REQUEST_METHOD"] = ""
@ -199,8 +191,6 @@ func prepareGatewayVariables(config SysConfig, URL *url.URL, conn net.Conn) map[
vars["TLS_CLIENT_ISSUER_CN"] = cert.Issuer.CommonName vars["TLS_CLIENT_ISSUER_CN"] = cert.Issuer.CommonName
vars["TLS_CLIENT_SUBJECT"] = cert.Subject.String() vars["TLS_CLIENT_SUBJECT"] = cert.Subject.String()
vars["TLS_CLIENT_SUBJECT_CN"] = cert.Subject.CommonName vars["TLS_CLIENT_SUBJECT_CN"] = cert.Subject.CommonName
// To make it easier to detect when a cert is present
vars["AUTH_TYPE"] = "Certificate"
} }
return vars return vars
} }

View File

@ -14,9 +14,7 @@
# #
## Directory listing ## Directory listing
# #
#DirectoryListing = true
#DirectorySort = "Time" #DirectorySort = "Time"
#DirectorySubdirsFirst = false
#DirectoryReverse = true #DirectoryReverse = true
#DirectoryTitles = true #DirectoryTitles = true
# #

8
go.mod
View File

@ -1,8 +0,0 @@
module tildegit.org/solderpunk/molly-brown
go 1.15
require (
github.com/BurntSushi/toml v1.2.1 // indirect
golang.org/x/sys v0.5.0 // indirect
)

4
go.sum
View File

@ -1,4 +0,0 @@
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -9,251 +9,153 @@ import (
"log" "log"
"mime" "mime"
"net" "net"
"net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
// Utility function below borrowed from func handleGeminiRequest(conn net.Conn, config Config, accessLogEntries chan LogEntry, errorLog *log.Logger) {
// https://stackoverflow.com/questions/28024731/check-if-given-path-is-a-subdirectory-of-another-in-golang
func isSubdir(subdir, superdir string) (bool, error) {
up := ".." + string(os.PathSeparator)
// path-comparisons using filepath.Abs don't work reliably according to docs (no unique representation).
rel, err := filepath.Rel(superdir, subdir)
if err != nil {
return false, err
}
if !strings.HasPrefix(rel, up) && rel != ".." {
return true, nil
}
return false, nil
}
func handleGeminiRequest(conn net.Conn, sysConfig SysConfig, config UserConfig, accessLogEntries chan LogEntry, rl *RateLimiter, wg *sync.WaitGroup) {
defer conn.Close() defer conn.Close()
defer wg.Done()
var tlsConn (*tls.Conn) = conn.(*tls.Conn) var tlsConn (*tls.Conn) = conn.(*tls.Conn)
var logEntry LogEntry var log LogEntry
logEntry.Time = time.Now() log.Time = time.Now()
logEntry.RemoteAddr = conn.RemoteAddr() log.RemoteAddr = conn.RemoteAddr()
logEntry.RequestURL = "-" log.RequestURL = "-"
logEntry.Status = 0 log.Status = 0
if accessLogEntries != nil { defer func() { accessLogEntries <- log }()
defer func() { accessLogEntries <- logEntry }()
}
// Enforce rate limiting
if sysConfig.RateLimitEnable {
noPort := logEntry.RemoteAddr.String()
noPort = noPort[0:strings.LastIndex(noPort, ":")]
limited := rl.hardLimited(noPort)
if limited {
conn.Close()
return
}
delay, limited := rl.softLimited(noPort)
if limited {
conn.Write([]byte("44 " + strconv.Itoa(delay) + " second cool down, please!\r\n"))
logEntry.Status = 44
return
}
}
// Read request // Read request
URL, err := readRequest(conn, &logEntry) URL, err := readRequest(conn, &log, errorLog)
if err != nil { if err != nil {
return return
} }
// Enforce client certificate validity // Enforce client certificate validity
clientCerts := tlsConn.ConnectionState().PeerCertificates clientCerts := tlsConn.ConnectionState().PeerCertificates
enforceCertificateValidity(clientCerts, conn, &logEntry) enforceCertificateValidity(clientCerts, conn, &log)
if logEntry.Status != 0 { if log.Status != 0 {
return return
} }
// Reject non-gemini schemes // Reject non-gemini schemes
if URL.Scheme != "gemini" { if URL.Scheme != "gemini" {
conn.Write([]byte("53 No proxying to non-Gemini content!\r\n")) conn.Write([]byte("53 No proxying to non-Gemini content!\r\n"))
logEntry.Status = 53 log.Status = 53
return return
} }
// Reject requests for content from other servers // Reject requests for content from other servers
requestedHost := strings.ToLower(URL.Hostname()) if URL.Hostname() != config.Hostname || (URL.Port() != "" && URL.Port() != strconv.Itoa(config.Port)) {
// Trim trailing . from FQDNs
if strings.HasSuffix(requestedHost, ".") {
requestedHost = requestedHost[:len(requestedHost)-1]
}
if requestedHost != sysConfig.Hostname || (URL.Port() != "" && URL.Port() != strconv.Itoa(sysConfig.Port)) {
conn.Write([]byte("53 No proxying to other hosts or ports!\r\n")) conn.Write([]byte("53 No proxying to other hosts or ports!\r\n"))
logEntry.Status = 53 log.Status = 53
return return
} }
// Fail if there are dots in the path // Fail if there are dots in the path
if strings.Contains(URL.Path, "..") { if strings.Contains(URL.Path, "..") {
conn.Write([]byte("50 Your directory traversal technique has been defeated!\r\n")) conn.Write([]byte("50 Your directory traversal technique has been defeated!\r\n"))
logEntry.Status = 50 log.Status = 50
return
}
// Check whether this URL is in a certificate zone
handleCertificateZones(URL, clientCerts, config, conn, &logEntry)
if logEntry.Status != 0 {
return
}
// Check for redirects
handleRedirects(URL, config, conn, &logEntry)
if logEntry.Status != 0 {
return return
} }
// Resolve URI path to actual filesystem path // Resolve URI path to actual filesystem path
path := resolvePath(URL.Path, sysConfig) path := resolvePath(URL.Path, config)
// Read Molly files. Yes, even before checking if `path` exists! // Paranoid security measures:
// /foo/bar/baz.gmi may not exist on the disk but /foo/.molly may and it // Fail ASAP if the URL has mapped to a sensitive file
// may inform us that /foo/bar/baz.gmi ought to redirect to somewhere which if path == config.CertPath || path == config.KeyPath || path == config.AccessLog || path == config.ErrorLog || filepath.Base(path) == ".molly" {
// *does* exist on disk! conn.Write([]byte("51 Not found!\r\n"))
if sysConfig.ReadMollyFiles { log.Status = 51
config = parseMollyFiles(path, sysConfig.DocBase, config) return
// We may have picked up new cert zones and/or redirects above, so: }
handleCertificateZones(URL, clientCerts, config, conn, &logEntry)
if logEntry.Status != 0 { // Read Molly files
return if config.ReadMollyFiles {
} parseMollyFiles(path, &config, errorLog)
handleRedirects(URL, config, conn, &logEntry) }
if logEntry.Status != 0 {
// Check whether this URL is in a certificate zone
handleCertificateZones(URL, clientCerts, config, conn, &log)
if log.Status != 0 {
return
}
// Check for redirects
handleRedirects(URL, config, conn, &log, errorLog)
if log.Status != 0 {
return
}
// Check whether this URL is mapped to an SCGI app
for scgiPath, scgiSocket := range config.SCGIPaths {
if strings.HasPrefix(URL.Path, scgiPath) {
handleSCGI(URL, scgiPath, scgiSocket, config, &log, errorLog, conn)
return return
} }
} }
// Check whether this URL is in a configured CGI path // Check whether this URL is in a configured CGI path
for _, cgiPath := range sysConfig.CGIPaths { for _, cgiPath := range config.CGIPaths {
if strings.HasPrefix(path, cgiPath) { if strings.HasPrefix(path, cgiPath) {
handleCGI(sysConfig, path, cgiPath, URL, &logEntry, conn) handleCGI(config, path, cgiPath, URL, &log, errorLog, conn)
if logEntry.Status != 0 { if log.Status != 0 {
return return
} }
} }
} }
// Check whether this URL is mapped to an SCGI app // Fail if file does not exist or perms aren't right
for scgiPath, scgiSocket := range sysConfig.SCGIPaths { info, err := os.Stat(path)
if strings.HasPrefix(URL.Path, scgiPath) { if os.IsNotExist(err) || os.IsPermission(err) {
handleSCGI(URL, scgiPath, scgiSocket, sysConfig, &logEntry, conn)
return
}
}
// Okay, at this point we really are committed to looking on disk for `path`.
// Make sure it exists, and is world readable, and if it's a symbolic link,
// follow it and check these things again!
rawPath := path
var info os.FileInfo
for {
info, err = os.Stat(path)
if os.IsNotExist(err) || os.IsPermission(err) {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
} else if err != nil {
log.Println("Error getting info for file " + path + ": " + err.Error())
conn.Write([]byte("40 Temporary failure!\r\n"))
logEntry.Status = 40
return
} else if uint64(info.Mode().Perm())&0444 != 0444 {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
}
newPath, err := filepath.EvalSymlinks(path)
if err!= nil {
log.Println("Error evaluating path " + path + " for symlinks: " + err.Error())
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
}
if newPath == path {
break
}
path = newPath
}
// If symbolic links have been used to escape the intended document directory,
// deny all knowledge
isSub, err := isSubdir(path, sysConfig.DocBase)
if err != nil {
log.Println("Error testing whether path " + path + " is below DocBase: " + err.Error())
}
if !isSub {
log.Println("Refusing to follow symlink from " + rawPath + " outside of DocBase!")
}
if err != nil || !isSub {
conn.Write([]byte("51 Not found!\r\n")) conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51 log.Status = 51
return
} else if err != nil {
errorLog.Println("Error getting info for file " + path + ": " + err.Error())
conn.Write([]byte("40 Temporary failure!\r\n"))
log.Status = 40
return
} else if uint64(info.Mode().Perm())&0444 != 0444 {
conn.Write([]byte("51 Not found!\r\n"))
log.Status = 51
return return
} }
// Refuse to serve sensitive files even if they are inside DocBase and // Finally, serve the file or directory
// world-readable because if they are it's likely a mistake
if path == sysConfig.KeyPath || path == sysConfig.AccessLog || path == sysConfig.ErrorLog || filepath.Base(path) == ".molly" {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
}
// Finally, serve a simple static file or directory
if info.IsDir() { if info.IsDir() {
serveDirectory(URL, path, &logEntry, conn, config) serveDirectory(URL, path, &log, conn, config, errorLog)
} else { } else {
serveFile(path, info, &logEntry, conn, config) serveFile(path, &log, conn, config, errorLog)
} }
} }
func readRequest(conn net.Conn, logEntry *LogEntry) (*url.URL, error) { func readRequest(conn net.Conn, log *LogEntry, errorLog *log.Logger) (*url.URL, error) {
err := conn.SetReadDeadline(time.Now().Add(30 * time.Second))
if err != nil {
log.Println("Error setting read deadline: " + err.Error())
return nil, err
}
reader := bufio.NewReaderSize(conn, 1024) reader := bufio.NewReaderSize(conn, 1024)
request, overflow, err := reader.ReadLine() request, overflow, err := reader.ReadLine()
if overflow { if overflow {
conn.Write([]byte("59 Request too long!\r\n")) conn.Write([]byte("59 Request too long!\r\n"))
logEntry.Status = 59 log.Status = 59
return nil, errors.New("Request too long") return nil, errors.New("Request too long")
} else if err != nil { } else if err != nil {
if errors.Is(err, os.ErrDeadlineExceeded) { errorLog.Println("Error reading request from " + conn.RemoteAddr().String() + ": " + err.Error())
conn.Write([]byte("40 Request timed out!\r\n")) conn.Write([]byte("40 Unknown error reading request!\r\n"))
} else { log.Status = 40
log.Println("Error reading request from " + conn.RemoteAddr().String() + ": " + err.Error()) return nil, errors.New("Error reading request")
conn.Write([]byte("40 Unknown error reading request!\r\n"))
}
logEntry.Status = 40
return nil, err
} }
// Parse request as URL // Parse request as URL
URL, err := url.Parse(string(request)) URL, err := url.Parse(string(request))
if err != nil { if err != nil {
log.Println("Error parsing request URL " + string(request) + ": " + err.Error()) errorLog.Println("Error parsing request URL " + string(request) + ": " + err.Error())
conn.Write([]byte("59 Error parsing URL!\r\n")) conn.Write([]byte("59 Error parsing URL!\r\n"))
logEntry.Status = 59 log.Status = 59
return nil, errors.New("Bad URL in request") return nil, errors.New("Bad URL in request")
} }
logEntry.RequestURL = URL.String() log.RequestURL = URL.String()
// Set implicit scheme // Set implicit scheme
if URL.Scheme == "" { if URL.Scheme == "" {
@ -263,7 +165,7 @@ func readRequest(conn net.Conn, logEntry *LogEntry) (*url.URL, error) {
return URL, nil return URL, nil
} }
func resolvePath(path string, config SysConfig) string { func resolvePath(path string, config Config) string {
// Handle tildes // Handle tildes
if strings.HasPrefix(path, "/~") { if strings.HasPrefix(path, "/~") {
bits := strings.Split(path, "/") bits := strings.Split(path, "/")
@ -277,65 +179,57 @@ func resolvePath(path string, config SysConfig) string {
return path return path
} }
func handleRedirects(URL *url.URL, config UserConfig, conn net.Conn, logEntry *LogEntry) { func handleRedirects(URL *url.URL, config Config, conn net.Conn, log *LogEntry, errorLog *log.Logger) {
handleRedirectsInner(URL, config.TempRedirects, 30, conn, logEntry) handleRedirectsInner(URL, config.TempRedirects, 30, conn, log, errorLog)
handleRedirectsInner(URL, config.PermRedirects, 31, conn, logEntry) handleRedirectsInner(URL, config.PermRedirects, 31, conn, log, errorLog)
} }
func handleRedirectsInner(URL *url.URL, redirects map[string]string, status int, conn net.Conn, logEntry *LogEntry) { func handleRedirectsInner(URL *url.URL, redirects map[string]string, status int, conn net.Conn, log *LogEntry, errorLog *log.Logger) {
strStatus := strconv.Itoa(status) strStatus := strconv.Itoa(status)
for src, dst := range redirects { for src, dst := range redirects {
compiled, err := regexp.Compile(src) compiled, err := regexp.Compile(src)
if err != nil { if err != nil {
log.Println("Error compiling redirect regexp " + src + ": " + err.Error()) errorLog.Println("Error compiling redirect regexp " + src + ": " + err.Error())
continue continue
} }
if compiled.MatchString(URL.Path) { if compiled.MatchString(URL.Path) {
new_target := compiled.ReplaceAllString(URL.Path, dst) URL.Path = compiled.ReplaceAllString(URL.Path, dst)
if !strings.HasPrefix(new_target, "gemini://") { conn.Write([]byte(strStatus + " " + URL.String() + "\r\n"))
URL.Path = new_target log.Status = status
new_target = URL.String()
}
conn.Write([]byte(strStatus + " " + new_target + "\r\n"))
logEntry.Status = status
return return
} }
} }
} }
func serveDirectory(URL *url.URL, path string, logEntry *LogEntry, conn net.Conn, config UserConfig) { func serveDirectory(URL *url.URL, path string, log *LogEntry, conn net.Conn, config Config, errorLog *log.Logger) {
// Redirect to add trailing slash if missing // Redirect to add trailing slash if missing
// (otherwise relative links don't work properly) // (otherwise relative links don't work properly)
if !strings.HasSuffix(URL.Path, "/") { if !strings.HasSuffix(URL.Path, "/") {
URL.Path += "/" conn.Write([]byte(fmt.Sprintf("31 %s\r\n", URL.String()+"/")))
conn.Write([]byte(fmt.Sprintf("31 %s\r\n", URL.String()))) log.Status = 31
logEntry.Status = 31
return return
} }
// Check for index.gmi if path is a directory // Check for index.gmi if path is a directory
index_path := filepath.Join(path, "index."+config.GeminiExt) index_path := filepath.Join(path, "index."+config.GeminiExt)
index_info, err := os.Stat(index_path) index_info, err := os.Stat(index_path)
if err == nil && uint64(index_info.Mode().Perm())&0444 == 0444 { if err == nil && uint64(index_info.Mode().Perm())&0444 == 0444 {
serveFile(index_path, index_info, logEntry, conn, config) serveFile(index_path, log, conn, config, errorLog)
// Serve a generated listing // Serve a generated listing
} else if config.DirectoryListing { } else {
listing, err := generateDirectoryListing(URL, path, config) listing, err := generateDirectoryListing(URL, path, config)
if err != nil { if err != nil {
log.Println("Error generating listing for directory " + path + ": " + err.Error()) errorLog.Println("Error generating listing for directory " + path + ": " + err.Error())
conn.Write([]byte("40 Server error!\r\n")) conn.Write([]byte("40 Server error!\r\n"))
logEntry.Status = 40 log.Status = 40
return return
} }
conn.Write([]byte("20 text/gemini\r\n")) conn.Write([]byte("20 text/gemini\r\n"))
logEntry.Status = 20 log.Status = 20
conn.Write([]byte(listing)) conn.Write([]byte(listing))
} else {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
} }
} }
func serveFile(path string, info os.FileInfo, logEntry *LogEntry, conn net.Conn, config UserConfig) { func serveFile(path string, log *LogEntry, conn net.Conn, config Config, errorLog *log.Logger) {
// Get MIME type of files // Get MIME type of files
ext := filepath.Ext(path) ext := filepath.Ext(path)
var mimeType string var mimeType string
@ -344,7 +238,6 @@ func serveFile(path string, info os.FileInfo, logEntry *LogEntry, conn net.Conn,
} else { } else {
mimeType = mime.TypeByExtension(ext) mimeType = mime.TypeByExtension(ext)
} }
// Override extension-based MIME type // Override extension-based MIME type
for pathRegex, newType := range config.MimeOverrides { for pathRegex, newType := range config.MimeOverrides {
overridden, err := regexp.Match(pathRegex, []byte(path)) overridden, err := regexp.Match(pathRegex, []byte(path))
@ -352,79 +245,25 @@ func serveFile(path string, info os.FileInfo, logEntry *LogEntry, conn net.Conn,
mimeType = newType mimeType = newType
} }
} }
// Set a generic MIME type if the extension wasn't recognised
if mimeType == "" {
mimeType = "application/octet-stream"
}
// Add lang parameter
if mimeType == "text/gemini" && config.DefaultLang != "" {
mimeType += "; lang=" + config.DefaultLang
}
// Try to open the file
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
log.Println("Error reading file " + path + ": " + err.Error()) errorLog.Println("Error reading file " + path + ": " + err.Error())
conn.Write([]byte("50 Error!\r\n")) conn.Write([]byte("50 Error!\r\n"))
logEntry.Status = 50 log.Status = 50
return return
} }
defer f.Close() defer f.Close()
// If the file extension wasn't recognised, or there's not one, use bytes
// from the now open file to sniff!
if mimeType == "" {
buffer := make([]byte, 512)
n, err := f.Read(buffer)
if err == nil {
_, err = f.Seek(0, 0)
}
if err != nil {
log.Println("Error peeking into file " + path + ": " + err.Error())
conn.Write([]byte("50 Error!\r\n"))
logEntry.Status = 50
return
}
mimeType = http.DetectContentType(buffer[0:n])
}
// Add charset parameter
if strings.HasPrefix(mimeType, "text/gemini") && config.DefaultEncoding != "" {
mimeType += "; charset=" + config.DefaultEncoding
}
// Add lang parameter
if strings.HasPrefix(mimeType, "text/gemini") && config.DefaultLang != "" {
mimeType += "; lang=" + config.DefaultLang
}
// Derive a maximum allowed download time from the filesyize.
// Assume non-malicious clients can manage an average of 0.5 KB/s or better.
// But always allow at least 30 seconds
allowedTime := int(info.Size() / 512)
if allowedTime < 30 {
allowedTime = 30
}
err = conn.SetWriteDeadline(time.Now().Add(time.Duration(allowedTime) * time.Second))
if err != nil {
log.Println("Error setting write deadline: " + err.Error())
conn.Write([]byte("40 Error!\r\n"))
logEntry.Status = 40
return
}
// Send response
conn.Write([]byte(fmt.Sprintf("20 %s\r\n", mimeType))) conn.Write([]byte(fmt.Sprintf("20 %s\r\n", mimeType)))
_, err = io.Copy(conn, f) io.Copy(conn, f)
if err != nil { log.Status = 20
// Prepare to close the connection *without* TLS Close Notify so the client
// knows something has gone wrong!
tlsConn, _ := conn.(*tls.Conn)
netConn := tlsConn.NetConn()
tcpConn := netConn.(*net.TCPConn)
remoteAddr := conn.RemoteAddr().String()
if errors.Is(err, os.ErrDeadlineExceeded) {
log.Println("Writing to " + remoteAddr + " timed out.")
// Make sure Close() below takes immediate effect in
// the case of a timeout as a defence against
// socket exhaustion attacks
tcpConn.SetLinger(0)
} else {
log.Println("Error writing response to " + remoteAddr + ": " + err.Error())
}
tcpConn.Close()
return
}
logEntry.Status = 20
} }

185
launch.go
View File

@ -1,185 +0,0 @@
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io/ioutil"
"log"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
)
var VERSION = "0.0.0"
func launch(sysConfig SysConfig, userConfig UserConfig, privInfo userInfo) int {
var err error
// Open log files
if sysConfig.ErrorLog != "" {
errorLogFile, err := os.OpenFile(sysConfig.ErrorLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Println("Error opening error log file: " + err.Error())
return 1
}
defer errorLogFile.Close()
log.SetOutput(errorLogFile)
}
log.SetFlags(log.Ldate|log.Ltime)
var accessLogFile *os.File
if sysConfig.AccessLog == "-" {
accessLogFile = os.Stdout
} else if sysConfig.AccessLog != "" {
accessLogFile, err = os.OpenFile(sysConfig.AccessLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Println("Error opening access log file: " + err.Error())
return 1
}
defer accessLogFile.Close()
}
// Read TLS files, create TLS config
// Check key file permissions first
info, err := os.Stat(sysConfig.KeyPath)
if err != nil {
log.Println("Error opening TLS key file: " + err.Error())
return 1
}
if uint64(info.Mode().Perm())&0444 == 0444 {
log.Println("Refusing to use world-readable TLS key file " + sysConfig.KeyPath)
return 1
}
// Check certificate hostname matches server hostname
info, err = os.Stat(sysConfig.CertPath)
if err != nil {
log.Println("Error opening TLS certificate file: " + err.Error())
return 1
}
certFile, err := os.Open(sysConfig.CertPath)
if err != nil {
log.Println("Error opening TLS certificate file: " + err.Error())
return 1
}
certBytes, err := ioutil.ReadAll(certFile)
if err != nil {
log.Println("Error reading TLS certificate file: " + err.Error())
return 1
}
certDer, _ := pem.Decode(certBytes)
if certDer == nil {
log.Println("Error decoding TLS certificate file: " + err.Error())
return 1
}
certx509, err := x509.ParseCertificate(certDer.Bytes)
if err != nil {
log.Println("Error parsing TLS certificate: " + err.Error())
return 1
}
err = certx509.VerifyHostname(sysConfig.Hostname)
if err != nil {
log.Println("Invalid TLS certificate: " + err.Error())
return 1
}
// Warn if certificate is expired
now := time.Now()
if now.After(certx509.NotAfter) {
log.Println("Hey, your certificate expired on " + certx509.NotAfter.String() + "!!!")
}
// Load certificate and private key
cert, err := tls.LoadX509KeyPair(sysConfig.CertPath, sysConfig.KeyPath)
if err != nil {
log.Println("Error loading TLS keypair: " + err.Error())
return 1
}
var tlscfg tls.Config
tlscfg.Certificates = []tls.Certificate{cert}
tlscfg.ClientAuth = tls.RequestClientCert
if sysConfig.AllowTLS12 {
tlscfg.MinVersion = tls.VersionTLS12
} else {
tlscfg.MinVersion = tls.VersionTLS13
}
if len(userConfig.CertificateZones) > 0 || sysConfig.ReadMollyFiles {
tlscfg.ClientAuth = tls.RequestClientCert
}
// Try to chdir to /, so we don't block any mountpoints
// But if we can't for some reason it's no big deal
err = os.Chdir("/")
if err != nil {
log.Println("Could not change working directory to /: " + err.Error())
}
// Apply security restrictions
err = enableSecurityRestrictions(sysConfig, privInfo)
if err != nil {
log.Println("Exiting due to failure to apply security restrictions.")
return 1
}
// Create TLS listener
listener, err := tls.Listen("tcp", ":"+strconv.Itoa(sysConfig.Port), &tlscfg)
if err != nil {
log.Println("Error creating TLS listener: " + err.Error())
return 1
}
defer listener.Close()
// Start log handling routines
var accessLogEntries chan LogEntry
if sysConfig.AccessLog == "" {
accessLogEntries = nil
} else {
accessLogEntries = make(chan LogEntry, 10)
go func() {
for {
entry := <-accessLogEntries
if entry.Status != 0 {
writeLogEntry(accessLogFile, entry)
}
}
}()
}
// Start listening for signals
shutdown := make(chan struct{})
sigterm := make(chan os.Signal, 1)
signal.Notify(sigterm, syscall.SIGTERM)
go func() {
<-sigterm
log.Println("Caught SIGTERM. Waiting for handlers to finish...")
close(shutdown)
listener.Close()
}()
// Infinite serve loop (SIGTERM breaks out)
running := true
var wg sync.WaitGroup
rl := newRateLimiter(sysConfig.RateLimitAverage, sysConfig.RateLimitSoft, sysConfig.RateLimitHard)
for running {
conn, err := listener.Accept()
if err == nil {
wg.Add(1)
go handleGeminiRequest(conn, sysConfig, userConfig, accessLogEntries, &rl, &wg)
} else {
select {
case <-shutdown:
running = false
default:
log.Println("Error accepting connection: " + err.Error())
}
}
}
// Wait for still-running handler Go routines to finish
wg.Wait()
log.Println("Exiting.")
// Exit successfully
return 0
}

82
main.go
View File

@ -1,36 +1,82 @@
// +build js nacl plan9 windows
package main package main
import ( import (
"crypto/tls"
"flag" "flag"
"fmt"
"log" "log"
"os" "os"
"strconv"
) )
func main() { func main() {
var conf_file string var conf_file string
var version bool
// Parse args // Parse args and read config
flag.StringVar(&conf_file, "c", "/etc/molly.conf", "Path to config file") flag.StringVar(&conf_file, "c", "", "Path to config file")
flag.BoolVar(&version, "v", false, "Print version and exit")
flag.Parse() flag.Parse()
if conf_file == "" {
// If requested, print version and exit _, err := os.Stat("/etc/molly.conf")
if version { if err == nil {
fmt.Println("Molly Brown version", VERSION) conf_file = "/etc/molly.conf"
os.Exit(0) }
} }
config, err := getConfig(conf_file)
// Read config
sysConfig, userConfig, err := getConfig(conf_file)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Run server and exit // Open log files
var dummy userInfo errorLogFile, err := os.OpenFile(config.ErrorLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
os.Exit(launch(sysConfig, userConfig, dummy)) if err != nil {
log.Fatal(err)
}
defer errorLogFile.Close()
errorLog := log.New(errorLogFile, "", log.Ldate | log.Ltime)
accessLogFile, err := os.OpenFile(config.AccessLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
errorLog.Println("Error opening access log file: " + err.Error())
log.Fatal(err)
}
defer accessLogFile.Close()
// Read TLS files, create TLS config
cert, err := tls.LoadX509KeyPair(config.CertPath, config.KeyPath)
if err != nil {
errorLog.Println("Error loading TLS keypair: " + err.Error())
log.Fatal(err)
}
tlscfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
ClientAuth: tls.RequestClientCert,
}
// Create TLS listener
listener, err := tls.Listen("tcp", ":"+strconv.Itoa(config.Port), tlscfg)
if err != nil {
errorLog.Println("Error creating TLS listener: " + err.Error())
log.Fatal(err)
}
defer listener.Close()
// Start log handling routines
accessLogEntries := make(chan LogEntry, 10)
go func() {
for {
entry := <-accessLogEntries
writeLogEntry(accessLogFile, entry)
}
}()
// Infinite serve loop
for {
conn, err := listener.Accept()
if err != nil {
errorLog.Println("Error accepting connection: " + err.Error())
log.Fatal(err)
}
go handleGeminiRequest(conn, config, accessLogEntries, errorLog)
}
} }

View File

@ -1,55 +0,0 @@
// +build aix darwin dragonfly freebsd illumos linux netbsd openbsd solaris
package main
import (
"flag"
"fmt"
"log"
"os"
"syscall"
)
func main() {
var conf_file string
var chroot string
var user string
var version bool
// Parse args
flag.StringVar(&conf_file, "c", "/etc/molly.conf", "Path to config file")
flag.StringVar(&chroot, "C", "", "Path to chroot into")
flag.StringVar(&user, "u", "nobody", "Unprivileged user")
flag.BoolVar(&version, "v", false, "Print version and exit")
flag.Parse()
// If requested, print version and exit
if version {
fmt.Println("Molly Brown version", VERSION)
os.Exit(0)
}
// Read config
sysConfig, userConfig, err := getConfig(conf_file)
if err != nil {
log.Fatal(err)
}
// Read user info
privInfo, err := getUserInfo(user)
// Chroot, if asked
if chroot != "" {
err := syscall.Chroot(chroot)
if err == nil {
err = os.Chdir("/")
}
if err != nil {
log.Println("Could not chroot to " + chroot + ": " + err.Error())
os.Exit(1)
}
}
// Run server and exit
os.Exit(launch(sysConfig, userConfig, privInfo))
}

View File

@ -1,87 +0,0 @@
package main
import (
"log"
"sync"
"strconv"
"time"
)
type RateLimiter struct {
mu sync.Mutex
bucket map[string]int
bans map[string]time.Time
banCounts map[string]int
rate int
softLimit int
hardLimit int
}
func newRateLimiter(rate int, softLimit int, hardLimit int) RateLimiter {
var rl = new(RateLimiter)
rl.bucket = make(map[string]int)
rl.bans = make(map[string]time.Time)
rl.banCounts = make(map[string]int)
rl.rate = rate
rl.softLimit = softLimit
rl.hardLimit = hardLimit
// Leak periodically
go func () {
for(true) {
rl.mu.Lock()
// Leak the buckets
for addr, drips := range rl.bucket {
if drips <= rate {
delete(rl.bucket, addr)
} else {
rl.bucket[addr] = drips - rl.rate
}
}
// Expire bans
now := time.Now()
for addr, expiry := range rl.bans {
if now.After(expiry) {
delete(rl.bans, addr)
}
}
// Wait
rl.mu.Unlock()
time.Sleep(time.Second)
}
}()
return *rl
}
func (rl *RateLimiter) softLimited(addr string) (int, bool) {
rl.mu.Lock()
defer rl.mu.Unlock()
drips, present := rl.bucket[addr]
if !present {
rl.bucket[addr] = 1
return 1, false
}
drips += 1
rl.bucket[addr] = drips
if drips > rl.hardLimit {
banCount, present := rl.banCounts[addr]
if present {
banCount += 1
} else {
banCount = 1
}
rl.banCounts[addr] = banCount
banDuration := 1 << (banCount - 1)
now := time.Now()
expiry := now.Add(time.Duration(banDuration)*time.Hour)
rl.bans[addr] = expiry
log.Println("Banning " + addr + " for " + strconv.Itoa(banDuration) + " hours due to ignoring rate limiting.")
}
return drips, drips > rl.softLimit
}
func (rl *RateLimiter) hardLimited(addr string) bool {
_, present := rl.bans[addr]
return present
}

View File

@ -1,14 +0,0 @@
// +build js nacl plan9 windows
package main
type userInfo struct {
}
// Restrict access to the files specified in config in an OS-dependent way.
// This is intended to be called immediately prior to accepting client
// connections and may be used to establish a security "jail" for the molly
// brown executable.
func enableSecurityRestrictions(config SysConfig, ui userInfo) error {
return nil
}

View File

@ -1,119 +0,0 @@
// +build linux,go1.16 aix darwin dragonfly freebsd illumos netbsd openbsd solaris
package main
import (
"log"
"os"
"os/user"
"strconv"
"syscall"
)
type userInfo struct {
uid int
euid int
gid int
egid int
supp_groups []int
is_setuid bool
is_setgid bool
root_user bool
root_prim_group bool
root_supp_group bool
need_drop bool
unpriv_uid int
unpriv_gid int
}
func getUserInfo(unprivUser string) (userInfo, error) {
var ui userInfo
ui.uid = os.Getuid()
ui.euid = os.Geteuid()
ui.gid = os.Getgid()
ui.egid = os.Getegid()
supp_groups, err := os.Getgroups()
if err != nil {
log.Println("Could not get supplementary groups: ", err.Error())
return ui, err
}
ui.supp_groups = supp_groups
ui.unpriv_uid = -1
ui.unpriv_gid = -1
ui.is_setuid = ui.uid != ui.euid
ui.is_setgid = ui.gid != ui.egid
ui.root_user = ui.uid == 0 || ui.euid == 0
ui.root_prim_group = ui.gid == 0 || ui.egid == 0
for _, gid := range ui.supp_groups {
if gid == 0 {
ui.root_supp_group = true
break
}
}
ui.need_drop = ui.is_setuid || ui.is_setgid || ui.root_user || ui.root_prim_group || ui.root_supp_group
if ui.root_user || ui.root_prim_group {
nobody_user, err := user.Lookup(unprivUser)
if err != nil {
log.Println("Running as root but could not lookup UID for user " + unprivUser + ": " + err.Error())
return ui, err
}
ui.unpriv_uid, err = strconv.Atoi(nobody_user.Uid)
ui.unpriv_gid, err = strconv.Atoi(nobody_user.Gid)
if err != nil {
log.Println("Running as root but could not lookup UID for user " + unprivUser + ": " + err.Error())
return ui, err
}
}
return ui, nil
}
func DropPrivs(ui userInfo) error {
// If we're already unprivileged, all good
if !ui.need_drop {
return nil
}
// Drop supplementary groups
if ui.root_supp_group {
err := syscall.Setgroups([]int{})
if err != nil {
log.Println("Could not unset supplementary groups: " + err.Error())
return err
}
}
// Setgid()
if ui.root_prim_group || ui.is_setgid {
var target_gid int
if ui.root_prim_group {
target_gid = ui.unpriv_gid
} else {
target_gid = ui.gid
}
err := syscall.Setgid(target_gid)
if err != nil {
log.Println("Could not setgid to " + strconv.Itoa(target_gid) + ": " + err.Error())
return err
}
}
// Setuid()
if ui.root_user || ui.is_setuid {
var target_uid int
if ui.root_user {
target_uid = ui.unpriv_uid
} else {
target_uid = ui.uid
}
err := syscall.Setuid(target_uid)
if err != nil {
log.Println("Could not setuid to " + strconv.Itoa(target_uid) + ": " + err.Error())
return err
}
}
return nil
}

View File

@ -1,32 +0,0 @@
// +build linux,!go1.16
package main
import (
"errors"
"log"
"os"
)
type userInfo struct {
}
func getUserInfo(unprivUser string) (userInfo, error) {
var dummy userInfo
return dummy, nil
}
func enableSecurityRestrictions(config SysConfig, ui userInfo) error {
// Prior to Go 1.6, setuid did not work reliably on Linux
// So, absolutely refuse to run as root
uid := os.Getuid()
euid := os.Geteuid()
if uid == 0 || euid == 0 {
setuid_err := "Refusing to run with root privileges when setuid() will not work!"
log.Println(setuid_err)
return errors.New(setuid_err)
}
return nil
}

View File

@ -1,77 +0,0 @@
package main
import (
"golang.org/x/sys/unix"
"log"
"path/filepath"
)
// Restrict access to the files specified in config in an OS-dependent way.
// The OpenBSD implementation uses pledge(2) and unveil(2) to restrict the
// operations available to the molly brown executable. Please note that (S)CGI
// processes that molly brown spawns or communicates with are unrestricted
// and should pledge their own restrictions and unveil their own files.
func enableSecurityRestrictions(config SysConfig, ui userInfo) error {
// Setuid to an unprivileged user
err := DropPrivs(ui)
if err != nil {
return err
}
// Unveil the configured document base as readable.
log.Println("Unveiling \"" + config.DocBase + "\" as readable.")
err = unix.Unveil(config.DocBase, "r")
if err != nil {
log.Println("Could not unveil DocBase: " + err.Error())
return err
}
// Unveil cgi path globs as executable.
for _, cgiPath := range config.CGIPaths {
cgiGlobbedPaths, err := filepath.Glob(cgiPath)
for _, cgiGlobbedPath := range cgiGlobbedPaths {
log.Println("Unveiling \"" + cgiGlobbedPath + "\" as executable.")
err = unix.Unveil(cgiGlobbedPath, "rx")
if err != nil {
log.Println("Could not unveil CGIPaths: " + err.Error())
return err
}
}
}
// Unveil scgi socket paths as readable and writeable.
for _, scgiSocket := range config.SCGIPaths {
log.Println("Unveiling \"" + scgiSocket + "\" as read/write.")
err = unix.Unveil(scgiSocket, "rw")
if err != nil {
return err
}
}
// Finalize the unveil list.
// Any files not whitelisted above won't be accessible to molly brown.
err = unix.UnveilBlock()
if err != nil {
log.Println("Could not block unveil: " + err.Error())
return err
}
// Pledge to only use stdio, inet, and rpath syscalls.
promises := "stdio inet rpath"
if len(config.CGIPaths) > 0 {
// If CGI paths have been specified, also allow exec syscalls.
promises += " exec proc"
}
if len(config.SCGIPaths) > 0 {
// If SCGI paths have been specified, also allow unix sockets.
promises += " unix"
}
err = unix.PledgePromises(promises)
if err != nil {
log.Println("Could not pledge: " + err.Error())
return err
}
return nil
}

View File

@ -1,10 +0,0 @@
// +build linux,go1.16 aix darwin dragonfly freebsd illumos netbsd solaris
package main
func enableSecurityRestrictions(config SysConfig, ui userInfo) error {
// Setuid to an unprivileged user
return DropPrivs(ui)
}