diff options
| -rw-r--r-- | auth.go | 115 | ||||
| -rw-r--r-- | config.go | 102 | ||||
| -rw-r--r-- | example.conf (renamed from config/example.sr71) | 61 | ||||
| -rw-r--r-- | finger.go | 85 | ||||
| -rw-r--r-- | gemini.go | 151 | ||||
| -rw-r--r-- | go.mod | 6 | ||||
| -rw-r--r-- | gopher.go | 119 | ||||
| -rw-r--r-- | logging.go | 18 | ||||
| -rw-r--r-- | main.go | 103 | ||||
| -rw-r--r-- | parse.go | 492 | ||||
| -rw-r--r-- | privdrop.go | 34 | ||||
| -rw-r--r-- | routes.go | 157 | ||||
| -rw-r--r-- | servers.go | 39 | ||||
| -rw-r--r-- | types.go | 66 | ||||
| -rw-r--r-- | userpath.go | 61 |
15 files changed, 1265 insertions, 344 deletions
@@ -0,0 +1,115 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "os/user" + "path/filepath" + "slices" + "strings" + + "tildegit.org/tjp/sliderule" + "tildegit.org/tjp/sliderule/gemini" +) + +func GeminiAuthMiddleware(auth *Auth) sliderule.Middleware { + if auth == nil { + return func(inner sliderule.Handler) sliderule.Handler { return inner } + } + + return func(inner sliderule.Handler) sliderule.Handler { + return sliderule.HandlerFunc(func(ctx context.Context, request *sliderule.Request) *sliderule.Response { + if auth.Strategy.Approve(ctx, request) { + return inner.Handle(ctx, request) + } + + if len(request.TLSState.PeerCertificates) == 0 { + return gemini.RequireCert("client certificate required") + } + return gemini.CertAuthFailure("client certificate rejected") + }) + } +} + +func ClientTLSFile(path string) (AuthStrategy, error) { + if strings.Contains(path, "~") { + return UserClientTLSAuth(path), nil + } + + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + contents, err := io.ReadAll(f) + if err != nil { + return nil, err + } + + fingerprints := []string{} + for _, line := range strings.Split(string(contents), "\n") { + line = strings.Trim(line, " \t\r") + if len(line) == sha256.Size*2 { + fingerprints = append(fingerprints, line) + } + } + return ClientTLSAuth(fingerprints), nil +} + +func ClientTLS(raw string) AuthStrategy { + fingerprints := []string{} + for _, fp := range strings.Split(raw, ",") { + fp = strings.Trim(fp, " \t\r") + if len(fp) == sha256.Size*2 { + fingerprints = append(fingerprints, fp) + } + } + return ClientTLSAuth(fingerprints) +} + +type UserClientTLSAuth string + +func (ca UserClientTLSAuth) Approve(ctx context.Context, request *sliderule.Request) bool { + u, err := user.Lookup(sliderule.RouteParams(ctx)["username"]) + if err != nil { + return false + } + fpath := resolveTilde(string(ca), u) + + strat, err := ClientTLSFile(fpath) + if err != nil { + return false + } + return strat.Approve(ctx, request) +} + +func resolveTilde(path string, u *user.User) string { + if strings.HasPrefix(path, "~/") { + return filepath.Join(u.HomeDir, path[1:]) + } + return strings.ReplaceAll(path, "~", u.Username) +} + +type ClientTLSAuth []string + +func (ca ClientTLSAuth) Approve(_ context.Context, request *sliderule.Request) bool { + if request.TLSState == nil || len(request.TLSState.PeerCertificates) == 0 { + return false + } + return slices.Contains(ca, fingerprint(request.TLSState.PeerCertificates[0].Raw)) +} + +func fingerprint(raw []byte) string { + hash := sha256.Sum256(raw) + return hex.EncodeToString(hash[:]) +} + +type HasClientTLSAuth struct{} + +func (_ HasClientTLSAuth) Approve(_ context.Context, request *sliderule.Request) bool { + return request.TLSState != nil && len(request.TLSState.PeerCertificates) > 0 +} diff --git a/config.go b/config.go deleted file mode 100644 index 7160c19..0000000 --- a/config.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "context" - "os" - "os/signal" - "os/user" - "strconv" - "strings" - "syscall" - - "tildegit.org/tjp/sliderule/logging" -) - -type config struct { - hostname string - - geminiRoot string - gopherRoot string - - geminiRepos string - gopherRepos string - - tlsKeyFile string - tlsCertFile string - - privilegedUsers []string - - fingerResponses map[string]string - - geminiAutoAtom bool -} - -func configure() config { - privileged := strings.Split(os.Getenv("PRIVILEGED_FINGERPRINTS"), ",") - - fingers := map[string]string{} - for _, pair := range os.Environ() { - key, val, _ := strings.Cut(pair, "=") - if !strings.HasPrefix(key, "FINGER_") { - continue - } - fingers[strings.ToLower(key[7:])] = val - } - - autoatom, err := strconv.ParseBool(os.Getenv("GEMINI_AUTOATOM")) - if err != nil { - autoatom = false - } - - return config{ - hostname: os.Getenv("HOST_NAME"), - geminiRoot: os.Getenv("GEMINI_ROOT"), - gopherRoot: os.Getenv("GOPHER_ROOT"), - geminiRepos: os.Getenv("GEMINI_REPOS"), - gopherRepos: os.Getenv("GOPHER_REPOS"), - tlsKeyFile: os.Getenv("TLS_KEY_FILE"), - tlsCertFile: os.Getenv("TLS_CERT_FILE"), - - privilegedUsers: privileged, - - fingerResponses: fingers, - - geminiAutoAtom: autoatom, - } -} - -func dropPrivileges() (bool, error) { - me, err := user.Current() - if err != nil { - return false, err - } - - if me.Uid != "0" { - return false, nil - } - - nobody, err := user.Lookup("nobody") - if err != nil { - return false, err - } - uid, err := strconv.Atoi(nobody.Uid) - if err != nil { - return false, err - } - - if err := syscall.Setuid(uid); err != nil { - return false, err - } - return true, nil -} - -func serverContext() (context.Context, logging.Logger, logging.Logger, logging.Logger, logging.Logger) { - debug, info, warn, err := logging.DefaultLoggers() - ctx, _ := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGHUP) - ctx = context.WithValue(ctx, "debuglog", debug) //nolint:staticcheck - ctx = context.WithValue(ctx, "infolog", info) //nolint:staticcheck - ctx = context.WithValue(ctx, "warnlog", warn) //nolint:staticcheck - ctx = context.WithValue(ctx, "errorlog", err) //nolint:staticcheck - - return ctx, debug, info, warn, err -} diff --git a/config/example.sr71 b/example.conf index 6c1a977..6181fcf 100644 --- a/config/example.sr71 +++ b/example.conf @@ -1,3 +1,26 @@ +# "auth" is a global directive that defines a named authentication strategy. +# The "auth" keyword is followed by a name, and then the strategy. +# "clienttlsfile" is a strategy which takes a path to a file which contains line-delimited SHA256 fingerprints of client certificates. +# Tildes (~) are allowed in the file path, in which case the strategy is only usable in a ~user-scoped directive. +auth private_gemini clienttlsfile ~/.private_gemini + +# The "clienttls" strategy takes comma-separated SHA256 fingerprints of client certificates. +auth is_tony clienttls 0284bcb38d7c98548df4a67587163276373ea8f9a8cc931a89f475557bd9f3a3 + +# The "hasclienttls" strategy requires only that the request be made with a client certificate. +auth is_named hasclienttls + +# "systemuser" is a global directive which controls privilege dropping. +# After performing some root-only actions (binding to gopher and finger ports, reading server key and certificate files), +# sr-71 will attempt to change its effective user to the named user (which may be a numeric user id). +# Alternatively, sr-71 can work when started as a non-root user but the "systemuser" directive shouldn't be used, and it won't be able to serve any protocol on privileged ports. +systemuser nobody + +# "loglevel" defines the minimum log level that will be sent to stdout. +# Allowed values are "debug", "info", "warn", "error". +# Omitting the "loglevel" directive allows all logs through, equivalent to "loglevel debug". +loglevel debug + # define a gopher server # This IP/port is the default, both components are optional. # To specify a port without changing the IP default, write it like ":70". @@ -6,6 +29,9 @@ gopher 0.0.0.0:70 { # It will be used for internal links, such as in directory listings. host tjp.lol + # A gopher server may include a single "servertls" directive like gemini (example below). + # In that case the gopher server will host encrypted gopher with TLS. + # The "static" directive exposes a filesystem directory at a given path prefix. # It will only serve files which are world-readable. # "with" introduces comma-separated modifiers to a directive. @@ -17,15 +43,15 @@ gopher 0.0.0.0:70 { # The "cgi" directive exposes a filesystem directory at a path prefix as well but executes requested files. # It will only execute world-executable files. - # It supports the "extendedgophermap" and "dirdefault" modifiers. + # It supports only the "extendedgophermap" modifier. # Executed files are assumed to produce gophermap, although the "extendedgophermap" modifier can make this more friendly. cgi /var/gopher/cgi at /cgi-bin with extendedgophermap # Directives which result in exposing a filesystem directory may include tilde (~) characters. - # It must be present in both the path prefix and the filesystem path, or neither. + # It must be present in both the path prefix and the filesystem path, or neither (not one without the other). # In the path prefix it will match a "~username" path segment and the user name will be captured. - # If the filesystem path begins with the ~ character it represents the user's home directory. - # Otherwise, it will be replaced by the user's name. + # If the filesystem path begins with "~/", it represents the user's home directory. + # Otherwise, the tilde will be replaced by the user's name. # So on a system where users' home directories are at /home, "/home/~" and "~" are the same (though the latter is more general). static ~/public_gopher at /~ with dirdefault gophermap, dirlist, exec, extendedgophermap cgi ~/public_gopher/cgi-bin at /~/cgi-bin @@ -62,27 +88,18 @@ gemini 0.0.0.0:1965 { static /var/gemini/docs at / with dirdefault index.gmi, dirlist, exec cgi /var/gemini/cgi at /cgi-bin - static ~/public_gemini at /~ with dirdefault index.gmi, dirlist, exec + # The "autoatom" modifier is allowed on directives in a gemini server. + # It causes any text/gemini responses to be available as atom at <path>.atom. + # It uses the "Subscribing to Gemini pages" spec (gemini://geminiprotocol.net/docs/companion/subscription.gmi) + # to convert the text/gemini to Atom. + # The "titan" modifier allows uploads to world-writable directories. + # It can only be used on "static" directives in gemini servers. + # It takes a required auth name which will guard just titan requests. + static ~/public_gemini at /~ with dirdefault index.gmi, dirlist, exec, autoatom, titan private_gemini cgi ~/public_gemini/cgi-bin at /~/cgi-bin - # "titan" enables uploads into to (over-)write into world-writable directories. - # It REQUIRES an "auth" clause that references an auth directive. - titan ~/public_gemini at /~ auth private_gemini - - # "static" and "cgi" directives support an "auth <name>" clause which requires that an authentication pass. + # "static", "cgi", and "git" directives support an "auth <name>" clause which requires that an authentication to pass. cgi ~/public_gemini/cgi-bin/private at /~/cgi-bin/private auth private_gemini git ~/code at /~/code } - -# "auth" is a global directive that defines a named authentication strategy. -# The "auth" keyword is followed by a name, and then the strategy. -# "clienttlsfile" is a strategy which takes a path to a file which contains line-delimited SHA256 fingerprints of client certificates. -# Tildes (~) are allowed in the file path, in which case the strategy is only usable in a ~user-scoped directive. -auth private_gemini clienttlsfile ~/.private_gemini - -# The "clienttls" strategy takes comma-separated SHA256 fingerprints of client certificates. -auth is_tony clienttls 0284bcb38d7c98548df4a67587163276373ea8f9a8cc931a89f475557bd9f3a3 - -# The "hasclienttls" strategy requires only that the request be made with a client certificate. -auth is_named hasclienttls diff --git a/finger.go b/finger.go new file mode 100644 index 0000000..6ae82dd --- /dev/null +++ b/finger.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + + sr "tildegit.org/tjp/sliderule" + "tildegit.org/tjp/sliderule/contrib/cgi" + "tildegit.org/tjp/sliderule/finger" + "tildegit.org/tjp/sliderule/logging" +) + +func buildFingerServer(server Server, config *Configuration) (sr.Server, error) { + addr := fmt.Sprintf("%s:%d", server.IP.String(), server.Port) + + _, info, _, errlog := Loggers(config) + _ = info.Log("msg", "building finger server", "addr", addr) + + if len(server.Routes) != 1 { + return nil, fmt.Errorf("finger server must have 1 route directive, found %d", len(server.Routes)) + } + + return finger.NewServer( + context.Background(), + "", + "tcp", + addr, + logging.LogRequests(info)(fingerHandler(server.Routes[0])), + errlog, + ) +} + +func fingerHandler(route RouteDirective) sr.Handler { + if route.Type != "static" && route.Type != "cgi" { + panic("invalid finger route type '" + route.Type + "'") + } + + return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { + u, err := user.Lookup(strings.TrimPrefix(request.Path, "/")) + if err != nil { + return nil + } + + var fpath string + if strings.HasPrefix(route.FsPath, "~/") { + fpath = filepath.Join(u.HomeDir, route.FsPath[2:]) + } else { + fpath = strings.Replace(route.FsPath, "~", u.Username, 1) + } + + st, err := os.Stat(fpath) + if err != nil { + return finger.Error(err.Error()) + } + if !st.Mode().IsRegular() { + return nil + } + + if st.Mode()&5 == 5 && (route.Modifiers.Exec || route.Type == "cgi") { + buf, code, err := cgi.RunCGI(ctx, request, fpath, "/", nil) + if err != nil { + return finger.Error("execution error") + } + if code != 0 { + return finger.Error(fmt.Sprintf("execution error: code %d", code)) + } + + return finger.Success(buf) + } + + if route.Type != "static" { + return nil + } + + file, err := os.Open(fpath) + if err != nil { + return finger.Error(err.Error()) + } + return finger.Success(file) + }) +} diff --git a/gemini.go b/gemini.go new file mode 100644 index 0000000..fd5dec5 --- /dev/null +++ b/gemini.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "errors" + "fmt" + + sr "tildegit.org/tjp/sliderule" + "tildegit.org/tjp/sliderule/contrib/cgi" + "tildegit.org/tjp/sliderule/contrib/fs" + "tildegit.org/tjp/sliderule/gemini" + "tildegit.org/tjp/sliderule/gemini/gemtext/atomconv" + "tildegit.org/tjp/sliderule/logging" + "tildegit.org/tjp/syw" +) + +func buildGeminiServers(servers []Server, config *Configuration) ([]sr.Server, error) { + _, info, _, errlog := Loggers(config) + _ = info.Log("msg", "building gemini servers", "count", len(servers)) + + groups := map[string][]*Server{} + for i := range servers { + addr := fmt.Sprintf("%s:%d", servers[i].IP.String(), servers[i].Port) + grp, ok := groups[addr] + if !ok { + groups[addr] = []*Server{&servers[i]} + } else { + groups[addr] = append(grp, &servers[i]) + } + } + + result := []sr.Server{} + for addr, configs := range groups { + _ = info.Log("msg", "building gemini server", "addr", addr) + var handler sr.Handler + if len(configs) == 1 { + handler = routes(*configs[0]) + } else { + mapping := map[string]sr.Handler{} + for _, config := range configs { + router := routes(*config) + for _, hostname := range config.Hostnames { + mapping[hostname] = router + } + } + + var catchall sr.Handler + if len(configs[0].Hostnames) > 0 { + catchall = mapping[configs[0].Hostnames[0]] + } + + handler = sr.VirtualHosts(mapping, catchall) + } + + var hostname string + for _, conf := range configs { + if len(conf.Hostnames) > 0 { + hostname = conf.Hostnames[0] + break + } + } + + if configs[0].TLS == nil { + return nil, errors.New("gemini server must have a servertls directive") + } + + gemsrv, err := gemini.NewServer( + context.Background(), + hostname, + "tcp", + addr, + logging.LogRequests(info)(handler), + errlog, + configs[0].TLS, + ) + if err != nil { + return nil, err + } + + result = append(result, gemsrv) + } + + return result, nil +} + +func addGeminiRoute(router *sr.Router, route RouteDirective) { + switch route.Type { + case "static": + addGeminiStaticRoute(router, route) + case "cgi": + buildAndAddRoute(router, route, func(route RouteDirective) sr.Handler { + handler := cgi.GeminiCGIDirectory(route.FsPath, route.URLPath) + if route.Modifiers.AutoAtom { + handler = atomconv.Auto(handler) + } + return GeminiAuthMiddleware(route.Auth)(handler) + }) + case "git": + buildAndAddRoute(router, route, func(route RouteDirective) sr.Handler { + var handler sr.Handler = syw.GeminiRouter(route.FsPath, nil) + if route.Modifiers.AutoAtom { + handler = atomconv.Auto(handler) + } + return GeminiAuthMiddleware(route.Auth)(handler) + }) + } +} + +func addGeminiStaticRoute(router *sr.Router, route RouteDirective) { + buildAndAddRoute(router, route, func(route RouteDirective) sr.Handler { + handlers := []sr.Handler{} + + if route.Modifiers.Exec { + handlers = append(handlers, cgi.GeminiCGIDirectory(route.FsPath, route.URLPath)) + } + + handlers = append(handlers, fs.GeminiFileHandler(route.FsPath, route.URLPath)) + + if route.Modifiers.DirDefault != "" { + handlers = append( + handlers, + fs.GeminiDirectoryDefault(route.FsPath, route.URLPath, route.Modifiers.DirDefault), + ) + } + + if route.Modifiers.DirList { + handlers = append(handlers, fs.GeminiDirectoryListing(route.FsPath, route.URLPath, nil)) + } + + var handler sr.Handler + if len(handlers) == 1 { + handler = handlers[0] + } + handler = sr.FallthroughHandler(handlers...) + + if route.Modifiers.AutoAtom { + handler = atomconv.Auto(handler) + } + + handler = GeminiAuthMiddleware(route.Auth)(handler) + + if route.Modifiers.Titan != nil { + titan := fs.TitanUpload(route.FsPath, route.URLPath, route.Modifiers.Titan.Strategy.Approve)(handler) + handler = sr.Filter(func(ctx context.Context, request *sr.Request) bool { + return request.Scheme == "titan" + }, handler)(titan) + } + + return handler + }) +} @@ -3,11 +3,9 @@ module tildegit.org/tjp/sr-71 go 1.21.0 require ( + github.com/go-kit/log v0.2.1 tildegit.org/tjp/sliderule v1.3.4-0.20230923191849-09c482d5016c tildegit.org/tjp/syw v0.9.1-0.20230923192020-d5566a4ed9ad ) -require ( - github.com/go-kit/log v0.2.1 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect -) +require github.com/go-logfmt/logfmt v0.6.0 // indirect diff --git a/gopher.go b/gopher.go new file mode 100644 index 0000000..5e36266 --- /dev/null +++ b/gopher.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "fmt" + "strings" + + sr "tildegit.org/tjp/sliderule" + "tildegit.org/tjp/sliderule/contrib/cgi" + "tildegit.org/tjp/sliderule/contrib/fs" + "tildegit.org/tjp/sliderule/gopher" + "tildegit.org/tjp/sliderule/gopher/gophermap" + "tildegit.org/tjp/sliderule/logging" + "tildegit.org/tjp/syw" +) + +func buildGopherServer(server Server, config *Configuration) (sr.Server, error) { + addr := fmt.Sprintf("%s:%d", server.IP.String(), server.Port) + + _, info, _, errlog := Loggers(config) + _ = info.Log("msg", "building gopher server", "addr", addr) + + return gopher.NewServer( + context.Background(), + server.Hostnames[0], + "tcp", + addr, + logging.LogRequests(info)(routes(server)), + errlog, + ) +} + +func addGopherRoute(router *sr.Router, route RouteDirective) { + switch route.Type { + case "static": + addGopherStaticRoute(router, route) + case "cgi": + addGopherCGIRoute(router, route) + case "git": + addGopherGitRoute(router, route) + } +} + +func addGopherStaticRoute(router *sr.Router, route RouteDirective) { + dirmaps := []string{} + if route.Modifiers.DirDefault != "" { + dirmaps = append(dirmaps, route.Modifiers.DirDefault) + } + settings := &gophermap.FileSystemSettings{ + ParseExtended: route.Modifiers.ExtendedGophermap, + Exec: route.Modifiers.Exec, + ListUsers: false, + DirMaps: dirmaps, + } + + buildAndAddRoute(router, route, func(route RouteDirective) sr.Handler { + handlers := []sr.Handler{} + + if route.Modifiers.Exec { + handlers = append(handlers, cgi.ExecGopherMaps(route.FsPath, route.URLPath, settings)) + } + + handlers = append(handlers, fs.GopherFileHandler(route.FsPath, route.URLPath, settings)) + + if route.Modifiers.DirDefault != "" { + handlers = append(handlers, fs.GopherDirectoryDefault(route.FsPath, route.URLPath, settings)) + } + + if route.Modifiers.DirList { + handlers = append(handlers, fs.GopherDirectoryListing(route.FsPath, route.URLPath, settings)) + } + + if len(handlers) == 1 { + return handlers[0] + } + return sr.FallthroughHandler(handlers...) + }) +} + +func addGopherCGIRoute(router *sr.Router, route RouteDirective) { + dirmaps := []string{} + if route.Modifiers.DirDefault != "" { + dirmaps = append(dirmaps, route.Modifiers.DirDefault) + } + settings := &gophermap.FileSystemSettings{ + ParseExtended: route.Modifiers.ExtendedGophermap, + Exec: true, + ListUsers: false, + DirMaps: dirmaps, + } + + buildAndAddRoute(router, route, func(route RouteDirective) sr.Handler { + return cgi.GopherCGIDirectory(route.FsPath, route.URLPath, settings) + }) +} + +func addGopherGitRoute(router *sr.Router, route RouteDirective) { + buildAndAddRoute(router, route, func(route RouteDirective) sr.Handler { + return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { + subrouter := syw.GopherRouter(route.FsPath, nil) + + reqclone := cloneRequest(request) + reqclone.Path = strings.TrimPrefix(reqclone.Path, route.URLPath) + + handler, params := subrouter.Match(reqclone) + if handler == nil { + return nil + } + return handler.Handle(context.WithValue(ctx, sr.RouteParamsKey, params), request) + }) + }) +} + +func cloneRequest(request *sr.Request) *sr.Request { + r := *request + u := *request.URL + r.URL = &u + return &r +} diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..9221b24 --- /dev/null +++ b/logging.go @@ -0,0 +1,18 @@ +package main + +import ( + "sync" + + "github.com/go-kit/log/level" + "tildegit.org/tjp/sliderule/logging" +) + +var ( + once sync.Once + debug, info, warn, errlog logging.Logger +) + +func Loggers(config *Configuration) (logging.Logger, logging.Logger, logging.Logger, logging.Logger) { + base := level.NewFilter(logging.Base(), level.Allow(config.LogLevel)) + return level.Debug(base), level.Info(base), level.Warn(base), level.Error(base) +} @@ -1,96 +1,57 @@ package main import ( - "context" - "crypto/tls" + "fmt" + "io" "log" + "os" "sync" - - sr "tildegit.org/tjp/sliderule" - "tildegit.org/tjp/sliderule/finger" - "tildegit.org/tjp/sliderule/gemini" - "tildegit.org/tjp/sliderule/gopher" - "tildegit.org/tjp/sliderule/logging" ) func main() { - conf := configure() - gemTLS, err := gemini.FileTLS(conf.tlsCertFile, conf.tlsKeyFile) - if err != nil { - log.Fatal(err) + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "%s <configfile>\n", os.Args[0]) + os.Exit(1) } - ctx, _, info, warn, errlog := serverContext() - - gopherSrv, err := buildGopher(ctx, conf, info, errlog) - if err != nil { - log.Fatal(err) + var configfile io.ReadCloser + if os.Args[1] == "-" { + configfile = os.Stdin + } else { + var err error + configfile, err = os.Open(os.Args[1]) + if err != nil { + log.Fatal(err) + } } - fingerSrv, err := buildFinger(ctx, conf, info, errlog) + config, err := Parse(configfile) if err != nil { log.Fatal(err) } - dropped, err := dropPrivileges() + _, _, _, errlog := Loggers(config) + + servers, err := buildServers(config) if err != nil { - log.Fatal(err) - } - if !dropped { - _ = warn.Log("msg", "dropping privileges to 'nobody' failed") + _ = errlog.Log("msg", "error building servers", "err", err) + os.Exit(1) } - gemSrv, err := buildGemini(ctx, conf, gemTLS, info, errlog) - if err != nil { - log.Fatal(err) + if err := privdrop(config); err != nil { + _ = errlog.Log("msg", "failed to drop privileges", "err", err) + os.Exit(1) } wg := &sync.WaitGroup{} - - - wg.Add(3) - go runServer(gemSrv, wg) - go runServer(gopherSrv, wg) - go runServer(fingerSrv, wg) + wg.Add(len(servers)) + for i := range servers { + server := servers[i] + go func() { + defer wg.Done() + _ = server.Serve() + }() + } wg.Wait() } - -func buildGemini( - ctx context.Context, - conf config, - gemTLS *tls.Config, - infolog logging.Logger, - errlog logging.Logger, -) (sr.Server, error) { - handler := logging.LogRequests(infolog)(geminiRouter(conf)) - infolog.Log("msg", "starting gemini server", "gemini_root", conf.geminiRoot) - return gemini.NewServer(ctx, conf.hostname, "tcp", "", handler, errlog, gemTLS) -} - -func buildGopher( - ctx context.Context, - conf config, - infolog logging.Logger, - errlog logging.Logger, -) (sr.Server, error) { - handler := logging.LogRequests(infolog)(gopherRouter(conf)) - infolog.Log("msg", "starting gopher server", "gopher_root", conf.gopherRoot) - return gopher.NewServer(ctx, conf.hostname, "tcp", "", handler, errlog) -} - -func buildFinger( - ctx context.Context, - conf config, - infolog logging.Logger, - errlog logging.Logger, -) (sr.Server, error) { - handler := logging.LogRequests(infolog)(fingerHandler(conf)) - infolog.Log("msg", "starting finger server", "finger_users", len(conf.fingerResponses)) - return finger.NewServer(ctx, conf.hostname, "tcp", "", handler, errlog) -} - -func runServer(server sr.Server, wg *sync.WaitGroup) error { - defer wg.Done() - return server.Serve() -} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..0fe3f30 --- /dev/null +++ b/parse.go @@ -0,0 +1,492 @@ +package main + +import ( + "bufio" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "os/user" + "strconv" + "strings" + + "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) + } + } + + for i := range servers { + for j := range servers[i].Routes { + if name := servers[i].Routes[j].authName; name != "" { + auth, ok := auths[name] + if !ok { + return nil, fmt.Errorf("auth '%s' not found", name) + } + servers[i].Routes[j].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 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": + 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 validateRoute(serverType string, dir *RouteDirective) error { + if dir.Type == "git" && !dir.Modifiers.Empty() { + return errors.New("git directives don't support 'with' modifiers") + } + + 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.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" && dir.Modifiers.AutoAtom { + return fmt.Errorf("%s servers don't support 'with autoatom'", serverType) + } + if dir.Modifiers.titanName != "" && (serverType != "gemini" || dir.Type != "static") { + return fmt.Errorf("titan modifier only allowed on gemini{static}") + } + + return nil +} + +func parseServerTLS(text 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") + } + + return gemini.FileTLS(spl[3], spl[1]) +} + +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 + } + + word, rest, found := strings.Cut(rest, " ") + if found && word == "at" { + var urlpath string + urlpath, rest, _ = strings.Cut(rest, " ") + dir.URLPath = urlpath + } else if found { + rest = word + " " + rest + } + + for rest != "" { + word, rest, found = strings.Cut(rest, " ") + if !found { + return dir, fmt.Errorf("invalid '%s' directive", tag) + } + + var err error + if word == "with" { + dir.Modifiers, rest, err = parseModifiers(rest) + } else if word == "auth" { + dir.authName, rest, err = parseAuth(rest) + } + if err != nil { + return dir, err + } + } + + return dir, nil +} + +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 "extendedgophermap": + mod.ExtendedGophermap = true + case "autoatom": + mod.AutoAtom = true + 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:] + } + default: + return mod, text, nil + } + } +} + +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" + 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 +} diff --git a/privdrop.go b/privdrop.go new file mode 100644 index 0000000..c866430 --- /dev/null +++ b/privdrop.go @@ -0,0 +1,34 @@ +package main + +import ( + "errors" + "fmt" + "os/user" + "strconv" + "syscall" +) + +func privdrop(config *Configuration) error { + if config.SystemUser == nil { + return nil + } + + current, err := user.Current() + if err != nil { + return fmt.Errorf("looking up current user: %w", err) + } + if current.Uid != "0" { + return errors.New("'systemuser' directive requires running as root user") + } + + uid, err := strconv.Atoi(config.SystemUser.Uid) + if err != nil { + return errors.New("invalid 'systemuser' directive") + } + + if err := syscall.Setuid(uid); err != nil { + return fmt.Errorf("setuid: %w", err) + } + + return nil +} @@ -1,157 +1,24 @@ package main import ( - "context" - "crypto/sha256" - "crypto/x509" - "encoding/hex" - "os" - "path/filepath" - "sort" - "strings" - sr "tildegit.org/tjp/sliderule" - "tildegit.org/tjp/sliderule/contrib/cgi" - "tildegit.org/tjp/sliderule/contrib/fs" - "tildegit.org/tjp/sliderule/contrib/tlsauth" - "tildegit.org/tjp/sliderule/finger" - "tildegit.org/tjp/sliderule/gemini" - "tildegit.org/tjp/sliderule/gemini/gemtext/atomconv" - "tildegit.org/tjp/sliderule/gopher/gophermap" - "tildegit.org/tjp/sliderule/logging" - "tildegit.org/tjp/syw" ) -func geminiRouter(conf config) sr.Handler { - fsys := os.DirFS(conf.geminiRoot) - - privileged := tlsAuth(conf.privilegedUsers) - - router := &sr.Router{} - - router.Route( - "/*", - gemini.GeminiOnly(true)(sr.FallthroughHandler( - fs.TitanUpload(privileged, conf.geminiRoot)(postUploadRedirect), - fs.GeminiFileHandler(fsys), - fs.GeminiDirectoryDefault(fsys, "index.gmi"), - fs.GeminiDirectoryListing(fsys, nil), - )), - ) - - router.Route( - "/cgi-bin/*", - gemini.GeminiOnly(false)(cgi.GeminiCGIDirectory( - "/cgi-bin/", - strings.Join([]string{".", strings.Trim(conf.geminiRoot, "/"), "cgi-bin"}, "/"), - )), - ) - - router.Route( - "/cgi-bin/private/*", - gemini.GeminiOnly(false)(tlsauth.GeminiAuth(privileged)( - cgi.GeminiCGIDirectory("/cgi-bin/private/", strings.Join([]string{ - ".", - strings.Trim(conf.geminiRoot, "/"), - "cgi-bin", - "private", - }, "/")), - )), - ) - - if conf.geminiRepos != "" { - router.Mount("/git", syw.GeminiRouter(conf.geminiRepos, nil)) - } - - h := router.Handler() - if conf.geminiAutoAtom { - h = atomconv.Auto(h) - } - - return h -} - -func gopherRouter(conf config) sr.Handler { - settings := gophermap.FileSystemSettings{ - ParseExtended: true, - Exec: true, - ListUsers: false, - DirMaps: []string{"gophermap"}, - DirTag: "gophertag", - } - +func routes(server Server) *sr.Router { router := &sr.Router{} - - router.Route( - "/*", - sr.FallthroughHandler( - cgi.ExecGopherMaps("/", conf.gopherRoot, &settings), - fs.GopherFileHandler(conf.gopherRoot, &settings), - fs.GopherDirectoryDefault(conf.gopherRoot, &settings), - fs.GopherDirectoryListing(conf.gopherRoot, &settings), - ), - ) - - router.Route( - "/cgi-bin/*", - cgi.GopherCGIDirectory("/cgi-bin/", filepath.Join(conf.gopherRoot, "cgi-bin"), &settings), - ) - - if conf.gopherRepos != "" { - router.Mount("/git", syw.GopherRouter(conf.gopherRepos, nil)) + for _, route := range server.Routes { + addRoute(server, router, route) } - - return router.Handler() + return router } -var postUploadRedirect = sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { - u := *request.URL - u.Path = strings.SplitN(u.Path, ";", 2)[0] - u.Scheme = "gemini" - return gemini.Redirect(u.String()) -}) - -func tlsAuth(uploaders []string) tlsauth.Approver { - sort.Strings(uploaders) - - return func(cert *x509.Certificate) bool { - raw := sha256.Sum256(cert.Raw) - user := hex.EncodeToString(raw[:]) - - _, found := sort.Find(len(uploaders), func(i int) int { - switch { - case uploaders[i] < user: - return 1 - case uploaders[i] == user: - return 0 - default: - return -1 - } - }) - return found +func addRoute(server Server, router *sr.Router, route RouteDirective) { + switch server.Type { + case "gopher": + addGopherRoute(router, route) + case "gemini": + addGeminiRoute(router, route) + default: + panic("invalid server type '" + server.Type + "'") } } - -func fingerHandler(conf config) sr.Handler { - return sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { - name := strings.TrimPrefix(request.Path, "/") - if name == "" { - return finger.Error("listings not permitted") - } - - path, ok := conf.fingerResponses[strings.ToLower(name)] - if !ok { - return finger.Error("user not found") - } - - file, err := os.Open(path) - if err != nil { - ctx.Value("errorlog").(logging.Logger).Log( - "msg", "finger response file open error", - "error", err, - ) - } - - return finger.Success(file) - }) -} diff --git a/servers.go b/servers.go new file mode 100644 index 0000000..285b69c --- /dev/null +++ b/servers.go @@ -0,0 +1,39 @@ +package main + +import ( + sr "tildegit.org/tjp/sliderule" +) + +func buildServers(config *Configuration) ([]sr.Server, error) { + result := []sr.Server{} + + geminis := []Server{} + for _, server := range config.Servers { + switch server.Type { + case "gopher": + srv, err := buildGopherServer(server, config) + if err != nil { + return nil, err + } + result = append(result, srv) + case "finger": + srv, err := buildFingerServer(server, config) + if err != nil { + return nil, err + } + result = append(result, srv) + case "gemini": + geminis = append(geminis, server) + } + } + + if len(geminis) > 0 { + srvs, err := buildGeminiServers(geminis, config) + if err != nil { + return nil, err + } + result = append(result, srvs...) + } + + return result, nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..8d010b2 --- /dev/null +++ b/types.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "crypto/tls" + "net" + "os/user" + + "github.com/go-kit/log/level" + "tildegit.org/tjp/sliderule" +) + +type Modifiers struct { + DirDefault string + DirList bool + Exec bool + ExtendedGophermap bool + AutoAtom bool + Titan *Auth + + titanName string +} + +func (m Modifiers) Empty() bool { + return m.DirDefault == "" && !m.DirList && !m.Exec && !m.ExtendedGophermap +} + +type RouteDirective struct { + // Allowed: "static", "cgi", "git", "titan" + Type string + + // "<FsPath> at <URLPath>" + FsPath string + URLPath string + + // "with ..." + Modifiers Modifiers + + Auth *Auth + authName string +} + +type Server struct { + Type string + IP net.IP + Port uint16 + TLS *tls.Config + Hostnames []string + Routes []RouteDirective +} + +type Auth struct { + Name string + Strategy AuthStrategy +} + +type AuthStrategy interface { + Approve(context.Context, *sliderule.Request) bool +} + +type Configuration struct { + SystemUser *user.User + LogLevel level.Value + + Servers []Server +} diff --git a/userpath.go b/userpath.go new file mode 100644 index 0000000..9c9793f --- /dev/null +++ b/userpath.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "os/user" + "path" + "path/filepath" + "strings" + + sr "tildegit.org/tjp/sliderule" +) + +func usernameFromRouter(ctx context.Context) (string, bool) { + username, ok := sr.RouteParams(ctx)["username"] + return username, ok +} + +func userFsRoute(ctx context.Context, route RouteDirective) (RouteDirective, bool) { + username, ok := usernameFromRouter(ctx) + if !ok { + return route, false + } + + u, err := user.Lookup(username) + if err != nil { + return route, false + } + + route.URLPath = strings.ReplaceAll(route.URLPath, "~", "~"+u.Username) + if strings.HasPrefix(route.FsPath, "~/") { + route.FsPath = filepath.Join(u.HomeDir, route.FsPath[2:]) + } else { + route.FsPath = strings.ReplaceAll(route.FsPath, "/~/", "/"+u.Username+"/") + } + return route, true +} + +func buildAndAddRoute(router *sr.Router, route RouteDirective, handlerf func(RouteDirective) sr.Handler) { + var ( + urlpath string + handler sr.Handler + ) + + if strings.IndexByte(route.FsPath, '~') < 0 { + urlpath = route.URLPath + handler = handlerf(route) + } else { + urlpath = strings.Replace(route.URLPath, "~", "~:username", 1) + handler = sr.HandlerFunc(func(ctx context.Context, request *sr.Request) *sr.Response { + route, ok := userFsRoute(ctx, route) + if !ok { + return nil + } + + return handlerf(route).Handle(ctx, request) + }) + } + + router.Route(urlpath, handler) + router.Route(path.Join(urlpath, "*"), handler) +} |
