// Command comparesrht is the compare.sr.ht daemon: a stateless HTTP service // that renders git ref-to-ref diffs and single-commit views for a self-hosted // SourceHut instance. // // It reuses core-go's server.New assembly so it shares the standard SourceHut // daemon lifecycle (crypto initialization, -b/-m/-p flags, warm shutdown on // SIGINT) with the rest of the fleet, but it deliberately does NOT call // WithDefaultMiddleware: compare.sr.ht owns no Postgres or Redis and must serve // anonymous viewers, so it installs its own lightweight middleware group on the // anonymous router instead (see the web package documentation for the exact // chain). // // Flags (parsed by core-go's server.New): // // -b addr bind address (repeatable); default 127.0.0.1:5090 // -d debug (verbose request logging in core-go) // -m addr Prometheus metrics bind (default random port) // -p addr pprof bind (default random localhost port) // // Configuration is loaded from the shared SourceHut config.ini via core-go's // fixed search path (./config.ini, ../config.ini, /etc/sr.ht/config.ini, // /etc/sr.ht/*.ini). All required keys are validated up front with clear // messages so a misconfiguration fails loudly at startup rather than as a deep // panic on the first request. package main import ( "fmt" "log/slog" "os" "slices" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/vaughan0/go-ini" "go.bigb.es/auxilia/scribe" "sourcecraft.dev/bigbes/sr-ht-core/config" coreserver "sourcecraft.dev/bigbes/sr-ht-core/server" "sourcecraft.dev/bigbes/sr-ht-ecore/login" "sourcecraft.dev/bigbes/sr-ht-compare/authz" "sourcecraft.dev/bigbes/sr-ht-compare/web" ) const ( service = "compare.sr.ht" defaultBind = "127.0.0.1:5090" // authzTTL is how long the GraphQL authorizer memoizes positive and // not-found repository lookups, sparing git.sr.ht a round trip per page. authzTTL = 60 * time.Second ) // initLogging installs the instance's log handler as slog's default. // // Setting the *default* is the load-bearing part, not the formatting: the // packages that log below this one hold no logger of their own — the web tier // calls slog's package functions, and so does ecore's panic-recovery // middleware, whose report (method, path, panic, stack) would otherwise go to // Go's plain stderr handler with the stack as one unreadable field. // // -d is read straight out of the argument vector because core-go's server.New // owns the real flag parse and does not run until after this: a daemon that // only became verbose once it had finished starting would be silent for exactly // the part of its life an operator passes -d to watch. func initLogging(debug bool) { level := slog.LevelInfo if debug { level = slog.LevelDebug } slog.SetDefault(slog.New(scribe.NewTintHandler( scribe.WithWriter(os.Stderr), scribe.WithLevel(level), scribe.WithSource(true), scribe.WithTimeFormat(time.DateTime), // Colour is for a terminal; under systemd stderr is the journal, where // the escapes are noise in every stored record. Asked of the stdlib // rather than of golang.org/x/term, which would be a whole new // dependency for one predicate. scribe.WithNoColor(!isTerminal(os.Stderr)), // This daemon logs no credential deliberately, which is precisely why // the masks are here: the one that leaks is the attribute somebody adds // later, and a request or a cookie is the likeliest thing to be handed // to a log line while debugging the very cookie path this service reads. scribe.WithMaskKeys("token", "cookie", "authorization"), scribe.WithMask(`(?i)(secret|token|api_?key|password)`, "***"), ))) } // isTerminal reports whether f is a character device — a tty rather than the // pipe systemd, a build runner or a shell redirect hands the process. func isTerminal(f *os.File) bool { info, err := f.Stat() return err == nil && info.Mode()&os.ModeCharDevice != 0 } func main() { initLogging(slices.Contains(os.Args[1:], "-d")) // LoadConfig never panics on a missing file (it returns a nil ini.File); // validateConfig turns any absent required key into a single clear fatal. conf := config.LoadConfig() apiOrigin := validateConfig(conf) // server.New parses -b/-d/-m/-p from the argument vector and runs // crypto.InitCrypto(conf) — the network-key and webhook key validated above // are exactly what it needs, so this cannot fatal after validateConfig. // It expects the full os.Args (core-go's getopt skips argv[0] as the // program name, exactly as every upstream SourceHut daemon calls it). srv := coreserver.New(service, defaultBind, conf, os.Args) authorizer := authz.NewAuthorizer(authzTTL) app, err := web.New(conf, authorizer) if err != nil { // slog has no Fatal, and the explicit exit is the better shape anyway: // the line above is a log record like any other, and the decision to // stop is visible on its own line rather than hidden in a logger call. slog.Error("initialize the web server", scribe.Err(err)) os.Exit(1) } // Middleware chain per the web package contract (outermost first). This is // the hand-rolled substitute for WithDefaultMiddleware: no database, no // redis, and login.Optional never issues a 401 so anonymous browsing // works. config.Middleware must be present because the GraphQL authorizer // resolves git.sr.ht's API origin from config.ForContext at request time. // // server.New already froze the anonymous router for direct middleware // registration (it built inline sub-routers during construction), so the // group + middleware + routes are installed together inside a Group, which // chi permits on a fresh inline mux sharing the same routing tree. // // Register installs three more of its own inside a nested group: the // private cache policy, panic recovery through the service's error page, // and the same-origin guard. chi's Recoverer stays here as the outer net // for a panic in the two middlewares above, which are outside that group — // it re-panics http.ErrAbortHandler, which is what the inner one raises for // a panic arriving after the response has already started. srv.AnonRouter().Group(func(r chi.Router) { r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Use(middleware.Logger) r.Use(config.Middleware(conf, service)) // The instance's one cookie decode, with the default validator: a name // this service narrowed further would be an account logged out of // compare alone, and meta.sr.ht is the authority on which names exist. r.Use(login.Optional()) app.Register(r) }) reposRoot, _ := conf.Get("git.sr.ht", "repos") slog.Info("compare.sr.ht starting", "bind", resolveBind(os.Args[1:]), "repos", reposRoot, "git.sr.ht-api", apiOrigin, ) // Run blocks until SIGINT, then performs a warm shutdown. systemd should // stop this unit with KillSignal=SIGINT (see contrib/compare-srht.service). srv.Run() } // validateConfig verifies every configuration key compare.sr.ht needs before it // can serve or authorize a request. It reports all missing keys at once, in a // single record followed by a single exit, so operators fix the config in one // pass instead of discovering each gap on a separate restart. It returns the // git.sr.ht internal API origin that GraphQL authorization will use (also // logged at startup). // // This runs BEFORE anything can reach config.GetAPI, which panics when no // origin candidate is configured, and before crypto.InitCrypto (invoked by // server.New), which would otherwise fatal with a terse message on a missing // network-key or webhook key. func validateConfig(conf ini.File) string { var missing []string require := func(section, key string) { if v, ok := conf.Get(section, key); !ok || strings.TrimSpace(v) == "" { missing = append(missing, fmt.Sprintf("[%s] %s", section, key)) } } require("sr.ht", "network-key") // crypto: unified-login cookie fernet key require("webhooks", "private-key") // crypto: webhook signing key (shared) require("git.sr.ht", "repos") // bare repository root on disk require("meta.sr.ht", "origin") // login/logout links in the nav require("compare.sr.ht", "origin") // our own external origin // git.sr.ht needs at least one internal API origin candidate; without it // config.GetAPI panics on the first authorization request. apiOrigin := firstConfigured(conf, "git.sr.ht", "api-internal-origin", "internal-origin", "api-origin", "origin") if apiOrigin == "" { missing = append(missing, "[git.sr.ht] one of api-internal-origin, internal-origin, api-origin, origin") } if len(missing) > 0 { // One record and one exit, deliberately: an operator fixing a config // wants every gap in front of them at once, and a line per missing key // is a restart per missing key. The keys go in as a slice attribute // rather than a joined string so the structured sinks keep them as a // list — the tint handler still renders them on one line. slog.Error("incomplete configuration", "missing", missing) os.Exit(1) } return apiOrigin } // firstConfigured returns the value of the first present, non-empty key in // section, or "" if none are set. func firstConfigured(conf ini.File, section string, keys ...string) string { for _, k := range keys { if v, ok := conf.Get(section, k); ok && strings.TrimSpace(v) != "" { return v } } return "" } // resolveBind reconstructs the primary bind address server.New will use, for // logging only. server.New owns the authoritative parse; this mirrors its -b // handling (last -b wins here; the real server binds every -b given) and falls // back to the same default. func resolveBind(args []string) string { bind := defaultBind for i := 0; i < len(args); i++ { switch a := args[i]; { case a == "-b" && i+1 < len(args): bind = args[i+1] i++ case strings.HasPrefix(a, "-b") && len(a) > 2: bind = a[2:] } } return bind }