Gemini server
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

372 lines
7.8 KiB

package main
import (
"bufio"
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net"
"net/url"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
readTimeout = 30 * time.Second
urlMaxLength = 1024
)
const (
statusInput = 10
statusSensitiveInput = 11
statusSuccess = 20
statusRedirectTemporary = 30
statusRedirectPermanent = 31
statusTemporaryFailure = 40
statusUnavailable = 41
statusCGIError = 42
statusProxyError = 43
statusPermanentFailure = 50
statusNotFound = 51
statusGone = 52
statusProxyRequestRefused = 53
statusBadRequest = 59
)
var slashesRegexp = regexp.MustCompile(`[^\\]\/`)
var newLine = "\r\n"
func writeHeader(c net.Conn, code int, meta string) {
fmt.Fprintf(c, "%d %s%s", code, meta, newLine)
if verbose {
log.Printf("< %d %s\n", code, meta)
}
}
func writeStatus(c net.Conn, code int) {
var meta string
switch code {
case statusTemporaryFailure:
meta = "Temporary failure"
case statusProxyError:
meta = "Proxy error"
case statusBadRequest:
meta = "Bad request"
case statusNotFound:
meta = "Not found"
case statusProxyRequestRefused:
meta = "Proxy request refused"
}
writeHeader(c, code, meta)
}
func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, '\r'); i >= 0 {
// We have a full newline-terminated line.
return i + 1, data[0:i], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}
func replaceWithUserInput(command []string, request *url.URL) []string {
newCommand := make([]string, len(command))
copy(newCommand, command)
for i, piece := range newCommand {
if strings.Contains(piece, "$USERINPUT") {
requestQuery, err := url.QueryUnescape(request.RawQuery)
if err == nil {
newCommand[i] = strings.ReplaceAll(piece, "$USERINPUT", requestQuery)
}
}
}
return newCommand
}
func servePath(c *tls.Conn, request *url.URL, serve *pathConfig) {
resolvedPath := request.Path
requestSplit := strings.Split(request.Path, "/")
pathSlashes := len(slashesRegexp.FindAllStringIndex(serve.Path, -1))
if len(serve.Path) > 0 {
if serve.Path[0] == '/' {
pathSlashes++ // Regexp does not match starting slash
}
if serve.Path[len(serve.Path)-1] != '/' {
pathSlashes++
}
}
if len(requestSplit) >= pathSlashes {
resolvedPath = strings.Join(requestSplit[pathSlashes:], "/")
}
var filePath string
if serve.Root != "" {
root := serve.Root
if root[len(root)-1] != '/' {
root += "/"
}
filePath = path.Join(root, resolvedPath)
}
if serve.Proxy != "" {
serveProxy(c, request, serve.Proxy)
return
} else if serve.FastCGI != "" {
if filePath == "" {
writeStatus(c, statusNotFound)
return
}
contentType := "text/gemini; charset=utf-8"
if serve.Type != "" {
contentType = serve.Type
}
writeHeader(c, statusSuccess, contentType)
serveFastCGI(c, config.fcgiPools[serve.FastCGI], request, filePath)
return
} else if serve.cmd != nil {
requireInput := serve.Input != "" || serve.SensitiveInput != ""
if requireInput {
newCommand := replaceWithUserInput(serve.cmd, request)
if newCommand != nil {
serveCommand(c, request, newCommand)
return
}
}
serveCommand(c, request, serve.cmd)
return
}
if filePath == "" {
writeStatus(c, statusNotFound)
return
}
fi, err := os.Stat(filePath)
if err != nil {
writeStatus(c, statusNotFound)
return
}
mode := fi.Mode()
hasTrailingSlash := len(request.Path) > 0 && request.Path[len(request.Path)-1] == '/'
if mode.IsDir() {
if !hasTrailingSlash {
writeHeader(c, statusRedirectPermanent, request.String()+"/")
return
}
_, err := os.Stat(path.Join(filePath, "index.gmi"))
if err != nil {
_, err := os.Stat(path.Join(filePath, "index.gemini"))
if err != nil {
if serve.ListDirectory {
serveDirList(c, request, filePath)
return
}
writeStatus(c, statusNotFound)
return
}
filePath = path.Join(filePath, "index.gemini")
} else {
filePath = path.Join(filePath, "index.gmi")
}
} else if hasTrailingSlash && len(request.Path) > 1 {
r := request.String()
writeHeader(c, statusRedirectPermanent, r[:len(r)-1])
return
}
serveFile(c, filePath)
}
func serveConn(c *tls.Conn) {
var requestData string
scanner := bufio.NewScanner(c)
if !config.SaneEOL {
scanner.Split(scanCRLF)
}
if scanner.Scan() {
requestData = scanner.Text()
}
if err := scanner.Err(); err != nil {
writeStatus(c, statusBadRequest)
return
}
state := c.ConnectionState()
certs := state.PeerCertificates
var clientCertKeys [][]byte
for _, cert := range certs {
pubKey, err := x509.MarshalPKIXPublicKey(cert.PublicKey)
if err != nil {
continue
}
clientCertKeys = append(clientCertKeys, pubKey)
}
if verbose {
log.Printf("> %s\n", requestData)
}
if len(requestData) > urlMaxLength || !utf8.ValidString(requestData) {
writeStatus(c, statusBadRequest)
return
}
request, err := url.Parse(requestData)
if err != nil {
writeStatus(c, statusBadRequest)
return
}
requestHostname := request.Hostname()
if requestHostname == "" || strings.ContainsRune(requestHostname, ' ') {
writeStatus(c, statusBadRequest)
return
}
var requestPort int
if request.Port() != "" {
requestPort, err = strconv.Atoi(request.Port())
if err != nil {
requestPort = 0
}
}
if request.Scheme == "" {
request.Scheme = "gemini"
}
if request.Scheme != "gemini" || (requestPort > 0 && requestPort != config.port) {
writeStatus(c, statusProxyRequestRefused)
}
if request.Path == "" {
// Redirect to /
writeHeader(c, statusRedirectPermanent, requestData+"/")
return
}
pathBytes := []byte(request.Path)
strippedPath := request.Path
if strippedPath[0] == '/' {
strippedPath = strippedPath[1:]
}
var matchedHost bool
for hostname := range config.Hosts {
if requestHostname != hostname {
continue
}
matchedHost = true
for _, serve := range config.Hosts[hostname].Paths {
matchedRegexp := serve.r != nil && serve.r.Match(pathBytes)
matchedPrefix := serve.r == nil && strings.HasPrefix(request.Path, serve.Path)
if !matchedRegexp && !matchedPrefix {
continue
}
requireInput := serve.Input != "" || serve.SensitiveInput != ""
if request.RawQuery == "" && requireInput {
if serve.Input != "" {
writeHeader(c, statusInput, serve.Input)
return
} else if serve.SensitiveInput != "" {
writeHeader(c, statusSensitiveInput, serve.SensitiveInput)
return
}
}
if matchedRegexp || matchedPrefix {
servePath(c, request, serve)
return
}
}
break
}
if matchedHost {
writeStatus(c, statusNotFound)
} else {
writeStatus(c, statusProxyRequestRefused)
}
}
func handleConn(c *tls.Conn) {
if verbose {
t := time.Now()
defer func() {
d := time.Since(t)
if d > time.Second {
d = d.Round(time.Second)
} else {
d = d.Round(time.Millisecond)
}
log.Printf("took %s", d)
}()
}
defer c.Close()
c.SetReadDeadline(time.Now().Add(readTimeout))
serveConn(c)
}
func getCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
host := config.Hosts[info.ServerName]
if host != nil {
return host.cert, nil
}
for _, host := range config.Hosts {
return host.cert, nil
}
return nil, nil
}
func handleListener(l net.Listener) {
for {
conn, err := l.Accept()
if err != nil {
log.Fatal(err)
}
go handleConn(conn.(*tls.Conn))
}
}
func listen(address string) {
tlsConfig := &tls.Config{
ClientAuth: tls.RequestClientCert,
GetCertificate: getCertificate,
}
listener, err := tls.Listen("tcp", address, tlsConfig)
if err != nil {
log.Fatalf("failed to listen on %s: %s", address, err)
}
handleListener(listener)
}