Skip to content

Runtime settings with env-wins pinning + Cloudflare Zero Trust proxy provider - #33

Merged
swimmesberger merged 5 commits into
mainfrom
wt/runtime-settings-cloudflare-zero-trust-aca368
Aug 17, 2026
Merged

Runtime settings with env-wins pinning + Cloudflare Zero Trust proxy provider#33
swimmesberger merged 5 commits into
mainfrom
wt/runtime-settings-cloudflare-zero-trust-aca368

Conversation

@swimmesberger

@swimmesberger swimmesberger commented Aug 17, 2026

Copy link
Copy Markdown
Owner

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 routes table instead of clicked together in the dashboard.

What changed

Runtime settings with env-wins layering (ADR-0014)

  • Configuration layers reordered to appsettings < boot snapshot < settings store < env vars: stored settings live-reload (no restart), but a WATCHTOWER__* env var pins its setting — env stays infrastructure-as-code, and WATCHTOWER__AUTH__ENABLED=false + restart is a guaranteed lockout escape hatch by construction.
  • A synchronous boot snapshot reads the stored Global settings straight from SQLite before the host builds. Without it a stored Auth:Enabled could never take effect: the Elarion settings provider only loads in a hosted service, after the pre-DI pipeline reads in Program.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.
  • New settings surfaces: Authentication (restart-required contract around Auth:Enabled, plus a lockout guard — enabling requires an existing enabled admin) and Reverse proxy (fully runtime-switchable; CaddyManager reacts to options changes — enable reconciles, disable stops/removes the container keeping certificates).
  • 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 (shown as pinned).

Pluggable proxy provider (ADR-0015)

  • IProxyProvider + ProxyProviderRouter (per-call off Proxy:Provider, same pattern as the metrics backend); the per-stack ingress-network topology is shared via ProxyIngressNetworks. Switching provider is one settings save: the old data plane tears down while the new one reconciles, no restart.
  • Cloudflare Tunnel provider: finds/creates the remotely-managed tunnel by name, replaces its ingress rules with a projection of the route table (terminal http_status:404 catch-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)

  • Protected routes get a self_hosted Access app (watchtower: {domain}) with a Watchtower-owned allow policy. Authenticated admits 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). Restricted admits exactly the route's grant-derived emails and is never widened by instance-wide sources.
  • Safety rails: an empty allow-list skips with a warning instead of publishing a deny-all app; only apps carrying the watchtower: name prefix are ever deleted.

Edge portability for apps

  • New IdentityHeaderMode.Cloudflare (integrated auth): forwards Cf-Access-Authenticated-User-Email and duplicates the Watchtower assertion under Cf-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).
  • Deploys now inject WATCHTOWER_AUTH_JWKS_URL resolved from the active edge (Cloudflare team certs URL via the new Proxy:Cloudflare:TeamDomain setting, or Watchtower's /api/auth/jwks under 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

  • The boot snapshot reads elarion_settings with a raw read-only SQLite connection before migrations run — tolerant of a missing file/table by design (first run).
  • proxy.getStatus gained a provider field; caddyRunning keeps its wire name but reports the active provider's data plane.
  • Single-zone assumption for DNS upserts (documented); per-domain failures are best-effort logged.
  • Docs: ADR-0014, ADR-0015, docs/reverse-proxy/cloudflare.md, central-auth README (new forwarding mode), docs/public-app-api.md (new injected variable), README config section.
  • 699 backend tests green (new: layering precedence, pin detection/rejection, auth-enable guard, provider transitions incl. switch, ingress + Access projections, Cf header mode, JWKS resolution/injection).

…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.
@swimmesberger
swimmesberger merged commit 239257a into main Aug 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant