// 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 ( "errors" "log/slog" "os" "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/instconf" "sourcecraft.dev/bigbes/sr-ht-ecore/logging" "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 do ecore's panic-recovery middleware // and its request logger, whose records would otherwise go to Go's plain // stderr handler with the stack as one unreadable field. // // The policy is ecore's and the handler is ours, which is the split logging // documents: what is masked and how verbose to be are facts about the instance, // while a tinting handler is auxilia's and does not belong in a library every // service links for its page chrome. // // Defaults(nil, "") because config.ini is not loaded yet, and deliberately so: // -d is read straight out of the argument vector — core-go's server.New owns // the real flag parse and does not run until after config validation, and 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. The two // knobs that work here are -d and $LOG_LEVEL; a [compare.sr.ht] log-level key // would be read too late to matter and is not supported. func initLogging() { opts := logging.Defaults(nil, "") logging.Install(scribe.NewTintHandler( scribe.WithWriter(os.Stderr), scribe.WithLevel(opts.Level), scribe.WithSource(opts.AddSource), scribe.WithTimeFormat(opts.TimeFormat), // Colour is for a terminal; under systemd stderr is the journal, where // the escapes are noise in every stored record. logging.ColorEnabled // answers that from one Stat, and honours NO_COLOR besides. scribe.WithNoColor(!opts.Color), // 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. // The list is the instance's, so a key another service learned to redact // is redacted here without anybody editing this file. scribe.WithMaskKeys(opts.MaskKeys...), scribe.WithMask(opts.MaskPattern, opts.MaskReplacement), )) } func main() { initLogging() // 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 { err := instconf.Require(conf, instconf.Need("sr.ht", "network-key"), // crypto: unified-login cookie fernet key instconf.Need("webhooks", "private-key"), // crypto: webhook signing key (shared) instconf.Need("git.sr.ht", "repos"), // bare repository root on disk instconf.Need("meta.sr.ht", "origin"), // login/logout links in the nav instconf.Need(service, "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. The // ladder is asked for by name rather than written out, so this check // and the lookup below cannot come to disagree about it. instconf.NeedAny("git.sr.ht", instconf.APIOriginKeys()...), ) if err != nil { // 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. Strings() keeps them 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. var missing *instconf.MissingKeysError if errors.As(err, &missing) { slog.Error("incomplete configuration", "missing", missing.Strings()) } else { slog.Error("incomplete configuration", scribe.Err(err)) } os.Exit(1) } // Cannot report false: NeedAny above walked the same ladder. apiOrigin, _ := instconf.InternalAPIOrigin(conf, "git.sr.ht") return apiOrigin } // 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 }