Support serving the output of local commands

This commit is contained in:
Trevor Slocum 2020-10-30 11:19:16 -07:00
parent 29c059c43f
commit ae6e0b6b7b
7 changed files with 164 additions and 57 deletions

View file

@ -11,10 +11,15 @@ When accessing a directory `index.gemini` or `index.gmi` is served.
# config.yaml # config.yaml
```yaml ```yaml
# Hostname and port to listen for connections on
hostname: gemini.rocks
port: 1965
# Path to certificate and private key # Path to certificate and private key
cert: /home/twins/data/certfile.crt cert: /home/twins/data/certfile.crt
key: /home/twins/data/keyfile.key key: /home/twins/data/keyfile.key
# Server paths # Server paths
serve: serve:
- -
@ -25,7 +30,10 @@ serve:
root: /home/twins/data/help root: /home/twins/data/help
- -
path: ^/proxy-example$ path: ^/proxy-example$
proxy: gemini://gemini.rocks proxy: gemini://localhost:1966
-
path: ^/cmd-example$
command: uname -a
- -
path: / path: /
root: /home/twins/data/home root: /home/twins/data/home

View file

@ -7,7 +7,8 @@
## Features ## Features
- Serve static files - Serve static files
- Reverse proxy support - Serve output of system commands
- Reverse proxy requests
## Download ## Download

View file

@ -7,26 +7,35 @@ import (
"os" "os"
"regexp" "regexp"
"github.com/kballard/go-shellquote"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type serveConfig struct { type serveConfig struct {
// Path to match
Path string Path string
// Resource to serve
Root string Root string
Proxy string Proxy string
Command string
r *regexp.Regexp r *regexp.Regexp
cmd []string
} }
type serverConfig struct { type serverConfig struct {
Cert string Cert string
Key string Key string
Address string Hostname string
Port int
Serve []*serveConfig Serve []*serveConfig
} }
var config = &serverConfig{} var config = &serverConfig{
Hostname: "localhost",
Port: 1965,
}
func readconfig(configPath string) error { func readconfig(configPath string) error {
if configPath == "" { if configPath == "" {
@ -50,8 +59,10 @@ func readconfig(configPath string) error {
for _, serve := range config.Serve { for _, serve := range config.Serve {
if serve.Path == "" { if serve.Path == "" {
log.Fatal("path must be specified in serve entry") log.Fatal("path must be specified in serve entry")
} else if serve.Root != "" && serve.Proxy != "" { } else if (serve.Root != "" && (serve.Proxy != "" || serve.Command != "")) ||
log.Fatal("only one root or reverse proxy may defined for a serve entry") (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] == '^' { if serve.Path[0] == '^' {
@ -59,6 +70,13 @@ func readconfig(configPath string) error {
} else if serve.Path[len(serve.Path)-1] == '/' { } else if serve.Path[len(serve.Path)-1] == '/' {
serve.Path = 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 return nil

1
go.mod
View file

@ -4,6 +4,7 @@ go 1.15
require ( require (
github.com/h2non/filetype v1.1.0 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 github.com/makeworld-the-better-one/go-gemini v0.9.0
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
) )

2
go.sum
View file

@ -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/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 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA=
github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 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 h1:Iz4ywRDrfsyoR8xZOkSKGXXftMR2spIV6ibVuhrKvSw=
github.com/makeworld-the-better-one/go-gemini v0.9.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View file

@ -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) log.Fatalf("failed to read configuration file at %s: %v\nSee CONFIGURATION.md for information on configuring twins", *configFile, err)
} }
if config.Address == "" { if config.Hostname == "" || config.Port <= 0 {
log.Fatal("listen address must be specified") log.Fatal("hostname and port must be specified")
} }
if config.Cert == "" || config.Key == "" { if config.Cert == "" || config.Key == "" {
log.Fatal("certificate file and private key must be specified (gemini requires TLS for all connections)") 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)
} }

159
server.go
View file

@ -10,9 +10,12 @@ import (
"net" "net"
"net/url" "net/url"
"os" "os"
"os/exec"
"path" "path"
"strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/h2non/filetype" "github.com/h2non/filetype"
"github.com/makeworld-the-better-one/go-gemini" "github.com/makeworld-the-better-one/go-gemini"
@ -39,39 +42,12 @@ func writeStatus(c net.Conn, code int) {
meta = "Bad request" meta = "Bad request"
case gemini.StatusNotFound: case gemini.StatusNotFound:
meta = "Not found" meta = "Not found"
case gemini.StatusProxyRequestRefused:
meta = "Proxy request refused"
} }
writeHeader(c, code, meta) 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) { func serveFile(c net.Conn, filePath string) {
fi, err := os.Stat(filePath) fi, err := os.Stat(filePath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
@ -130,6 +106,60 @@ func serveFile(c net.Conn, filePath string) {
io.Copy(c, file) 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) { func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 { if atEOF && len(data) == 0 {
return 0, nil, nil 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) { 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)) c.SetReadDeadline(time.Now().Add(readTimeout))
var requestData string var requestData string
@ -162,30 +204,63 @@ func handleConn(c net.Conn) {
return return
} }
request, err := url.Parse(requestData) if verbose {
if err != nil || request.Scheme != "gemini" || request.Host == "" { log.Printf("> %s\n", requestData)
}
if len(requestData) > 1024 || !utf8.ValidString(requestData) {
writeStatus(c, gemini.StatusBadRequest) writeStatus(c, gemini.StatusBadRequest)
return 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) pathBytes := []byte(request.Path)
strippedPath := request.Path strippedPath := request.Path
if strippedPath[0] == '/' { if strippedPath[0] == '/' {
strippedPath = strippedPath[1:] strippedPath = strippedPath[1:]
} }
if verbose {
log.Printf("> %s\n", request)
}
for _, serve := range config.Serve { for _, serve := range config.Serve {
if serve.Proxy != "" { if serve.Proxy != "" {
if serve.r != nil && serve.r.Match(pathBytes) { if serve.r != nil && serve.r.Match(pathBytes) {
serveProxy(c, serve.Proxy, requestData) serveProxy(c, requestData, serve.Proxy)
return return
} else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { } 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 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) cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil { if err != nil {
log.Fatalf("failed to load certificate: %s", err) log.Fatalf("failed to load certificate: %s", err)
@ -223,9 +298,9 @@ func listen(address, certFile, keyFile string) {
Certificates: []tls.Certificate{cert}, 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 { 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) handleListener(listener)