~bigbes/sr-ht-compare

ref: e01e9ed02ec22b7bdebac9ae8327784c4fac2245 sr-ht-compare/cmd/comparesrht/main.go -rw-r--r-- 10.6 KiB
e01e9ed0 — bigbes chimw: the request line in the journal, HEAD routes, and a 405 page 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// 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"
	chimiddleware "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/chimw"
	"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 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.
	//
	// The request line is chimw's and no longer chi's. chi's Logger writes an
	// unstructured, colourised line to *stdout*, which on this daemon is the
	// highest-volume record it emits and the only one not beside the rest in
	// the journal: an operator grepping stderr for a path finds every panic and
	// none of the requests that caused them. RequestID goes above it so the
	// request line and the panic report carry the same id, and Recoverer below
	// it so its own report goes through the log entry rather than to stdout.
	// /healthz is skipped: it is a probe every second and says nothing.
	srv.AnonRouter().Group(func(r chi.Router) {
		r.Use(chimiddleware.RequestID)
		r.Use(chimiddleware.RealIP)
		r.Use(chimw.RequestLogger(chimw.SlogFormatter{
			Skip: chimw.SkipPaths("/healthz"),
		}))
		r.Use(chimiddleware.Recoverer)
		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
}