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) } } 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 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" 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 }