~bigbes/sr-ht-ecore

ref: 9a97b126e5a4f23591b42da7d75b0bb843c31521 sr-ht-ecore/instconf/instconf.go -rw-r--r-- 13.7 KiB
9a97b126 — Eugene Blikh ecore: the gaps the third adoption pass found 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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
// Package instconf is one reading of the origins in a self-hosted SourceHut
// instance's shared config.ini — the "where does this service live" strings
// that every custom service on the instance has to agree about.
//
// It exists because they did not agree. Five services and this library each
// grew their own copy of the same three lines, and the copies drifted in ways
// that are invisible until something breaks far from the config: one service
// canonicalized an origin with strings.TrimRight(o, "/") and another with
// strings.TrimSuffix(o, "/"), so a config.ini carrying "https://x//" produced
// two different origins in two daemons that must produce the same string when
// one of them checks a browser-supplied Origin header against it. One repo held
// two origin-to-host extractors that disagreed about a malformed origin — one
// returned an error, the other quietly answered "localhost". A third copy feeds
// the DNS-rebinding guard on an MCP endpoint, where an empty host means the
// guard turns itself off. That is not a place for a fourth spelling.
//
// So: one canonicalization, one host extraction, one internal/external
// distinction, used by everyone.
//
// The origin vocabulary this package reads is upstream sourcehut's, and the
// accessors correspond to core-go's config.GetOrigin and config.GetAPI:
//
//   - [ExternalOrigin] — [<svc>] origin, the address a browser is sent to.
//   - [InternalOrigin] — [<svc>] internal-origin, falling back to origin: the
//     address a daemon dials from inside the instance's network.
//   - [InternalAPIOrigin] — the four-key ladder api-internal-origin,
//     internal-origin, api-origin, origin, for a service calling a sibling's
//     GraphQL API.
//
// Two named accessors rather than one taking an external bool, deliberately.
// The donors spelled it config.GetOrigin(conf, svc, true) and
// config.GetOrigin(conf, svc, false) in different files, each with a comment
// explaining which was which, and a flipped flag is invisible at the call site:
// it shows up as a browser redirected to an address only the daemon can reach,
// or as a daemon dialing out through the public load balancer.
//
// Everything returned is canonical — whitespace-trimmed and stripped of every
// trailing slash — so a URL built by joining an origin with a path never grows
// a double slash and two callers never hold two spellings of the same address.
// An absent key, an absent section and a present-but-blank value all read as
// "", which the caller is expected to treat as "not configured": see [Require]
// for reporting that in one pass rather than one restart per missing key.
package instconf

import (
	"errors"
	"fmt"
	"net/url"
	"strings"

	"github.com/vaughan0/go-ini"
)

// ErrIncompleteConfig is the sentinel behind every error [Require] returns, so
// a daemon can tell "the operator has not finished configuring me" apart from
// the failures that come later.
var ErrIncompleteConfig = errors.New("incomplete configuration")

// apiOriginKeys is the internal API-origin ladder, in preference order. It is
// upstream core-go's own candidate list for config.GetAPI with external=false,
// restated here because config.GetAPI panics when none of them is set, which is
// no way to tell an operator about a missing config key.
var apiOriginKeys = []string{
	"api-internal-origin",
	"internal-origin",
	"api-origin",
	"origin",
}

// APIOriginKeys returns the key names [InternalAPIOrigin] consults, in
// preference order. Mostly useful for [NeedAny], so that a startup check and
// the lookup itself cannot drift apart.
func APIOriginKeys() []string {
	return append([]string(nil), apiOriginKeys...)
}

// CanonicalOrigin returns the one spelling of an origin: surrounding whitespace
// removed, then every trailing slash removed. "" stays "".
//
// Both trims matter, and both come from a donor that had been bitten:
//
//   - TrimSpace, because a value can reach this function from somewhere other
//     than the ini parser (a test fixture, a flag, a config assembled in code),
//     and " https://x" does not even parse as a URL.
//   - TrimRight over TrimSuffix, because TrimSuffix removes one slash and
//     leaves "https://x/" from "https://x//". The donors used one each. This is
//     the stricter reading, and it is the one the comparison callers need: an
//     Origin header from a browser never carries a trailing slash, so an origin
//     that kept one silently fails every equality check made against it.
//
// Only trailing slashes are touched. The scheme, host, port and any path
// prefix are left exactly as configured — an instance that serves a service
// under https://example.org/git means it.
func CanonicalOrigin(origin string) string {
	return strings.TrimRight(strings.TrimSpace(origin), "/")
}

