TLS front door (Caddy) for this repo's own services: one hostname, a permanent HTTP->HTTPS redirect, and the API on the same origin as the page. Home Assistant, Grocy, Frigate and the rest keep their own ports — fronting HA brings its own auth and websocket concerns and none of the problems below need it. Three concrete reasons, not hygiene: 1. The admin panel's URL carries IDENTITY_TOKEN, which grants administrative access to the person registry AND to the device grants that decide whether a smart lock opens. On plain HTTP that is readable by anything on the smart-home VLAN — a segment deliberately full of cheap IoT hardware. 2. getUserMedia requires a secure context, so register.html's camera cannot work over http://192.168.x.x at all. It also failed SILENTLY: the call site used `navigator.mediaDevices?.getUserMedia(...).then().catch()`, and optional chaining short-circuits the whole chain — so neither handler ran, no "Camera unavailable" message appeared, and registration proceeded photo-less with no explanation. Verified in node. register.js now checks explicitly and says which of the two cases it is; HTTPS is what actually fixes it. 3. Serving the page over HTTPS while ?api= still pointed at http://...:8097 would have every call blocked as mixed content, so the API has to be proxied too. tls: internal runs Caddy's own CA (no external dependency, works with no WAN at all) with tools/export-proxy-ca.sh to fetch the root; tls: custom takes an existing cert, which is how you'd use a real one from a DNS-01 challenge without exposing anything. HSTS is deliberately not set — with an internal CA it would turn a dismissible warning into a hard failure. Kiosks stay on plain HTTP for now: a full-screen cert interstitial is not dismissible on a device with no keyboard, so moving them is documented as a follow-up rather than done blind. Empty service tokens now fill themselves in on the first build and land in tokens.txt with what each is for. They are written BACK to the config, which is the part that matters: a token is only useful because two machines agree on it, so generating fresh randomness per build would produce a door panel that cannot talk to the service it was built for. Blanks are filled once and never overwritten. ha_token, mqtt_password, admin_password_hash and ssh_authorized_key are deliberately not invented — tokens.txt lists them with the reason, so an empty field is never a mystery. 32 new checks: token generation and stability across runs, disabled services skipped, tokens.txt contents, config still valid after the rewrite, Caddyfile routes and redirect, conditional pantry route, both TLS modes, and that the derived admin URL keeps page and API on one origin. Nothing has been run against a real Caddy — see proxy/README.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| README.md | ||
README.md
proxy
The household's TLS front door: one HTTPS entry point, an HTTP→HTTPS redirect, and a
single hostname instead of a fistful of http://192.168.x.x:80xx URLs.
Caddy, because it does automatic certificate management with a two-line config and
tls internal gives a working local CA with no external dependency at all — which
matters when docs/network-integration.md §1 forbids exposing anything to the WAN, and
therefore forbids the usual HTTP-01 challenge.
Why this exists, concretely
It isn't hygiene. Three specific things were broken or unsafe without it.
1. The admin panel's token travels in the URL
admin.html?api=…&token=<IDENTITY_TOKEN> carries a credential that grants full
administrative access to the person registry and to device-access grants — the
things that decide whether a smart lock opens. Over plain HTTP that token is readable
by anything on the smart-home VLAN, which is a VLAN deliberately full of cheap IoT
hardware (see network-integration.md §3's own reasoning about why that segment is
treated as untrusted).
TLS doesn't fix the token being in a URL — it's still in browser history and the address bar. It fixes it being on the wire in cleartext, which is the part that scales to "anyone who ever joins this network".
2. The registration camera cannot work over plain HTTP
getUserMedia requires a secure context. On http://192.168.x.x:8098,
navigator.mediaDevices is simply undefined in Chromium and Firefox — so
register.html's camera never starts.
Worse, it failed silently. The call site is:
navigator.mediaDevices?.getUserMedia({…}).then(…).catch(…)
Optional chaining short-circuits the whole chain, not just the property access — so
when mediaDevices is undefined the expression evaluates to undefined and neither
.then nor .catch ever runs. No error, no "Camera unavailable" message, no photo, no
explanation. Registration quietly proceeds without the audit photo it was supposed to
capture. (register.js now also handles this defensively and says so out loud, but
HTTPS is what actually makes the camera work.)
3. Mixed content would break the admin panel anyway
Serving the page over HTTPS while its ?api= still pointed at http://…:8097 would
have every API call blocked by the browser as mixed content. So the API has to be
reachable over the same origin — which is why this proxies both the static frontend
and the API, rather than just putting a certificate in front of a static file server.
What it routes
| Path | To | Notes |
|---|---|---|
/ |
identity-web |
the dashboard and registration pages |
/admin.html |
identity-web |
the admin panel |
/api/identity/* |
identity:8097 |
prefix stripped before forwarding |
/api/pantry/* |
pantry-vision:8095 |
prefix stripped; only if that service is enabled |
http://… |
→ https://… |
permanent redirect, all paths |
Everything else in the stack (Home Assistant, Grocy, Frigate, Portainer…) keeps its own port and is untouched. This is the front door for this repo's own services, not a household-wide gateway — bringing HA's own auth and websockets behind a proxy is a separate decision with its own failure modes, and it isn't needed to fix any of the three problems above.
Certificates
Two modes, set by proxy.tls in CoreSystemConfig.json:
internal (default)
Caddy runs its own CA and issues itself a certificate. Zero configuration, zero external dependencies, works on a network with no internet at all.
The catch: browsers don't trust that CA until you install its root. Until you do, you get an interstitial warning — clickable on a laptop, but genuinely a problem on a kiosk, where a full-screen certificate interstitial is not something anyone can dismiss with no keyboard. That's why the kiosk images still use plain HTTP by default, and why moving them over is a documented follow-up rather than something done for you.
Export the root once the proxy has started:
tools/export-proxy-ca.sh # writes proxy/ca/root.crt from the running container
Then install it on the machines that need it (on Debian: copy to
/usr/local/share/ca-certificates/ and run update-ca-certificates; Firefox and
Chromium each have their own store as well).
custom
Point proxy.cert_file / proxy.key_file at a certificate you already have. This is
the better option if you own a domain — and you can get a real, publicly-trusted
certificate for a LAN-only host without exposing anything, via a DNS-01 challenge
(certbot or acme.sh with your DNS provider's API). Nothing is port-forwarded; the
challenge is answered in DNS. Then no CA needs installing anywhere, and kiosks can move
to HTTPS with no interstitial.
Configure
"proxy": {
"enabled": true,
"hostname": "home.example.lan",
"tls": "internal",
"cert_file": "",
"key_file": ""
}
hostname is what the certificate is issued for and what you type in the browser. It
needs to resolve to the container host — either a DNS record on your own resolver
(OPNsense's Unbound has an override for exactly this) or a hosts entry on the machines
that use it. An IP address works for tls internal too, if you'd rather not touch DNS.
Manual verification still outstanding
- Never run. No Caddy container has been started, no certificate issued, no request proxied. The Caddyfile is generated and its structure is checked by tests, but that is not the same as Caddy having parsed it.
handle_path's prefix stripping is written from Caddy's documented behaviour, not observed. If/api/identity/peoplearrives at the identity container as/api/identity/peoplerather than/people, that directive is the thing to look at.- Nothing tests the HTTP→HTTPS redirect end to end — Caddy does this automatically
for any site with TLS, which is exactly the kind of "the tool handles it" assumption
worth confirming once with
curl -I. - The
custommode's file paths are mounted, not validated. A wrong path fails at Caddy startup, which is visible indocker logs caddyand nowhere else. - Whether a kiosk's Chromium accepts the internal CA once installed into the image's trust store is unverified — Chromium keeps its own NSS store on Linux, and "installed the CA system-wide" does not always mean "Chromium trusts it".