coverage~bigbes/sr-ht-ecore3bd158fbinstconf/instconf.go

Coverage
94.3% 50/53 statements
Δ
Blob
549a25c
Uncovered L227-L230L246-L247
1 // Package instconf is one reading of the origins in a self-hosted SourceHut
2 // instance's shared config.ini — the "where does this service live" strings
3 // that every custom service on the instance has to agree about.
4 //
5 // It exists because they did not agree. Five services and this library each
6 // grew their own copy of the same three lines, and the copies drifted in ways
7 // that are invisible until something breaks far from the config: one service
8 // canonicalized an origin with strings.TrimRight(o, "/") and another with
9 // strings.TrimSuffix(o, "/"), so a config.ini carrying "https://x//" produced
10 // two different origins in two daemons that must produce the same string when
11 // one of them checks a browser-supplied Origin header against it. One repo held
12 // two origin-to-host extractors that disagreed about a malformed origin — one
13 // returned an error, the other quietly answered "localhost". A third copy feeds
14 // the DNS-rebinding guard on an MCP endpoint, where an empty host means the
15 // guard turns itself off. That is not a place for a fourth spelling.
16 //
17 // So: one canonicalization, one host extraction, one internal/external
18 // distinction, used by everyone.
19 //
20 // The origin vocabulary this package reads is upstream sourcehut's, and the
21 // accessors correspond to core-go's config.GetOrigin and config.GetAPI:
22 //
23 // - [ExternalOrigin] — [<svc>] origin, the address a browser is sent to.
24 // - [InternalOrigin] — [<svc>] internal-origin, falling back to origin: the
25 // address a daemon dials from inside the instance's network.
26 // - [InternalAPIOrigin] — the four-key ladder api-internal-origin,
27 // internal-origin, api-origin, origin, for a service calling a sibling's
28 // GraphQL API.
29 //
30 // Two named accessors rather than one taking an external bool, deliberately.
31 // The donors spelled it config.GetOrigin(conf, svc, true) and
32 // config.GetOrigin(conf, svc, false) in different files, each with a comment
33 // explaining which was which, and a flipped flag is invisible at the call site:
34 // it shows up as a browser redirected to an address only the daemon can reach,
35 // or as a daemon dialing out through the public load balancer.
36 //
37 // Everything returned is canonical — whitespace-trimmed and stripped of every
38 // trailing slash — so a URL built by joining an origin with a path never grows
39 // a double slash and two callers never hold two spellings of the same address.
40 // An absent key, an absent section and a present-but-blank value all read as
41 // "", which the caller is expected to treat as "not configured": see [Require]
42 // for reporting that in one pass rather than one restart per missing key.
43 package instconf
44
45 import (
46 "errors"
47 "fmt"
48 "net/url"
49 "strings"
50
51 "github.com/vaughan0/go-ini"
52 )
53
54 // ErrIncompleteConfig is the sentinel behind every error [Require] returns, so
55 // a daemon can tell "the operator has not finished configuring me" apart from
56 // the failures that come later.
57 var ErrIncompleteConfig = errors.New("incomplete configuration")
58
59 // apiOriginKeys is the internal API-origin ladder, in preference order. It is
60 // upstream core-go's own candidate list for config.GetAPI with external=false,
61 // restated here because config.GetAPI panics when none of them is set, which is
62 // no way to tell an operator about a missing config key.
63 var apiOriginKeys = []string{
64 "api-internal-origin",
65 "internal-origin",
66 "api-origin",
67 "origin",
68 }
69
70 // APIOriginKeys returns the key names [InternalAPIOrigin] consults, in
71 // preference order. Mostly useful for [NeedAny], so that a startup check and
72 // the lookup itself cannot drift apart.
73 5 func APIOriginKeys() []string {
74 5 return append([]string(nil), apiOriginKeys...)
75 5 }
76
77 // CanonicalOrigin returns the one spelling of an origin: surrounding whitespace
78 // removed, then every trailing slash removed. "" stays "".
79 //
80 // Both trims matter, and both come from a donor that had been bitten:
81 //
82 // - TrimSpace, because a value can reach this function from somewhere other
83 // than the ini parser (a test fixture, a flag, a config assembled in code),
84 // and " https://x" does not even parse as a URL.
85 // - TrimRight over TrimSuffix, because TrimSuffix removes one slash and
86 // leaves "https://x/" from "https://x//". The donors used one each. This is
87 // the stricter reading, and it is the one the comparison callers need: an
88 // Origin header from a browser never carries a trailing slash, so an origin
89 // that kept one silently fails every equality check made against it.
90 //
91 // Only trailing slashes are touched. The scheme, host, port and any path
92 // prefix are left exactly as configured — an instance that serves a service
93 // under https://example.org/git means it.
94 69 func CanonicalOrigin(origin string) string {
95 69 return strings.TrimRight(strings.TrimSpace(origin), "/")
96 69 }
97
98 // ExternalOrigin returns the canonical external origin of a service: the
99 // address a browser is sent to, from [<section>] origin.
100 //
101 // This is the origin that belongs in a page, a redirect, a webhook payload or
102 // an email — anything a user's own client will follow. It returns "" when the
103 // section or the key is missing, or the value is blank.
104 5 func ExternalOrigin(conf ini.File, section string) string {
105 5 return CanonicalOrigin(lookup(conf, section, "origin"))
106 5 }
107
108 // InternalOrigin returns the canonical origin a daemon should dial to reach a
109 // service from inside the instance: [<section>] internal-origin when set,
110 // otherwise [<section>] origin.
111 //
112 // It is the address of the same service, not the same address: an instance may
113 // route service-to-service traffic over a private network, and on such an
114 // instance the external origin resolves to a load balancer the daemon cannot
115 // or should not use. Never put this string in front of a user.
116 4 func InternalOrigin(conf ini.File, section string) string {
117 4 if v := lookup(conf, section, "internal-origin"); v != "" {
118 1 return CanonicalOrigin(v)
119 1 }
120 3 return CanonicalOrigin(lookup(conf, section, "origin"))
121 }
122
123 // InternalAPIOrigin returns the canonical origin at which a service's GraphQL
124 // API can be reached from inside the instance, and whether one is configured at
125 // all. The returned origin does not include the /query path.
126 //
127 // It walks [APIOriginKeys] in order, taking the first key that is present and
128 // non-blank. The ladder exists because an instance may put the API behind a
129 // different address than the web UI, and may or may not have a separate
130 // internal route to either; every service that calls a sibling's API has to
131 // walk it, because core-go's config.GetAPI panics when it reaches the end of
132 // the ladder with nothing.
133 //
134 // The bool is the point: it turns that panic into a decision the caller makes
135 // at startup, alongside every other missing key, rather than a stack trace on
136 // the first authorization request.
137 7 func InternalAPIOrigin(conf ini.File, section string) (string, bool) {
138 22 for _, key := range apiOriginKeys {
139 22 if v := lookup(conf, section, key); v != "" {
140 4 return CanonicalOrigin(v), true
141 4 }
142 }
143 3 return "", false
144 }
145
146 // OriginHost returns the host name of an origin, without any port: the string
147 // to compare a request's Host header against. It returns "" when the origin is
148 // blank, does not parse as a URL, or parses to no host at all — which is what a
149 // scheme-less value such as "example.org" does, since a URL without a scheme is
150 // a path.
151 //
152 // "" means "this origin names no host", and callers on a security path must
153 // treat it as a configuration error rather than as permission to skip a check.
154 // One donor uses this value for the DNS-rebinding guard on an MCP endpoint and
155 // disables the guard when it comes back empty (loudly, at warn level, which is
156 // the only reason that is defensible). Another donor's copy answered "localhost"
157 // for anything it could not parse, which is worse: it is a guess that looks like
158 // an answer, and it silently makes every malformed origin agree with a local
159 // client.
160 //
161 // The host is returned exactly as configured, case included, because it is also
162 // what identifies the endpoint elsewhere. Host names are case-insensitive, so
163 // compare with strings.EqualFold and not ==.
164 //
165 // A host name is not an origin, so the two live under two names: use
166 // [CanonicalOrigin] when what is wanted back is an address to fetch.
167 22 func OriginHost(origin string) string {
168 22 u, err := url.Parse(CanonicalOrigin(origin))
169 22 if err != nil {
170 6 return ""
171 6 }
172 16 return u.Hostname()
173 }
174
175 // OriginAuthority returns the authority of an origin — host[:port], with an
176 // IPv6 host still bracketed — or "" under exactly the conditions [OriginHost]
177 // returns "".
178 //
179 // It is the other half of a disagreement between two copies in one repo: a
180 // Host-header check wants the name alone, because it compares against a request
181 // Host whose port it has already stripped, while a JWT audience or a sealed URL
182 // wants the authority that actually identifies the endpoint — https://x:8080
183 // and https://x:9090 are two audiences. Neither is the general answer, so both
184 // are here and the caller names which one it means.
185 //
186 // A synthesized email domain is NOT one of these: an address is built from
187 // [OriginHost], because agent@localhost:5091 is not a mailbox.
188 22 func OriginAuthority(origin string) string {
189 22 u, err := url.Parse(CanonicalOrigin(origin))
190 22 if err != nil {
191 6 return ""
192 6 }
193 16 if u.Hostname() == "" {
194 7 return ""
195 7 }
196 9 return u.Host
197 }
198
199 // Key is one configuration requirement: a section, and the key names that
200 // satisfy it. More than one name means an alternation — any of them will do —
201 // which is how the internal API-origin ladder is expressed. Build one with
202 // [Need] or [NeedAny], and add the reason with [Key.Because].
203 type Key struct {
204 Section string
205 Names []string
206 // Why is what the operator is told the key is for, e.g. "crypto.InitCrypto
207 // exits without it". It is optional, and it is the difference between a
208 // list of names and a message somebody can act on: three services kept
209 // their own hand-written checker rather than lose this sentence.
210 Why string
211 }
212
213 // Need is a requirement for one named key in a section.
214 8 func Need(section, name string) Key {
215 8 return Key{Section: section, Names: []string{name}}
216 8 }
217
218 // NeedAny is a requirement satisfied by any one of several keys in a section,
219 // e.g. NeedAny("git.sr.ht", instconf.APIOriginKeys()...).
220 3 func NeedAny(section string, names ...string) Key {
221 3 return Key{Section: section, Names: names}
222 3 }
223
224 // Because attaches the reason the key is required, for the operator reading the
225 // refusal: Need("sr.ht", "network-key").Because("crypto.InitCrypto exits
226 // without it").
227 0 func (k Key) Because(why string) Key {
228 0 k.Why = why
229 0 return k
230 0 }
231
232 // String renders the requirement the way an operator has to read it back into
233 // config.ini: "[git.sr.ht] origin", or "[git.sr.ht] one of api-internal-origin,
234 // internal-origin, api-origin, origin".
235 9 func (k Key) String() string {
236 9 var named string
237 9 switch len(k.Names) {
238 1 case 0:
239 1 named = fmt.Sprintf("[%s] <no key>", k.Section)
240 6 case 1:
241 6 named = fmt.Sprintf("[%s] %s", k.Section, k.Names[0])
242 2 default:
243 2 named = fmt.Sprintf("[%s] one of %s", k.Section, strings.Join(k.Names, ", "))
244 }
245 9 if k.Why != "" {
246 0 return named + " — " + k.Why
247 0 }
248 9 return named
249 }
250
251 // satisfied reports whether the config has a non-blank value for any of the
252 // key's names. A requirement with no names is unsatisfiable by construction,
253 // which surfaces the programming mistake instead of silently passing.
254 11 func (k Key) satisfied(conf ini.File) bool {
255 14 for _, name := range k.Names {
256 14 if lookup(conf, k.Section, name) != "" {
257 5 return true
258 5 }
259 }
260 6 return false
261 }
262
263 // MissingKeysError reports every configuration key a caller required and did
264 // not find. It wraps [ErrIncompleteConfig].
265 type MissingKeysError struct {
266 Keys []Key
267 }
268
269 // Error lists every missing key on one line.
270 2 func (e *MissingKeysError) Error() string {
271 2 return fmt.Sprintf("%s; missing required keys: %s",
272 2 ErrIncompleteConfig, strings.Join(e.Strings(), ", "))
273 2 }
274
275 // Unwrap makes errors.Is(err, ErrIncompleteConfig) work.
276 2 func (e *MissingKeysError) Unwrap() error { return ErrIncompleteConfig }
277
278 // Strings renders the missing keys one per element, for a structured log
279 // attribute that keeps them a list — a slog handler will then render them as a
280 // list, and the operator gets every gap at once instead of one per line.
281 3 func (e *MissingKeysError) Strings() []string {
282 3 out := make([]string, 0, len(e.Keys))
283 9 for _, k := range e.Keys {
284 9 out = append(out, k.String())
285 9 }
286 3 return out
287 }
288
289 // Require checks that every given key is present and non-blank, and reports
290 // *all* the missing ones in a single [*MissingKeysError]. It returns nil when
291 // nothing is missing.
292 //
293 // Reporting all of them is the entire point, and it is the reason this is a
294 // function rather than five ifs at each daemon's startup: a daemon that fatals
295 // on the first gap it finds costs the operator one restart per missing key, and
296 // an operator editing config.ini wants the whole list in front of them. It also
297 // wants to run before anything reaches core-go's config.GetAPI or
298 // crypto.InitCrypto, both of which panic on missing configuration with a
299 // message aimed at a programmer.
300 //
301 // A present-but-blank value counts as missing: "origin =" in config.ini is a
302 // half-finished edit, not a configured empty origin.
303 5 func Require(conf ini.File, keys ...Key) error {
304 5 var missing []Key
305 11 for _, k := range keys {
306 11 if !k.satisfied(conf) {
307 6 missing = append(missing, k)
308 6 }
309 }
310 5 if len(missing) == 0 {
311 2 return nil
312 2 }
313 3 return &MissingKeysError{Keys: missing}
314 }
315
316 // lookup reads one key, treating a present-but-blank value as absent and
317 // trimming what it returns. The ini parser already trims, but a File can be
318 // built in code — every test fixture in this repo does — and a lookup that
319 // depends on which of the two produced the map is exactly the kind of
320 // disagreement this package exists to remove.
321 48 func lookup(conf ini.File, section, key string) string {
322 48 v, ok := conf.Get(section, key)
323 48 if !ok {
324 31 return ""
325 31 }
326 17 return strings.TrimSpace(v)
327 }