// ExternalOrigin returns the canonical external origin of a service: the
// address a browser is sent to, from [<section>] origin.
//
// This is the origin that belongs in a page, a redirect, a webhook payload or
// an email — anything a user's own client will follow. It returns "" when the
// section or the key is missing, or the value is blank.
func ExternalOrigin(conf ini.File, section string) string {
	return CanonicalOrigin(lookup(conf, section, "origin"))
}

// InternalOrigin returns the canonical origin a daemon should dial to reach a
// service from inside the instance: [<section>] internal-origin when set,
// otherwise [<section>] origin.
//
// It is the address of the same service, not the same address: an instance may
// route service-to-service traffic over a private network, and on such an
// instance the external origin resolves to a load balancer the daemon cannot
// or should not use. Never put this string in front of a user.
func InternalOrigin(conf ini.File, section string) string {
	if v := lookup(conf, section, "internal-origin"); v != "" {
		return CanonicalOrigin(v)
	}
	return CanonicalOrigin(lookup(conf, section, "origin"))
}

// InternalAPIOrigin returns the canonical origin at which a service's GraphQL
// API can be reached from inside the instance, and whether one is configured at
// all. The returned origin does not include the /query path.
//
// It walks [APIOriginKeys] in order, taking the first key that is present and
// non-blank. The ladder exists because an instance may put the API behind a
// different address than the web UI, and may or may not have a separate
// internal route to either; every service that calls a sibling's API has to
// walk it, because core-go's config.GetAPI panics when it reaches the end of
// the ladder with nothing.
//
// The bool is the point: it turns that panic into a decision the caller makes
// at startup, alongside every other missing key, rather than a stack trace on
// the first authorization request.
func InternalAPIOrigin(conf ini.File, section string) (string, bool) {
	for _, key := range apiOriginKeys {
		if v := lookup(conf, section, key); v != "" {
			return CanonicalOrigin(v), true
		}
	}
	return "", false
}

// OriginHost returns the host name of an origin, without any port: the string
// to compare a request's Host header against. It returns "" when the origin is
// blank, does not parse as a URL, or parses to no host at all — which is what a
// scheme-less value such as "example.org" does, since a URL without a scheme is
// a path.
//
// "" means "this origin names no host", and callers on a security path must
// treat it as a configuration error rather than as permission to skip a check.
// One donor uses this value for the DNS-rebinding guard on an MCP endpoint and
// disables the guard when it comes back empty (loudly, at warn level, which is
// the only reason that is defensible). Another donor's copy answered "localhost"
// for anything it could not parse, which is worse: it is a guess that looks like
// an answer, and it silently makes every malformed origin agree with a local
// client.
//
// The host is returned exactly as configured, case included, because it is also
// what identifies the endpoint elsewhere. Host names are case-insensitive, so
// compare with strings.EqualFold and not ==.
//
// A host name is not an origin, so the two live under two names: use
// [CanonicalOrigin] when what is wanted back is an address to fetch.
func OriginHost(origin string) string {
	u, err := url.Parse(CanonicalOrigin(origin))
	if err != nil {
		return ""
	}
	return u.Hostname()
}

// OriginAuthority returns the authority of an origin — host[:port], with an
// IPv6 host still bracketed — or "" under exactly the conditions [OriginHost]
// returns "".
//
// It is the other half of a disagreement between two copies in one repo: a
// Host-header check wants the name alone, because it compares against a request
// Host whose port it has already stripped, while a JWT audience or a sealed URL
// wants the authority that actually identifies the endpoint — https://x:8080
// and https://x:9090 are two audiences. Neither is the general answer, so both
// are here and the caller names which one it means.
//
// A synthesized email domain is NOT one of these: an address is built from
// [OriginHost], because agent@localhost:5091 is not a mailbox.
func OriginAuthority(origin string) string {
	u, err := url.Parse(CanonicalOrigin(origin))
	if err != nil {
		return ""
	}
	if u.Hostname() == "" {
		return ""
	}
	return u.Host
}

// Key is one configuration requirement: a section, and the key names that
// satisfy it. More than one name means an alternation — any of them will do —
// which is how the internal API-origin ladder is expressed. Build one with
// [Need] or [NeedAny], and add the reason with [Key.Because].
type Key struct {
	Section string
	Names   []string
	// Why is what the operator is told the key is for, e.g. "crypto.InitCrypto
	// exits without it". It is optional, and it is the difference between a
	// list of names and a message somebody can act on: three services kept
	// their own hand-written checker rather than lose this sentence.
	Why string
}

// Need is a requirement for one named key in a section.
func Need(section, name string) Key {
	return Key{Section: section, Names: []string{name}}
}

// NeedAny is a requirement satisfied by any one of several keys in a section,
// e.g. NeedAny("git.sr.ht", instconf.APIOriginKeys()...).
func NeedAny(section string, names ...string) Key {
	return Key{Section: section, Names: names}
}

// Because attaches the reason the key is required, for the operator reading the
// refusal: Need("sr.ht", "network-key").Because("crypto.InitCrypto exits
// without it").
func (k Key) Because(why string) Key {
	k.Why = why
	return k
}

// String renders the requirement the way an operator has to read it back into
// config.ini: "[git.sr.ht] origin", or "[git.sr.ht] one of api-internal-origin,
// internal-origin, api-origin, origin".
func (k Key) String() string {
	var named string
	switch len(k.Names) {
	case 0:
		named = fmt.Sprintf("[%s] <no key>", k.Section)
	case 1:
		named = fmt.Sprintf("[%s] %s", k.Section, k.Names[0])
	default:
		named = fmt.Sprintf("[%s] one of %s", k.Section, strings.Join(k.Names, ", "))
	}
	if k.Why != "" {
		return named + " — " + k.Why
	}
	return named
}

// satisfied reports whether the config has a non-blank value for any of the
// key's names. A requirement with no names is unsatisfiable by construction,
// which surfaces the programming mistake instead of silently passing.
func (k Key) satisfied(conf ini.File) bool {
	for _, name := range k.Names {
		if lookup(conf, k.Section, name) != "" {
			return true
		}
	}
	return false
}

// MissingKeysError reports every configuration key a caller required and did
// not find. It wraps [ErrIncompleteConfig].
type MissingKeysError struct {
	Keys []Key
}

// Error lists every missing key on one line.
func (e *MissingKeysError) Error() string {
	return fmt.Sprintf("%s; missing required keys: %s",
		ErrIncompleteConfig, strings.Join(e.Strings(), ", "))
}

// Unwrap makes errors.Is(err, ErrIncompleteConfig) work.
func (e *MissingKeysError) Unwrap() error { return ErrIncompleteConfig }

// Strings renders the missing keys one per element, for a structured log
// attribute that keeps them a list — a slog handler will then render them as a
// list, and the operator gets every gap at once instead of one per line.
func (e *MissingKeysError) Strings() []string {
	out := make([]string, 0, len(e.Keys))
	for _, k := range e.Keys {
		out = append(out, k.String())
	}
	return out
}

// Require checks that every given key is present and non-blank, and reports
// *all* the missing ones in a single [*MissingKeysError]. It returns nil when
// nothing is missing.
//
// Reporting all of them is the entire point, and it is the reason this is a
// function rather than five ifs at each daemon's startup: a daemon that fatals
// on the first gap it finds costs the operator one restart per missing key, and
// an operator editing config.ini wants the whole list in front of them. It also
// wants to run before anything reaches core-go's config.GetAPI or
// crypto.InitCrypto, both of which panic on missing configuration with a
// message aimed at a programmer.
//
// A present-but-blank value counts as missing: "origin =" in config.ini is a
// half-finished edit, not a configured empty origin.
func Require(conf ini.File, keys ...Key) error {
	var missing []Key
	for _, k := range keys {
		if !k.satisfied(conf) {
			missing = append(missing, k)
		}
	}
	if len(missing) == 0 {
		return nil
	}
	return &MissingKeysError{Keys: missing}
}

// lookup reads one key, treating a present-but-blank value as absent and
// trimming what it returns. The ini parser already trims, but a File can be
// built in code — every test fixture in this repo does — and a lookup that
// depends on which of the two produced the map is exactly the kind of
// disagreement this package exists to remove.
func lookup(conf ini.File, section, key string) string {
	v, ok := conf.Get(section, key)
	if !ok {
		return ""
	}
	return strings.TrimSpace(v)
}