Skip to content

Architecture

The two halves of the process (a control plane that owns configuration and a data plane that serves traffic) and the trust model between them.

Two planes, one process

go-proxy-manager is one process with two cooperating halves: a control plane that owns configuration and a data plane that serves traffic. They are decoupled by an in-memory snapshot: a config change recompiles the data plane atomically, so the running state never drifts from the stored config.

   write config (UI/API/files)                 serve traffic
            |                                        ^
            v                                        |
   +------------------+   recompile + swap   +------------------+
   |  config store    |--------------------->|   data plane     |
   |  (git, validated)|   (atomic snapshot)  | (router + certs) |
   +------------------+                      +------------------+
            |                                        ^
            v                                        |
   +------------------+                      +------------------+
   |  ACME manager    |---- issues certs --->|   cert store     |
   +------------------+                      +------------------+

Control plane

Config store (internal/store). Configuration lives as one YAML file per object under a git repository (default /data/config). Every mutation (from the API or the UI) merges the object into the in-memory config, validates the entire graph (cross-references must resolve; a delete that would dangle a reference is rejected), writes the file, and makes a git commit. There is no non-git mode; history is a first-class feature. Git is invoked with a fixed argv (no shell), a repo-local identity, and prompts disabled. Rollback comes in two scopes, both recorded as a new commit (a revert is itself revertible): a whole-tree revert (Store.Revert) resets the entire config to a target commit (git read-tree --reset -u + clean -fd), and a per-object revert (Store.RevertObject) restores only one object's file from a target commit (git checkout <hash> -- <rel>, the path always after -- and derived from the trusted object-kind directory mapping) so objects created after that commit are left untouched. Both re-validate the whole graph before committing and roll the working tree back to HEAD if the result does not load cleanly; a per-object revert whose object is absent at the target commit is refused rather than deleting it. APIToken is exempt from both: its file carries a credential digest, so a per-object revert is refused (ErrNotRevertible) and the whole-tree revert preserves the api-tokens directory verbatim across the restore.

REST API + web UI (internal/api, internal/ui). The API is a small JSON CRUD surface over the config objects; the UI is a vanilla-JS single-page app embedded in the binary with go:embed. Both are served by the admin listener. Mutating requests require an admin session plus a double-submit CSRF token, behind a same-origin guard. Every admin response also carries hardening headers: a strict CSP (script-src 'self', with 'unsafe-inline' on style-src for the SPA's inline style attributes as its only carve-out and no external origin at all: the web fonts are vendored into the binary) as an XSS backstop behind the UI's output escaping, nosniff, X-Frame-Options: DENY, and Referrer-Policy: same-origin. HSTS is deliberately left to the data plane, which is the TLS edge for the proxied admin host.

Auth (internal/auth, internal/oidc, internal/session). Operators authenticate with a local bcrypt password and/or OIDC (authorization code + PKCE). Sessions are server-side (SQLite), referenced by an opaque gpm_session cookie (HttpOnly, SameSite=Lax, Secure by default). OIDC group claims map to roles; there is no path by which a user outside the configured admin groups becomes an admin.

The local admin can carry a TOTP second factor (RFC 6238, HMAC-SHA-1, 6 digits, 30-second step, one step of drift either way), enabled by the presence of GPM_LOCAL_ADMIN_TOTP_SECRET (or its _FILE form) and implemented on the standard library alone. A correct password mints no session: it stores a single-use, five-minute pending entry in the same bounded map the OIDC flow uses (the two are not interchangeable) and renders a second server-side page asking for the code. The accepted time step is remembered per user in memory, so a code is single-use; a wrong code counts toward the same per-client-IP lockout as a wrong password, and a correct password with the code still outstanding does not clear that lockout. An unparseable secret is fatal at startup and, defensively, fails closed in the authenticator rather than silently disabling the factor.

Roles are scope grants. admin is unconstrained; user is *:read minus the api-tokens subject, so it reaches every GET under /api/ except the credential routes and is refused every write. GET /config drops apiTokens for any caller lacking api-tokens:read, so the exclusion cannot be walked around by asking for the whole tree. The role gate and the API-token scope gate are therefore the same mechanism and compose by intersection: a token presented on a user session can never exceed *:read, whatever scopes it holds. Writes are refused twice over (by HTTP method before the API mux is reached, and by scope per route), so "a viewer can never write" does not depend on every route carrying the right annotation. GET /api/me reports readOnly for the role so the SPA can render itself accordingly.

API tokens (internal/auth, internal/model). Besides session cookies the admin API accepts scoped bearer tokens (Authorization: Bearer gpm_...). The secret is minted server-side and returned exactly once; only its SHA-256 digest is committed as an APIToken object, so the git config never holds a usable credential. Bearer resolution runs before the cookie path and never falls through to it, and matching is a constant-time digest compare across the enabled, unexpired tokens. The token set is cached in the authenticator and invalidated from the single config-reload path, so a revoked token stops working immediately while an unauthenticated bearer flood cannot force a full config load (walk + parse + whole-graph validate) per request: a failed bearer auth never reaches the login rate gate, so re-reading per request was an internet-facing DoS lever. A token principal is admin-role (satisfying the coarse gate) but scope-limited per route: register() wraps each resource route in a <plural>:read / <plural>:write check, with admin reserved for token management, writing settings, backup, restore, whole-config revert and the pprof endpoints. Scope enforcement is injected into the API as a closure, so the API package stays independent of auth for testing; the daemon's implementation denies when no principal is on the request (which the mounted route makes impossible: reaching it means broken wiring, and a broken authorization check must fail closed). A nil closure means "allow" and exists only for the unwired case, i.e. tests. /debug/pprof/ is gated the same way plus its own admin-scope check, because a heap dump and the process command line carry resolved backend credentials in cleartext and every token principal is admin-role. Token principals are CSRF-exempt by construction: the double-submit token defends against ambient browser credentials, and a bearer header is never attached automatically. Last-use is tracked in memory only: persisting it would turn every API call into a git commit.

DNS sync (internal/dnssync). An optional reconciler that publishes CNAMEs for the proxy hosts that opted in (dns.lanDirect / dns.publicCname), into a local Pi-hole v6 resolver and/or the authoritative Cloudflare zone. It follows the webhook dispatcher's shape (live settings read on every run, non-blocking triggers, a hardened HTTP client that never follows redirects and refuses link-local destinations at connect time), with two additions. Reconcile is full-state: the desired set is recomputed from the whole config and compared against what the backend actually holds, so out-of-band drift is repaired in both directions. And deletion is ledger-gated: gpm removes only records it recorded creating.

That ledger (model.DNSLedger, persisted as the singleton config/dns-ledger.yaml next to settings.yaml) is the subsystem's load-bearing piece, and it exists because the thing it replaced was not ownership at all. Pi-hole/dnsmasq CNAMEs carry no comment field, so the original backend inferred ownership from target equality: "this CNAME points at apexTarget, therefore gpm made it". On a shared apex that is simply false, and it is the regression this ledger guards against: an operator enabling the backend for the first time with hand-written LAN CNAMEs already pointing at the same edge host, and no host yet carrying dns.lanDirect, would otherwise see the desired set come up empty, every one of those records look managed, and the first reconcile delete the lot. Ownership is now recorded rather than inferred: decide() (in dnssync.go) is the single place the rules live, and both backends plus the dry-run planner call it, so a preview cannot disagree with the run it previews. Per desired name it creates what is absent, adopts what is already correct but not yet in the ledger (the migration path: an empty ledger makes a first reconcile adopt-only, never a purge), retargets a record it created that still holds exactly what gpm wrote after apexTarget moved, and skips and warns on a name held by a record it does not own rather than shadowing or replacing it. It deletes only ledger entries the config no longer wants, and only while the record still matches what the ledger says gpm left there; re-pointed out of band, it is disowned instead. A name absent from the ledger is never in a delete list, whatever it points at.

Each entry also records how the claim was acquired (adopted), because adoption is a claim on a record somebody else made and must not become permission to destroy it: an adopted entry the config no longer wants is released (dropped from the ledger, record left standing), never deleted. The same applies when apexTarget moves: a retarget is a delete plus a create, so an adopted record is released there too rather than replaced, which also stops the claim being quietly upgraded to "created" and arming a later deletion. Without that distinction, adoption was a one-way trap: turn dns.lanDirect on for a hand-written name, turn it off again, and the next reconcile deleted the operator's record: the same deletion regression, deferred by one config edit. An entry with no recorded provenance (a ledger written before the field existed) reads as adopted, the only reading of a missing field that cannot destroy anything on upgrade. Deletions are logged at warn together with the ledger revision that authorised them, since a whole-tree revert can restore a claim that reality has moved past. A reconcile hands the revision it read the ledger at back to the store on write; if the repo has moved since (a revert landing mid-run) the write is refused, and the run re-reads and rewrites without the claims the revert withdrew rather than resurrecting them. Cloudflare keeps its managed-by:gpm record comment as an independent second condition on both adoption and deletion (re-checked inside the delete call itself, so it cannot become an arbitrary-delete primitive); the ledger is authoritative, the comment is additive. The ledger lives in the config repo rather than beside it so it is committed, diffable and reverted with everything else: rolling the config back to before a host existed also rolls back gpm's claim on the record that host published. It is written by the reconciler alone (there is no CRUD route onto it, which would amount to an "authorise a DNS deletion" API), and an unchanged ledger produces no commit. GET /dns-sync/plan renders the same decisions as a read-only dry run, so enabling a backend is checkable before it is done. Runs are serialised by a single-flight mutex. The event-triggered path waits for an in-flight run (that is what makes trigger coalescing correct: the follow-up must see the config that caused it), so a bulk restore costs one reconcile rather than one per object; the HTTP-triggered ReconcileNow instead refuses with ErrReconcileInProgress to 409, so repeated manual runs cannot pile blocked goroutines up behind a slow backend. The Cloudflare client is separate from the ACME solver on purpose: record lifecycle management and certificate issuance should not be able to break each other.

Both backends read defensively and write reversibly. A Pi-hole listing that does not carry a config.dns.cnameRecords list is an error, never an empty resolver: a nil slice would read as "everything gpm owns has been deleted out of band", which a full-state reconciler answers by emptying the ledger and reporting a clean run. Cloudflare pagination terminates on a short page and treats result_info as advisory, so a response without it cannot truncate the listing into a partial view of the zone. Neither backend can update a record in place, so a retarget is a delete followed by a create; if the create fails the original is restored and the run fails loudly, rather than leaving the name unresolved until some later reconcile heals it, and the counter is incremented as soon as the delete lands so a destructive half-step can never be reported as a run that changed nothing. The Pi-hole session is closed on a context detached from the caller's, because logout is reached by defer and the commonest reason to reach it is the caller having gone away: cancelling the logout with it leaks one of Pi-hole's few session slots per aborted run.

Access-list source sync (internal/accesssync). An optional fetcher that keeps the remote IP feeds an AccessList declares (sources) current, so a rule can say "the addresses this monitoring provider publishes" instead of naming two hundred CIDRs the operator has to re-paste whenever the provider changes them. It follows the reconcilers above (live config read on every run, coalescing non-blocking triggers, a hardened client that never follows redirects), and, like DNS sync, persists its state in a committed singleton (model.AccessListSourceLedger, config/access-list-sources.yaml) rather than in memory, so a restart serves the last known set instead of an empty one and every change to who can reach a host is a diffable commit.

The design constraint is that a remote body decides who reaches a host, so every step fails closed and refuses whole rather than accepting part. Its SSRF guard is strictly tighter than dnssync's: https only, and the dialer refuses loopback, link-local, RFC1918/ULA and multicast destinations post-DNS at connect time; dnssync deliberately permits private targets (a LAN Pi-hole is the point), while nothing legitimate here is internal. The body is capped at 1 MiB, and a non-200, an empty result, a result over maxEntries, or a single unparseable line refuses the fetch and keeps the previously fetched set; a feed that changed shape is one gpm no longer understands, and serving the subset it still parses would be a silent, partial allow list. Each entry carries a sha256 over its network set, validated on load, so a hand-pasted network fails the load instead of quietly widening an allow list.

Two consequences are worth stating. An unchanged feed writes nothing: advancing fetchedAt on a no-op would commit a timestamp-only diff every interval forever, so the last attempt is kept in memory instead and the interval gate still holds across a restart's one re-fetch. And a source with no ledger entry resolves to the empty set rather than being dropped, which keeps the list's IP dimension intact: dropping the rule would leave a default-deny list with nothing to match on, which is exactly the shape that serves open.

Kubernetes Ingress discovery (internal/k8s). An optional, read-only poll loop that turns annotated cluster Ingress objects into gpm-managed proxy hosts, which then feed the DNS reconciler above: one DNS code path, not two. The client is plain net/http + encoding/json against /apis/networking.k8s.io/v1 (no client-go: its transitive tree would dwarf this project's entire direct dependency set), with in-cluster or explicit apiURL/tokenFile/caFile config; the latter is the real deployment, because gpm runs on the edge host rather than as a pod. The bearer token is re-read from disk on a TTL, and dropped immediately on a 401, so a rotated projected ServiceAccount token keeps working unattended. Transport hardening matches dnssync: TLS verified against the supplied CA with no skip-verify, redirects never followed, link-local destinations refused at connect time, bounded reads and bounded pagination.

Docker container discovery (internal/docker). The same subsystem pointed at containers instead of a cluster: an optional, read-only loop that turns containers labelled gpm.rake.pro/enabled: "true" into gpm-managed proxy hosts, which feed the same DNS reconciler. The client is plain net/http + encoding/json against the Docker Engine API over a unix socket (or a tcp:///https:// endpoint, which is how a read-only socket proxy is used), and issues exactly three GETs (/version, /containers/json and /events) with no code path that can write to the Engine. Reconciles are driven by the event stream with a 2s debounce and a poll interval as the fallback, so correctness never depends on the stream.

Both reconcilers share one planner (internal/discovery): full-state desired set, ownership by name and domain, freeze on an unreadable source, fail closed on an unresolvable profile, operator-owned disabled/maintenance carried forward, one commit per run. They are told apart by the value of the <prefix>/managed-by label they stamp (ingress-discovery versus docker-discovery), so the two can run on one instance and neither can update or delete the other's hosts. What stays per-source is only the listing and the derive: an Ingress contributes hostnames from spec.rules, a container contributes them from one comma-separated label plus a port and scheme within its own address.

A derived host is a full ProxyHost, not a reduced one: the operator's template (or the profile an Ingress selected by name) supplies the upstream, TLS/mTLS, middleware and access-list chains, websockets, robotsNoIndex, timeouts and tags, and every reference type among them is deep-copied per host so two derived hosts never share backing memory with each other or with the loaded settings. Parity matters because the alternative is silent: a service moved into discovery that loses its no-index header or its upstream timeout keeps serving, just differently, and the workaround is a second mechanism (a headers middleware) for something the model already expresses. The one field a derived host deliberately cannot carry is locations: path routing belongs to the cluster ingress controller, which does it from the same Ingress gpm read.

Three properties define the reconciler. It is full-state: the desired set is recomputed from a complete list on every poll and compared with the config, so a missed event is impossible by construction. It is ownership-gated: only proxy hosts labelled gpm.rake.pro/managed-by: ingress-discovery are ever written or deleted, and a collision with a hand-written host is skipped with a warning, exactly as the DNS backends treat a record they do not own. Ownership is gated on the domain as well as the name, because the router keys its per-domain maps by hostname and fills them in config load order: without it a derived host whose name merely sorts after the operator's could take over sso.example.com and serve it with the template's chain instead of the operator's. The same rule is enforced one layer down: Config.Validate rejects any two enabled hosts claiming one domain, whatever wrote them, and re-checked under the store lock at write time, since the plan is computed before a multi-second network list. And it freezes on error: a managed host is deleted only after a complete, successful, fully-paginated list; any transport error, non-200, decode failure, mid-pagination failure, over-cap body, exceeded per-reconcile deadline, or a 200 whose body is not a kind: IngressList aborts before any write, and the client never returns a partial list with a nil error, so "empty" and "failed" are different return shapes rather than different values of one. Everything security-relevant on a derived host (upstream, certificate, middleware, access lists) comes from an operator-authored chain (the default template, or one of the named profiles an Ingress may select by name with gpm.rake.pro/profile). The Ingress contributes only strictly-validated, suffix-restricted hostnames, two DNS booleans, and that one name. Profiles exist because a real fleet is heterogeneous (public-and-rate-limited, SSO-and-VPN, login-middleware) and a single template can only adopt the group that happens to match it; the annotation carries a name and never a chain, so a cluster tenant chooses among configurations the operator sanctioned but can never invent one, and an undefined profile name skips the Ingress rather than silently downgrading it to the default. Every profile validates exactly as the template does, at settings-write time. Because gpm is off-cluster, in-cluster Service DNS is unusable, so the upstream is the ingress controller's address and the data plane's preserved Host header is what routes the request to the right workload. A whole reconcile lands as one commit (Store.ApplyBatch), and a no-drift run writes nothing at all. ApplyBatch is transactional in both directions: it takes an ownership guard it re-evaluates against freshly loaded state under the store lock, and it snapshots every file it touches so a failed write, removal or commit rolls the working tree back: a tree left mutated but uncommitted would be read as live config by the next load and swept into the next unrelated commit.

ACME manager (internal/acme). A background loop (12h interval) issues and renews Certificate objects of type acme: register/reuse an account key per directory URL (per external account when EAB is configured), create the order, solve every authorization, finalize the CSR, and write fullchain.pem + privkey.pem atomically. Renewal triggers when a cert is unissued, its domain set changed, or it is within 30 days of expiry. On any change it signals the data plane to reload.

Two challenge types are solved. DNS-01 writes the _acme-challenge TXT record through the referenced DNS provider and waits for propagation against a public resolver before accepting; it is the only way to prove a wildcard. Six solvers sit behind one DNSSolver interface:

  • Cloudflare, DigitalOcean, Hetzner, deSEC: plain REST clients, one API token each.
  • rfc2136: an RFC 2136 dynamic UPDATE signed with a TSIG key (RFC 8945), packed onto the wire here rather than pulling in a DNS library; the same code walks up the name with SOA queries when no zone is configured.
  • acme-dns: one HTTP POST to a joohoi/acme-dns account, reached through a CNAME the operator places in the real zone once, so gpm never holds a credential with authority over that zone. HTTP-01 needs no provider: the manager parks the key authorization in an in-memory token store (HTTP01Store, entries expiring with the order) which the data plane reads through the one-method dataplane.ACMEChallengeStore interface: the two packages stay decoupled, the manager owns the map, the listener only reads it. The plaintext :80 handler answers /.well-known/acme-challenge/<token> for an in-flight token before host routing, the force-SSL redirect, and auth run, so a name that has no host yet (or that redirects everything to https) can still be validated; an unknown token falls through to normal routing so a proxied upstream's own ACME client keeps working. Only the HA leader runs the manager, so only it holds tokens.

HA role gate (internal/ha, phase 1 of design/ha.md). A two-node pair designates its single writer statically: GPM_HA_ROLE=leader (default) runs the ACME and Ingress-discovery loops and accepts admin/API writes; GPM_HA_ROLE=follower disables both loops and wraps the API mux in a gate that refuses every mutating method with 503, naming the leader. The follower's config arrives instead from Store.FollowRemote, which polls git pull --ff-only against the leader's repo and calls the same reload() path as any other change, but only when HEAD actually moved; a pull that is not a clean fast-forward is refused and logged, never merged or reset, so the two repos cannot diverge. Independently of the role, every instance re-reads the persisted SSO revocation watermark (<cert-dir>/sso_not_before) on a ticker and advances it monotonically, so a revoke (or any out-of-band edit) takes effect within one interval rather than at the next restart. Traffic-side failover is out of process (keepalived VIP, see High availability (two-node active/standby)).

Metrics (internal/metrics). An optional Prometheus exposition at GET /metrics on the admin listener (-metrics / GPM_METRICS=1; 404 when off), gated by admin role plus a dedicated metrics:read token scope. It is a small in-tree implementation of the exposition format (integer counters and gauges, fixed-bucket histograms, a text writer) rather than prometheus/client_golang, whose transitive tree would have several times outweighed this project's entire direct dependency set for one read-only route.

Two properties matter more than the metric list. Cardinality is bounded by config, not by clients: every host label is the operator's ProxyHost/StreamHost name, resolved once per request in the observe wrapper, never the client's Host header: a header is attacker-chosen, so labelling by it would hand any client an unbounded series generator pointed at this process's memory. A request matching no host collapses onto one - label, and each metric independently caps its series count and folds the rest into a single __overflow__ series, so the bound survives a mistake upstream of that rule. And the data plane does not know about metrics: it declares a MetricsHook interface (internal/dataplane/metricshook.go) that internal/metrics happens to satisfy structurally, exactly as the ACME manager reaches the plaintext listener through ACMEChallengeStore. With no hook installed (and every other observability toggle off) each listener's handler switch serves the plain dispatch chain (the observe wrapper is built but never on the hot path), so an instance without -metrics pays one atomic pointer load per request and nothing else. That switch is also what makes access logging a live toggle: PUT /api/logs (admin scope) swaps the observed chain in or out at runtime with no listener restart, in-flight requests finishing on the chain they started with; the toggle is never persisted, so a restart reverts to -access-log. Subsystems that already track their own state (ACME expiry and renewal failures, the DNS and Ingress reconcilers' run/success timestamps and counts) are read at scrape time through pull collectors wired in cmd/gpm, so nothing is mirrored into a second source of truth and no package gains a metrics import.

Client-CA generation (internal/clientcert/generate.go). The paste-a-PEM path assumes an operator who already has a CA; POST /api/client-cas/{name}/generate removes that assumption, producing a self-signed RSA-4096 CA (CA:TRUE, pathlen:0, certSign|cRLSign, random serial), writing its private key into the certificate store at client-cas/{name}.key, and saving the ClientCA object that points at it, so a working mTLS setup needs no external tooling and no hand-placed file. The key size is doubled relative to an issued leaf because this key is dated for a decade and cannot be rotated without re-provisioning every device that trusts it; pathlen:0 keeps it from ever minting a subordinate CA.

Three properties shape the implementation. Everything is checked before anything is generated: the object name, the request, the confinement of the derived key path, the absence of a config object, and what any existing key file at the derived path means, so a refusal leaves nothing on disk to roll back (and RSA-4096 keygen is never spent on a request that was always going to fail). A key file in use is never overwritten: it is placed with a temp-file-plus-os.Link, the one primitive that is both atomic and ErrExist-on-collision, because that file may still be the signing key behind certificates already on devices: os.Rename would clobber it silently. The "in use" test is ownership, not mere existence: the handler asks whether any ClientCA names that exact caKeyFile, refuses with the referrer's name if one does, and otherwise reclaims the file, because an unreferenced key can only be residue from a crash or from a deleted CA and refusing it forever would burn the name permanently. The same reasoning drives an age-bounded sweep of abandoned .tmp-* files in that directory, each of which holds a raw private key from an interrupted generate. And unlike issuance this is a config mutation: the object goes through the ordinary Store.Save, so it is graph-validated, committed and in history exactly like a UI PUT, and the response is the created object. If that save fails after the key landed, the handler removes the key it just wrote, otherwise the no-overwrite rule would permanently block retrying the same name, leaving an operator stuck with no fix available from the UI. Deleting a ClientCA deliberately does not delete its key file, matching how a deleted Certificate leaves its ACME artifacts: a delete is revertible from git, and a restored object pointing at a deleted key would be a silent breakage.

Client-certificate issuance (internal/clientcert). A ClientCA that carries an optional signing key (caKeyFile, confined to the cert store exactly like crlFile, or an inline caKeyPEM secret) stops being only a verification anchor and becomes an issuing CA: POST /api/client-cas/{name}/issue mints a client certificate signed by it and returns a password-protected PKCS#12 bundle. The key is resolved by model.ClientCA.SigningKey, which matches it against the certificates in caPEM: the one it belongs to is the issuer, so a bundle holding an intermediate and its root resolves to whichever the operator actually holds the key for. A key that matches nothing, or matches a non-CA certificate, is refused; an inline key is checked at config validation, a caKeyFile the first time it is used, since only the data plane knows the cert store path.

Three properties define the subsystem. It is stateless and write-free: the generated private key is never written to the config store, the cert store, or a log line; it exists only in the response body, so the endpoint creates no config revision, no history entry and no lifecycle event, and a lost bundle means re-issuing rather than recovering. Issuance is logged with the CA name, subject, serial and validity window and nothing else. The crypto choices are compatibility-driven, not modernity-driven: RSA-2048 because iOS refuses ECDSA client certificates during the handshake, and pkcs12.Legacy (SHA-1/3DES) because modern PBES2 bundles fail to import into the iOS keychain and into several Android/Wear OS releases. That trade has a cost worth naming: the legacy encoder derives the bundle's integrity MAC with a single KDF iteration, so there is almost no work factor between the password and the key inside, and the file itself ends up on phones and in shared folders. The encoder cannot be hardened without losing the imports the feature exists for, so the password carries the whole load and a 12-character floor (clientcert.MinPasswordLen) is enforced on issue and renew alike. SANs are likewise constrained to printable ASCII, because a certificate stores them as IA5String and anything else fails inside ASN.1 marshalling rather than at the door. It is gated as a mutation even though it mutates nothing: the same client-cas:write scope, admin session, CSRF and same-origin guard that editing the CA takes, because minting a credential from the CA's key is at least as privileged, and the HA follower's method-based read-only gate refuses it like any other POST.

Issuance records (internal/clientcert/records.go). "Stateless" above is about secrets: gpm keeps no key, no certificate and no bundle. It does keep a record of each issuance (CA, common name, SANs, serial, validity window, issued-at) because an operator who cannot see what a CA issued cannot see what is about to expire. The records are runtime state, not configuration: they describe what gpm did rather than what the operator declared, so they live in the certificate store (<certDir>/client-certs/<ca>.json) and never in the git-backed config repo. The storage shape is deliberately the ACME issued-certificate metadata's (internal/acme/store.go): one JSON file per object, written temp-file-plus-rename at 0600 so a crash mid-write cannot leave a truncated file, read back on demand rather than cached. A single Ledger value is shared by the handlers and its mutex serializes the read-modify-write that appending and superseding both need. The record is written before the bundle is streamed, so a bundle that gpm has no memory of cannot exist.

From the records the API derives a per-certificate ok / expiring / expired status against the CA's expiryWarningDays (default 30), which drives the UI's pre-expiry banner, and POST /api/client-cas/{name}/certificates/{serial}/renew reissues the recorded identity (same subject and SANs, taken from the record rather than the request) with a new key and serial, marking the predecessor superseded in the same atomic write. That write refuses rather than no-ops when its target is absent, and the handler refuses with 409 when the target is already superseded: either would otherwise leave two records that both look current, which is the exact state the supersede link exists to prevent.

Two properties are load-bearing here and are stated in the UI as well as the docs. Renewal is not automatic and cannot be: unlike an ACME server certificate, which gpm both installs and serves, a client certificate lives in a keychain on someone's device, so every renewal ends in a human importing a .p12; the banner warns before expiry precisely because the fix has a human in it. And renewing does not revoke: the superseded certificate stays valid until its own notAfter on every device that has not re-imported, which is why superseded records stay listed rather than being deleted. Killing an old certificate early is a CRL edit, the same mechanism that revokes any other client certificate.

Data plane

Listeners (internal/dataplane). An HTTPS listener (TLS 1.2+ by default, a fixed set of forward-secret AEAD cipher suites, ALPN h2,http/1.1) and an HTTP listener. settings.tls.minVersion raises the floor for the whole fleet (including stream-terminate listeners and an unknown SNI), and a host may pin its own minimum TLS version either side of it (tls.minTLSVersion), applied per-connection by SNI via GetConfigForClient. Certificates are chosen per-connection by SNI: an exact-domain match wins, otherwise the left-most label is stripped and a wildcard match is tried; an unknown SNI is an error (there is no default certificate to leak). Custom certs load from the cert store; ACME certs load from their issued artifacts, and an unissued ACME cert is skipped until the manager produces it. Both listeners bind a bare :port by default, which in Go is the IPv6 wildcard with v4-mapped addresses enabled: one socket serving both families, so an inbound IPv6 client is routed, gated and logged as its own v6 address with no second listener and no toggle. Stream hosts add their own raw TCP and/or UDP listeners (one per listenPort), reconciled on every reload: ports added are opened, ports removed are closed, and a changed route table is swapped without dropping the port.

Inbound PROXY protocol (internal/dataplane/proxyproto.go). When settings.proxyProtocol is on, every data-plane listener (HTTP, HTTPS, and each TCP stream listener) is wrapped so an accepted connection parses a HAProxy PROXY header (v1 text and v2 binary, hand-written against the spec with no dependency, v2 TLVs consumed and ignored) and the asserted source replaces the connection's RemoteAddr. That is deliberately the only integration point: it is the L4 tier of the trust model below, and the single client-IP derivation (clientip.go) starts from RemoteAddr, so every IP-based control follows with no per-feature wiring. The header is an unauthenticated claim, so it is parsed only when the real TCP peer is inside trustedCIDRs; from any other peer the bytes are left untouched as payload and the peer address stands (warned once per peer). Parsing is lazy (on the first Read/RemoteAddr, not in Accept), so one stalled sender cannot hold up the accept loop; a malformed header closes the connection with a sticky error, and a stalled one is cut at the configured deadline. The compiled config is read from an atomic pointer per connection, so a settings change applies without rebinding a listener.

Stream TLS and L4 access control (internal/dataplane/streamhost.go, clienthello.go). A TCP stream host may declare tls.mode. In passthrough, the forwarder peeks the ClientHello with a hand-written, bounded, stdlib-only parser (record header -> handshake message, reassembled across records -> the server_name extension), routes on the SNI, and replays the peeked bytes verbatim to the backend: gpm never decrypts and never holds the key. In terminate, the same peeked bytes are replayed into crypto/tls with the referenced certificate and plaintext is forwarded on. Routing is compiled per listen port into an exact map plus single-label wildcard suffixes; a name no host claims (or a connection that is not TLS) is closed rather than handed to an arbitrary backend. Several hosts share a port only when every one of them is SNI-routed, which config validation enforces so routing can never depend on compile order. Independently, a stream host may reference access lists: only the IP/CIDR and geo dimensions are evaluated (basic auth has no challenge/response on a raw stream and is rejected at validation), and the gate runs before the backend dial (for UDP, once per session), so a denied client never causes an upstream socket to exist.

Routing. An HTTP(S) request is dispatched by Host to its compiled handler: proxy hosts run the middleware chain to a reverse proxy; redirect hosts return the configured 3xx to their target (scheme/status/path-preservation per config); parked hosts return a fixed status (default 404). An unknown host -> 404; no default-host leakage. On the HTTP listener, a host with forceSSL gets a 308 redirect to HTTPS. Within a proxy host, locations are matched longest-prefix-first and fall back to the host default; a location carries its own upstream and a middleware/access-list chain that is appended to the host's, so it is always at least as restrictive.

Upstream groups (internal/dataplane/upstreamgroup.go). A proxy host (or a single location) may reference an UpstreamGroup instead of one upstream: an ordered backend list with per-group health state. A health manager living on the data-plane server (across reloads) runs one prober per group (TCP connect or HTTP GET, with rise/fall hysteresis), and live-traffic connect failures feed the same counters. Reloads are serialized and staged: a new config's group state is built first, the router compiles against it, and only a successful build commits (an unchanged group keeps its probers and up/down state; a rejected config disturbs nothing). Per request, a failover-aware transport orders the healthy upstreams by the group's policy (failover (list order), round-robin (smooth weighted), least-connections (in-flight/weight), ip-hash (rendezvous)), optionally honors a signed sticky-session cookie with a server-enforced TTL, and retries the next candidate only on connect-phase errors (dial/TLS: the request was never transmitted, so non-idempotent requests cannot double-apply; request bodies up to 1 MiB are buffered to make the replay possible). With every upstream down the group fails open and attempts them anyway. Live state is exposed at GET /api/upstream-health.

Maintenance mode (internal/dataplane/maintenance.go) sits in front of that chain, not inside it. A host in maintenance is answered at the router dispatch layer (before path normalization, before the identity strip, before any gate) so a downtime window costs no auth subrequest and no upstream dial. The switch has two halves that meet in one predicate: the per-host maintenance flag is compiled into its hostHandler like any other host field, while the fleet-wide settings.maintenance.enabled lives in a package-level atomic installed by SetMaintenance, alongside SetErrorPages and SetSecurityHeaders, and is read live on every request. That split is what makes the global toggle apply on the very next request instead of waiting on a router rebuild, and it is why the global switch wins over a per-host false: an operator taking the whole edge down must not have to walk every host to do it.

The response reuses the refusal seam below rather than inventing a second one: maintenance renders as a 503 through serveErrorPage, so a custom maintenance page is just the 503 entry in errorPages. Only the built-in fallback is new, and it negotiates on Accept (JSON / HTML / plain text) because a maintenance window is exactly when non-browser clients hit the edge, and a bodyless Content-Type-less error from this proxy has crashed a real API client.

The flag is deliberately stored on the ProxyHost (and in Settings) rather than in runtime-only state: a window has to survive a restart, and git-backed config is where every other durable host property already lives. That puts it in the discovery reconcilers' blast radius, which derive the whole object on every run, so the shared planner carries maintenance forward from the stored host exactly as it does disabled. Without that the next poll would quietly put a host back into service while someone was still working on its backend.

Refusal rendering (internal/dataplane/errorpages.go). Every tier that refuses a request writes its response through one seam, serveErrorPage (and, for the auth tier, the refuse wrapper over it) rather than calling http.Error directly. The seam resolves the host's own errorPages override first, then the settings-level pages, and renders the matching (or default) html/template; with nothing configured it calls the tier's own writer, so an unconfigured deployment's output stays byte-identical to what it was before error pages existed. That is why the compiled pages are threaded through buildChain into every gate constructor instead of being read from a global at write time: a host override is per-chain state, and the settings-level set is the fallback behind it.

The auth gates joined that seam rather than keeping their own bare strings, so a 401 from forward-auth, a 403 from a client certificate, an auth-request 502, an OIDC callback failure and the 503 a middleware that would not compile serves are all as brandable as an access-list denial. Two categories stay out on purpose. A redirect into a sign-in flow is not an error: the OIDC 302 to the IdP and the auth-request 302 into the outpost carry the user forward, and replacing them with a page would break login. And a response proxied from the identity provider is the IdP's to own: in auth-request mode gpm passes the outpost's sign-in, callback and sign-out endpoints through verbatim, so the IdP's response always wins there. Error pages apply exactly where gpm generates the body itself. Identity-header stripping is unaffected: it happens on the refusal path as before, and the render reads only the same template context an access-list denial gets (status, status text, host name, request id), so a refusal cannot leak request data that tier was not already exposing.

Security response headers (internal/dataplane/securityheaders.go). A configurable header set (settings.securityHeaders, merged per key with a ProxyHost's own override) is injected at the router's dispatch layer: serveHTTPS/serveHTTP wrap the ResponseWriter before dispatching, the same place and mechanism that emits per-host HSTS. This is deliberately outside the middleware chain, and therefore outside the auth gate: a headers middleware lives inside the chain and never runs when a gate refuses ahead of it, which is exactly why gpm's own denials (the 401/403/503, the 302 sign-in redirects, rendered error pages, the 400 path-rejection, the 404/421, parked and redirect hosts) previously carried nothing but HSTS. Wrapping at dispatch means one injection point covers every response gpm generates below it, so no denial path can be missed. Injection is set-if-absent: a gpm-generated response never set these headers, so they are added; a proxied upstream response has its own headers copied into the map before WriteHeader, so an app's own X-Frame-Options/Referrer-Policy is preserved and never clobbered or duplicated. Strict-Transport-Security is excluded from the set (the per-host hsts setting owns it); the feature is additive and leaves HSTS behaviour unchanged.

Each header carries a scope (all / generated-only / proxied-only) so an operator can place a header that is safe on gpm's own pages but breaks a backed app (Content-Security-Policy: frame-ancestors 'none', Permissions-Policy) on gpm-generated responses only. The compiled set is split into two subsets at build time: generated (scope all + generated-only) and proxied (scope all + proxied-only); the dispatch writer picks the subset at inject time from a single bit: is this response proxied? The writer defaults to the generated subset; the reverse proxy flips it to proxied via ModifyResponse, which the stdlib runs only when an upstream actually answered. The upstream-unreachable 502/504 is written by the proxy's ErrorHandler, not ModifyResponse, so it correctly stays gpm-generated. The bit reaches ModifyResponse through the request context: serveHTTP(S) stashes the dispatch writer on the context after wrapping, and ModifyResponse reads it back off the (context-preserving) outbound request and sets the flag, so the choice does not depend on the wrapper's Unwrap chain surviving the middleware stack. Per-host merges happen on the scoped rule map before the split, so a host override replaces a header's value and its scope. Both injections happen at the FINAL WriteHeader (>= 200): an upstream 1xx interim response (103 Early Hints, or 100 Continue) is written and then has its header map cleared by httputil.ReverseProxy's Got1xxResponse hook, so injecting on the interim status would seed headers the clear deletes; the writer skips 1xx and injects only on the final status. The per-host HSTS and X-Robots-Tag emission was moved onto this same writer (previously Set into the map before proxying, where the 1xx clear dropped them): HSTS with override semantics, X-Robots-Tag set-if-absent so an explicit headers-middleware value still wins. The one path the dispatch writer does not reach is a 101 WebSocket upgrade: the stdlib hijacks the connection and writes the 101 without a WriteHeader the writer sees, so these headers (and HSTS/robots) are absent there; immaterial, since a wss upgrade rides an already-loaded https document that carried HSTS and robots on a socket is meaningless.

Response-header stripping (internal/dataplane/stripheaders.go) is the removal half of the same subsystem: settings.stripResponseHeaders, unioned with a ProxyHost's own list, names response headers deleted from what an upstream sends (Server, X-Powered-By, X-AspNet-Version, ...). Names are canonicalized to MIME header form at compile time, making the match case-insensitive.

The deletion happens in the reverse proxy's ModifyResponse hook, on resp.Header (the upstream's own header map), before httputil.ReverseProxy copies it onto the client response. That placement is the whole design, and it is deliberately not the dispatch writer the injection half uses: by the time the writer runs there is ONE merged header map, so a name in the strip list would also delete headers gpm itself put there earlier: forward-auth's copySetCookie refresh of the IdP session cookie, the compression handler's Content-Encoding/Vary, a headers middleware's setResponse value, an injected security header. Stripping at ModifyResponse can only ever reach what the backend sent, so "never removes a header gpm added" is a structural property rather than an ordering convention.

It also covers the one response the dispatch writer can never see: a 101. The stdlib runs modifyResponse for a switching-protocols response before handleUpgradeResponse copies the headers to the hijacked connection, while the writer is bypassed entirely by the hijack, so a WebSocket handshake would otherwise leak the exact Server/X-Powered-By fingerprint every other response hides. Hop-by-hop names are refused at validation, so Connection/Upgrade survive and the handshake stays intact, and the Sec-WebSocket-* trio is refused alongside them (a browser aborts a handshake with no Sec-WebSocket-Accept). An interim 1xx is the one response the strip does not sit on: the stdlib forwards it through Got1xxResponse and then clears the header map, so its headers are neither stripped nor carried into the final response.

gpm-generated responses have no upstream response at all, so nothing runs on them, including the ErrorHandler's 502/504, which never reaches ModifyResponse. Validation also refuses the headers that carry a response's own semantics (Content-Type, Content-Length, Content-Encoding, Vary, Location) on top of the hop-by-hop set; Content-Type most of all, since removing it hands the body to DetectContentType and can re-label a JSON response as text/html. The compile step drops the same names again as defence in depth. The list merges as a union rather than per-key like securityHeaders, because a list has no per-name value for a host to override, so the only alternative would let a host silently re-expose a header the fleet strips. This is also not the headers middleware's removeResponse: that runs inside a host's chain, applies only where the middleware is attached, and is skipped entirely when an auth gate refuses ahead of it. removeResponse remains available for per-middleware, per-location removal.

The bouncer middleware (internal/dataplane/bouncer.go) is a WAF/CrowdSec deny hook, not a bundled WAF: gpm ships no rules, no signatures and no detection engine, it asks an operator-run bouncer whether the client IP is currently banned and acts on that verdict. The crowdsec provider speaks the LAPI bouncer flow (GET /v1/decisions?ip= with X-Api-Key; ban and captcha both deny, since gpm has no captcha flow to hand the client and serving anyway would silently downgrade the operator's decision); the http provider is a generic 2xx=allow / 403=deny endpoint any custom bouncer can implement. Optional stream mode pulls the whole decision set once and then deltas on the cache interval, so the hot path is a local IP/CIDR lookup with no per-request LAPI call: refreshes run in the background off a request that finds the set stale (rather than a per-chain ticker, which would leak one poller per config reload), and a failed refresh keeps serving the previous set. Verdicts are cached per client IP with a bounded LRU; a verdict derived from an error is capped at 5s so an outage cannot pin a long TTL of guesses. onError decides what an unanswerable lookup means and defaults to fail-open: an unreachable threat feed must not take the site down, which is the opposite of the right default for auth. It sits between the access list and auth deliberately: an explicit operator allow-list (allowFrom, or the access list itself) still wins outright over an external feed, and a banned IP never reaches the IdP. Denials go through the shared per-host denial counter.

A reference that resolves to nothing fails the host closed. If a host (or one of its locations) names a middleware or access list that does not exist in the compiled registry, the chain is replaced by a handler answering 503 and the mismatch is logged at error. Config.Validate already rejects dangling references and is the primary guard, but it must not be the only thing between a typo and an unauthenticated route: skipping an unresolvable access list would turn a restricted host into an open one, which is the opposite of what the reference was written for. The blast radius is deliberately one host: a config that cannot pass validation anyway must not take unrelated hosts down as a side effect of this defence in depth.

Trust model

  • One client IP, three trust tiers. internal/dataplane/clientip.go is the only place that answers "what is the client IP", and every IP-comparing tier reads its result: access lists, geo, rate limits, the basic-auth lockout, all four allowFrom exemptions, the access log, and the X-Forwarded-For / X-Real-IP sent upstream. It is derived once per request at router dispatch and carried on the request context, so no two tiers can disagree. Three separate grants feed it and none is inferred from another: settings.proxyProtocol.trustedCIDRs (L4: who may rewrite RemoteAddr), settings.trustedProxies or a proxy host's own trustedProxies (L7: whose X-Forwarded-For is believed, via a rightmost-untrusted walk), and identityProvider.forwardAuth.trustedProxies (identity: who may assert Remote-User and friends, and nothing else). The default is empty: trust nobody, RemoteAddr is the client. A per-host list REPLACES the fleet list, so one host being behind a proxy never widens another host's trust.
  • Identity-header stripping. Headers that carry an asserted identity are stripped from any untrusted peer before the request reaches a backend, so a direct client cannot forge an identity. Forward-auth and auth-request handlers re-strip their own header sets as a second layer.
  • Network exemptions are explicit and IP-rooted, and only as good as trustedProxies. A middleware's allowFrom (rate limit, guard, bouncer, auth-request and client-cert auth) is evaluated against the derived client IP above (the same value the access list compared), with no per-middleware notion of its own. With trustedProxies empty that is the raw TCP peer, which is fail-safe when gpm is the edge (a client cannot forge a header gpm does not read) and dangerous when it is not: an undeclared L7 proxy whose own address falls inside an allowFrom network exempts every request through it, which on a client-cert host is a total mTLS bypass. The fix is to declare that proxy in settings.trustedProxies (or the host's own): one knob, every tier; see Client IP and the three trust tiers. In client-cert mode the exemption is decided before the certificate check, so an exempt client is never asked for one and never role-checked, and because identity-passthrough headers are set only from a handshake-verified certificate, such a request reaches the upstream carrying no certificate identity at all. It is refused in oidc and forward-auth mode rather than silently ignored, and because an unset mode inherits the provider's type, Config.Validate resolves the effective mode and refuses that case too rather than accepting an exemption the runtime would drop.
  • Fail-closed. Misconfigured or unknown auth modes deny rather than pass; a nil/unparseable client IP is denied; an access list with no matching rule falls through to its defaultAction (deny by default).
  • Least privilege for automation. API tokens are scope-limited, not role-limited: a CI token can hold proxy-hosts:write without being able to read the whole config, mint another token, or restore an archive. Escalation paths are closed deliberately: token management, writing settings, backup, restore, whole-config revert and /debug/pprof/ are admin-scope only, and a client-supplied tokenHash is discarded so nobody can install a digest whose preimage only they know. Writing settings counts as admin because a settings write can aim DNS sync or a webhook at an attacker-controlled URL with a ${ENV:...} placeholder as its credential, and the write itself triggers the delivery that resolves and sends it, as well as rewrite adminAuth.
  • The stored digest never leaves the process. APIToken.TokenHash is json:"-", so no endpoint returns it (not GET /api-tokens, not the config dump); only the YAML at rest carries it. The backup archive is that raw YAML, which is why downloading it is admin-scope rather than *:read.
  • Rotation means revocation. Reverting an APIToken (scoped or whole-tree) would restore an older tokenHash and silently revive a secret the operator rotated away. Store.RevertObject refuses the kind outright (ErrNotRevertible), and Store.Revert snapshots the api-tokens directory before the tree restore and writes it back over the result, so a whole-config revert neither revives a rotated digest nor resurrects a deleted token.
  • Ownership-gated external writes. The DNS reconciler only ever deletes records it recorded creating, in the git-backed ownership ledger (config/dns-ledger.yaml); Cloudflare additionally requires its managed-by:gpm comment. Nothing is inferred from a record's target, because target-equality inference is exactly what deletes an operator's hand-written CNAMEs on a shared apex. A name owned by a foreign record is left alone rather than replaced, so a misconfiguration cannot take an operator's zone apart. Ingress and Docker discovery apply the same rule inward: only proxy hosts carrying that reconciler's own managed-by label VALUE are written or deleted, and only when neither the derived name nor any of its domains is already claimed by a host it does not own, and it deletes at all only on the strength of a complete, successful cluster list (freeze on error).
  • A remote feed is bounded on both sides. An access-list source is fetched over https, with no proxy honoured (a proxy would move the address the SSRF guard inspects), from a destination the dialer refuses if it is loopback, link-local, private/ULA, CGNAT, multicast or otherwise non-public. Any anomaly (non-200, empty, oversized, one unparseable line) refuses the fetch whole and keeps the previous set, and a source that has never been fetched resolves to the empty set. The contents are bounded by the same range table: an entry that is a default route (0.0.0.0/0, ::/0), broader than a /8 (v4) or /32 (v6), or inside a non-public range refuses the whole fetch: one such line in a hijacked feed would otherwise pass every other check and allow the internet.

A source rule without paths still grants its addresses full access to the host, exactly like any other allow rule; the path scoping is what bounds a feed to the endpoints the operator chose to expose. Pair every source rule with paths unless the feed is genuinely trusted with the whole host. - Discovery is opt-in and read-only. gpm never writes to the cluster: the shipped RBAC grants list on ingresses and nothing else, and an Ingress without gpm.rake.pro/managed: "true" is invisible. There is no namespace-sweep mode, and no field of an Ingress can supply an upstream, a certificate, a middleware or an access list, only the name of an operator-authored discovery profile, which is why there is no annotation that names those objects directly.

Repository layout

Where each mechanism above lives in the source tree.

cmd/gpm/            entrypoint, subcommands (daemon, hashpw, totp-secret)
internal/model/     config object types + validation
internal/store/     git-backed config store
internal/dataplane/ TLS, routing, middleware chain, reverse proxy
internal/acme/      ACME issuance + renewal (DNS-01 solvers, HTTP-01 tokens)
internal/dnssync/   Pi-hole / Cloudflare DNS record reconciler
internal/auth/      sessions, OIDC, forward-auth, role mapping
internal/oidc/      OIDC client (discovery, PKCE, token verification)
internal/api/       REST API
internal/ui/        embedded web UI (go:embed)

Dependencies

A deliberately small, vetted set (Go 1.26, CGO disabled):

Dependency Use
coreos/go-oidc/v3 (+ go-jose/v4) OIDC discovery + ID-token verification
golang.org/x/oauth2 OAuth2 authorization-code flow
golang.org/x/crypto bcrypt, TLS helpers
rs/zerolog structured logging
go.yaml.in/yaml/v3 config (de)serialization
modernc.org/sqlite pure-Go session store (no CGO)
software.sslmate.com/src/go-pkcs12 PKCS#12 (.p12) encoding for issued client certificates
oschwald/maxminddb-golang/v2 pure-Go MaxMind .mmdb reader for GeoIP access-list rules

Everything else is the Go standard library.

go-pkcs12 is the one place the standard library genuinely cannot do the job: crypto/x509 has no PKCS#12 encoder at all, and golang.org/x/crypto/pkcs12 is frozen and decode-only. It is a single pure-Go package (its only module requirement is golang.org/x/crypto, which is already a direct dependency here), it adds no transitive tree, and it is the maintained fork of the frozen x/crypto package. Hand-rolling the ASN.1 for a format whose whole point is interoperability with iOS and Android keychains would have been the larger risk.