From 8aec55c459169d896ffcbcc57a834f5b09b5f319 Mon Sep 17 00:00:00 2001 From: Trevor Slocum Date: Fri, 30 Oct 2020 13:30:09 -0700 Subject: [PATCH] Support multiple hostnames Resolves #1. --- CONFIGURATION.md | 60 +++++++++++++++++++------------ README.md | 2 +- config.go | 77 ++++++++++++++++++++++++++------------- main.go | 20 ++++++++--- server.go | 93 +++++++++++++++++++++++++++++------------------- 5 files changed, 163 insertions(+), 89 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 93b3c56..8ba148e 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -4,37 +4,51 @@ Fixed string paths will match with and without a trailing slash. Serve entries have either a `root` path or `proxy` URL. When a `root` path is provided static files and directories are served from that location. When a -`proxy` URL is provided requests are forwarded to the Gemini server at that URL. +`proxy` URL is provided requests are forwarded to the Gemini server at that URL. + +Paths are matched in the order they are provided. When accessing a directory `index.gemini` or `index.gmi` is served. # config.yaml ```yaml -# Hostname and port to listen for connections on -hostname: gemini.rocks -port: 1965 +# Address to listen on +listen: 0.0.0.0:1965 -# Path to certificate and private key -cert: /home/twins/data/certfile.crt -key: /home/twins/data/keyfile.key +# TLS certificates +certificates: + - + cert: /home/gemini.rocks/data/cert.crt + key: /home/gemini.rocks/data/cert.key - -# Server paths -serve: +# Hosts and paths to serve +hosts: - - path: /sites - root: /home/twins/data/sites + name: gemini.rocks + paths: + - + path: /sites + root: /home/gemini.rocks/data/sites + - + path: ^/(help|info)$ + root: /home/gemini.rocks/data/help + - + path: ^/proxy-example$ + proxy: gemini://localhost:1966 + - + path: ^/cmd-example$ + command: uname -a + - + path: / + root: /home/gemini.rocks/data/home - - path: ^/(help|info)$ - root: /home/twins/data/help - - - path: ^/proxy-example$ - proxy: gemini://localhost:1966 - - - path: ^/cmd-example$ - command: uname -a - - - path: / - root: /home/twins/data/home + name: twins.rocketnine.space + paths: + - + path: /sites + root: /home/twins/data/sites + - + path: / + root: /home/twins/data/home ``` \ No newline at end of file diff --git a/README.md b/README.md index a8d9752..bcb3794 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Features - Serve static files -- Serve output of system commands +- Serve the output of system commands - Reverse proxy requests ## Download diff --git a/config.go b/config.go index 5d49a37..eaf88a3 100644 --- a/config.go +++ b/config.go @@ -6,12 +6,14 @@ import ( "log" "os" "regexp" + "strconv" + "strings" "github.com/kballard/go-shellquote" "gopkg.in/yaml.v3" ) -type serveConfig struct { +type pathConfig struct { // Path to match Path string @@ -24,17 +26,30 @@ type serveConfig struct { cmd []string } +type hostConfig struct { + Name string + Paths []*pathConfig +} + +type certConfig struct { + Cert string + Key string +} + type serverConfig struct { - Cert string - Key string - Hostname string - Port int - Serve []*serveConfig + Listen string + + Certificates []*certConfig + + Hosts []*hostConfig + + hostname string + port int } var config = &serverConfig{ - Hostname: "localhost", - Port: 1965, + hostname: "localhost", + port: 1965, } func readconfig(configPath string) error { @@ -56,25 +71,39 @@ func readconfig(configPath string) error { return err } - for _, serve := range config.Serve { - if serve.Path == "" { - log.Fatal("path must be specified in serve entry") - } else if (serve.Root != "" && (serve.Proxy != "" || serve.Command != "")) || - (serve.Proxy != "" && (serve.Root != "" || serve.Command != "")) || - (serve.Command != "" && (serve.Root != "" || serve.Proxy != "")) { - log.Fatal("only one root, reverse proxy or command may specified in a serve entry") + split := strings.Split(config.Listen, ":") + if len(split) != 2 { + config.hostname = config.Listen + config.Listen += ":1965" + } else { + config.hostname = split[0] + config.port, err = strconv.Atoi(split[1]) + if err != nil { + log.Fatalf("invalid port specified: %s", err) } + } - if serve.Path[0] == '^' { - serve.r = regexp.MustCompile(serve.Path) - } else if serve.Path[len(serve.Path)-1] == '/' { - serve.Path = serve.Path[:len(serve.Path)-1] - } + for _, host := range config.Hosts { + for _, serve := range host.Paths { + if serve.Path == "" { + log.Fatal("path must be specified in serve entry") + } else if (serve.Root != "" && (serve.Proxy != "" || serve.Command != "")) || + (serve.Proxy != "" && (serve.Root != "" || serve.Command != "")) || + (serve.Command != "" && (serve.Root != "" || serve.Proxy != "")) { + log.Fatal("only one root, reverse proxy or command may specified in a serve entry") + } - if serve.Command != "" { - serve.cmd, err = shellquote.Split(serve.Command) - if err != nil { - log.Fatalf("failed to parse command %s: %s", serve.cmd, err) + if serve.Path[0] == '^' { + serve.r = regexp.MustCompile(serve.Path) + } else if serve.Path[len(serve.Path)-1] == '/' { + serve.Path = serve.Path[:len(serve.Path)-1] + } + + if serve.Command != "" { + serve.cmd, err = shellquote.Split(serve.Command) + if err != nil { + log.Fatalf("failed to parse command %s: %s", serve.cmd, err) + } } } } diff --git a/main.go b/main.go index 941088f..2262454 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/tls" "flag" "log" "os" @@ -30,15 +31,24 @@ func main() { log.Fatalf("failed to read configuration file at %s: %v\nSee CONFIGURATION.md for information on configuring twins", *configFile, err) } - if config.Hostname == "" || config.Port <= 0 { + if config.hostname == "" || config.port <= 0 { log.Fatal("hostname and port must be specified") } - if config.Cert == "" || config.Key == "" { - log.Fatal("certificate file and private key must be specified (gemini requires TLS for all connections)") + if len(config.Certificates) == 0 { + log.Fatal("at least one certificate must be specified (gemini requires TLS for all connections)") } - log.Printf("twins running on port %d", config.Port) + var certificates []tls.Certificate + for _, cert := range config.Certificates { + cert, err := tls.LoadX509KeyPair(cert.Cert, cert.Key) + if err != nil { + log.Fatalf("failed to load certificate: %s", err) + } + certificates = append(certificates, cert) + } - listen(config.Hostname, config.Port, config.Cert, config.Key) + log.Printf("twins running on %s:%d", config.hostname, config.port) + + listen(config.Listen, certificates) } diff --git a/server.go b/server.go index 12d1e80..5f6d52f 100644 --- a/server.go +++ b/server.go @@ -48,7 +48,7 @@ func writeStatus(c net.Conn, code int) { writeHeader(c, code, meta) } -func serveFile(c net.Conn, filePath string) { +func serveFile(c net.Conn, requestData, filePath string) { fi, err := os.Stat(filePath) if os.IsNotExist(err) { writeStatus(c, gemini.StatusNotFound) @@ -59,6 +59,13 @@ func serveFile(c net.Conn, filePath string) { } if mode := fi.Mode(); mode.IsDir() { + if requestData[len(requestData)-1] != '/' { + // Add trailing slash + log.Println(requestData) + writeHeader(c, gemini.StatusRedirectPermanent, requestData+"/") + return + } + _, err := os.Stat(path.Join(filePath, "index.gemini")) if err == nil { filePath = path.Join(filePath, "index.gemini") @@ -200,6 +207,7 @@ func handleConn(c net.Conn) { requestData = scanner.Text() } if err := scanner.Err(); err != nil { + log.Println(scanner.Text(), "FAILED") writeStatus(c, gemini.StatusBadRequest) return } @@ -214,7 +222,13 @@ func handleConn(c net.Conn) { } request, err := url.Parse(requestData) - if err != nil || request.Hostname() == "" || strings.ContainsRune(request.Hostname(), ' ') { + if err != nil { + writeStatus(c, gemini.StatusBadRequest) + return + } + + requestHostname := request.Hostname() + if requestHostname == "" || strings.ContainsRune(requestHostname, ' ') { writeStatus(c, gemini.StatusBadRequest) return } @@ -230,7 +244,7 @@ func handleConn(c net.Conn) { if request.Scheme == "" { request.Scheme = "gemini" } - if request.Scheme != "gemini" || request.Hostname() != config.Hostname || (requestPort > 0 && requestPort != config.Port) { + if request.Scheme != "gemini" || (requestPort > 0 && requestPort != config.port) { writeStatus(c, gemini.StatusProxyRequestRefused) } @@ -246,35 +260,47 @@ func handleConn(c net.Conn) { strippedPath = strippedPath[1:] } - for _, serve := range config.Serve { - if serve.Proxy != "" { - if serve.r != nil && serve.r.Match(pathBytes) { - serveProxy(c, requestData, serve.Proxy) - return - } else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { - serveProxy(c, requestData, serve.Proxy) - return - } - } else if serve.cmd != nil { - if serve.r != nil && serve.r.Match(pathBytes) { - serveCommand(c, serve.cmd) - return - } else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { - serveCommand(c, serve.cmd) - return - } + var matchedHost bool + for _, host := range config.Hosts { + if requestHostname != host.Name { + continue } + matchedHost = true - if serve.r != nil && serve.r.Match(pathBytes) { - serveFile(c, path.Join(serve.Root, strippedPath)) - return - } else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { - serveFile(c, path.Join(serve.Root, strippedPath[len(serve.Path)-1:])) - return + for _, serve := range host.Paths { + if serve.Proxy != "" { + if serve.r != nil && serve.r.Match(pathBytes) { + serveProxy(c, requestData, serve.Proxy) + return + } else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { + serveProxy(c, requestData, serve.Proxy) + return + } + } else if serve.cmd != nil { + if serve.r != nil && serve.r.Match(pathBytes) { + serveCommand(c, serve.cmd) + return + } else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { + serveCommand(c, serve.cmd) + return + } + } + + if serve.r != nil && serve.r.Match(pathBytes) { + serveFile(c, requestData, path.Join(serve.Root, strippedPath)) + return + } else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { + serveFile(c, requestData, path.Join(serve.Root, strippedPath[len(serve.Path)-1:])) + return + } } } - writeStatus(c, gemini.StatusNotFound) + if matchedHost { + writeStatus(c, gemini.StatusNotFound) + } else { + writeStatus(c, gemini.StatusProxyRequestRefused) + } } func handleListener(l net.Listener) { @@ -288,19 +314,14 @@ func handleListener(l net.Listener) { } } -func listen(hostname string, port int, certFile, keyFile string) { - cert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - log.Fatalf("failed to load certificate: %s", err) - } - +func listen(address string, certificates []tls.Certificate) { tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, + Certificates: certificates, } - listener, err := tls.Listen("tcp", fmt.Sprintf("%s:%d", hostname, port), tlsConfig) + listener, err := tls.Listen("tcp", address, tlsConfig) if err != nil { - log.Fatalf("failed to listen on %s:%d: %s", hostname, port, err) + log.Fatalf("failed to listen on %s: %s", address, err) } handleListener(listener)