diff --git a/.gitignore b/.gitignore index 881ba2d..760234e 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,8 @@ hosts/*/live-build/binary/ hosts/*/live-build/*.iso config/generated-*.yaml hosts/audio-endpoint/rpi-image-gen/config/generated-*.yaml + +# Generated by tools/generate-tokens.py — a plaintext copy of every service token. +tokens.txt +# Caddy's exported root CA (public, but not something to publish casually). +proxy/ca/ diff --git a/CoreSystemConfig.json.template b/CoreSystemConfig.json.template index ed51f0f..2e9d22e 100644 --- a/CoreSystemConfig.json.template +++ b/CoreSystemConfig.json.template @@ -109,7 +109,9 @@ "transit": 8099, "otp": 8100, "music_assistant": 8101, - "ollama": 11434 + "ollama": 11434, + "proxy_http": 80, + "proxy_https": 443 }, "secrets": { @@ -125,6 +127,15 @@ "admin_password_hash": "" }, + "proxy": { + "_comment": "One HTTPS front door for this repo's own services (Caddy), plus an HTTP->HTTPS redirect. Not a household-wide gateway — Home Assistant, Grocy and friends keep their own ports. hostname is what the certificate is issued for and what you type in the browser; it must resolve to the container host (a DNS override on OPNsense, a hosts entry, or just use the IP with tls: internal). tls 'internal' makes Caddy run its own CA — no external dependency, but browsers show a warning until you install its root (tools/export-proxy-ca.sh). tls 'custom' uses cert_file/key_file, which is how you'd use a real cert obtained via a DNS-01 challenge without exposing anything. See proxy/README.md.", + "enabled": true, + "hostname": "home.example.lan", + "tls": "internal", + "cert_file": "", + "key_file": "" + }, + "voice": { "_comment": "Defaults for any kiosk with voice_satellite enabled; a kiosk may override wake_word individually.", "wake_word": "ok_nabu" diff --git a/docs/network-integration.md b/docs/network-integration.md index 9d1dd87..b3984b6 100644 --- a/docs/network-integration.md +++ b/docs/network-integration.md @@ -172,6 +172,7 @@ column shows which are opt-in vs. always-on with the base stack. | 8096 | pantry-web | `ENABLE_PANTRY_VISION` | none | | 8097 | identity | `ENABLE_IDENTITY` | bearer token (`IDENTITY_TOKEN`) | | 8098 | identity-web | `ENABLE_IDENTITY` | none | +| 80, 443 | Caddy reverse proxy | `ENABLE_PROXY` (default off) | none itself — it fronts `identity`/`identity-web`, whose own bearer token still applies. **This is the one service whose whole job is TLS**: the admin panel's URL carries `IDENTITY_TOKEN`, and on plain HTTP that credential is readable by anything on this VLAN. Still LAN-only — HTTPS here is about the local wire, not about exposure; §1's no-port-forward rule is unchanged | | 8099 | transit | `ENABLE_TRANSIT` | bearer token (`TRANSIT_TOKEN`) | | 8100 | OpenTripPlanner (OTP) | `ENABLE_TRIP_PLANNING` | none — OTP has no built-in auth; this is why it's only ever called server-side by `transit`, never exposed to a kiosk browser directly | | ~8095 (VERIFY) | Music Assistant | `ENABLE_MUSIC_ASSISTANT` | Music Assistant's own auth (if configured) — **also flagged as an assumed/unverified port that collides with `PANTRY_VISION_PORT`, see `docs/project-plan.md` open decision #31** — resolve the actual port before relying on this table for it | diff --git a/identity/frontend/register.js b/identity/frontend/register.js index 61b063a..fea140b 100644 --- a/identity/frontend/register.js +++ b/identity/frontend/register.js @@ -37,16 +37,31 @@ const video = document.getElementById("camera-preview"); const cameraError = document.getElementById("camera-error"); let stream = null; -navigator.mediaDevices - ?.getUserMedia({ video: { facingMode: "user" }, audio: false }) - .then((s) => { - stream = s; - video.srcObject = s; - }) - .catch((err) => { - cameraError.textContent = `Camera unavailable: ${err.message} (registration still works without it)`; - cameraError.hidden = false; - }); +// `navigator.mediaDevices` is undefined on a NON-SECURE ORIGIN — getUserMedia requires +// a secure context, so plain http://192.168.x.x has no camera API at all. That case is +// checked explicitly rather than left to `?.`: optional chaining short-circuits the +// WHOLE chain, so `navigator.mediaDevices?.getUserMedia(...).then(...).catch(...)` +// evaluates to undefined and neither handler ever runs — the camera silently doesn't +// start, no message appears, and registration proceeds photo-less with no explanation. +// See proxy/README.md; serving this page over HTTPS is what actually fixes it. +if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + cameraError.textContent = window.isSecureContext + ? "No camera API in this browser (registration still works without it)." + : "Camera needs HTTPS — this page is on a non-secure origin, so the browser blocks " + + "camera access entirely. Registration still works without a photo. See proxy/README.md."; + cameraError.hidden = false; +} else { + navigator.mediaDevices + .getUserMedia({ video: { facingMode: "user" }, audio: false }) + .then((s) => { + stream = s; + video.srcObject = s; + }) + .catch((err) => { + cameraError.textContent = `Camera unavailable: ${err.message} (registration still works without it)`; + cameraError.hidden = false; + }); +} function capturePhoto() { if (!stream) return Promise.resolve(null); diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 0000000..9e17a23 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,136 @@ +# 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=` 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: + +```js +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: + +```sh +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 + +```json +"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". diff --git a/tools/README.md b/tools/README.md index 14875ec..47e9f82 100644 --- a/tools/README.md +++ b/tools/README.md @@ -86,6 +86,9 @@ Warnings print but don't block. Errors block and nothing is written. | `build-audio-endpoint-image-arm64.sh` | An arm64 audio endpoint (Pi + HiFiBerry, rpi-image-gen → `.img`) | | `setup-container-host.sh` | The container-host setup itself, run by its ISO's first-boot unit (or by hand) | | `setup-llm-host.sh` | Same, for the LLM host | +| `generate-caddyfile.sh` | The reverse proxy's config, derived like everything else | +| `export-proxy-ca.sh` | Fetch Caddy's internal root CA so browsers stop warning | +| `generate-tokens.py` | Fill in empty service tokens and write `tokens.txt` | | `validate-config.py` | Check the config | | `config-export.py` | Config → shell variables, deriving URLs. Where the twinning happens | | `lib/coreconfig.sh` | The loader every builder sources | @@ -117,6 +120,37 @@ builder. Set `"arm64_prebake": false` in the config's `build` section to go back single generic `.img`, built once regardless of how many arm64 rooms are listed. Wi-Fi and SSH keys are still set in Imager for arm64 either way — only the hostname moved. +## Tokens fill themselves in + +Leave `identity_token`, `pantry_vision_token` and `transit_token` empty and the first +build generates them, **writes them back into the config**, and drops a `tokens.txt` +next to it with the values and what each is for. + +Writing them back is the part that matters. A token is only useful because two machines +agree on it — the container host runs `identity` with it, and every kiosk image is built +with the same value baked into its URLs. Generating fresh randomness per build would +produce a door panel that cannot talk to the service it was built for. So blanks are +filled once, persisted, and never overwritten. + +Only tokens this project can legitimately invent are generated. `ha_token` isn't (only +Home Assistant can mint one, and not until it's running), nor is `mqtt_password` (it has +to match Mosquitto), nor `admin_password_hash` (needs `mkpasswd`), nor +`ssh_authorized_key` (a generated key would have no private half you hold). +`tokens.txt` lists those too, with the reason, so an empty field is never a mystery. + +Both `tokens.txt` and the filled-in config are gitignored. + +## HTTPS + +`proxy.enabled` puts a Caddy reverse proxy in front of this repo's own services: one +hostname, a real HTTP→HTTPS redirect, and the API on the *same origin* as the page. + +That last part isn't cosmetic — serving the admin panel over HTTPS while its `?api=` +still pointed at `http://…:8097` would have every call blocked as mixed content. And +the reason to want HTTPS at all is that 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. See `proxy/README.md`. + ## The ISOs contain secrets This is deliberate — burning everything in is what makes installation unattended, with diff --git a/tools/build-container-host-iso.sh b/tools/build-container-host-iso.sh index 035a0ac..cd40b2e 100755 --- a/tools/build-container-host-iso.sh +++ b/tools/build-container-host-iso.sh @@ -101,9 +101,22 @@ DIGEST_WEB_PORT=${CORE_PORT_DIGEST_WEB} ADMIN_WEB_PORT=${CORE_PORT_ADMIN_WEB} TRANSIT_PORT=${CORE_PORT_TRANSIT} OTP_PORT=${CORE_PORT_OTP} +ENABLE_PROXY=${CORE_PROXY_ENABLED} +PROXY_HOSTNAME=${CORE_PROXY_HOSTNAME} +PROXY_TLS=${CORE_PROXY_TLS} +PROXY_CERT_FILE=${CORE_PROXY_CERT_FILE} +PROXY_KEY_FILE=${CORE_PROXY_KEY_FILE} +PROXY_HTTP_PORT=${CORE_PORT_PROXY_HTTP} +PROXY_HTTPS_PORT=${CORE_PORT_PROXY_HTTPS} EOF chmod 600 "$PAYLOAD/setup.env" +# Reverse proxy config, generated from the same source as everything else. +if [[ "$CORE_PROXY_ENABLED" == "true" ]]; then + mkdir -p "$PAYLOAD/proxy/config" + "${CORE_TOOLS_DIR}/generate-caddyfile.sh" "$PAYLOAD/proxy/config/Caddyfile" +fi + # --------------------------------------------------------------------------- # 2. Generated env files. EVERY URL BELOW IS DERIVED — see tools/config-export.py. # No address, port or token is written literally in this script. diff --git a/tools/config-export.py b/tools/config-export.py index 365d22c..5c8cfce 100755 --- a/tools/config-export.py +++ b/tools/config-export.py @@ -125,6 +125,25 @@ def main(argv: list[str]) -> int: if not flag.startswith("_"): emit(f"CORE_ENABLE_{flag.upper()}", value) + proxy = cfg.get("proxy", {}) or {} + emit("CORE_PROXY_ENABLED", proxy.get("enabled", False)) + emit("CORE_PROXY_HOSTNAME", proxy.get("hostname", "")) + emit("CORE_PROXY_TLS", proxy.get("tls", "internal")) + emit("CORE_PROXY_CERT_FILE", proxy.get("cert_file", "")) + emit("CORE_PROXY_KEY_FILE", proxy.get("key_file", "")) + # The admin panel's own URL, derived like every other one. Through the proxy when + # there is one, straight at identity-web when there isn't — so the printed URL is + # always the one that actually works. + if proxy.get("enabled") and proxy.get("hostname"): + base = f"https://{proxy['hostname']}" + emit("CORE_ADMIN_URL", f"{base}/admin.html?api={base}/api/identity") + emit("CORE_PROXY_BASE_URL", base) + else: + emit("CORE_ADMIN_URL", + f"http://{container_ip}:{ports['identity_web']}/admin.html" + f"?api=http://{container_ip}:{ports['identity']}") + emit("CORE_PROXY_BASE_URL", "") + emit("CORE_VOICE_WAKE_WORD", cfg.get("voice", {}).get("wake_word", "ok_nabu")) emit("CORE_BUILD_OUTPUT_DIR", cfg.get("build", {}).get("output_dir", "iso-out")) emit("CORE_ARM64_PREBAKE", cfg.get("build", {}).get("arm64_prebake", True)) diff --git a/tools/export-proxy-ca.sh b/tools/export-proxy-ca.sh new file mode 100755 index 0000000..737b5fa --- /dev/null +++ b/tools/export-proxy-ca.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Pull Caddy's internal root CA certificate off the running container host. +# +# With `proxy.tls: internal` Caddy runs its own CA, which means nothing trusts its +# certificate until you install that root. This fetches it so you can. +# +# tools/export-proxy-ca.sh # from the local Docker daemon +# tools/export-proxy-ca.sh # over SSH, from the container host +# +# The CA does not exist until Caddy has started at least once — there is nothing to +# export from a machine that has never run it, which is also why this can't be baked +# into an ISO at build time. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/coreconfig.sh +source "${SCRIPT_DIR}/lib/coreconfig.sh" + +REMOTE="${1:-}" +# Caddy's documented location for the local CA's root inside its data directory. +CA_PATH="/data/caddy/pki/authorities/local/root.crt" +DEST_DIR="${CORE_REPO_ROOT}/proxy/ca" +DEST="${DEST_DIR}/root.crt" + +mkdir -p "$DEST_DIR" + +if [[ -n "$REMOTE" ]]; then + core_log "Fetching the root CA from ${REMOTE}" + ssh "$REMOTE" "sudo docker exec caddy cat ${CA_PATH}" > "$DEST" +else + core_log "Fetching the root CA from the local Docker daemon" + docker exec caddy cat "$CA_PATH" > "$DEST" +fi + +if [[ ! -s "$DEST" ]]; then + rm -f "$DEST" + core_die "Got an empty certificate. + Has the proxy started at least once? The CA is created on first run: + docker logs caddy + If you are running this from a different machine, pass the host: + $0 user@container-host" +fi + +core_log "Wrote ${DEST}" +cat < Privacy & Security -> Certificates -> View Certificates + -> Authorities -> Import (Firefox keeps its own store, so the system + install above does NOT cover it) + + Chromium: Settings -> Privacy and security -> Security -> Manage certificates + -> Authorities -> Import (its own NSS store, same caveat) + + Android: Settings -> Security -> Encryption & credentials -> Install a certificate + -> CA certificate + + This file is a public certificate, not a secret — but it IS gitignored, because a + root CA your machines trust is not something to publish casually either. +EOF diff --git a/tools/generate-caddyfile.sh b/tools/generate-caddyfile.sh new file mode 100755 index 0000000..829a9fb --- /dev/null +++ b/tools/generate-caddyfile.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Generate the reverse proxy's Caddyfile from CoreSystemConfig.json. +# +# Called by the container-host ISO builder and by setup-container-host.sh, so the proxy +# is configured from the same single source as everything else — the hostname it serves, +# the ports it forwards to, and which services exist at all. +# +# tools/generate-caddyfile.sh +# +# See proxy/README.md for why this exists (short version: the admin panel's token is in +# a URL, the registration camera needs a secure context, and a mixed-content page can't +# call its own API). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/coreconfig.sh +source "${SCRIPT_DIR}/lib/coreconfig.sh" + +DEST="${1:-}" +[[ -n "$DEST" ]] || core_die "Usage: $0 " + +core_load + +if [[ "$CORE_PROXY_ENABLED" != "true" ]]; then + core_warn "proxy.enabled is false — nothing to generate." + exit 0 +fi + +# TLS directive. `tls internal` is Caddy's own CA; the custom branch points at mounted +# files. Note the paths are the CONTAINER's, not the host's — the compose service +# mounts them read-only at these locations. +if [[ "$CORE_PROXY_TLS" == "custom" ]]; then + TLS_LINE=" tls /etc/caddy/certs/cert.pem /etc/caddy/certs/key.pem" +else + TLS_LINE=" tls internal" +fi + +# Only proxy services that exist. A route to a container that was never started would +# give a 502 that looks like a proxy fault rather than a service that isn't enabled. +PANTRY_ROUTE="" +if [[ "$CORE_ENABLE_PANTRY_VISION" == "true" ]]; then + PANTRY_ROUTE=" + # pantry-vision's API, same prefix-stripping as identity's. + handle_path /api/pantry/* { + reverse_proxy pantry-vision:${CORE_PORT_PANTRY_VISION} + } +" +fi + +mkdir -p "$(dirname "$DEST")" +cat > "$DEST" < (enable flag that makes it required, what it's for) +GENERATABLE = { + "identity_token": ("identity", "identity's API — also baked into the door panel and kitchen display"), + "pantry_vision_token": ("pantry_vision", "pantry-vision's API — also baked into the kitchen display"), + "transit_token": ("transit", "transit's API — called by Home Assistant for voice departures"), +} + +# Deliberately NOT generated, with the reason. Inventing a value for any of these would +# produce something that looks configured and cannot work. +NOT_GENERATABLE = { + "ha_token": "a Long-Lived Access Token from Home Assistant's own UI (profile -> Security). " + "It doesn't exist until HA is running and an account exists, and only HA can mint it.", + "mqtt_password": "must match what Mosquitto is configured to accept. Generating one here " + "would just mean nothing can connect.", + "admin_password_hash": "a crypt(3) hash for the installer — generate with: mkpasswd -m sha-512", + "kiosk_password": "only needed if a kiosk account should have a password at all.", + "ssh_authorized_key": "your existing PUBLIC key (~/.ssh/id_ed25519.pub) — a generated one " + "would have no private half you hold.", +} + + +def main(argv: list[str]) -> int: + path = Path(argv[1]) if len(argv) > 1 else Path(__file__).resolve().parent.parent / "CoreSystemConfig.json" + if not path.exists(): + print(f"error: {path} not found", file=sys.stderr) + return 2 + + try: + cfg = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + print(f"error: {path} is not valid JSON — line {exc.lineno}: {exc.msg}", file=sys.stderr) + return 2 + + secrets_block = cfg.setdefault("secrets", {}) + enable = (cfg.get("container_host", {}) or {}).get("enable", {}) or {} + + generated = [] + for name, (flag, _purpose) in GENERATABLE.items(): + # A token for a service you haven't enabled isn't needed, and filling it in + # anyway would put a live-looking credential in a file for no reason. + if not enable.get(flag): + continue + if (secrets_block.get(name) or "").strip(): + continue + secrets_block[name] = secrets.token_hex(32) + generated.append(name) + + if generated: + # Written back with the same 2-space indent the template uses, so the diff is + # just the token lines rather than the whole file reformatting. + path.write_text(json.dumps(cfg, indent=2) + "\n") + print(f" generated {len(generated)} token(s) into {path.name}: {', '.join(generated)}") + + _write_tokens_txt(path.parent / "tokens.txt", cfg, enable) + return 0 + + +def _write_tokens_txt(dest: Path, cfg: dict, enable: dict) -> None: + secrets_block = cfg.get("secrets", {}) or {} + lines = [ + "SmartestHome — service tokens", + "=" * 60, + "", + "GENERATED by tools/generate-tokens.py from CoreSystemConfig.json.", + "This is a convenience copy for you, not a source of truth — the config is.", + "Regenerated on every build; edit the config, not this file.", + "", + "TREAT THIS FILE AS A CREDENTIAL. It is gitignored, but it is plain text on", + "disk: every token below grants full API access to the service named, and", + "identity's in particular can grant a person the right to open a smart lock.", + "", + ] + + for name, (flag, purpose) in GENERATABLE.items(): + if not enable.get(flag): + lines += [f"{name}:", " (not set — container_host.enable." + flag + " is false)", ""] + continue + value = (secrets_block.get(name) or "").strip() or "(EMPTY — run a build to generate)" + lines += [f"{name}:", f" {value}", f" for: {purpose}", ""] + + lines += ["", "Not generated here, and why:", ""] + for name, why in NOT_GENERATABLE.items(): + value = (secrets_block.get(name) or "").strip() + state = "set" if value else "EMPTY" + lines += [f"{name} [{state}]", f" {why}", ""] + + dest.write_text("\n".join(lines)) + print(f" wrote {dest.name}") + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/lib/coreconfig.sh b/tools/lib/coreconfig.sh index 6776311..30caf15 100644 --- a/tools/lib/coreconfig.sh +++ b/tools/lib/coreconfig.sh @@ -43,6 +43,13 @@ core_load() { command -v python3 >/dev/null 2>&1 || core_die "python3 is required to read CoreSystemConfig.json" + # Fill in any empty service tokens BEFORE validating, since the validator's whole + # job is to refuse a build with empty ones. Only fills blanks, never overwrites — see + # generate-tokens.py for why persisting them matters rather than generating per build. + core_log "Checking secrets" + python3 "${CORE_TOOLS_DIR}/generate-tokens.py" "$CORE_CONFIG_PATH" \ + || core_die "Could not generate tokens into $CORE_CONFIG_PATH" + # VALIDATE BEFORE ANYTHING ELSE. An ISO build is long and mostly unattended; the # entire value of the validator evaporates if it runs after 40 minutes of debootstrap # rather than before it. Warnings print but don't stop the build. diff --git a/tools/setup-container-host.sh b/tools/setup-container-host.sh index f442367..653b3ec 100755 --- a/tools/setup-container-host.sh +++ b/tools/setup-container-host.sh @@ -138,6 +138,19 @@ PANTRY_WEB_PORT="${PANTRY_WEB_PORT:-8096}" # LAN-facing read-only st # --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's # --- kiosk browsers call this directly. ENABLE_IDENTITY="${ENABLE_IDENTITY:-false}" + +# --- Reverse proxy (Caddy) — one HTTPS front door for this repo's own services, plus +# --- an HTTP->HTTPS redirect. See proxy/README.md for why it isn't just hygiene: the +# --- admin panel's token rides in a URL, getUserMedia needs a secure context, and a +# --- mixed-content page can't call its own API. Home Assistant and the rest keep their +# --- own ports and stay off it. +ENABLE_PROXY="${ENABLE_PROXY:-false}" +PROXY_HOSTNAME="${PROXY_HOSTNAME:-}" +PROXY_TLS="${PROXY_TLS:-internal}" # internal | custom +PROXY_CERT_FILE="${PROXY_CERT_FILE:-}" # host paths, only used when PROXY_TLS=custom +PROXY_KEY_FILE="${PROXY_KEY_FILE:-}" +PROXY_HTTP_PORT="${PROXY_HTTP_PORT:-80}" +PROXY_HTTPS_PORT="${PROXY_HTTPS_PORT:-443}" # Where this repo's identity/ directory lives on THIS host (build context). IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}" IDENTITY_PORT="${IDENTITY_PORT:-8097}" # LAN-facing — kiosk browsers call this directly @@ -888,6 +901,81 @@ fi # Node-RED's own HA access below); identity.env's HA_URL has to be the host's real # LAN IP, not "homeassistant". IDENTITY_BLOCK="" +PROXY_BLOCK="" +if [[ "$ENABLE_PROXY" == "true" ]]; then + if [[ -z "$PROXY_HOSTNAME" ]]; then + echo "ENABLE_PROXY is true but PROXY_HOSTNAME is empty — the certificate needs a name." >&2 + exit 1 + fi + mkdir -p "$BASE_DIR"/proxy/{config,data} + # The Caddyfile is generated from CoreSystemConfig.json by the ISO builder. When this + # script is run by hand, a minimal equivalent is written here so the proxy still comes + # up — same routes, same reasoning, just without the config file to derive from. + if [[ ! -f "$BASE_DIR/proxy/config/Caddyfile" ]]; then + PROXY_TLS_LINE=" tls internal" + [[ "$PROXY_TLS" == "custom" ]] && PROXY_TLS_LINE=" tls /etc/caddy/certs/cert.pem /etc/caddy/certs/key.pem" + cat > "$BASE_DIR/proxy/config/Caddyfile" <&device=" echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=" - echo " Admin panel : http://${HOST_IP}:${IDENTITY_WEB_PORT}/admin.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=" + if [[ "$ENABLE_PROXY" == "true" ]]; then + echo " Admin panel : https://${PROXY_HOSTNAME}/admin.html?api=https://${PROXY_HOSTNAME}/api/identity&token=" + echo " (HTTP redirects to HTTPS. With tls=internal, install the root" + echo " CA first or the browser will warn: tools/export-proxy-ca.sh)" + else + echo " Admin panel : http://${HOST_IP}:${IDENTITY_WEB_PORT}/admin.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=" + echo " NOTE: plain HTTP puts the admin token on the wire in clear." + echo " Set ENABLE_PROXY=true for HTTPS — see proxy/README.md" + fi echo " (people/guests, pruning, visit history, device rights —" echo " NOT a kiosk page; keep this URL off the wall panels)" fi diff --git a/tools/validate-config.py b/tools/validate-config.py index 68e4d0a..c1117a2 100755 --- a/tools/validate-config.py +++ b/tools/validate-config.py @@ -279,6 +279,34 @@ def validate_secrets(cfg: dict, rep: Report) -> None: "only ever touch physically, painful for a headless host") +def validate_proxy(cfg: dict, rep: Report) -> None: + proxy = _get(cfg, "proxy") or {} + if not proxy.get("enabled"): + return + hostname = (proxy.get("hostname") or "").strip() + if not hostname: + rep.error("proxy.hostname", "required when the proxy is enabled — it's what the " + "certificate is issued for") + elif not re.match(r"^[a-z0-9]([a-z0-9.-]{0,253}[a-z0-9])?$", hostname): + rep.error("proxy.hostname", f"'{hostname}' is not a valid hostname or IP") + elif hostname == "home.example.lan": + rep.warn("proxy.hostname", "still the template's placeholder — set a name that " + "actually resolves to the container host") + + mode = proxy.get("tls", "internal") + if mode not in {"internal", "custom"}: + rep.error("proxy.tls", f"must be 'internal' or 'custom', got {mode!r}") + elif mode == "custom": + for key in ("cert_file", "key_file"): + if not (proxy.get(key) or "").strip(): + rep.error(f"proxy.{key}", "required when proxy.tls is 'custom'") + else: + rep.warn("proxy.tls", + "'internal' means Caddy runs its own CA — browsers warn until you install " + "its root (tools/export-proxy-ca.sh). A kiosk cannot dismiss that warning, " + "which is why the kiosk images still use plain HTTP; see proxy/README.md") + + def validate_kiosks(cfg: dict, rep: Report) -> None: kiosks = _get(cfg, "kiosks") if kiosks is None: @@ -359,6 +387,7 @@ def validate(cfg: dict) -> Report: validate_ports(cfg, rep) validate_secrets(cfg, rep) validate_kiosks(cfg, rep) + validate_proxy(cfg, rep) return rep