From 6e1c25af361dde4c063eccbf769e966df4b65f23 Mon Sep 17 00:00:00 2001 From: tjpcc Date: Thu, 28 Sep 2023 08:08:48 -0600 Subject: config file refactor --- auth.go | 115 ++++++++++++ config.go | 102 ----------- config/example.sr71 | 88 ---------- example.conf | 105 +++++++++++ finger.go | 85 +++++++++ gemini.go | 151 ++++++++++++++++ go.mod | 6 +- gopher.go | 119 +++++++++++++ logging.go | 18 ++ main.go | 103 ++++------- parse.go | 492 ++++++++++++++++++++++++++++++++++++++++++++++++++++ privdrop.go | 34 ++++ routes.go | 157 ++--------------- servers.go | 39 +++++ types.go | 66 +++++++ userpath.go | 61 +++++++ 16 files changed, 1331 insertions(+), 410 deletions(-) create mode 100644 auth.go delete mode 100644 config.go delete mode 100644 config/example.sr71 create mode 100644 example.conf create mode 100644 finger.go create mode 100644 gemini.go create mode 100644 gopher.go create mode 100644 logging.go create mode 100644 parse.go create mode 100644 privdrop.go create mode 100644 servers.go create mode 100644 types.go create mode 100644 userpath.go diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..c482366 --- /dev/null +++ b/auth.go @@ -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/config/example.sr71 deleted file mode 100644 index 6c1a977..0000000 --- a/config/example.sr71 +++ /dev/null @@ -1,88 +0,0 @@ -# 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". -gopher 0.0.0.0:70 { - # A gopher server MUST include a single "host" directive with a single hostname. - # It will be used for internal links, such as in directory listings. - host tjp.lol - - # 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. - # - "exec" causes world-executable files to be executed as if they were in a cgi directory. - # - "extendedgophermap" allows the sliderule extended form for gophermap files. - # - "dirdefault " uses a given filename for requests made for the directory. - # - "dirlist" builds listings of requested directories, at a lower priority than "dirdefault". - static /var/gopher/docs at / with dirdefault gophermap, dirlist, exec, extendedgophermap - - # 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. - # 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. - # 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. - # 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 - - # The "git" directive exposes git repos under a filesystem directory. - # Only git repositories in the given filesystem directory (not its children) are exposed. - git ~/code at /~/code -} - -# define a finger server -# This is the default host and port, and both or either may be omitted. -# Only a single "static" or "cgi" directive is allowed in a finger server. -# In either case a ~ must be present in the path, and there is no "at " clause. -# "static ... with exec" differs from "cgi" in that if the file is not executable, "static" will serve the file's contents instead. -# There is no support for /W extended form, user listings, or serving as a jump host. -finger 0.0.0.0:79 { - static ~/.finger with exec -} - -# define a gemini server -# This is the default host and port, and both or either may be omitted. -gemini 0.0.0.0:1965 { - # "host" directives are allowed in gemini servers. - # "host" is followed by one or more comma-separated hostnames that will be used to match this server. - # Multiple gemini servers may be defined on the same IP/port, in which case the hosts will delineate - # which server's behavior is triggered by a given request. - host tjp.lol - - # A gemini server MUST have a single "servertls" directive with "key " and "cert " clauses. - servertls key /var/gemini/tls/server.key cert /var/gemini/tls/server.crt - - # "static" and "cgi" directives work much like in gopher servers. - # There is no "extendedgophermap" modifier in gemini, however. - 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 - 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 " clause which requires that an authentication 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/example.conf b/example.conf new file mode 100644 index 0000000..6181fcf --- /dev/null +++ b/example.conf @@ -0,0 +1,105 @@ +# "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". +gopher 0.0.0.0:70 { + # A gopher server MUST include a single "host" directive with a single hostname. + # 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. + # - "exec" causes world-executable files to be executed as if they were in a cgi directory. + # - "extendedgophermap" allows the sliderule extended form for gophermap files. + # - "dirdefault " uses a given filename for requests made for the directory. + # - "dirlist" builds listings of requested directories, at a lower priority than "dirdefault". + static /var/gopher/docs at / with dirdefault gophermap, dirlist, exec, extendedgophermap + + # 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 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 (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 "~/", 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 + + # The "git" directive exposes git repos under a filesystem directory. + # Only git repositories in the given filesystem directory (not its children) are exposed. + git ~/code at /~/code +} + +# define a finger server +# This is the default host and port, and both or either may be omitted. +# Only a single "static" or "cgi" directive is allowed in a finger server. +# In either case a ~ must be present in the path, and there is no "at " clause. +# "static ... with exec" differs from "cgi" in that if the file is not executable, "static" will serve the file's contents instead. +# There is no support for /W extended form, user listings, or serving as a jump host. +finger 0.0.0.0:79 { + static ~/.finger with exec +} + +# define a gemini server +# This is the default host and port, and both or either may be omitted. +gemini 0.0.0.0:1965 { + # "host" directives are allowed in gemini servers. + # "host" is followed by one or more comma-separated hostnames that will be used to match this server. + # Multiple gemini servers may be defined on the same IP/port, in which case the hosts will delineate + # which server's behavior is triggered by a given request. + host tjp.lol + + # A gemini server MUST have a single "servertls" directive with "key " and "cert " clauses. + servertls key /var/gemini/tls/server.key cert /var/gemini/tls/server.crt + + # "static" and "cgi" directives work much like in gopher servers. + # There is no "extendedgophermap" modifier in gemini, however. + static /var/gemini/docs at / with dirdefault index.gmi, dirlist, exec + cgi /var/gemini/cgi at /cgi-bin + + # The "autoatom" modifier is allowed on directives in a gemini server. + # It causes any text/gemini responses to be available as atom at .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 + + # "static", "cgi", and "git" directives support an "auth " 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 +} 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 + }) +} diff --git a/go.mod b/go.mod index 556f2ea..97eef39 100644 --- a/go.mod +++ b/go.mod @@ -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) +} diff --git a/main.go b/main.go index 69ec021..b6bd5de 100644 --- a/main.go +++ b/main.go @@ -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 \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 +} diff --git a/routes.go b/routes.go index 83f2868..f1f57fd 100644 --- a/routes.go +++ b/routes.go @@ -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 + + // " at " + 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) +} -- cgit v1.2.3