| 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 |
|
} |