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>digest-per-person-and-agendas
parent
2ae5cb3449
commit
00991b9864
|
|
@ -116,3 +116,8 @@ hosts/*/live-build/binary/
|
||||||
hosts/*/live-build/*.iso
|
hosts/*/live-build/*.iso
|
||||||
config/generated-*.yaml
|
config/generated-*.yaml
|
||||||
hosts/audio-endpoint/rpi-image-gen/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/
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,9 @@
|
||||||
"transit": 8099,
|
"transit": 8099,
|
||||||
"otp": 8100,
|
"otp": 8100,
|
||||||
"music_assistant": 8101,
|
"music_assistant": 8101,
|
||||||
"ollama": 11434
|
"ollama": 11434,
|
||||||
|
"proxy_http": 80,
|
||||||
|
"proxy_https": 443
|
||||||
},
|
},
|
||||||
|
|
||||||
"secrets": {
|
"secrets": {
|
||||||
|
|
@ -125,6 +127,15 @@
|
||||||
"admin_password_hash": ""
|
"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": {
|
"voice": {
|
||||||
"_comment": "Defaults for any kiosk with voice_satellite enabled; a kiosk may override wake_word individually.",
|
"_comment": "Defaults for any kiosk with voice_satellite enabled; a kiosk may override wake_word individually.",
|
||||||
"wake_word": "ok_nabu"
|
"wake_word": "ok_nabu"
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@ column shows which are opt-in vs. always-on with the base stack.
|
||||||
| 8096 | pantry-web | `ENABLE_PANTRY_VISION` | none |
|
| 8096 | pantry-web | `ENABLE_PANTRY_VISION` | none |
|
||||||
| 8097 | identity | `ENABLE_IDENTITY` | bearer token (`IDENTITY_TOKEN`) |
|
| 8097 | identity | `ENABLE_IDENTITY` | bearer token (`IDENTITY_TOKEN`) |
|
||||||
| 8098 | identity-web | `ENABLE_IDENTITY` | none |
|
| 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`) |
|
| 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 |
|
| 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 |
|
| ~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 |
|
||||||
|
|
|
||||||
|
|
@ -37,16 +37,31 @@ const video = document.getElementById("camera-preview");
|
||||||
const cameraError = document.getElementById("camera-error");
|
const cameraError = document.getElementById("camera-error");
|
||||||
let stream = null;
|
let stream = null;
|
||||||
|
|
||||||
navigator.mediaDevices
|
// `navigator.mediaDevices` is undefined on a NON-SECURE ORIGIN — getUserMedia requires
|
||||||
?.getUserMedia({ video: { facingMode: "user" }, audio: false })
|
// a secure context, so plain http://192.168.x.x has no camera API at all. That case is
|
||||||
.then((s) => {
|
// checked explicitly rather than left to `?.`: optional chaining short-circuits the
|
||||||
stream = s;
|
// WHOLE chain, so `navigator.mediaDevices?.getUserMedia(...).then(...).catch(...)`
|
||||||
video.srcObject = s;
|
// 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.
|
||||||
.catch((err) => {
|
// See proxy/README.md; serving this page over HTTPS is what actually fixes it.
|
||||||
cameraError.textContent = `Camera unavailable: ${err.message} (registration still works without it)`;
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
cameraError.hidden = false;
|
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() {
|
function capturePhoto() {
|
||||||
if (!stream) return Promise.resolve(null);
|
if (!stream) return Promise.resolve(null);
|
||||||
|
|
|
||||||
|
|
@ -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=<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:
|
||||||
|
|
||||||
|
```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".
|
||||||
|
|
@ -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`) |
|
| `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-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 |
|
| `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 |
|
| `validate-config.py` | Check the config |
|
||||||
| `config-export.py` | Config → shell variables, deriving URLs. Where the twinning happens |
|
| `config-export.py` | Config → shell variables, deriving URLs. Where the twinning happens |
|
||||||
| `lib/coreconfig.sh` | The loader every builder sources |
|
| `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
|
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.
|
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
|
## The ISOs contain secrets
|
||||||
|
|
||||||
This is deliberate — burning everything in is what makes installation unattended, with
|
This is deliberate — burning everything in is what makes installation unattended, with
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,22 @@ DIGEST_WEB_PORT=${CORE_PORT_DIGEST_WEB}
|
||||||
ADMIN_WEB_PORT=${CORE_PORT_ADMIN_WEB}
|
ADMIN_WEB_PORT=${CORE_PORT_ADMIN_WEB}
|
||||||
TRANSIT_PORT=${CORE_PORT_TRANSIT}
|
TRANSIT_PORT=${CORE_PORT_TRANSIT}
|
||||||
OTP_PORT=${CORE_PORT_OTP}
|
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
|
EOF
|
||||||
chmod 600 "$PAYLOAD/setup.env"
|
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.
|
# 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.
|
# No address, port or token is written literally in this script.
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,25 @@ def main(argv: list[str]) -> int:
|
||||||
if not flag.startswith("_"):
|
if not flag.startswith("_"):
|
||||||
emit(f"CORE_ENABLE_{flag.upper()}", value)
|
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_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_BUILD_OUTPUT_DIR", cfg.get("build", {}).get("output_dir", "iso-out"))
|
||||||
emit("CORE_ARM64_PREBAKE", cfg.get("build", {}).get("arm64_prebake", True))
|
emit("CORE_ARM64_PREBAKE", cfg.get("build", {}).get("arm64_prebake", True))
|
||||||
|
|
|
||||||
|
|
@ -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 <user@host> # 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 <<EOF
|
||||||
|
|
||||||
|
Install it where you need HTTPS without a warning:
|
||||||
|
|
||||||
|
Debian/Ubuntu (system-wide):
|
||||||
|
sudo cp ${DEST} /usr/local/share/ca-certificates/smarthome-root.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
|
||||||
|
Firefox: Settings -> 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
|
||||||
|
|
@ -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 <destination-path>
|
||||||
|
#
|
||||||
|
# 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 <destination Caddyfile path>"
|
||||||
|
|
||||||
|
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" <<EOF
|
||||||
|
# GENERATED from CoreSystemConfig.json by tools/generate-caddyfile.sh.
|
||||||
|
# Edit the config and re-run; edits here are overwritten on the next build.
|
||||||
|
#
|
||||||
|
# One HTTPS front door for this repo's own services. Home Assistant, Grocy, Frigate,
|
||||||
|
# Portainer and the rest keep their own ports and are deliberately NOT behind this —
|
||||||
|
# fronting HA in particular brings its own auth and websocket concerns, and none of the
|
||||||
|
# problems this proxy solves (see proxy/README.md) need it.
|
||||||
|
|
||||||
|
{
|
||||||
|
# No ACME account e-mail: with 'tls internal' there is no public CA involved, and
|
||||||
|
# with 'custom' the certificate is already issued. Neither path talks to Let's
|
||||||
|
# Encrypt, which is what keeps this working on a network with no WAN access at all.
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- HTTP: redirect everything, permanently -----------------------------------------
|
||||||
|
# Caddy does this automatically for a site with TLS, but it is written out explicitly
|
||||||
|
# because "the tool probably handles it" is a poor thing to rely on for the one rule
|
||||||
|
# that stops a token being sent in cleartext.
|
||||||
|
http://${CORE_PROXY_HOSTNAME} {
|
||||||
|
redir https://{host}{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- HTTPS ---------------------------------------------------------------------------
|
||||||
|
https://${CORE_PROXY_HOSTNAME} {
|
||||||
|
${TLS_LINE}
|
||||||
|
|
||||||
|
# identity's API. handle_path strips the matched prefix, so /api/identity/people
|
||||||
|
# reaches the container as /people — the service is unchanged and unaware it is
|
||||||
|
# behind a proxy.
|
||||||
|
handle_path /api/identity/* {
|
||||||
|
reverse_proxy identity:${CORE_PORT_IDENTITY}
|
||||||
|
}
|
||||||
|
${PANTRY_ROUTE}
|
||||||
|
# Everything else is the static frontend: dashboard.html, register.html, admin.html.
|
||||||
|
handle {
|
||||||
|
reverse_proxy identity-web:80
|
||||||
|
}
|
||||||
|
|
||||||
|
# The admin panel's token is in its URL, so keep that URL out of shared caches and
|
||||||
|
# out of anything that might log a referer to a third party.
|
||||||
|
header {
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
Cache-Control "no-store"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
# HSTS is deliberately NOT set. With 'tls internal' it would pin browsers to
|
||||||
|
# HTTPS for a hostname whose CA they may not trust yet, turning a dismissible
|
||||||
|
# warning into a hard failure that is genuinely awkward to undo.
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output stderr
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
core_log "Wrote ${DEST}"
|
||||||
|
echo " Front door : https://${CORE_PROXY_HOSTNAME}"
|
||||||
|
echo " Admin panel: ${CORE_ADMIN_URL}"
|
||||||
|
if [[ "$CORE_PROXY_TLS" == "internal" ]]; then
|
||||||
|
echo
|
||||||
|
echo " TLS is Caddy's internal CA. Browsers will warn until you install its root:"
|
||||||
|
echo " tools/export-proxy-ca.sh"
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fill in any empty service tokens in CoreSystemConfig.json, and write tokens.txt.
|
||||||
|
|
||||||
|
Runs before validation on every build (see lib/coreconfig.sh), so a fresh config only
|
||||||
|
needs the interesting fields filled in — addresses, rooms, which services you want —
|
||||||
|
and the random secrets appear on their own.
|
||||||
|
|
||||||
|
THE CRITICAL PART IS THAT GENERATED TOKENS ARE WRITTEN BACK TO THE CONFIG. A token is
|
||||||
|
only useful because two machines agree on it: the container host runs `identity` with
|
||||||
|
IDENTITY_TOKEN, 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 this fills empties ONCE, persists them, and never
|
||||||
|
touches a value that is already set.
|
||||||
|
|
||||||
|
tokens.txt is a convenience copy for the human: same values, plus what each one is for
|
||||||
|
and which of them this script deliberately cannot generate.
|
||||||
|
|
||||||
|
Usage: generate-tokens.py [config.json]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# name -> (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))
|
||||||
|
|
@ -43,6 +43,13 @@ core_load() {
|
||||||
|
|
||||||
command -v python3 >/dev/null 2>&1 || core_die "python3 is required to read CoreSystemConfig.json"
|
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
|
# 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
|
# 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.
|
# rather than before it. Warnings print but don't stop the build.
|
||||||
|
|
|
||||||
|
|
@ -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
|
# --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's
|
||||||
# --- kiosk browsers call this directly.
|
# --- kiosk browsers call this directly.
|
||||||
ENABLE_IDENTITY="${ENABLE_IDENTITY:-false}"
|
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).
|
# Where this repo's identity/ directory lives on THIS host (build context).
|
||||||
IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}"
|
IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}"
|
||||||
IDENTITY_PORT="${IDENTITY_PORT:-8097}" # LAN-facing — kiosk browsers call this directly
|
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
|
# Node-RED's own HA access below); identity.env's HA_URL has to be the host's real
|
||||||
# LAN IP, not "homeassistant".
|
# LAN IP, not "homeassistant".
|
||||||
IDENTITY_BLOCK=""
|
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" <<PROXYEOF
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
http://${PROXY_HOSTNAME} {
|
||||||
|
redir https://{host}{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
https://${PROXY_HOSTNAME} {
|
||||||
|
${PROXY_TLS_LINE}
|
||||||
|
|
||||||
|
handle_path /api/identity/* {
|
||||||
|
reverse_proxy identity:${IDENTITY_PORT}
|
||||||
|
}
|
||||||
|
handle_path /api/pantry/* {
|
||||||
|
reverse_proxy pantry-vision:${PANTRY_VISION_PORT}
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy identity-web:80
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
Cache-Control "no-store"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
}
|
||||||
|
log {
|
||||||
|
output stderr
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PROXYEOF
|
||||||
|
echo " Wrote a default $BASE_DIR/proxy/config/Caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROXY_CERT_MOUNT=""
|
||||||
|
if [[ "$PROXY_TLS" == "custom" ]]; then
|
||||||
|
PROXY_CERT_MOUNT="
|
||||||
|
- ${PROXY_CERT_FILE}:/etc/caddy/certs/cert.pem:ro
|
||||||
|
- ${PROXY_KEY_FILE}:/etc/caddy/certs/key.pem:ro"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROXY_BLOCK="
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- \"${PROXY_HTTP_PORT}:80\"
|
||||||
|
- \"${PROXY_HTTPS_PORT}:443\"
|
||||||
|
volumes:
|
||||||
|
- ${BASE_DIR}/proxy/config/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
# Caddy's own CA and issued certs live here. Persisting it matters: a fresh
|
||||||
|
# volume means a NEW internal CA, so every client that trusted the old root
|
||||||
|
# would start warning again.
|
||||||
|
- ${BASE_DIR}/proxy/data:/data${PROXY_CERT_MOUNT}
|
||||||
|
environment:
|
||||||
|
- TZ=${TIMEZONE}
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
IDENTITY_WEB_BLOCK=""
|
IDENTITY_WEB_BLOCK=""
|
||||||
if [[ "$ENABLE_IDENTITY" == "true" ]]; then
|
if [[ "$ENABLE_IDENTITY" == "true" ]]; then
|
||||||
IDENTITY_BLOCK="
|
IDENTITY_BLOCK="
|
||||||
|
|
@ -1141,7 +1229,7 @@ ${FRIGATE_DEVICES}
|
||||||
- PUID=1000
|
- PUID=1000
|
||||||
- PGID=1000
|
- PGID=1000
|
||||||
- TZ=${TIMEZONE}
|
- TZ=${TIMEZONE}
|
||||||
${MEALIE_BLOCK}${NODERED_BLOCK}${NETDATA_BLOCK}${HOMEPAGE_BLOCK}${NTFY_BLOCK}${PORTAINER_BLOCK}${GALLERY_SMB_BLOCK}${DIGEST_ENGINE_BLOCK}${DIGEST_WEB_BLOCK}${WHATSAPP_BRIDGE_BLOCK}${ADMIN_CANVAS_BLOCK}${ADMIN_WEB_BLOCK}${PANTRY_VISION_BLOCK}${PANTRY_WEB_BLOCK}${IDENTITY_BLOCK}${IDENTITY_WEB_BLOCK}${TRASH_CALENDAR_BLOCK}${TRANSIT_BLOCK}${TRANSIT_SYNC_BLOCK}${OTP_BLOCK}${CHORES_BLOCK}${MUSIC_ASSISTANT_BLOCK}
|
${MEALIE_BLOCK}${NODERED_BLOCK}${NETDATA_BLOCK}${HOMEPAGE_BLOCK}${NTFY_BLOCK}${PORTAINER_BLOCK}${GALLERY_SMB_BLOCK}${DIGEST_ENGINE_BLOCK}${DIGEST_WEB_BLOCK}${WHATSAPP_BRIDGE_BLOCK}${ADMIN_CANVAS_BLOCK}${ADMIN_WEB_BLOCK}${PANTRY_VISION_BLOCK}${PANTRY_WEB_BLOCK}${IDENTITY_BLOCK}${IDENTITY_WEB_BLOCK}${PROXY_BLOCK}${TRASH_CALENDAR_BLOCK}${TRANSIT_BLOCK}${TRANSIT_SYNC_BLOCK}${OTP_BLOCK}${CHORES_BLOCK}${MUSIC_ASSISTANT_BLOCK}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -1420,7 +1508,15 @@ if [[ "$ENABLE_IDENTITY" == "true" ]]; then
|
||||||
echo " Identity API : http://${HOST_IP}:${IDENTITY_PORT} (bearer-token gated)"
|
echo " Identity API : http://${HOST_IP}:${IDENTITY_PORT} (bearer-token gated)"
|
||||||
echo " Register page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/register.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=<IDENTITY_TOKEN>&device=<name>"
|
echo " Register page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/register.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=<IDENTITY_TOKEN>&device=<name>"
|
||||||
echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=<IDENTITY_TOKEN>"
|
echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=<IDENTITY_TOKEN>"
|
||||||
echo " Admin panel : http://${HOST_IP}:${IDENTITY_WEB_PORT}/admin.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=<IDENTITY_TOKEN>"
|
if [[ "$ENABLE_PROXY" == "true" ]]; then
|
||||||
|
echo " Admin panel : https://${PROXY_HOSTNAME}/admin.html?api=https://${PROXY_HOSTNAME}/api/identity&token=<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=<IDENTITY_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 " (people/guests, pruning, visit history, device rights —"
|
||||||
echo " NOT a kiosk page; keep this URL off the wall panels)"
|
echo " NOT a kiosk page; keep this URL off the wall panels)"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,34 @@ def validate_secrets(cfg: dict, rep: Report) -> None:
|
||||||
"only ever touch physically, painful for a headless host")
|
"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:
|
def validate_kiosks(cfg: dict, rep: Report) -> None:
|
||||||
kiosks = _get(cfg, "kiosks")
|
kiosks = _get(cfg, "kiosks")
|
||||||
if kiosks is None:
|
if kiosks is None:
|
||||||
|
|
@ -359,6 +387,7 @@ def validate(cfg: dict) -> Report:
|
||||||
validate_ports(cfg, rep)
|
validate_ports(cfg, rep)
|
||||||
validate_secrets(cfg, rep)
|
validate_secrets(cfg, rep)
|
||||||
validate_kiosks(cfg, rep)
|
validate_kiosks(cfg, rep)
|
||||||
|
validate_proxy(cfg, rep)
|
||||||
return rep
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue