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