ADR 0044 — Single-port onboarding and HA Ingress auth passthrough¶
- Status: Accepted
- Date: 2026-06-25
- Related: ADR 0041 — persist auth sessions, ADR 0043 — CCU authentication provider, SPECIFICATION.md §4.1
Context¶
Up to 0.13.0 the daemon ran two separate HTTP listeners:
:8080— REST API and Svelte SPA.:8081— HTMX bootstrap surface (login form, first-run/setupwizard, OIDC callback handler, server-rendered/healthand/about).
The split was introduced to keep pre-auth flows (login, setup) reachable even when the SPA bundle was unavailable. In practice it created two friction points:
-
Docker users and reverse-proxy operators had to open and forward two ports. The Home Assistant add-on (ADR packaging, 0.2.0) routes exactly one port through HA Ingress (
:8080). With the bootstrap surface on a separate port, the first-run/setupwizard was unreachable through Ingress — operators had to SSH into the host, set up a user via the CLI, and only then open the panel. -
HA Ingress does not forward arbitrary ports. The add-on's Ingress panel has always pointed at
:8080. The second listener existed outside that path permanently.
Separately, the HA supervised add-on has a well-defined security boundary: the HA Supervisor gate keeps Ingress restricted to HA admins (panel_admin: true in the add-on's config.yaml), but the daemon received those requests unauthenticated — operators still had to create a local Loom account or configure OIDC before Ingress was useful. For a supervised deployment where the HA admin is the natural Loom admin, requiring a second credential is friction with no security benefit.
Decision¶
1. Fold the HTMX bootstrap surface onto the REST listener¶
The HTMX bootstrap routes (/login, /logout, /setup, /about, /auth/oidc/callback, /health) are served on the same listener as the REST API and the SPA embed (north.rest.listen, default :8080). The separate :8081 listener is removed.
The north.ui.listen config key is deprecated (silently ignored; the daemon logs a startup warning when it is present). Operators who set it in their config.yaml see a one-line warning and can remove the key at their convenience — there is no breakage.
First-run redirect: when no admin user exists in the database, a GET / (the SPA entrypoint) returns a 302 redirect to /setup. The setup wizard completes, the redirect target becomes the SPA, and subsequent visits reach the normal app. This is how the wizard becomes reachable through a single port — and therefore through HA Ingress.
2. Opt-in HA Ingress auth passthrough¶
A new config block north.rest.auth.ha_ingress enables the daemon to accept a request forwarded by the HA Supervisor as an authenticated admin, without requiring the operator to present a local credential.
Trust chain (three layers, all required)¶
A request qualifies for Ingress passthrough only when all of the following hold:
ha_ingress.enabled: true— explicit operator opt-in; defaultfalse.- Supervised build/env — the daemon was started with
OPENCCU_LOOM_SUPERVISORset, or was compiled with the supervised add-on build stamp. Prevents the feature from activating on a bare Linux install that happens to have the config key set. - RemoteAddr ∈
trusted_proxy_cidr— the TCP peer address (neverX-Forwarded-For) must fall within the configured CIDR block. Default is172.30.32.0/23, the well-known HA Supervisor subnet. TheX-Forwarded-Forheader is deliberately not consulted — spoofing it is trivial; the TCP peer address is not. X-Ingress-Pathheader present — the HA Supervisor injects this header on every Ingress-proxied request. Its presence is a reliable signal that the request passed through the Supervisor's auth gate.
Why the trust chain is sufficient¶
The HA add-on's config.yaml carries panel_admin: true. The HA Supervisor enforces this flag: only HA admins can open the add-on's Ingress panel. The Supervisor does not forward unauthenticated requests; it verifies the caller's HA session before proxying. Because OpenCCU-Loom cannot verify the HA session itself, it relies on the Supervisor as the outer gate — and trusts only connections that arrive from the Supervisor's known subnet over the TCP-peer check, not a forgeable header.
This chain mirrors how other supervised HA add-ons handle admin-level Ingress access (e.g. the Grafana add-on's authproxy mode). It does not apply to direct connections on :8080 from other hosts, because those do not arrive from the Supervisor subnet.
Credential priority¶
A request that carries a valid Bearer token, session cookie, or Basic credentials is authenticated by the normal pipeline first. Ingress passthrough is a fallback invoked only when no other credential is present. This means: - An operator who has a local Loom session can revoke Ingress access for themselves by using their local session instead. - A misconfigured Ingress header on a direct-port request (wrong subnet) is simply ignored.
Audit identity¶
Passthrough sessions are recorded with subject: "ha-ingress" and scheme: "ingress" in the audit log, making them distinguishable from normal local logins.
3. Configuration¶
north:
rest:
auth:
# HA Ingress auth passthrough (supervised add-on only; ADR 0044).
# ha_ingress:
# enabled: false # opt-in; default false
# trusted_proxy_cidr: "172.30.32.0/23" # HA Supervisor subnet
# role: admin # granted role: admin | operator | viewer
# DEPRECATED since 0.14.0 — the bootstrap surface (login, /setup,
# OIDC callback, /health, /about) is now served on the REST listener.
# north.ui.listen is silently ignored; a startup warning is emitted.
# ui:
# listen: ":8081"
Security considerations¶
- Default off.
ha_ingress.enabledisfalseby default. A new installation or an upgrade from 0.13.0 is unaffected unless the operator explicitly opts in. - Supervised-only guard. The three-layer check (enabled + supervised env + subnet + header) ensures the feature cannot be triggered on a bare Docker or binary deployment, even if someone copies an add-on
config.yamlverbatim. panel_admin: truedependency. The security model depends on the HA add-on'sconfig.yamlcarryingpanel_admin: trueto restrict Ingress to HA admins. If an operator removes this flag, any HA user (including non-admins) could reach the add-on panel — and, with Ingress passthrough enabled, would be granted the configured Loomrole. This risk must be documented in the add-onconfig.yamland the user guide.- No
X-Forwarded-Fortrust.X-Forwarded-Foris attacker-controlled. Only the TCP peer address is checked. - Single-port change is a net security improvement. Removing the separate listener eliminates a second bind surface with different auth semantics that could be overlooked during firewall configuration.
Alternatives considered¶
- Keep
:8081as a deprecated alias for:8080: adds maintenance cost for no user benefit; rejected. - Trust
X-Ingress-Pathalone: trivial to forge from the LAN; rejected. - Trust
X-Forwarded-Forfor subnet check: attacker-controlled; rejected. - Read HA session token from Ingress request and validate against HA API: requires an outbound call to the Supervisor API on every request, adds latency, and creates a dependency on Supervisor uptime for every page load; rejected.
Consequences¶
- Positive: onboarding works end-to-end through one port and through HA Ingress; Docker users open one port; the add-on's "Open" button reaches the setup wizard on a fresh install without SSH access.
- Positive: operators of the supervised add-on get frictionless Ingress access once the opt-in flag is set; no second credential required.
- Negative / migration cost:
north.ui.listenin existing configs emits a startup warning. This is deliberate — it is a soft deprecation, not a hard error. Operators can remove the key on their own schedule. - Testing: existing HTMX surface tests need their base URL updated from
:8081to:8080; theha_ingresspath needs unit tests covering each layer of the trust chain (disabled, wrong subnet, missing header, creds-win), and a contract test pinning thatX-Forwarded-Foris never used for the subnet check.
Update (0.14.3): passthrough default-on in the HA add-on¶
The original decision shipped ha_ingress.enabled as a plain false default (opt-in everywhere). In practice the HA add-on has no local admin and CCU auth is off there (the HA add-on binary is not stamped AddonBuild=true), so every add-on user hit the first-run /setup wizard through Ingress instead of simply landing in the app — the friction this ADR set out to remove.
ha_ingress.enabled is therefore now tri-state (*bool): unset defaults to the supervised stamp — on in the HA add-on (which pins panel_admin: true, so Ingress is admin-only), off in a plain build / Docker image. An explicit true/false still overrides. The trust chain is unchanged (supervised + real RemoteAddr in the subnet + X-Ingress-Path; a real token/session still wins), so it remains inert outside genuine Supervisor traffic — including the CCU/OpenCCU add-on, which is supervised but not reached through Ingress. Net effect: opening the add-on via the HA panel logs the operator straight in as admin, with no setup or login page, and the first-run redirect never triggers.