diff --git a/CONFIGURATION.md b/CONFIGURATION.md index d00358d..93b3c56 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -11,10 +11,15 @@ 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 + # Path to certificate and private key cert: /home/twins/data/certfile.crt key: /home/twins/data/keyfile.key + # Server paths serve: - @@ -25,7 +30,10 @@ serve: root: /home/twins/data/help - path: ^/proxy-example$ - proxy: gemini://gemini.rocks + proxy: gemini://localhost:1966 + - + path: ^/cmd-example$ + command: uname -a - path: / root: /home/twins/data/home diff --git a/README.md b/README.md index 1298de3..a8d9752 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ ## Features - Serve static files -- Reverse proxy support +- Serve output of system commands +- Reverse proxy requests ## Download diff --git a/config.go b/config.go index 832e516..5d49a37 100644 --- a/config.go +++ b/config.go @@ -7,26 +7,35 @@ import ( "os" "regexp" + "github.com/kballard/go-shellquote" "gopkg.in/yaml.v3" ) type serveConfig struct { + // Path to match Path string - Root string - Proxy string + // Resource to serve + Root string + Proxy string + Command string - r *regexp.Regexp + r *regexp.Regexp + cmd []string } type serverConfig struct { - Cert string - Key string - Address string - Serve []*serveConfig + Cert string + Key string + Hostname string + Port int + Serve []*serveConfig } -var config = &serverConfig{} +var config = &serverConfig{ + Hostname: "localhost", + Port: 1965, +} func readconfig(configPath string) error { if configPath == "" { @@ -50,8 +59,10 @@ func readconfig(configPath string) error { for _, serve := range config.Serve { if serve.Path == "" { log.Fatal("path must be specified in serve entry") - } else if serve.Root != "" && serve.Proxy != "" { - log.Fatal("only one root or reverse proxy may defined for a 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.Path[0] == '^' { @@ -59,6 +70,13 @@ func readconfig(configPath string) error { } 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) + } + } } return nil diff --git a/go.mod b/go.mod index 6bbebc4..bc719d4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.15 require ( github.com/h2non/filetype v1.1.0 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/makeworld-the-better-one/go-gemini v0.9.0 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 ) diff --git a/go.sum b/go.sum index 54f48bc..0087166 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/h2non/filetype v1.1.0 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA= github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/makeworld-the-better-one/go-gemini v0.9.0 h1:Iz4ywRDrfsyoR8xZOkSKGXXftMR2spIV6ibVuhrKvSw= github.com/makeworld-the-better-one/go-gemini v0.9.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/main.go b/main.go index 03ef6df..941088f 100644 --- a/main.go +++ b/main.go @@ -30,13 +30,15 @@ func main() { log.Fatalf("failed to read configuration file at %s: %v\nSee CONFIGURATION.md for information on configuring twins", *configFile, err) } - if config.Address == "" { - log.Fatal("listen address must be specified") + 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)") } - listen(config.Address, config.Cert, config.Key) + log.Printf("twins running on port %d", config.Port) + + listen(config.Hostname, config.Port, config.Cert, config.Key) } diff --git a/server.go b/server.go index bd85bda..12d1e80 100644 --- a/server.go +++ b/server.go @@ -10,9 +10,12 @@ import ( "net" "net/url" "os" + "os/exec" "path" + "strconv" "strings" "time" + "unicode/utf8" "github.com/h2non/filetype" "github.com/makeworld-the-better-one/go-gemini" @@ -39,39 +42,12 @@ func writeStatus(c net.Conn, code int) { meta = "Bad request" case gemini.StatusNotFound: meta = "Not found" + case gemini.StatusProxyRequestRefused: + meta = "Proxy request refused" } writeHeader(c, code, meta) } -func serveProxy(c net.Conn, proxyURL, requestData string) { - original := proxyURL - - tlsConfig := &tls.Config{} - if strings.HasPrefix(proxyURL, "gemini://") { - proxyURL = proxyURL[9:] - } else if strings.HasPrefix(proxyURL, "gemini-insecure://") { - proxyURL = proxyURL[18:] - tlsConfig.InsecureSkipVerify = true - } - proxy, err := tls.Dial("tcp", proxyURL, tlsConfig) - if err != nil { - writeStatus(c, gemini.StatusProxyError) - return - } - defer proxy.Close() - - // Forward request - proxy.Write([]byte(requestData)) - proxy.Write([]byte("\r\n")) - - // Forward response - io.Copy(c, proxy) - - if verbose { - log.Printf("< %s\n", original) - } -} - func serveFile(c net.Conn, filePath string) { fi, err := os.Stat(filePath) if os.IsNotExist(err) { @@ -130,6 +106,60 @@ func serveFile(c net.Conn, filePath string) { io.Copy(c, file) } +func serveProxy(c net.Conn, requestData, proxyURL string) { + original := proxyURL + + tlsConfig := &tls.Config{} + if strings.HasPrefix(proxyURL, "gemini://") { + proxyURL = proxyURL[9:] + } else if strings.HasPrefix(proxyURL, "gemini-insecure://") { + proxyURL = proxyURL[18:] + tlsConfig.InsecureSkipVerify = true + } + proxy, err := tls.Dial("tcp", proxyURL, tlsConfig) + if err != nil { + writeStatus(c, gemini.StatusProxyError) + return + } + defer proxy.Close() + + // Forward request + proxy.Write([]byte(requestData)) + proxy.Write([]byte("\r\n")) + + // Forward response + io.Copy(c, proxy) + + if verbose { + log.Printf("< %s\n", original) + } +} + +func serveCommand(c net.Conn, command []string) { + var args []string + if len(command) > 0 { + args = command[1:] + } + cmd := exec.Command(command[0], args...) + + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + + err := cmd.Run() + if err != nil { + writeStatus(c, gemini.StatusProxyError) + return + } + + writeHeader(c, gemini.StatusSuccess, "text/gemini; charset=utf-8") + c.Write(buf.Bytes()) + + if verbose { + log.Printf("< %s\n", command) + } +} + func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) { if atEOF && len(data) == 0 { return 0, nil, nil @@ -147,8 +177,20 @@ func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) { } func handleConn(c net.Conn) { - defer c.Close() + 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)) var requestData string @@ -162,30 +204,63 @@ func handleConn(c net.Conn) { return } - request, err := url.Parse(requestData) - if err != nil || request.Scheme != "gemini" || request.Host == "" { + if verbose { + log.Printf("> %s\n", requestData) + } + + if len(requestData) > 1024 || !utf8.ValidString(requestData) { writeStatus(c, gemini.StatusBadRequest) return } - if request.Path == "" { - request.Path = "/" + + request, err := url.Parse(requestData) + if err != nil || request.Hostname() == "" || strings.ContainsRune(request.Hostname(), ' ') { + writeStatus(c, gemini.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" || request.Hostname() != config.Hostname || (requestPort > 0 && requestPort != config.Port) { + writeStatus(c, gemini.StatusProxyRequestRefused) + } + + if request.Path == "" { + // Redirect to / + writeHeader(c, gemini.StatusRedirectPermanent, requestData+"/") + return + } + pathBytes := []byte(request.Path) strippedPath := request.Path if strippedPath[0] == '/' { strippedPath = strippedPath[1:] } - if verbose { - log.Printf("> %s\n", request) - } for _, serve := range config.Serve { if serve.Proxy != "" { if serve.r != nil && serve.r.Match(pathBytes) { - serveProxy(c, serve.Proxy, requestData) + serveProxy(c, requestData, serve.Proxy) return } else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { - serveProxy(c, serve.Proxy, requestData) + 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 } } @@ -213,7 +288,7 @@ func handleListener(l net.Listener) { } } -func listen(address, certFile, keyFile string) { +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) @@ -223,9 +298,9 @@ func listen(address, certFile, keyFile string) { Certificates: []tls.Certificate{cert}, } - listener, err := tls.Listen("tcp", address, tlsConfig) + listener, err := tls.Listen("tcp", fmt.Sprintf("%s:%d", hostname, port), tlsConfig) if err != nil { - log.Fatalf("failed to listen on %s: %s", address, err) + log.Fatalf("failed to listen on %s:%d: %s", hostname, port, err) } handleListener(listener)