Runtime settings with env-wins pinning + Cloudflare Zero Trust proxy provider - #33
Merged
swimmesberger merged 5 commits intoAug 17, 2026
Merged
Conversation
…ime-editable (ADR-0014) Reposition the Elarion settings source below the environment provider so WATCHTOWER__* env vars are infrastructure-as-code pins: appsettings < boot snapshot < settings store < env vars. A synchronous boot snapshot reads the stored Global settings straight from SQLite before the host is built, so the pre-DI reads in Program.cs (Auth:Enabled, Auth:KeyPath) see runtime-edited values on the next start - without it a stored Auth:Enabled could never survive a restart, because the live provider only loads in a hosted service. EnvironmentSettingPins maps WATCHTOWER__X__Y onto Watchtower:X:Y; every settings get-handler reports its pinned paths (the UI disables those fields per-field, naming the variable), and every update handler rejects changes to pinned values and never writes pinned keys. New settings surfaces: - system.getAuthConfig / system.updateAuthConfig: restart-required contract around Auth:Enabled (compared against the AuthStartupState snapshot) and a lockout guard - enabling requires an enabled admin in the system realm. WATCHTOWER__AUTH__ENABLED=false + restart stays the escape hatch by construction, since env always wins. - proxy.getConfig / proxy.updateConfig: fully runtime-switchable. CaddyManager stops snapshotting ProxyOptions and reacts to IOptionsMonitor.OnChange - enable runs the full reconcile, disable stops and removes the managed Caddy container (networks and certificates kept), an email change re-renders the config; transitions serialized behind one lock. Settings UI: new Reverse proxy and Authentication cards; pinned fields render disabled with a lock note on every card. Behavior change: a deployment that set an env var and later edited the same setting in the UI previously ran the UI value; it now runs the env value.
… (ADR-0015)
Extract IProxyProvider (Enabled/ApplyAsync/ConnectStackAsync/IsRunningAsync)
behind ProxyProviderRouter, which resolves Proxy:Provider per call from the
options monitor - same runtime-switchable pattern as the metrics backend
(ADR-0007). The deploy queue, tenant teardown and the proxy/realms handlers now
inject the interface; the per-stack ingress-network topology is extracted into
ProxyIngressNetworks and shared by both providers, so the route table stays the
single source of truth for either projection.
Each provider self-gates on "enabled AND selected" and computes its own
transition (shared ProxyTransitions.Decide) from every options change: a
provider switch is a Stop on one side and a Start on the other, driven by one
settings write, no restart. Teardown removes only the provider's own data
plane - Caddy keeps certificates, Cloudflare keeps the tunnel and DNS records.
CloudflareTunnelProvider finds (or, in managed mode, creates) the
remotely-managed tunnel by name, replaces its ingress rules with a projection
of the route table (hostname -> http://{project}-{service}:{port}, terminal
http_status:404 catch-all), and upserts one proxied CNAME per route domain
({tunnelId}.cfargotunnel.com). cloudflared runs as a Watchtower-managed
container by default; unmanaged mode leaves the operator's cloudflared alone
and only manages remote configuration + DNS, optionally connecting a named
operator-run container to the ingress networks. CloudflareApiClient follows
the GitHubApiClient pattern; credentials are validated against the API before
the settings persist.
Settings UI: the Reverse proxy card gains the provider select and the
Cloudflare fields (account/zone/token/tunnel, managed toggle, unmanaged
container hookup), all env-pinnable per ADR-0014. proxy.getStatus reports the
active provider.
…ions
Phase 3 of ADR-0015: on the cloudflare provider, every reconcile now also
makes the account's Access applications match the route table. Each protected
route gets a self_hosted app named "watchtower: {domain}" with a single
Watchtower-owned allow policy:
- Authenticated routes admit the instance-wide configured allowed emails /
email domains (new Proxy:Cloudflare:AccessAllowedEmails[Domains] settings,
env-pinnable per ADR-0014, editable on the Settings card);
- Restricted routes admit exactly the emails behind the route's grants -
granted users plus members of granted groups; disabled accounts and
accounts without an email are excluded;
- a protected route whose allow-list comes out empty is skipped with a
warning instead of published as a deny-all app, and any existing app is
left untouched - a silent total lockout is the worse failure;
- a route flipped back to Public gets its Watchtower-created app deleted;
only apps carrying the "watchtower: " name prefix are ever deleted, so
dashboard-made apps are never touched.
CloudflareApiClient gains the Access app/policy endpoints (list/create/
update/delete, app-scoped policies). The projection is a pure seam
(ProjectAccessApps) with tests covering the mode mapping, the restricted-vs-
instance-defaults boundary, the empty-allow-list skip, and stable ordering.
Token scope docs updated: Access: Apps and Policies:Edit.
…policy allow sources
Two additions for stacks that move between Cloudflare Access and integrated
auth without code changes:
1. IdentityHeaderMode.Cloudflare - a protected route can forward Cloudflare's
header vocabulary: Cf-Access-Authenticated-User-Email (when the account has
an email) plus the Watchtower-signed assertion duplicated under
Cf-Access-Jwt-Assertion. An app written against Cloudflare's contract runs
unchanged behind either edge; one that cryptographically verifies the
assertion re-points its JWKS/issuer configuration (env/appsettings) from
{team}.cloudflareaccess.com to Watchtower's /api/auth/jwks - the header
names stay identical. Both Cf-Access-* names join the strip set, so a
client cannot spoof them past the proxy (CopyHeaderNames subset invariant
holds for the new mode; Caddyfile golden test updated).
2. Access group ids + reusable policy ids as allow sources for Authenticated
routes on the cloudflare provider - the "main user group" workflow: the
allow-list lives in an existing Access group (e.g. Entra ID users) or a
dashboard-maintained reusable default policy, referenced by id instead of
duplicated as an email list. Reusable policies attach on the app; groups
join the Watchtower-generated app-scoped policy, which is now deleted when
its inline rule set becomes empty rather than left stale. Restricted
routes remain grant-derived only - instance-wide sources never widen them.
…e edge
Deploys already inject WATCHTOWER_URL/WATCHTOWER_STACK_ID/WATCHTOWER_APP_TOKEN
(ADR-0012); they now also inject the JWKS URL an app should verify its identity
assertion against, resolved from whichever edge is actually signing:
- cloudflare provider active + Proxy:Cloudflare:TeamDomain configured (new
setting, bare team name or full host, env-pinnable, on the Settings card)
-> https://{team}.cloudflareaccess.com/cdn-cgi/access/certs
- integrated auth enabled + PublicBaseUrl configured
-> {PublicBaseUrl}/api/auth/jwks
- no issuing edge -> the variable is simply not injected
An app that reads WATCHTOWER_AUTH_JWKS_URL instead of hard-coding an issuer
swaps between Cloudflare Access and integrated auth with zero app
configuration - combined with IdentityHeaderMode.Cloudflare the header names
and the key discovery are both environment-driven; the next deploy after an
edge switch re-injects the right URL.
Like WATCHTOWER_URL it identifies rather than authenticates, so it goes to
every service; the name joins the reserved set so operator variables cannot
shadow it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the env-var-only configuration behind runtime settings with an honest precedence story, and adds a Cloudflare Zero Trust reverse-proxy provider beside the built-in Caddy engine — tunnel public hostnames, DNS records and Access applications are now projected from the existing
routestable instead of clicked together in the dashboard.What changed
Runtime settings with env-wins layering (ADR-0014)
appsettings < boot snapshot < settings store < env vars: stored settings live-reload (no restart), but aWATCHTOWER__*env var pins its setting — env stays infrastructure-as-code, andWATCHTOWER__AUTH__ENABLED=false+ restart is a guaranteed lockout escape hatch by construction.Auth:Enabledcould never take effect: the Elarion settings provider only loads in a hosted service, after the pre-DI pipeline reads inProgram.cs.EnvironmentSettingPins: every settings get-handler reports its env-pinned paths (the UI disables those fields per-field, naming the variable); update handlers reject changes to pinned values and never write pinned keys.Auth:Enabled, plus a lockout guard — enabling requires an existing enabled admin) and Reverse proxy (fully runtime-switchable;CaddyManagerreacts to options changes — enable reconciles, disable stops/removes the container keeping certificates).Pluggable proxy provider (ADR-0015)
IProxyProvider+ProxyProviderRouter(per-call offProxy:Provider, same pattern as the metrics backend); the per-stack ingress-network topology is shared viaProxyIngressNetworks. Switching provider is one settings save: the old data plane tears down while the new one reconciles, no restart.http_status:404catch-all), and upserts one proxied CNAME per route domain. cloudflared runs as a Watchtower-managed container by default; unmanaged mode leaves an operator-run cloudflared alone and only manages remote config + DNS (optionally connecting a named container to the ingress networks). Credentials are validated against the Cloudflare API before persisting.Zero Trust Access applications (phase 3)
self_hostedAccess app (watchtower: {domain}) with a Watchtower-owned allow policy.Authenticatedadmits the configured allow sources — emails, email domains, Access group ids (the existing-group-of-Entra-users workflow) and/or reusable policy ids (attached on the app).Restrictedadmits exactly the route's grant-derived emails and is never widened by instance-wide sources.watchtower:name prefix are ever deleted.Edge portability for apps
IdentityHeaderMode.Cloudflare(integrated auth): forwardsCf-Access-Authenticated-User-Emailand duplicates the Watchtower assertion underCf-Access-Jwt-Assertion, so an app written against Cloudflare's header contract runs unchanged behind either edge. Both names joined the anti-spoofing strip set (invariant test-pinned).WATCHTOWER_AUTH_JWKS_URLresolved from the active edge (Cloudflare team certs URL via the newProxy:Cloudflare:TeamDomainsetting, or Watchtower's/api/auth/jwksunder integrated auth) — an app that reads it instead of hard-coding an issuer swaps edges with zero app configuration; the next deploy re-injects the right URL.Review notes
elarion_settingswith a raw read-only SQLite connection before migrations run — tolerant of a missing file/table by design (first run).proxy.getStatusgained aproviderfield;caddyRunningkeeps its wire name but reports the active provider's data plane.docs/reverse-proxy/cloudflare.md, central-auth README (new forwarding mode),docs/public-app-api.md(new injected variable), README config section.