608 lines
14 KiB
Go
608 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/go-kit/log/level"
|
|
"tildegit.org/tjp/sliderule/gemini"
|
|
)
|
|
|
|
func Parse(input io.ReadCloser) (*Configuration, error) {
|
|
defer func() { _ = input.Close() }()
|
|
|
|
var (
|
|
auths = map[string]*Auth{}
|
|
suser *user.User = nil
|
|
sawsuser = false
|
|
loglevel level.Value = level.DebugValue()
|
|
sawloglevel = false
|
|
servers = []Server{}
|
|
)
|
|
|
|
eof := false
|
|
buf := bufio.NewReader(input)
|
|
for !eof {
|
|
line, err := buf.ReadString('\n')
|
|
eof = errors.Is(err, io.EOF)
|
|
if err != nil && !eof {
|
|
return nil, err
|
|
}
|
|
|
|
line = strings.TrimRight(strings.TrimLeft(line, " \t"), "\n")
|
|
|
|
if line == "" || line[0] == '#' {
|
|
continue
|
|
}
|
|
|
|
spl := strings.Split(line, " ")
|
|
switch spl[0] {
|
|
case "auth":
|
|
auth, err := parseAuthDirective(spl[1:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := auths[auth.Name]; ok {
|
|
return nil, fmt.Errorf("repeated 'auth %s' directive", auth.Name)
|
|
}
|
|
auths[auth.Name] = &auth
|
|
case "systemuser":
|
|
if sawsuser {
|
|
return nil, errors.New("repeated 'systemuser' directive")
|
|
}
|
|
sawsuser = true
|
|
if len(spl) != 2 {
|
|
return nil, errors.New("invalid 'systemuser' directive")
|
|
}
|
|
lookup := user.Lookup
|
|
_, err := strconv.Atoi(spl[1])
|
|
if err == nil {
|
|
lookup = user.LookupId
|
|
}
|
|
suser, err = lookup(spl[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case "loglevel":
|
|
if sawloglevel {
|
|
return nil, errors.New("repeated 'loglevel' directive")
|
|
}
|
|
sawloglevel = true
|
|
if len(spl) != 2 {
|
|
return nil, errors.New("invalid 'loglevel' directive")
|
|
}
|
|
switch spl[1] {
|
|
case "debug":
|
|
case "info":
|
|
loglevel = level.InfoValue()
|
|
case "warn":
|
|
loglevel = level.WarnValue()
|
|
case "error":
|
|
loglevel = level.ErrorValue()
|
|
default:
|
|
return nil, errors.New("invalid 'loglevel' directive")
|
|
}
|
|
case "gopher":
|
|
s, err := parseGopherServer(line, buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
servers = append(servers, s)
|
|
case "finger":
|
|
s, err := parseFingerServer(line, buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
servers = append(servers, s)
|
|
case "gemini":
|
|
s, err := parseGeminiServer(line, buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
servers = append(servers, s)
|
|
case "spartan":
|
|
s, err := parseSpartanServer(line, buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
servers = append(servers, s)
|
|
case "nex":
|
|
s, err := parseNexServer(line, buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
servers = append(servers, s)
|
|
}
|
|
}
|
|
|
|
for i := range servers {
|
|
for j := range servers[i].Routes {
|
|
if name := servers[i].Routes[j].Modifiers.authName; name != "" {
|
|
auth, ok := auths[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("auth '%s' not found", name)
|
|
}
|
|
servers[i].Routes[j].Modifiers.Auth = auth
|
|
}
|
|
|
|
if name := servers[i].Routes[j].Modifiers.titanName; name != "" {
|
|
auth, ok := auths[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("auth '%s' not found", name)
|
|
}
|
|
servers[i].Routes[j].Modifiers.Titan = auth
|
|
}
|
|
}
|
|
}
|
|
|
|
return &Configuration{
|
|
SystemUser: suser,
|
|
LogLevel: loglevel,
|
|
Servers: servers,
|
|
}, nil
|
|
}
|
|
|
|
var invalidAuth = errors.New("invalid 'auth' directive")
|
|
|
|
func parseAuthDirective(info []string) (Auth, error) {
|
|
if len(info) < 2 {
|
|
return Auth{}, invalidAuth
|
|
}
|
|
auth := Auth{Name: info[0]}
|
|
|
|
switch info[1] {
|
|
case "clienttlsfile":
|
|
if len(info) < 3 {
|
|
return auth, invalidAuth
|
|
}
|
|
strat, err := ClientTLSFile(info[2])
|
|
if err != nil {
|
|
return auth, err
|
|
}
|
|
auth.Strategy = strat
|
|
case "clienttls":
|
|
if len(info) < 3 {
|
|
return auth, invalidAuth
|
|
}
|
|
auth.Strategy = ClientTLS(info[2])
|
|
case "hasclienttls":
|
|
auth.Strategy = HasClientTLSAuth{}
|
|
default:
|
|
return auth, invalidAuth
|
|
}
|
|
|
|
return auth, nil
|
|
}
|
|
|
|
func parseGopherServer(line string, buf *bufio.Reader) (Server, error) {
|
|
server := Server{Type: "gopher"}
|
|
|
|
if err := parseServerLine(&server, line); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
if err := parseServerDirectives(&server, buf); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
if len(server.Hostnames) != 1 {
|
|
return server, fmt.Errorf("gopher server expects 1 hostname, got %d", len(server.Hostnames))
|
|
}
|
|
|
|
return server, nil
|
|
}
|
|
|
|
func parseFingerServer(line string, buf *bufio.Reader) (Server, error) {
|
|
server := Server{Type: "finger"}
|
|
|
|
if err := parseServerLine(&server, line); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
if err := parseServerDirectives(&server, buf); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
if len(server.Hostnames) != 0 {
|
|
return server, errors.New("finger servers don't support 'host' directive")
|
|
}
|
|
|
|
return server, nil
|
|
}
|
|
|
|
func parseGeminiServer(line string, buf *bufio.Reader) (Server, error) {
|
|
server := Server{Type: "gemini"}
|
|
|
|
if err := parseServerLine(&server, line); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
if err := parseServerDirectives(&server, buf); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
return server, nil
|
|
}
|
|
|
|
func parseSpartanServer(line string, buf *bufio.Reader) (Server, error) {
|
|
server := Server{Type: "spartan"}
|
|
|
|
if err := parseServerLine(&server, line); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
if err := parseServerDirectives(&server, buf); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
return server, nil
|
|
}
|
|
|
|
func parseNexServer(line string, buf *bufio.Reader) (Server, error) {
|
|
server := Server{Type: "nex"}
|
|
|
|
if err := parseServerLine(&server, line); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
if err := parseServerDirectives(&server, buf); err != nil {
|
|
return server, err
|
|
}
|
|
|
|
return server, nil
|
|
}
|
|
|
|
func parseServerDirectives(server *Server, buf *bufio.Reader) error {
|
|
for {
|
|
line, err := buf.ReadString('\n')
|
|
if err != nil {
|
|
return err //EOF is unexpected inside a server
|
|
}
|
|
line = strings.Trim(line, " \t\n")
|
|
if line == "" || line[0] == '#' {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "}") {
|
|
break
|
|
}
|
|
|
|
tag, rest, _ := strings.Cut(line, " ")
|
|
switch tag {
|
|
case "host":
|
|
server.Hostnames = append(server.Hostnames, parseHost(rest)...)
|
|
case "servertls":
|
|
if server.Type == "spartan" {
|
|
return errors.New("servertls directive not allowed in spartan server")
|
|
}
|
|
if server.TLS != nil {
|
|
return fmt.Errorf("duplicate servertls directives in %s server", server.Type)
|
|
}
|
|
server.tlsCertFile, server.tlsKeyFile, server.TLS, err = parseServerTLS(rest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case "static", "cgi", "git":
|
|
dir, err := parseRouteDirective(line)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := validateRoute(server.Type, &dir); err != nil {
|
|
return err
|
|
}
|
|
server.Routes = append(server.Routes, dir)
|
|
default:
|
|
return fmt.Errorf("'%s' directives not supported in %s servers", tag, server.Type)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func emptyExceptTemplates(mods *Modifiers) bool {
|
|
if mods == nil {
|
|
return true
|
|
}
|
|
cpy := *mods
|
|
cpy.Templates = nil
|
|
return cpy.Empty()
|
|
}
|
|
|
|
func validateRoute(serverType string, dir *RouteDirective) error {
|
|
if dir.Type == "git" && !emptyExceptTemplates(&dir.Modifiers) {
|
|
return errors.New("unsupported 'with' modifier on 'git' directive")
|
|
}
|
|
|
|
if dir.Type == "cgi" && (dir.Modifiers.Exec || dir.Modifiers.DirList || dir.Modifiers.DirDefault != "") {
|
|
return errors.New("cgi directives only support the 'extendedgophermap' modifier")
|
|
}
|
|
|
|
if serverType == "finger" && (dir.Modifiers.DirDefault != "" || dir.Modifiers.DirList) {
|
|
return errors.New("finger servers don't support directory 'with' modifiers")
|
|
}
|
|
if serverType == "finger" && dir.Type != "static" && dir.Type != "cgi" {
|
|
return fmt.Errorf("finger servers don't support '%s' directives", dir.Type)
|
|
}
|
|
if serverType == "finger" && dir.Modifiers.authName != "" {
|
|
return errors.New("finger servers don't support 'auth' clauses")
|
|
}
|
|
if serverType != "finger" && dir.URLPath == "" {
|
|
return fmt.Errorf("url routes required in %s servers", serverType)
|
|
}
|
|
|
|
if serverType != "gopher" && dir.Modifiers.ExtendedGophermap {
|
|
return errors.New("'with extendedgophermap' outside gopher server")
|
|
}
|
|
|
|
if !(serverType == "gemini" || serverType == "spartan") && dir.Modifiers.AutoAtom {
|
|
return fmt.Errorf("%s servers don't support 'with autoatom'", serverType)
|
|
}
|
|
if dir.Modifiers.titanName != "" && (serverType != "gemini" || dir.Type != "static") {
|
|
return errors.New("titan modifier only allowed on gemini{static}")
|
|
}
|
|
|
|
if dir.Modifiers.ExecCmd != "" && !(dir.Type == "cgi" || (dir.Type == "static" && dir.Modifiers.Exec)) {
|
|
return errors.New("'cmd' modifier only valid on 'cgi' and 'static...with exec' directives")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseServerTLS(text string) (string, string, *tls.Config, error) {
|
|
spl := strings.Split(text, " ")
|
|
if len(spl) != 4 {
|
|
return "", "", nil, errors.New("invalid 'servertls' directive")
|
|
}
|
|
|
|
if spl[0] == "cert" {
|
|
spl[0], spl[1], spl[2], spl[3] = spl[2], spl[3], spl[0], spl[1]
|
|
}
|
|
if spl[0] != "key" || spl[2] != "cert" {
|
|
return "", "", nil, errors.New("invalid 'servertls' directive")
|
|
}
|
|
|
|
conf, err := gemini.FileTLS(spl[3], spl[1])
|
|
return spl[3], spl[1], conf, err
|
|
}
|
|
|
|
func parseHost(text string) []string {
|
|
hosts := []string{}
|
|
for _, segment := range strings.Split(text, ",") {
|
|
segment = strings.Trim(segment, " \t")
|
|
if segment == "" {
|
|
continue
|
|
}
|
|
hosts = append(hosts, segment)
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
func parseRouteDirective(line string) (RouteDirective, error) {
|
|
dir := RouteDirective{}
|
|
|
|
tag, rest, found := strings.Cut(line, " ")
|
|
if !found {
|
|
return dir, fmt.Errorf("invalid '%s' directive", tag)
|
|
}
|
|
dir.Type = tag
|
|
|
|
fspath, rest, _ := strings.Cut(rest, " ")
|
|
dir.FsPath = fspath
|
|
if rest == "" {
|
|
return dir, nil
|
|
}
|
|
|
|
var word string
|
|
for {
|
|
word, rest, found = strings.Cut(rest, " ")
|
|
if !found {
|
|
return dir, nil
|
|
}
|
|
|
|
switch word {
|
|
case "at":
|
|
var urlpath string
|
|
urlpath, rest, _ = strings.Cut(rest, " ")
|
|
dir.URLPath = urlpath
|
|
case "with":
|
|
var err error
|
|
dir.Modifiers, rest, err = parseModifiers(rest)
|
|
if err != nil {
|
|
return dir, err
|
|
}
|
|
default:
|
|
return dir, fmt.Errorf("invalid '%s' directive (unexpected '%s')", tag, word)
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseModifiers(text string) (Modifiers, string, error) {
|
|
text = strings.TrimPrefix(text, "with ")
|
|
mod := Modifiers{}
|
|
|
|
for {
|
|
idx := strings.IndexAny(text, " \t,")
|
|
if idx == 0 {
|
|
text = text[1:]
|
|
continue
|
|
}
|
|
|
|
var item, sep string
|
|
if idx > 0 {
|
|
item = text[:idx]
|
|
sep = string(text[idx])
|
|
text = text[idx+1:]
|
|
} else {
|
|
item = text
|
|
sep = ""
|
|
text = ""
|
|
}
|
|
|
|
switch item {
|
|
case "dirdefault":
|
|
if sep != " " {
|
|
return mod, "", errors.New("invalid 'dirdefault' clause")
|
|
}
|
|
text = strings.TrimLeft(text, " \t")
|
|
idx = strings.IndexAny(text, " \t,")
|
|
if idx == 0 {
|
|
return mod, "", errors.New("invalid 'dirdefault' clause")
|
|
} else if idx < 0 {
|
|
mod.DirDefault = text
|
|
text = ""
|
|
} else {
|
|
mod.DirDefault = text[0:idx]
|
|
text = text[idx+1:]
|
|
}
|
|
case "dirlist":
|
|
mod.DirList = true
|
|
case "exec":
|
|
mod.Exec = true
|
|
case "cmd":
|
|
if sep != " " {
|
|
return mod, "", errors.New("invalid 'cmd' clause")
|
|
}
|
|
text = strings.TrimLeft(text, " \t")
|
|
idx = strings.IndexAny(text, " \t,")
|
|
if idx == 0 {
|
|
return mod, "", errors.New("invalid 'cmd' clause")
|
|
} else if idx < 0 {
|
|
mod.ExecCmd = text
|
|
text = ""
|
|
} else {
|
|
mod.ExecCmd = text[0:idx]
|
|
text = text[idx+1:]
|
|
}
|
|
case "extendedgophermap":
|
|
mod.ExtendedGophermap = true
|
|
case "autoatom":
|
|
mod.AutoAtom = true
|
|
case "auth":
|
|
if sep != " " {
|
|
return mod, "", errors.New("invalid 'auth' clause")
|
|
}
|
|
text = strings.TrimLeft(text, " \t")
|
|
idx = strings.IndexAny(text, " \t,")
|
|
if idx == 0 {
|
|
return mod, "", errors.New("invalid 'auth' clause")
|
|
} else if idx < 0 {
|
|
mod.authName = text
|
|
text = ""
|
|
} else {
|
|
mod.authName = text[0:idx]
|
|
text = text[idx+1:]
|
|
}
|
|
case "titan":
|
|
if sep != " " {
|
|
return mod, "", errors.New("invalid 'titan' clause")
|
|
}
|
|
text = strings.TrimLeft(text, " \t")
|
|
idx = strings.IndexAny(text, " \t,")
|
|
if idx == 0 {
|
|
return mod, "", errors.New("invalid 'titan' clause")
|
|
} else if idx < 0 {
|
|
mod.titanName = text
|
|
text = ""
|
|
} else {
|
|
mod.titanName = text[0:idx]
|
|
text = text[idx+1:]
|
|
}
|
|
case "templates":
|
|
if sep != " " {
|
|
return mod, "", errors.New("invalid 'templates' clause")
|
|
}
|
|
text = strings.TrimLeft(text, " \t")
|
|
idx = strings.IndexAny(text, " \t,")
|
|
var err error
|
|
if idx == 0 {
|
|
return mod, "", errors.New("invalid 'templates' clause")
|
|
} else if idx < 0 {
|
|
mod.Templates, err = loadTemplates(text)
|
|
text = ""
|
|
} else {
|
|
mod.Templates, err = loadTemplates(text[0:idx])
|
|
text = text[idx+1:]
|
|
}
|
|
|
|
if err != nil {
|
|
return mod, "", err
|
|
}
|
|
default:
|
|
return mod, text, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadTemplates(dirpath string) (*template.Template, error) {
|
|
return template.ParseGlob(filepath.Join(dirpath, "*"))
|
|
}
|
|
|
|
func parseAuth(text string) (string, string, error) {
|
|
spl := strings.SplitN(text, " ", 2)
|
|
switch len(spl) {
|
|
case 1:
|
|
return spl[0], "", nil
|
|
case 2:
|
|
return spl[0], spl[1], nil
|
|
default:
|
|
return "", "", errors.New("invalid auth clause")
|
|
}
|
|
}
|
|
|
|
func parseServerLine(server *Server, line string) error {
|
|
if !strings.HasSuffix(line, "{") {
|
|
return errors.New("invalid server")
|
|
}
|
|
|
|
var ipstr, portstr string
|
|
defaultIP := "0.0.0.0"
|
|
var defaultPort string
|
|
|
|
line = strings.TrimRight(strings.TrimSuffix(line, "{"), " ")
|
|
tag, rest, found := strings.Cut(line, " ")
|
|
switch tag {
|
|
case "gopher":
|
|
defaultPort = "70"
|
|
case "finger":
|
|
defaultPort = "79"
|
|
case "gemini":
|
|
defaultPort = "1965"
|
|
case "spartan":
|
|
defaultPort = "300"
|
|
case "nex":
|
|
defaultPort = "1900"
|
|
default:
|
|
return errors.New("invalid server")
|
|
}
|
|
if !found {
|
|
ipstr = defaultIP
|
|
portstr = defaultPort
|
|
} else {
|
|
ipstr, portstr, found = strings.Cut(rest, ":")
|
|
if !found {
|
|
portstr = defaultPort
|
|
}
|
|
if ipstr == "" {
|
|
ipstr = "0.0.0.0"
|
|
}
|
|
}
|
|
|
|
port, err := strconv.ParseUint(portstr, 10, 16)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
server.IP = net.ParseIP(ipstr)
|
|
server.Port = uint16(port)
|
|
return nil
|
|
}
|