sr-71/parse.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
}