// 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" "os" "strings" "time" "git.sr.ht/~sircmpwn/core-go/config" coreserver "git.sr.ht/~sircmpwn/core-go/server" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/sirupsen/logrus" "github.com/vaughan0/go-ini" "go.bigb.es/sourcehut-compare/authz" "go.bigb.es/sourcehut-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 ) func main() { logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) // 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 { logrus.Fatalf("initialize web server: %v", err) } // Middleware chain per the web package contract (outermost first). This is // the hand-rolled substitute for WithDefaultMiddleware: no database, no // redis, and authz.Middleware 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. 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)) r.Use(authz.Middleware()) app.Register(r) }) reposRoot, _ := conf.Get("git.sr.ht", "repos") logrus.WithFields(logrus.Fields{ "bind": resolveBind(os.Args[1:]), "repos": reposRoot, "git.sr.ht-api": apiOrigin, }).Info("compare.sr.ht starting") // 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 via a // single logrus.Fatal 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 { logrus.Fatalf("incomplete configuration; missing required keys:\n\t%s", strings.Join(missing, "\n\t")) } 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 }