Support multiple hostnames

Resolves #1.
This commit is contained in:
Trevor Slocum 2020-10-30 13:30:09 -07:00
parent ae6e0b6b7b
commit 8aec55c459
5 changed files with 163 additions and 89 deletions

View file

@ -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 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 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. 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 # Address to listen on
hostname: gemini.rocks listen: 0.0.0.0:1965
port: 1965
# Path to certificate and private key # TLS certificates
cert: /home/twins/data/certfile.crt certificates:
key: /home/twins/data/keyfile.key -
cert: /home/gemini.rocks/data/cert.crt
key: /home/gemini.rocks/data/cert.key
# Hosts and paths to serve
# Server paths hosts:
serve:
- -
path: /sites name: gemini.rocks
root: /home/twins/data/sites 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)$ name: twins.rocketnine.space
root: /home/twins/data/help paths:
- -
path: ^/proxy-example$ path: /sites
proxy: gemini://localhost:1966 root: /home/twins/data/sites
- -
path: ^/cmd-example$ path: /
command: uname -a root: /home/twins/data/home
-
path: /
root: /home/twins/data/home
``` ```

View file

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

View file

@ -6,12 +6,14 @@ import (
"log" "log"
"os" "os"
"regexp" "regexp"
"strconv"
"strings"
"github.com/kballard/go-shellquote" "github.com/kballard/go-shellquote"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type serveConfig struct { type pathConfig struct {
// Path to match // Path to match
Path string Path string
@ -24,17 +26,30 @@ type serveConfig struct {
cmd []string cmd []string
} }
type hostConfig struct {
Name string
Paths []*pathConfig
}
type certConfig struct {
Cert string
Key string
}
type serverConfig struct { type serverConfig struct {
Cert string Listen string
Key string
Hostname string Certificates []*certConfig
Port int
Serve []*serveConfig Hosts []*hostConfig
hostname string
port int
} }
var config = &serverConfig{ var config = &serverConfig{
Hostname: "localhost", hostname: "localhost",
Port: 1965, port: 1965,
} }
func readconfig(configPath string) error { func readconfig(configPath string) error {
@ -56,25 +71,39 @@ func readconfig(configPath string) error {
return err return err
} }
for _, serve := range config.Serve { split := strings.Split(config.Listen, ":")
if serve.Path == "" { if len(split) != 2 {
log.Fatal("path must be specified in serve entry") config.hostname = config.Listen
} else if (serve.Root != "" && (serve.Proxy != "" || serve.Command != "")) || config.Listen += ":1965"
(serve.Proxy != "" && (serve.Root != "" || serve.Command != "")) || } else {
(serve.Command != "" && (serve.Root != "" || serve.Proxy != "")) { config.hostname = split[0]
log.Fatal("only one root, reverse proxy or command may specified in a serve entry") config.port, err = strconv.Atoi(split[1])
if err != nil {
log.Fatalf("invalid port specified: %s", err)
} }
}
if serve.Path[0] == '^' { for _, host := range config.Hosts {
serve.r = regexp.MustCompile(serve.Path) for _, serve := range host.Paths {
} else if serve.Path[len(serve.Path)-1] == '/' { if serve.Path == "" {
serve.Path = serve.Path[:len(serve.Path)-1] 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 != "" { if serve.Path[0] == '^' {
serve.cmd, err = shellquote.Split(serve.Command) serve.r = regexp.MustCompile(serve.Path)
if err != nil { } else if serve.Path[len(serve.Path)-1] == '/' {
log.Fatalf("failed to parse command %s: %s", serve.cmd, err) 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)
}
} }
} }
} }

20
main.go
View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"crypto/tls"
"flag" "flag"
"log" "log"
"os" "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) 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") log.Fatal("hostname and port must be specified")
} }
if config.Cert == "" || config.Key == "" { if len(config.Certificates) == 0 {
log.Fatal("certificate file and private key must be specified (gemini requires TLS for all connections)") 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)
} }

View file

@ -48,7 +48,7 @@ func writeStatus(c net.Conn, code int) {
writeHeader(c, code, meta) writeHeader(c, code, meta)
} }
func serveFile(c net.Conn, filePath string) { func serveFile(c net.Conn, requestData, filePath string) {
fi, err := os.Stat(filePath) fi, err := os.Stat(filePath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
writeStatus(c, gemini.StatusNotFound) writeStatus(c, gemini.StatusNotFound)
@ -59,6 +59,13 @@ func serveFile(c net.Conn, filePath string) {
} }
if mode := fi.Mode(); mode.IsDir() { 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")) _, err := os.Stat(path.Join(filePath, "index.gemini"))
if err == nil { if err == nil {
filePath = path.Join(filePath, "index.gemini") filePath = path.Join(filePath, "index.gemini")
@ -200,6 +207,7 @@ func handleConn(c net.Conn) {
requestData = scanner.Text() requestData = scanner.Text()
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
log.Println(scanner.Text(), "FAILED")
writeStatus(c, gemini.StatusBadRequest) writeStatus(c, gemini.StatusBadRequest)
return return
} }
@ -214,7 +222,13 @@ func handleConn(c net.Conn) {
} }
request, err := url.Parse(requestData) 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) writeStatus(c, gemini.StatusBadRequest)
return return
} }
@ -230,7 +244,7 @@ func handleConn(c net.Conn) {
if request.Scheme == "" { if request.Scheme == "" {
request.Scheme = "gemini" 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) writeStatus(c, gemini.StatusProxyRequestRefused)
} }
@ -246,35 +260,47 @@ func handleConn(c net.Conn) {
strippedPath = strippedPath[1:] strippedPath = strippedPath[1:]
} }
for _, serve := range config.Serve { var matchedHost bool
if serve.Proxy != "" { for _, host := range config.Hosts {
if serve.r != nil && serve.r.Match(pathBytes) { if requestHostname != host.Name {
serveProxy(c, requestData, serve.Proxy) continue
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
}
} }
matchedHost = true
if serve.r != nil && serve.r.Match(pathBytes) { for _, serve := range host.Paths {
serveFile(c, path.Join(serve.Root, strippedPath)) if serve.Proxy != "" {
return if serve.r != nil && serve.r.Match(pathBytes) {
} else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { serveProxy(c, requestData, serve.Proxy)
serveFile(c, path.Join(serve.Root, strippedPath[len(serve.Path)-1:])) return
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) { func handleListener(l net.Listener) {
@ -288,19 +314,14 @@ func handleListener(l net.Listener) {
} }
} }
func listen(hostname string, port int, certFile, keyFile string) { func listen(address string, certificates []tls.Certificate) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
log.Fatalf("failed to load certificate: %s", err)
}
tlsConfig := &tls.Config{ 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 { 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) handleListener(listener)