coverage~bigbes/sr-ht-compare9720ccc2cmd/comparesrht/main.go

Coverage
0.0% 0/38 statements
Δ
Blob
3c843a4
1 // Command comparesrht is the diff.sr.ht daemon: a stateless HTTP service
2 // that renders git ref-to-ref diffs and single-commit views for a self-hosted
3 // SourceHut instance.
4 //
5 // It reuses core-go's server.New assembly so it shares the standard SourceHut
6 // daemon lifecycle (crypto initialization, -b/-m/-p flags, warm shutdown on
7 // SIGINT) with the rest of the fleet, but it deliberately does NOT call
8 // WithDefaultMiddleware: diff.sr.ht owns no Postgres or Redis and must serve
9 // anonymous viewers, so it installs its own lightweight middleware group on the
10 // anonymous router instead (see the web package documentation for the exact
11 // chain).
12 //
13 // Flags (parsed by core-go's server.New):
14 //
15 // -b addr bind address (repeatable); default 127.0.0.1:5090
16 // -d debug (verbose request logging in core-go)
17 // -m addr Prometheus metrics bind (default random port)
18 // -p addr pprof bind (default random localhost port)
19 //
20 // Configuration is loaded from the shared SourceHut config.ini via core-go's
21 // fixed search path (./config.ini, ../config.ini, /etc/sr.ht/config.ini,
22 // /etc/sr.ht/*.ini). All required keys are validated up front with clear
23 // messages so a misconfiguration fails loudly at startup rather than as a deep
24 // panic on the first request.
25 package main
26
27 import (
28 "errors"
29 "log/slog"
30 "os"
31 "strings"
32 "time"
33
34 "github.com/go-chi/chi/v5"
35 chimiddleware "github.com/go-chi/chi/v5/middleware"
36 "github.com/vaughan0/go-ini"
37 "go.bigb.es/auxilia/scribe"
38 "sourcecraft.dev/bigbes/sr-ht-core/config"
39 coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"
40 "sourcecraft.dev/bigbes/sr-ht-ecore/chimw"
41 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
42 "sourcecraft.dev/bigbes/sr-ht-ecore/logging"
43 "sourcecraft.dev/bigbes/sr-ht-ecore/login"
44
45 "sourcecraft.dev/bigbes/sr-ht-compare/authz"
46 "sourcecraft.dev/bigbes/sr-ht-compare/web"
47 )
48
49 const (
50 service = "diff.sr.ht"
51 defaultBind = "127.0.0.1:5090"
52
53 // authzTTL is how long the GraphQL authorizer memoizes positive and
54 // not-found repository lookups, sparing git.sr.ht a round trip per page.
55 authzTTL = 60 * time.Second
56 )
57
58 // initLogging installs the instance's log handler as slog's default.
59 //
60 // Setting the *default* is the load-bearing part, not the formatting: the
61 // packages that log below this one hold no logger of their own — the web tier
62 // calls slog's package functions, and so do ecore's panic-recovery middleware
63 // and its request logger, whose records would otherwise go to Go's plain
64 // stderr handler with the stack as one unreadable field.
65 //
66 // The policy is ecore's and the handler is ours, which is the split logging
67 // documents: what is masked and how verbose to be are facts about the instance,
68 // while a tinting handler is auxilia's and does not belong in a library every
69 // service links for its page chrome.
70 //
71 // Defaults(nil, "") because config.ini is not loaded yet, and deliberately so:
72 // -d is read straight out of the argument vector — core-go's server.New owns
73 // the real flag parse and does not run until after config validation, and a
74 // daemon that only became verbose once it had finished starting would be silent
75 // for exactly the part of its life an operator passes -d to watch. The two
76 // knobs that work here are -d and $LOG_LEVEL; a [diff.sr.ht] log-level key
77 // would be read too late to matter and is not supported.
78 0 func initLogging() {
79 0 opts := logging.Defaults(nil, "")
80 0 logging.Install(scribe.NewTintHandler(
81 0 scribe.WithWriter(os.Stderr),
82 0 scribe.WithLevel(opts.Level),
83 0 scribe.WithSource(opts.AddSource),
84 0 scribe.WithTimeFormat(opts.TimeFormat),
85 0 // Colour is for a terminal; under systemd stderr is the journal, where
86 0 // the escapes are noise in every stored record. logging.ColorEnabled
87 0 // answers that from one Stat, and honours NO_COLOR besides.
88 0 scribe.WithNoColor(!opts.Color),
89 0 // This daemon logs no credential deliberately, which is precisely why
90 0 // the masks are here: the one that leaks is the attribute somebody adds
91 0 // later, and a request or a cookie is the likeliest thing to be handed
92 0 // to a log line while debugging the very cookie path this service reads.
93 0 // The list is the instance's, so a key another service learned to redact
94 0 // is redacted here without anybody editing this file.
95 0 scribe.WithMaskKeys(opts.MaskKeys...),
96 0 scribe.WithMask(opts.MaskPattern, opts.MaskReplacement),
97 0 ))
98 0 }
99
100 0 func main() {
101 0 initLogging()
102 0
103 0 // LoadConfig never panics on a missing file (it returns a nil ini.File);
104 0 // validateConfig turns any absent required key into a single clear fatal.
105 0 conf := config.LoadConfig()
106 0 apiOrigin := validateConfig(conf)
107 0
108 0 // server.New parses -b/-d/-m/-p from the argument vector and runs
109 0 // crypto.InitCrypto(conf) — the network-key and webhook key validated above
110 0 // are exactly what it needs, so this cannot fatal after validateConfig.
111 0 // It expects the full os.Args (core-go's getopt skips argv[0] as the
112 0 // program name, exactly as every upstream SourceHut daemon calls it).
113 0 srv := coreserver.New(service, defaultBind, conf, os.Args)
114 0
115 0 authorizer := authz.NewAuthorizer(authzTTL)
116 0 app, err := web.New(conf, authorizer)
117 0 if err != nil {
118 0 // slog has no Fatal, and the explicit exit is the better shape anyway:
119 0 // the line above is a log record like any other, and the decision to
120 0 // stop is visible on its own line rather than hidden in a logger call.
121 0 slog.Error("initialize the web server", scribe.Err(err))
122 0 os.Exit(1)
123 0 }
124
125 // Middleware chain per the web package contract (outermost first). This is
126 // the hand-rolled substitute for WithDefaultMiddleware: no database, no
127 // redis, and login.Optional never issues a 401 so anonymous browsing
128 // works. config.Middleware must be present because the GraphQL authorizer
129 // resolves git.sr.ht's API origin from config.ForContext at request time.
130 //
131 // server.New already froze the anonymous router for direct middleware
132 // registration (it built inline sub-routers during construction), so the
133 // group + middleware + routes are installed together inside a Group, which
134 // chi permits on a fresh inline mux sharing the same routing tree.
135 //
136 // Register installs three more of its own inside a nested group: the
137 // private cache policy, panic recovery through the service's error page,
138 // and the same-origin guard. chi's Recoverer stays here as the outer net
139 // for a panic in the middlewares above, which are outside that group — it
140 // re-panics http.ErrAbortHandler, which is what the inner one raises for a
141 // panic arriving after the response has already started.
142 //
143 // The request line is chimw's and no longer chi's. chi's Logger writes an
144 // unstructured, colourised line to *stdout*, which on this daemon is the
145 // highest-volume record it emits and the only one not beside the rest in
146 // the journal: an operator grepping stderr for a path finds every panic and
147 // none of the requests that caused them. RequestID goes above it so the
148 // request line and the panic report carry the same id, and Recoverer below
149 // it so its own report goes through the log entry rather than to stdout.
150 // /healthz is skipped: it is a probe every second and says nothing.
151 0 srv.AnonRouter().Group(func(r chi.Router) {
152 0 r.Use(chimiddleware.RequestID)
153 0 r.Use(chimiddleware.RealIP)
154 0 r.Use(chimw.RequestLogger(chimw.SlogFormatter{
155 0 Skip: chimw.SkipPaths("/healthz"),
156 0 }))
157 0 r.Use(chimiddleware.Recoverer)
158 0 r.Use(config.Middleware(conf, service))
159 0 // The instance's one cookie decode, with the default validator: a name
160 0 // this service narrowed further would be an account logged out of
161 0 // compare alone, and meta.sr.ht is the authority on which names exist.
162 0 r.Use(login.Optional())
163 0 app.Register(r)
164 0 })
165
166 0 reposRoot, _ := conf.Get("git.sr.ht", "repos")
167 0 slog.Info("diff.sr.ht starting",
168 0 "bind", resolveBind(os.Args[1:]),
169 0 "repos", reposRoot,
170 0 "git.sr.ht-api", apiOrigin,
171 0 )
172 0
173 0 // Run blocks until SIGINT, then performs a warm shutdown. systemd should
174 0 // stop this unit with KillSignal=SIGINT (see contrib/compare-srht.service).
175 0 srv.Run()
176 }
177
178 // validateConfig verifies every configuration key diff.sr.ht needs before it
179 // can serve or authorize a request. It reports all missing keys at once, in a
180 // single record followed by a single exit, so operators fix the config in one
181 // pass instead of discovering each gap on a separate restart. It returns the
182 // git.sr.ht internal API origin that GraphQL authorization will use (also
183 // logged at startup).
184 //
185 // This runs BEFORE anything can reach config.GetAPI, which panics when no
186 // origin candidate is configured, and before crypto.InitCrypto (invoked by
187 // server.New), which would otherwise fatal with a terse message on a missing
188 // network-key or webhook key.
189 0 func validateConfig(conf ini.File) string {
190 0 err := instconf.Require(conf,
191 0 instconf.Need("sr.ht", "network-key"), // crypto: unified-login cookie fernet key
192 0 instconf.Need("webhooks", "private-key"), // crypto: webhook signing key (shared)
193 0 instconf.Need("git.sr.ht", "repos"), // bare repository root on disk
194 0 instconf.Need("meta.sr.ht", "origin"), // login/logout links in the nav
195 0 instconf.Need(service, "origin"), // our own external origin
196 0
197 0 // git.sr.ht needs at least one internal API origin candidate; without
198 0 // it config.GetAPI panics on the first authorization request. The
199 0 // ladder is asked for by name rather than written out, so this check
200 0 // and the lookup below cannot come to disagree about it.
201 0 instconf.NeedAny("git.sr.ht", instconf.APIOriginKeys()...),
202 0 )
203 0 if err != nil {
204 0 // One record and one exit, deliberately: an operator fixing a config
205 0 // wants every gap in front of them at once, and a line per missing key
206 0 // is a restart per missing key. Strings() keeps them a slice attribute
207 0 // rather than a joined string, so the structured sinks keep them as a
208 0 // list — the tint handler still renders them on one line.
209 0 var missing *instconf.MissingKeysError
210 0 if errors.As(err, &missing) {
211 0 slog.Error("incomplete configuration", "missing", missing.Strings())
212 0 } else {
213 0 slog.Error("incomplete configuration", scribe.Err(err))
214 0 }
215 0 os.Exit(1)
216 }
217
218 // Cannot report false: NeedAny above walked the same ladder.
219 0 apiOrigin, _ := instconf.InternalAPIOrigin(conf, "git.sr.ht")
220 0 return apiOrigin
221 }
222
223 // resolveBind reconstructs the primary bind address server.New will use, for
224 // logging only. server.New owns the authoritative parse; this mirrors its -b
225 // handling (last -b wins here; the real server binds every -b given) and falls
226 // back to the same default.
227 0 func resolveBind(args []string) string {
228 0 bind := defaultBind
229 0 for i := 0; i < len(args); i++ {
230 0 switch a := args[i]; {
231 0 case a == "-b" && i+1 < len(args):
232 0 bind = args[i+1]
233 0 i++
234 0 case strings.HasPrefix(a, "-b") && len(a) > 2:
235 0 bind = a[2:]
236 }
237 }
238 0 return bind
239 }