SmartestHome/proxy
Amir Alexander Abdelbaki 00991b9864 Add an HTTPS reverse proxy, and auto-generate empty service tokens
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>
2026-07-31 14:04:02 +02:00
..
README.md Add an HTTPS reverse proxy, and auto-generate empty service tokens 2026-07-31 14:04:02 +02:00

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

  1. 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.
  2. handle_path's prefix stripping is written from Caddy's documented behaviour, not observed. If /api/identity/people arrives at the identity container as /api/identity/people rather than /people, that directive is the thing to look at.
  3. 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.
  4. The custom mode's file paths are mounted, not validated. A wrong path fails at Caddy startup, which is visible in docker logs caddy and nowhere else.
  5. 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".