config file refactor

This commit is contained in:
tjpcc 2023-09-28 08:08:48 -06:00
parent b4f45f7c65
commit 6e1c25af36
15 changed files with 1268 additions and 347 deletions

115
auth.go Normal file
View File

@ -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
}

102
config.go
View File

@ -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
}

View File

@ -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

85
finger.go Normal file
View File

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

151
gemini.go Normal file
View File

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

6
go.mod
View File

@ -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

119
gopher.go Normal file
View File

@ -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
}

18
logging.go Normal file
View File

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

109
main.go
View File

@ -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 len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "%s <configfile>\n", os.Args[0])
os.Exit(1)
}
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)
}
}
config, err := Parse(configfile)
if err != nil {
log.Fatal(err)
}
ctx, _, info, warn, errlog := serverContext()
_, _, _, errlog := Loggers(config)
gopherSrv, err := buildGopher(ctx, conf, info, errlog)
servers, err := buildServers(config)
if err != nil {
log.Fatal(err)
_ = errlog.Log("msg", "error building servers", "err", err)
os.Exit(1)
}
fingerSrv, err := buildFinger(ctx, conf, info, errlog)
if err != nil {
log.Fatal(err)
}
dropped, err := dropPrivileges()
if err != nil {
log.Fatal(err)
}
if !dropped {
_ = warn.Log("msg", "dropping privileges to 'nobody' failed")
}
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()
}

492
parse.go Normal file
View File

@ -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
}

34
privdrop.go Normal file
View File

@ -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
}

157
routes.go
View File

@ -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)
func routes(server Server) *sr.Router {
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))
for _, route := range server.Routes {
addRoute(server, router, route)
}
h := router.Handler()
if conf.geminiAutoAtom {
h = atomconv.Auto(h)
}
return h
return router
}
func gopherRouter(conf config) sr.Handler {
settings := gophermap.FileSystemSettings{
ParseExtended: true,
Exec: true,
ListUsers: false,
DirMaps: []string{"gophermap"},
DirTag: "gophertag",
}
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))
}
return router.Handler()
}
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)
})
}

39
servers.go Normal file
View File

@ -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
}

66
types.go Normal file
View File

@ -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
}

61
userpath.go Normal file
View File

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