~bigbes/sr-ht-compare

ref: 65ffb96c316be74225e3405ca5f9ef2ab68825f9 sr-ht-compare/cmd/comparesrht/main.go -rw-r--r-- 9.5 KiB
65ffb96c — bigbes log: slog through auxilia's scribe, not logrus 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
// 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"
	"log/slog"
	"os"
	"slices"
	"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-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 does ecore's panic-recovery
// middleware, whose report (method, path, panic, stack) would otherwise go to
// Go's plain stderr handler with the stack as one unreadable field.
//
// -d is read straight out of the argument vector because core-go's server.New
// owns the real flag parse and does not run until after this: 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.
func initLogging(debug bool) {
	level := slog.LevelInfo
	if debug {
		level = slog.LevelDebug
	}
	slog.SetDefault(slog.New(scribe.NewTintHandler(
		scribe.WithWriter(os.Stderr),
		scribe.WithLevel(level),
		scribe.WithSource(true),
		scribe.WithTimeFormat(time.DateTime),
		// Colour is for a terminal; under systemd stderr is the journal, where
		// the escapes are noise in every stored record. Asked of the stdlib
		// rather than of golang.org/x/term, which would be a whole new
		// dependency for one predicate.
		scribe.WithNoColor(!isTerminal(os.Stderr)),
		// 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.
		scribe.WithMaskKeys("token", "cookie", "authorization"),
		scribe.WithMask(`(?i)(secret|token|api_?key|password)`, "***"),
	)))
}

// isTerminal reports whether f is a character device — a tty rather than the
// pipe systemd, a build runner or a shell redirect hands the process.
func isTerminal(f *os.File) bool {
	info, err := f.Stat()
	return err == nil && info.Mode()&os.ModeCharDevice != 0
}

func main() {
	initLogging(slices.Contains(os.Args[1:], "-d"))

	// 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 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.
	//
	// 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))
		r.Use(authz.Middleware())
		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 {
	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 {
		// 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. The keys go in as 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.
		slog.Error("incomplete configuration", "missing", missing)
		os.Exit(1)
	}
	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
}