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