mirror of
https://code.rocketnine.space/tslocum/twins.git
synced 2024-11-27 15:28:13 +01:00
Support serving the output of local commands
This commit is contained in:
parent
29c059c43f
commit
ae6e0b6b7b
7 changed files with 164 additions and 57 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
26
config.go
26
config.go
|
@ -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
1
go.mod
|
@ -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
2
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/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=
|
||||||
|
|
8
main.go
8
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)
|
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
159
server.go
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue