From ee65c05b1b1bbf094201d337d3732328482dff34 Mon Sep 17 00:00:00 2001 From: Garrett Allen <98648590+Gerrrt@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:52:27 +0000 Subject: [PATCH 1/2] feat(sensitive): bring the Ring Alarm into Home Assistant through ring-mqtt (ADR-0104) Home Assistant's core Ring integration carries the cameras and doorbell but not the Ring Alarm: its library does not speak Ring's alarm websocket, so the base station's sensors, keypads and arming were invisible. ring-mqtt does, publishing them to an MQTT broker with Home Assistant discovery. - mosquitto: on a new internal network, `mqtt`, that only Home Assistant and ring-mqtt join (DIFFERENCE 14). Two users, an ACL keeping ring-mqtt to ring/# and homeassistant/#, no anonymous access, nothing on disk. Its argon2id password file is built in a tmpfs from SOPS on every start. - ring-mqtt: plain Node instead of the image's s6 init, so it runs as RENDER_UID on a read-only root with no capabilities, and never git-pulls code. Its config is written to a tmpfs and linked into /data; the volume holds only the Ring refresh token, made once by the new runbook. enable_cameras is false, so the cameras stay the core integration's. - CI boots both under their hardening, each with a smoke.sh: the broker's logins, refusals and ACL, and ring-mqtt reading its config. - MQTT_HA_PASSWORD and MQTT_RING_PASSWORD in SOPS, the render list and the validation seed; ring-mqtt-data listed disposable for backups. - ADR-0104, docs/runbooks/connect-the-ring-alarm.md, and the stack README, architecture, security, host runbook, README counts and changelog. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 18 +- README.md | 8 +- ...m-into-home-assistant-through-ring-mqtt.md | 154 +++++++++++++++++ docs/architecture.md | 2 +- docs/changelog.md | 24 +++ .../runbooks/build-the-sensitive-tier-host.md | 1 + docs/runbooks/connect-the-ring-alarm.md | 119 +++++++++++++ docs/security.md | 8 + scripts/backup-volumes.sh | 6 + scripts/render-config.sh | 2 + scripts/seed-validation-env.sh | 2 + secrets/sensitive.example.yaml | 12 ++ secrets/sensitive.sops.yaml | 6 +- stacks/sensitive/README.md | 73 ++++++-- stacks/sensitive/compose.yaml | 160 +++++++++++++++++- stacks/sensitive/mosquitto/acl | 12 ++ stacks/sensitive/mosquitto/mosquitto.conf | 18 ++ stacks/sensitive/mosquitto/smoke.sh | 44 +++++ stacks/sensitive/ring-mqtt/smoke.sh | 33 ++++ 19 files changed, 682 insertions(+), 20 deletions(-) create mode 100644 docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md create mode 100644 docs/runbooks/connect-the-ring-alarm.md create mode 100644 stacks/sensitive/mosquitto/acl create mode 100644 stacks/sensitive/mosquitto/mosquitto.conf create mode 100755 stacks/sensitive/mosquitto/smoke.sh create mode 100755 stacks/sensitive/ring-mqtt/smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b706774e..10f50222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -584,7 +584,8 @@ jobs: key: >- boot-proofs-${{ hashFiles('stacks/sensitive/compose.yaml', 'stacks/sensitive/home-assistant/**', 'stacks/sensitive/ntfy/**', - 'stacks/sensitive/stirling-pdf/**', 'stacks/soc/compose.yaml', + 'stacks/sensitive/stirling-pdf/**', 'stacks/sensitive/mosquitto/**', + 'stacks/sensitive/ring-mqtt/**', 'stacks/soc/compose.yaml', 'scripts/seed-validation-env.sh', 'scripts/check_hardened_boot.sh') }} restore-keys: boot-proofs- @@ -628,6 +629,21 @@ jobs: ./scripts/check_hardened_boot.sh --stack sensitive --service actual --proof-cache .boot-proofs/proofs.txt + # The Ring Alarm's broker and bridge (ADR-0104). Mosquitto builds its + # password file at start and runs read-only as 1883, and its smoke.sh + # signs in as both users and checks the ACL. ring-mqtt runs as plain + # Node, not its image's s6 init, as RENDER_UID on a read-only root; + # its smoke.sh checks that it read its config and waits for a token. + - name: Boot Mosquitto under its hardening + run: >- + ./scripts/check_hardened_boot.sh --stack sensitive + --service mosquitto --proof-cache .boot-proofs/proofs.txt + + - name: Boot ring-mqtt under its hardening + run: >- + ./scripts/check_hardened_boot.sh --stack sensitive + --service ring-mqtt --proof-cache .boot-proofs/proofs.txt + # The SOC stack's MSI server (#1068) is a numeric user, read-only, with # every capability dropped but NET_BIND_SERVICE. Its first deploy on odin # crash-looped on "exec caddy failed: Operation not permitted": the diff --git a/README.md b/README.md index 880f683c..aab74f8f 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,8 @@ documents for different readers. at all. Every pinned digest is also scanned weekly for fixable HIGH and CRITICAL CVEs, and each image with findings has an open issue until a scan finds it clean. -- **Documented decisions and runbooks.** 103 ADRs covering what was chosen - and what was rejected — including the costs accepted knowingly; 49 +- **Documented decisions and runbooks.** 104 ADRs covering what was chosen + and what was rejected — including the costs accepted knowingly; 50 runbooks for the operations that are easy to get wrong at 1am, one of which is the handover page a successor reads first. Every critical alert links to one. @@ -229,9 +229,9 @@ inbound passes may still reach it. Data flow and the maintained Mermaid topology │ ├── architecture.md network.md hardware.md │ ├── observability.md security.md roadmap.md changelog.md │ ├── diagrams/ # the network diagram (SVG) and its predecessors -│ ├── adr/ # 103 ADRs — architecture decision records +│ ├── adr/ # 104 ADRs — architecture decision records │ ├── exercises/ # purple-team exercise records — see its README -│ └── runbooks/ # 49 runbooks; successor-handover.md is the front door +│ └── runbooks/ # 50 runbooks; successor-handover.md is the front door └── Makefile # make help ``` diff --git a/docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md b/docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md new file mode 100644 index 00000000..d07a46bb --- /dev/null +++ b/docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md @@ -0,0 +1,154 @@ +# ADR-0104: Bring the Ring Alarm into Home Assistant through ring-mqtt + +**Status:** Accepted · 2026-10 + +## Context + +The household's rule is that Home Assistant is where everything is seen and +controlled. The Ring cameras and the doorbell already are, through Home +Assistant's core `ring` integration. The Ring Alarm is not. The house has its +base station (`alarm-hub` in `network.md`), its sensors and two keypads. +Which sensor watches which door is withheld, like the cameras' rooms +(`docs/security.md`). Home Assistant could +see none of them on 2026-10-22, when its Ring entry was signed in as the +Ring location's owner and Ring's API returned the base station among the +devices. + +The reason is the library, not the account. The core integration is built on +`ring_doorbell`, which reads Ring's device list and the cameras' event pushes. +The alarm's sensors, keypads and arming state are not in that list. They live +behind a separate alarm websocket the base station keeps with Ring's cloud, +and `ring_doorbell` does not speak it. No core integration does. + +[ring-mqtt](https://github.com/tsightler/ring-mqtt) does. It is a Node service +built on `ring-client-api`, which implements that websocket, and it publishes +every alarm device to an MQTT broker with Home Assistant discovery. It is the +community's standard path: Home Assistant OS users run it as an add-on beside +the Mosquitto add-on. This estate runs Home Assistant *Container*, with no +add-ons, by design (`stacks/sensitive/README.md`). That README already said +what would happen on the day a device needed a broker: it "becomes a pinned +service here". + +Two things found on 2026-10-22 shape where the sign-in comes from: + +1. **A shared Ring user cannot change device settings.** Home Assistant's Ring + entry was a shared user on the owner's location. Turning an indoor + camera's motion detection off (`packages/presence.yaml`) failed with + "Error communicating with Ring API" every time. +2. **Ring's newer shared-access role is invisible to that API.** Upgrading the + shared user's access in the Ring app emptied Ring's `ring_devices` and + `locations` answers to it, both to zero, while the Ring app still showed + everything. A fresh sign-in did not change that. + +So Home Assistant's Ring entry was moved to the owner's account the same day. +That made motion detection work and brought in the two floodlights and the +garage camera. ring-mqtt has the same constraint, for the same reason. + +## Decision + +**Run ring-mqtt and a Mosquitto broker as two pinned, hardened services in +`stacks/sensitive`, on a network of their own, with alarm devices only.** + +1. **Mosquitto is on an internal network, `mqtt`, and nothing else.** That + network has no gateway. Only Home Assistant and ring-mqtt join it; every + other service on the tier cannot reach port 1883. That was tested on + 2026-10-22 from a container on `sensitive`. The broker carries arming + commands, so being on the tier is not enough to reach it. +2. **Two users, one ACL.** `homeassistant` reads and writes everything. + `ring-mqtt` may use `ring/#` and `homeassistant/#` and nothing else, so a + compromised bridge cannot publish as another integration would. There is + no anonymous access. The passwords are `MQTT_HA_PASSWORD` and + `MQTT_RING_PASSWORD` in `secrets/sensitive.sops.yaml`. The broker builds + its argon2id password file from them in a tmpfs on every start, so + rotating one is a SOPS edit and `make up`. Home Assistant's copy is typed + once into its MQTT integration and changes there too. +3. **Nothing is on disk for the broker.** Persistence is off and it has no + volume. ring-mqtt republishes every device and its discovery when Home + Assistant's birth message arrives, which happens after either restarts. +4. **ring-mqtt runs as plain Node, not through its image's s6 init.** The init + only sets two variables and execs `ring-mqtt.js`, but it needs root, and on + its `latest` or `dev` branch it `git pull`s newer code at run time. Instead + it runs as `RENDER_UID`, read-only, with every capability dropped and the + image's own code only. +5. **Its config is written to a tmpfs, and its sign-in lives in its own + volume.** + - **The config:** ring-mqtt reads `/data/config.json`, which holds the + broker password. It is written to a tmpfs from the environment on every + start and linked into `/data`. + - **The sign-in:** `/data` is a volume for `ring-state.json`, the owner + account's Ring refresh token, which Ring rotates and ring-mqtt writes + back. That is how Home Assistant's own Ring entry holds its token, in + `/config/.storage`. Neither passes through SOPS. + - **The first sign-in:** made by hand, once, with Ring's 2FA: + `docs/runbooks/connect-the-ring-alarm.md`. +6. **Alarm only.** `enable_cameras` is false. The cameras and doorbell stay the + core integration's, so ring-mqtt neither lists them nor starts its bundled + go2rtc, and nothing listens on 8554. Its token web UI on 55123 runs only + when it has no token, is not exposed, and is not used. +7. **No disarm code in Home Assistant.** `disarm_code` is empty, so anyone + signed in to Home Assistant can arm and disarm. They are the household, + the same people the Ring app lets do it. A code would also have to sit in + any automation that disarms. This ADR adds no automation that arms or + disarms; that is a later, separate change. +8. **Both are booted under their hardening in CI.** Mosquitto's `smoke.sh` + signs in as both users, is refused with a wrong password and with none, + and checks the ACL. ring-mqtt's checks that it read its config through the + link and reached the point of asking for a token. + +No firewall rule changes. ring-mqtt talks to Ring's cloud from `trinity`, +as Home Assistant's Ring integration already does, and not to the base +station. + +## Alternatives considered + +- **Pair the sensors to Home Assistant directly over Z-Wave.** The alarm's + sensors are Z-Wave, and a USB stick with Z-Wave JS would bring them in with + no cloud at all. But it takes them off the Ring Alarm: the base station, + Ring's app, its arming and any monitoring stop working, and the keypads, + which only work with the base station, become ornaments. That is a + decision about the alarm, not about Home Assistant. Declined, and the one + this ADR's consequences point to if Ring's cloud ever becomes the problem. +- **ring-mqtt as published, through its s6 init and as root.** It is what + upstream documents. It needs root to start, and the update-on-start branch + path is one setting away. Declined for point 4. +- **A broker on the `sensitive` network** with passwords alone. It is one + network fewer, but every other service on the tier would be one leaked + password away from the alarm's arming topic. Declined for point 1. +- **Leave the alarm in the Ring app.** It works today, but the sensors would + never reach an automation, and Home Assistant would stop being the one + place the house is seen. Declined at the household's request. + +## Consequences + +- **The tier's first broker, and its second network.** `compose.yaml` marks it + DIFFERENCE 14. Anything that later wants MQTT joins `mqtt`, with a user and + an ACL entry of its own, rather than widening an existing one. +- **Community code holds the owner's Ring sign-in.** ring-mqtt is one author's + project, built on `ring-client-api`, another's. It runs with the Ring + location owner's refresh token, which can do anything that owner can, + including arming, disarming and changing every camera's settings. That + matches what Home Assistant's core integration already holds, and it is + accepted on the same terms: + - pinned by digest; + - non-root, read-only, with no capabilities; + - no inbound port; + - reachable only by Home Assistant over an authenticated, ACL'd broker. + + The token is listed in Ring's Control Center as `ring-mqtt-xxxxx`, where it + can be revoked. +- **ring-mqtt-data is not backed up.** `scripts/backup-volumes.sh` lists it as + disposable: Ring rotates the token as it is used, so a nightly copy is + usually already spent. A rebuild is one sign-in with the runbook. +- **Two more images to keep current.** ring-mqtt's newest release was 5.9.3, on + 2026-03-09, seven months before this ADR. The project answers issues but + releases slowly. A long silence while Ring changes its API is the main + risk. +- **The alarm's devices appear only after the runbook.** Until then ring-mqtt + runs, waits for a token and logs that it is waiting. Mosquitto runs with no + clients. +- **Reopened by:** + - a core Home Assistant integration that speaks the Ring Alarm, which + retires both services; + - ring-mqtt going unmaintained while Ring changes the alarm API, which + makes the Z-Wave alternative the live one; + - the household dropping the Ring Alarm, which makes this moot. diff --git a/docs/architecture.md b/docs/architecture.md index 725949e7..33b3fa60 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -203,7 +203,7 @@ hole from the monitoring VLAN into the monitored one. | `phoenix` (10.0.30.70) | 🟢 30 | *(none — a toolchain host, no Docker)* | The deployment host, built 2026-09-20: a Proxmox API token, an SSH key and a checkout, so that the Packer, OpenTofu and Ansible work after [#436](https://github.com/Gerrrt/HomeLab/issues/436) has somewhere to run from. A guest on `Saruman`, placed by [ADR-0043](adr/0043-keep-the-ca-on-prometheus-and-build-phoenix-as-the-deployment-host.md), which also decides that the estate's CA key stays on `prometheus` and does not follow the toolchain here. It holds no age key and runs no continuous convergence (what it configures, it configures on demand, below); it is the WireGuard endpoint of [ADR-0042](adr/0042-terminate-the-remote-path-on-the-lab-and-route-it.md), built 2026-09-22 ([#442](https://github.com/Gerrrt/HomeLab/issues/442)); the one thing it is meant to reach that no other guest does is `8006` on `Saruman`, by a single host-firewall rule — written 2026-09-20, when [#566](https://github.com/Gerrrt/HomeLab/issues/566) enabled the firewall the build had found disabled and narrowed the `local_network` alias that was admitting the whole segment underneath it. Its Alloy pushes to `alexander`, never to VLAN 99; the build is [`build-the-jumpbox.md`](runbooks/build-the-jumpbox.md). The first toolchain on it is [`packer/`](../packer/README.md), the lab's VM templates at VMIDs 901–912, with 903–909 for the dotfiles OS layers ([ADR-0090](adr/0090-test-the-dotfiles-os-layers-on-on-demand-saruman-guests.md)) ([ADR-0074](adr/0074-build-the-lab-templates-with-packer-from-phoenix.md), [`build-the-lab-templates.md`](runbooks/build-the-lab-templates.md)). The second is [`ansible/`](../ansible/README.md), which configures ADR-0029's six over SSH and touches nothing else ([ADR-0077](adr/0077-configure-the-lab-domain-with-ansible-from-phoenix.md), [#448](https://github.com/Gerrrt/HomeLab/issues/448)). Alloy agent (native package, `scripts/deploy-agent.sh`) | | `golem` (10.0.30.80) | 🟢 30 | *(none — an appliance, no Docker)* | Proxmox Backup Server 4.2, built 2026-10-03 by [`build-the-backup-guest.md`](runbooks/build-the-backup-guest.md) ([#485](https://github.com/Gerrrt/HomeLab/issues/485)): the lab's backups, placed by [ADR-0053](adr/0053-run-pbs-on-saruman-with-its-datastore-on-smaug-over-nfs.md). A guest on `Saruman`, VMID 180, whose only datastore, `erebor`, is `smaug`'s `erebor/pbs` over NFSv4 through one `2049` pass, with `atime` on because garbage collection depends on it, and the mountpoint made immutable so an unmounted share cannot fill the OS disk. Every backup is encrypted on `Saruman` before it leaves, with a key made on `Saruman` and copied, on 2026-10-03, into `secrets/lab.sops.yaml` and onto paper (the runbook's §7), so a lost `Saruman` does not take the backups' readability with it; `Saruman` writes through a token that can back up and cannot prune. PBS prunes (7 daily, 4 weekly, 3 monthly), collects garbage on Saturdays and verifies on Sundays; TrueNAS keeps fourteen daily snapshots of the dataset as the copy PBS cannot prune. The nightly job takes `odin` and the domain's six; `alexander`, `phoenix`, `fenrir` and `golem` itself are rebuilt from this repository and stay out. `scripts/collect-pbs-task-state.py` publishes every job's last outcome and the snapshots' verify state hourly, and seven rules in `stacks/lab` read them; the first nightly run, 2026-10-03, was verified the next morning. Its Alloy pushes to `alexander`, never to VLAN 99, since 2026-10-03. Alloy agent (native package, `scripts/deploy-agent.sh`) | | `oracle` (10.0.99.30) | 🔴 99 | [`stacks/wiki`](../stacks/wiki) | The Lemmiwinks wiki and its Postgres, since 2025-11-12 ([ADR-0011](adr/0011-keep-the-wiki-internal.md)), hand-run containers until [#251](https://github.com/Gerrrt/HomeLab/issues/251) wrote them down as a stack deployed by hand from `main`, like `smaug`'s. **Authored, not yet cut over** ([#847](https://github.com/Gerrrt/HomeLab/issues/847), [ADR-0082](adr/0082-serve-the-wiki-over-tls-and-deploy-it-from-a-verified-checkout.md)): deploying from a checkout whose signature is verified first, and https from a Caddy on a leaf from the estate's CA with port 80 only redirecting. Until [`stacks/wiki/README.md`](../stacks/wiki/README.md#tls)'s cutover runs, it is still Wiki.js over plain http on 80; its database is pulled to `prometheus` nightly as a `pg_dump` (`make backup-wiki`, [ADR-0065](adr/0065-pull-the-wikis-database-to-prometheus-as-a-dump.md)); Alloy agent (Docker, `scripts/deploy-agent.sh`); the off-host copies of the firewall export (`make backup-firewall`), of the weekly volume sets (`make backup`, [#535](https://github.com/Gerrrt/HomeLab/issues/535)) and of the media tier's state pulled off `smaug` (`make backup-nas`, [ADR-0045](adr/0045-pull-jellyfins-state-from-a-snapshot-over-ssh.md)). The estate's host for small off-host jobs — [ADR-0015](adr/0015-give-oracle-the-off-host-jobs.md) | -| `trinity` (10.0.99.40) | 🔴 99 | [`stacks/sensitive`](../stacks/sensitive) | ADR-0008's sensitive tier on the ProDesk 600 G4 of [ADR-0034](adr/0034-run-the-sensitive-tier-on-the-prodesk-and-make-it-the-spare-hardware.md). **Built and deployed 2026-09-28** ([#404](https://github.com/Gerrrt/HomeLab/issues/404), [`build-the-sensitive-tier-host.md`](runbooks/build-the-sensitive-tier-host.md)): Ubuntu 26.04 with both disks LUKS-encrypted and the root unlocked by the TPM ([ADR-0054](adr/0054-encrypt-trinitys-disks-and-seal-the-root-key-to-the-tpm.md)), all twelve containers healthy, and since that day AdGuard is the one forwarder behind `morpheus`'s resolver ([ADR-0055](adr/0055-forward-to-adguard-alone.md)). Holding real data since 2026-09-28, Immich's first 615 photographs, which passed the #404 step 10 gate while most of it was open. The library is copied nightly to `oracle`, off-host and not off-estate, until [#455](https://github.com/Gerrrt/HomeLab/issues/455) ([ADR-0064](adr/0064-copy-immichs-library-to-oracle-until-the-off-estate-copy-exists.md)). The stack: Caddy as the published HTTPS port and step-ca issuing beneath the tier's own root rather than the estate's, which is left untouched ([#129](https://github.com/Gerrrt/HomeLab/issues/129), [#130](https://github.com/Gerrrt/HomeLab/issues/130), [ADR-0037](adr/0037-give-the-sensitive-tier-its-own-root-and-issue-beneath-it-over-acme.md)), with AdGuard Home behind Caddy and publishing 53 to the firewall's forwarder alone ([#135](https://github.com/Gerrrt/HomeLab/issues/135), [ADR-0010](adr/0010-keep-the-resolver-on-the-gateway.md)); Home Assistant ([#134](https://github.com/Gerrrt/HomeLab/issues/134)), Immich — four containers behind Caddy with a memory limit on each ([#132](https://github.com/Gerrrt/HomeLab/issues/132)) — Paperless-ngx with a Postgres and a Valkey of its own ([#133](https://github.com/Gerrrt/HomeLab/issues/133)) and Vaultwarden ([#131](https://github.com/Gerrrt/HomeLab/issues/131)) run beside them, with the household's page of links to them at `home.matrix.elysium` and its live numbers on read-only tokens ([#137](https://github.com/Gerrrt/HomeLab/issues/137)) — Homepage until [ADR-0103](adr/0103-retire-homepage-and-serve-the-household-page-from-glance.md) made it a page of Glance's. ntfy ([#136](https://github.com/Gerrrt/HomeLab/issues/136)) is the in-house receiver for Alertmanager's three real channels, **deployed and cut over 2026-09-29** by [`verify-the-alert-path.md`](runbooks/verify-the-alert-path.md#cutting-over-to-the-in-house-ntfy), with `urgent` and `security` also sent to ntfy.sh for a phone away from home. Miniflux, a feed reader with a Postgres of its own, is the tier's first service beyond ADR-0008's nine, **deployed 2026-09-29** ([#147](https://github.com/Gerrrt/HomeLab/issues/147), [ADR-0057](adr/0057-add-miniflux-to-the-sensitive-tier-with-its-fetcher-kept-off-winterfell.md)): the one container here that reaches outward on a timer, with its fetcher refused every private address. Memos, the household's notes, is the second, decided by [ADR-0059](adr/0059-add-memos-to-the-sensitive-tier-for-notes-and-keep-documentation-in-docs.md) ([#145](https://github.com/Gerrrt/HomeLab/issues/145)) and deployed behind Caddy the same day, all sixteen containers healthy after that `make up`. Neither holds real data yet. Mealie, the household's recipes at `recipes.matrix.elysium`, is the third, **deployed 2026-09-29** ([#146](https://github.com/Gerrrt/HomeLab/issues/146), [ADR-0060](adr/0060-add-mealie-to-the-sensitive-tier-as-recipes.md)), behind the same Caddy with no new rule, its URL import refused every private address. linkding, a bookmark manager, is the fourth, **deployed 2026-09-29** ([#144](https://github.com/Gerrrt/HomeLab/issues/144), [ADR-0061](adr/0061-add-linkding-to-the-sensitive-tier-behind-one-factor.md)). Actual ([#142](https://github.com/Gerrrt/HomeLab/issues/142)), the household's budget, is the fifth beyond ADR-0008's nine, **deployed 2026-09-29** ([ADR-0062](adr/0062-add-actual-to-the-sensitive-tier.md)), claimed from SOPS before its first start. It holds no real budget before #404 step 10. Stirling-PDF, the household's PDF editor at `pdf.matrix.elysium`, is the sixth, **deployed 2026-09-29** ([#143](https://github.com/Gerrrt/HomeLab/issues/143), [ADR-0063](adr/0063-add-stirling-pdf-to-the-sensitive-tier-and-keep-its-documents-in-memory.md)): its documents live on a tmpfs and never reach a disk. Glance, the operator's browser start page at `start.matrix.elysium`, is **deployed 2026-10-21** ([#1223](https://github.com/Gerrrt/HomeLab/issues/1223), [ADR-0098](adr/0098-add-glance-as-the-operators-start-page-beside-homepage.md)): the estate's alerts, probes and targets read from `prometheus` with the reader token, and feeds read from a Miniflux user of its own. Since [ADR-0100](adr/0100-grow-glance-into-a-homelab-page-and-keep-the-operators-lists-in-sops.md) it has a page per subject — Home, Read, Watch, Sports & Markets, Homelab, Network & Storage and Maintenance — the estate's read from `prometheus`, and the operator's own lists come from SOPS. It also serves the household's page at `home.matrix.elysium` ([ADR-0103](adr/0103-retire-homepage-and-serve-the-household-page-from-glance.md)), which took over from Homepage. Alloy agent (Docker, `scripts/deploy-agent.sh`), pushing to `prometheus` like `oracle`'s | +| `trinity` (10.0.99.40) | 🔴 99 | [`stacks/sensitive`](../stacks/sensitive) | ADR-0008's sensitive tier on the ProDesk 600 G4 of [ADR-0034](adr/0034-run-the-sensitive-tier-on-the-prodesk-and-make-it-the-spare-hardware.md). **Built and deployed 2026-09-28** ([#404](https://github.com/Gerrrt/HomeLab/issues/404), [`build-the-sensitive-tier-host.md`](runbooks/build-the-sensitive-tier-host.md)): Ubuntu 26.04 with both disks LUKS-encrypted and the root unlocked by the TPM ([ADR-0054](adr/0054-encrypt-trinitys-disks-and-seal-the-root-key-to-the-tpm.md)), all twelve containers healthy, and since that day AdGuard is the one forwarder behind `morpheus`'s resolver ([ADR-0055](adr/0055-forward-to-adguard-alone.md)). Holding real data since 2026-09-28, Immich's first 615 photographs, which passed the #404 step 10 gate while most of it was open. The library is copied nightly to `oracle`, off-host and not off-estate, until [#455](https://github.com/Gerrrt/HomeLab/issues/455) ([ADR-0064](adr/0064-copy-immichs-library-to-oracle-until-the-off-estate-copy-exists.md)). The stack: Caddy as the published HTTPS port and step-ca issuing beneath the tier's own root rather than the estate's, which is left untouched ([#129](https://github.com/Gerrrt/HomeLab/issues/129), [#130](https://github.com/Gerrrt/HomeLab/issues/130), [ADR-0037](adr/0037-give-the-sensitive-tier-its-own-root-and-issue-beneath-it-over-acme.md)), with AdGuard Home behind Caddy and publishing 53 to the firewall's forwarder alone ([#135](https://github.com/Gerrrt/HomeLab/issues/135), [ADR-0010](adr/0010-keep-the-resolver-on-the-gateway.md)); Home Assistant ([#134](https://github.com/Gerrrt/HomeLab/issues/134)), Immich — four containers behind Caddy with a memory limit on each ([#132](https://github.com/Gerrrt/HomeLab/issues/132)) — Paperless-ngx with a Postgres and a Valkey of its own ([#133](https://github.com/Gerrrt/HomeLab/issues/133)) and Vaultwarden ([#131](https://github.com/Gerrrt/HomeLab/issues/131)) run beside them, with the household's page of links to them at `home.matrix.elysium` and its live numbers on read-only tokens ([#137](https://github.com/Gerrrt/HomeLab/issues/137)) — Homepage until [ADR-0103](adr/0103-retire-homepage-and-serve-the-household-page-from-glance.md) made it a page of Glance's. ntfy ([#136](https://github.com/Gerrrt/HomeLab/issues/136)) is the in-house receiver for Alertmanager's three real channels, **deployed and cut over 2026-09-29** by [`verify-the-alert-path.md`](runbooks/verify-the-alert-path.md#cutting-over-to-the-in-house-ntfy), with `urgent` and `security` also sent to ntfy.sh for a phone away from home. Miniflux, a feed reader with a Postgres of its own, is the tier's first service beyond ADR-0008's nine, **deployed 2026-09-29** ([#147](https://github.com/Gerrrt/HomeLab/issues/147), [ADR-0057](adr/0057-add-miniflux-to-the-sensitive-tier-with-its-fetcher-kept-off-winterfell.md)): the one container here that reaches outward on a timer, with its fetcher refused every private address. Memos, the household's notes, is the second, decided by [ADR-0059](adr/0059-add-memos-to-the-sensitive-tier-for-notes-and-keep-documentation-in-docs.md) ([#145](https://github.com/Gerrrt/HomeLab/issues/145)) and deployed behind Caddy the same day, all sixteen containers healthy after that `make up`. Neither holds real data yet. Mealie, the household's recipes at `recipes.matrix.elysium`, is the third, **deployed 2026-09-29** ([#146](https://github.com/Gerrrt/HomeLab/issues/146), [ADR-0060](adr/0060-add-mealie-to-the-sensitive-tier-as-recipes.md)), behind the same Caddy with no new rule, its URL import refused every private address. linkding, a bookmark manager, is the fourth, **deployed 2026-09-29** ([#144](https://github.com/Gerrrt/HomeLab/issues/144), [ADR-0061](adr/0061-add-linkding-to-the-sensitive-tier-behind-one-factor.md)). Actual ([#142](https://github.com/Gerrrt/HomeLab/issues/142)), the household's budget, is the fifth beyond ADR-0008's nine, **deployed 2026-09-29** ([ADR-0062](adr/0062-add-actual-to-the-sensitive-tier.md)), claimed from SOPS before its first start. It holds no real budget before #404 step 10. Stirling-PDF, the household's PDF editor at `pdf.matrix.elysium`, is the sixth, **deployed 2026-09-29** ([#143](https://github.com/Gerrrt/HomeLab/issues/143), [ADR-0063](adr/0063-add-stirling-pdf-to-the-sensitive-tier-and-keep-its-documents-in-memory.md)): its documents live on a tmpfs and never reach a disk. Glance, the operator's browser start page at `start.matrix.elysium`, is **deployed 2026-10-21** ([#1223](https://github.com/Gerrrt/HomeLab/issues/1223), [ADR-0098](adr/0098-add-glance-as-the-operators-start-page-beside-homepage.md)): the estate's alerts, probes and targets read from `prometheus` with the reader token, and feeds read from a Miniflux user of its own. Since [ADR-0100](adr/0100-grow-glance-into-a-homelab-page-and-keep-the-operators-lists-in-sops.md) it has a page per subject — Home, Read, Watch, Sports & Markets, Homelab, Network & Storage and Maintenance — the estate's read from `prometheus`, and the operator's own lists come from SOPS. It also serves the household's page at `home.matrix.elysium` ([ADR-0103](adr/0103-retire-homepage-and-serve-the-household-page-from-glance.md)), which took over from Homepage. Mosquitto and ring-mqtt bring the Ring Alarm into Home Assistant, the broker on an internal network that only those two join, written 2026-10-22 ([ADR-0104](adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md)). Alloy agent (Docker, `scripts/deploy-agent.sh`), pushing to `prometheus` like `oracle`'s | | `smaug` (10.0.40.30) | 🟡 40 | [`stacks/media`](../stacks/media) | [ADR-0008](adr/0008-place-services-by-data-trust.md)'s media tier on the ThinkServer TS150 of [#413](https://github.com/Gerrrt/HomeLab/issues/413), placed and addressed by [ADR-0016](adr/0016-open-casabonita-inward-and-keep-it-terminal-outward.md) and running TrueNAS rather than Ubuntu Server by [ADR-0040](adr/0040-run-truenas-on-smaug-and-keep-the-media-stack-in-this-repository.md). **Built, pooled and deployed**: TrueNAS on its boot SSD, the static above since 2026-09-16, the four inbound rules verified in position, the mirror `erebor` since 2026-09-18 and this stack running on it since 2026-09-19 — from a copy of the compose file on the pool, brought up with `docker compose` under TrueNAS's own Docker ([`build-the-nas.md`](runbooks/build-the-nas.md) §6). Jellyfin, publishing 8096 to the segment because the televisions reach it natively and no firewall rule is involved at all ([#138](https://github.com/Gerrrt/HomeLab/issues/138)); and Audiobookshelf on 13378, **deployed 2026-09-29**, whose clients are phones on Hicks and which therefore brought a fifth inbound rule by ADR-0050's count, the seventh to exist ([#140](https://github.com/Gerrrt/HomeLab/issues/140), [ADR-0050](adr/0050-add-audiobookshelf-to-the-media-tier-behind-a-fifth-hicks-pass.md)). And Navidrome on 4533 for music, **deployed 2026-09-30** on the same terms, whose Hicks pass was created 2026-09-22, ahead of the service and of Audiobookshelf's ([#141](https://github.com/Gerrrt/HomeLab/issues/141)). The `media` share gets a Hicks pass on `445` for workstations, used by a second SMB user, `samwise`, so the televisions' `bilbo` stays theirs; it was **created on 2026-09-23** ([#523](https://github.com/Gerrrt/HomeLab/issues/523), [ADR-0051](adr/0051-let-hicks-workstations-mount-the-media-share-as-a-user-of-their-own.md)). Scraped by `prometheus` on `9100`; it pushes nothing, and runs no Alloy — the estate's first scraped host, and the reason [#256](https://github.com/Gerrrt/HomeLab/issues/256) was more than a line of YAML. That issue settled the fork TrueNAS opened in it: `node_exporter`, as a digest-pinned container in this stack rather than TrueNAS's own endpoint, so the existing `99 → 40:9100` pass, the `host-overview` dashboard and seven rules in `host.rules.yaml` all keep working unchanged. The `node` job and `prometheus/targets/node.yaml` are live, and the target with them since 2026-09-19, once the exporter answered from the pool. SMART reaches the same scrape by [ADR-0047](adr/0047-collect-smaug-smart-through-a-root-cron-and-the-textfile-collector.md): a root cron job in TrueNAS's UI runs the estate's collector and the exporter serves the file — still no agent, still nothing initiating from 40 — and patch state is deliberately not collected on an appliance ([#483](https://github.com/Gerrrt/HomeLab/issues/483)) | | `bahamut` (10.0.30.50) | 🟢 30 | *(none — Windows)* | Windows Server 2025 domain controller, PDC emulator and DNS for `ad.matrix.elysium` — Tier 0. Static, because every member finds a DC through DNS and the DCs *are* the DNS. Built by hand 2026-09-24 to 2026-09-25 ([#414](https://github.com/Gerrrt/HomeLab/issues/414)) and promoted for `ad.matrix.elysium`. Scraped by `alexander` on `9182`; it pushes nothing, and runs no Alloy ([ADR-0029](adr/0029-size-the-lab-domain-and-separate-its-namespace-and-clock.md)) | | `leviathan` (10.0.30.51) | 🟢 30 | *(none — Windows)* | Windows Server 2025 second domain controller and DNS — Tier 0. Static, for the same reason. Built by hand 2026-09-24 to 2026-09-25 ([#414](https://github.com/Gerrrt/HomeLab/issues/414)) and promoted for `ad.matrix.elysium`. Scraped by `alexander` on `9182`; it pushes nothing, and runs no Alloy ([ADR-0029](adr/0029-size-the-lab-domain-and-separate-its-namespace-and-clock.md)) | diff --git a/docs/changelog.md b/docs/changelog.md index f9b871bd..fddad13a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,6 +19,30 @@ docstring gives: it is a record, not a claim about now. ## 2026-10-22 +- **Home Assistant's Ring entry moved to the Ring location owner's account, + and the Ring Alarm got a way in** + ([ADR-0104](adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md)). + - **Why the account changed.** Turning the indoor cameras' motion detection + off for `presence.yaml` failed every time with "Error communicating with + Ring API". The entry was a shared user, and Ring does not let a shared + user change device settings. + - **The upgraded role was worse.** Upgrading that user's access in the Ring + app made Ring's API return zero locations and zero devices to it, even + after a fresh sign-in, so every Ring entity went unavailable. + - **Re-added as the owner.** All seven Ring devices came back, including + both floodlights and a garage camera Home Assistant had never seen. + Home Assistant restores removed entities by unique id, so the cameras' + labels survived, though the indoor cameras' entity ids gained a room + prefix. Motion detection then switched off as intended. + - **The alarm still could not come in that way.** The core integration's + library does not speak Ring's alarm websocket, so the base station's + sensors and keypads stayed invisible. Mosquitto and ring-mqtt are + written for that: two hardened services, with the broker on an internal + network of its own. + - **Not yet deployed.** The sign-in and the MQTT integration are + [`connect-the-ring-alarm.md`](runbooks/connect-the-ring-alarm.md), after + merge. + - **Homepage is gone from `trinity`, and #1250 is closed** ([ADR-0103](adr/0103-retire-homepage-and-serve-the-household-page-from-glance.md), [#1250](https://github.com/Gerrrt/HomeLab/issues/1250)). `make up` removed diff --git a/docs/runbooks/build-the-sensitive-tier-host.md b/docs/runbooks/build-the-sensitive-tier-host.md index bd09e6bb..8773587a 100644 --- a/docs/runbooks/build-the-sensitive-tier-host.md +++ b/docs/runbooks/build-the-sensitive-tier-host.md @@ -346,6 +346,7 @@ what each key is for. The values: | `PAPERLESS_DBPASS` | `make gen-secret` | No | | `PAPERLESS_ADMIN_PASSWORD` | `make gen-secret` | **Yes**, it is a login | | `STIRLING_ADMIN_PASSWORD` | `make gen-secret` | **Yes**, it is a login, and a rebuild of the unbacked-up volume recreates the admin from it | +| `MQTT_HA_PASSWORD`, `MQTT_RING_PASSWORD` | `make gen-secret`, one each | No. The broker rebuilds its password file from them on every start; Home Assistant's is typed into its MQTT integration by [`connect-the-ring-alarm.md`](connect-the-ring-alarm.md) ([ADR-0104](../adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md)) | | `GLANCE_IMMICH_API_KEY`, `GLANCE_PAPERLESS_TOKEN` | Minted in Immich and Paperless-ngx once they are up, each read-only, for the Household page's two numbers — the clicks are beside the keys in the example file | No | | `GLANCE_PROMETHEUS_TOKEN` | The estate's reader token, `INGEST_TOKEN_READER` in `secrets/observability.sops.yaml` | No. `secrets/observability.sops.yaml` holds the original | | `GLANCE_MINIFLUX_FEVER_KEY` | The MD5 of `glance:`, once Miniflux is up and its user `glance` exists — the steps are beside the key in the example file ([ADR-0098](../adr/0098-add-glance-as-the-operators-start-page-beside-homepage.md)) | The Fever password and the `glance` login, both generated | diff --git a/docs/runbooks/connect-the-ring-alarm.md b/docs/runbooks/connect-the-ring-alarm.md new file mode 100644 index 00000000..43d8b616 --- /dev/null +++ b/docs/runbooks/connect-the-ring-alarm.md @@ -0,0 +1,119 @@ +# Runbook: Connect the Ring Alarm + +Sign ring-mqtt in to Ring once and point Home Assistant at its broker, so the +Ring Alarm's base station, sensors, keypads and arming appear in Home +Assistant ([ADR-0104](../adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md)). + +**Target:** `trinity` (10.0.99.40), and Home Assistant's web UI +**Time:** ~15 minutes +**Needs:** the Ring location owner, with their phone for Ring's 2FA code +**Reversible:** delete the MQTT integration in Home Assistant, and revoke +`ring-mqtt-xxxxx` in Ring's Control Center + +--- + +## Why this exists + +ring-mqtt and Mosquitto start with `make up STACK=sensitive` like everything +else on the tier, but ring-mqtt cannot sign in to Ring on its own: Ring wants +a password and a 2FA code from a person. Until that happens it runs, logs +*"No refresh token was found"* every start, and publishes nothing. And Home +Assistant's MQTT integration is set up in its UI, not in YAML, so it is a +step too. + +The sign-in must be the **Ring location owner's** account. A shared user's +token can read the alarm but Ring refuses its setting changes, and Ring's +newer shared-access role returns no devices at all (ADR-0104's context). + +## 1. Start the two services + +From the repository root on `trinity`, on a `main` that has ADR-0104: + +```bash +make up STACK=sensitive +``` + +```bash +docker ps --filter name=sensitive-mosquitto --filter name=sensitive-ring-mqtt --format '{{.Names}} {{.Status}}' +``` + +Mosquitto reads `(healthy)`. ring-mqtt reads `Up`, and its log says it is +waiting for a token: + +```bash +docker logs --tail 5 sensitive-ring-mqtt +``` + +## 2. Sign ring-mqtt in to Ring + +Run ring-mqtt's own sign-in tool inside the running container. It asks for +the owner's Ring email, password, and then the code Ring sends: + +```bash +docker exec -it sensitive-ring-mqtt node /app/ring-mqtt/init-ring-mqtt.js +``` + +It ends with *"State file /data/ring-state.json saved with updated refresh +token"* and a device name, `ring-mqtt-` and five characters. That name +appears in the Ring app under Control Center → Authorized Client Devices, +which is where this sign-in is revoked. + +Restart ring-mqtt so it starts with the token: + +```bash +docker restart sensitive-ring-mqtt +``` + +```bash +docker logs --since 2m sensitive-ring-mqtt 2>&1 | grep -E 'Successfully|Found|Device Discovery|MQTT' +``` + +It should list the base station, each sensor and keypad, then *"Device +Discovery Complete!"* and a successful MQTT connection. A broker refusal +here means `MQTT_RING_PASSWORD` and the running broker disagree: run +`make up STACK=sensitive` again so both start from the same SOPS file. + +## 3. Add the MQTT integration in Home Assistant + +Read Home Assistant's broker password on `trinity`. It prints to your +terminal only, so don't paste it anywhere else: + +```bash +sops -d --extract '["MQTT_HA_PASSWORD"]' secrets/sensitive.sops.yaml +``` + +Then in Home Assistant: **Settings → Devices & services → + Add integration → +MQTT**, choose to enter the broker by hand, and fill in: + +| Field | Value | +| --- | --- | +| Broker | `mosquitto` | +| Port | `1883` | +| Username | `homeassistant` | +| Password | the value printed above | + +Leave TLS off; the broker is only on the internal `mqtt` network. + +## 4. Check what arrived + +**Settings → Devices & services → MQTT** lists one device per alarm +component: the base station's alarm panel (`alarm_control_panel`), each +contact and motion sensor, each keypad with its battery, and any range +extender. Open a door with a contact sensor: its entity turns *open* within +a second or two. + +Then label what automations will use, in the UI, as the other packages do: +a label never reveals which sensor is on which door. + +## Rotating a broker password + +Generate a new value with `scripts/gen-secret.sh`, put it in +`secrets/sensitive.sops.yaml` with `make secrets-edit STACK=sensitive`, and +run `make up STACK=sensitive`. For `MQTT_HA_PASSWORD`, also open the MQTT +integration's options in Home Assistant and enter the new value there. + +## Rebuilding + +`ring-mqtt-data` is not backed up (ADR-0104). On a rebuilt `trinity`, or if +the token is revoked or expires, repeat step 2. Revoke the old +`ring-mqtt-xxxxx` in Ring's Control Center when you do. diff --git a/docs/security.md b/docs/security.md index 49f50d1c..df353b48 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1164,6 +1164,14 @@ this closes on. - Stirling-PDF's analytics, PostHog, Scarf and update checks disabled, and its heap dump on OOM too, because a dump would put the document being processed on a disk ([ADR-0063](adr/0063-add-stirling-pdf-to-the-sensitive-tier-and-keep-its-documents-in-memory.md)). +- The sensitive tier's MQTT broker, which carries the Ring Alarm's arming + commands, is on an internal network that only Home Assistant and ring-mqtt + join. Each signs in as its own user, and an ACL keeps ring-mqtt to its own + topics. ring-mqtt is community code holding the Ring location owner's + refresh token, as Home Assistant's Ring entry does. It runs non-root and + read-only, with no inbound port, and the token can be revoked in Ring's + Control Center + ([ADR-0104](adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md)). Alloy no longer runs `privileged: true`. It never needed it: `cgroup: host` is what makes cAdvisor see the host's cgroups, and dropping every capability diff --git a/scripts/backup-volumes.sh b/scripts/backup-volumes.sh index 12255ad0..cbe18c3a 100755 --- a/scripts/backup-volumes.sh +++ b/scripts/backup-volumes.sh @@ -660,11 +660,17 @@ declare -A COMPANIONS=( # enrolment and any account added in the UI since, which is a re-enrolment, # not a record lost. The nightly SQL dump Stirling writes into the same volume # is left out for the same reason. +# ring-mqtt-data (ADR-0104) holds ring-mqtt's state file, whose one thing of +# value is Ring's refresh token, and Ring rotates that token as it is used. A +# copy from last night is usually already spent, and restoring it would put a +# live credential back for nothing. A rebuild is one sign-in with 2FA: +# docs/runbooks/connect-the-ring-alarm.md. declare -A DISPOSABLE=( [immich-model-cache]="a model cache immich-machine-learning re-downloads on first use" [adguard-work]="blocklists, stats and a query log AdGuard rebuilds; archiving it would stop the house's only DNS forwarder" [ntfy-data]="users re-provisioned from SOPS on every start, and twelve hours of notifications already delivered" [stirling-pdf-configs]="an admin rebuilt from SOPS and settings from the environment; documents are never on it" + [ring-mqtt-data]="a Ring refresh token Ring rotates in use; a rebuild is one sign-in from the runbook" [jellyfin-cache]="transcode scratch and image caches Jellyfin regenerates on demand" ) diff --git a/scripts/render-config.sh b/scripts/render-config.sh index e253c627..2f10e986 100755 --- a/scripts/render-config.sh +++ b/scripts/render-config.sh @@ -573,6 +573,8 @@ COMPOSE_VARS=( GLANCE_PROMETHEUS_TOKEN HA_PROMETHEUS_AUTHORIZATION HA_MEDIATV_MAC + MQTT_HA_PASSWORD + MQTT_RING_PASSWORD STEPCA_PASSWORD ADGUARD_ADMIN_PASSWORD_HASH IMMICH_DB_PASSWORD diff --git a/scripts/seed-validation-env.sh b/scripts/seed-validation-env.sh index 361818cb..e8170a3e 100755 --- a/scripts/seed-validation-env.sh +++ b/scripts/seed-validation-env.sh @@ -62,6 +62,8 @@ STACK="${REPO_ROOT}/stacks/${2:-observability}" echo "GLANCE_PROMETHEUS_TOKEN=validation-only" echo "HA_PROMETHEUS_AUTHORIZATION=Bearer validation-only" echo "HA_MEDIATV_MAC=00:00:5e:00:53:00" + echo "MQTT_HA_PASSWORD=validation-only-ha" + echo "MQTT_RING_PASSWORD=validation-only-ring" echo "STEPCA_PASSWORD=validation-only" echo "ADGUARD_ADMIN_PASSWORD_HASH=validation-only" echo "IMMICH_DB_PASSWORD=validation-only" diff --git a/secrets/sensitive.example.yaml b/secrets/sensitive.example.yaml index 1b40c857..80216484 100644 --- a/secrets/sensitive.example.yaml +++ b/secrets/sensitive.example.yaml @@ -178,6 +178,18 @@ HA_PROMETHEUS_AUTHORIZATION: Bearer change-me-to-the-reader-token # morpheus. HA_MEDIATV_MAC: "00:00:5e:00:53:00" +# The Ring Alarm's MQTT broker (ADR-0104). One password per client, and the +# broker builds its password file from both on every start, so changing one +# here and `make up` is the rotation. Generate each with scripts/gen-secret.sh. +# +# MQTT_HA_PASSWORD Home Assistant's, user `homeassistant`. Also typed +# once into Home Assistant's MQTT integration, so a +# rotation changes it there as well. +# MQTT_RING_PASSWORD ring-mqtt's, user `ring-mqtt`. Only compose.yaml +# uses it. +MQTT_HA_PASSWORD: change-me +MQTT_RING_PASSWORD: change-me + # ntfy (#136). compose.yaml builds its users, access list and token from these # on every start (DIFFERENCE 12), so changing one here and `make up` IS the # rotation — there is no ntfy CLI step. diff --git a/secrets/sensitive.sops.yaml b/secrets/sensitive.sops.yaml index 81ab56b0..abbabfc0 100644 --- a/secrets/sensitive.sops.yaml +++ b/secrets/sensitive.sops.yaml @@ -119,6 +119,8 @@ GLANCE_MARKETS: ENC[AES256_GCM,data:ycX31ZWqcUfbenAw0ZkMU1uhTGRO1Za1Tj7NYQcg0HGP GLANCE_BOOKMARKS: ENC[AES256_GCM,data:Gcp8zeT5ettlXusyts/Oz7BvOubBiMkGbgW/egpF1z5L4eKxVXAnAb7DnGYmTIwE4YWsR8vOu3NdsNjfHrkiLY0DY6I86vU+3ZkbmLE2G1+5lYCD/j9l6pAI79TF2zy6B9l8BTrpocAJ0QxtkEAx8+UwR532GtCmS4p+32CNMUak7VrAw7Xei+c1FM7O7ERbTSqsmt1ZkPYhJkBFPKBn0EhrL+AdnehsXrnJMfs1Z54ChqP71HeUn7I8XJWR87Ov5FHNfU370sayhojol09jnzjISvqzkn2BzuMnW5vSFsTuPJqHiaUseBGfVU0dOjGL5AZWTWXxh0cIa/mCpmKfBJfTNjiLVqTncCFEC3BQP2T7EEqSExWnl9BMu0l1EPalAkuq9xMayIDDMZXpRx5ubNPJv9L5Qrw4qqRg5QqaOhhK6rIvK8wB7fLy+IaaO/Ve32Iq0PbgDkRfttyQBnYhb+FxYBf5sTixnMLzGVuU4ZhjqG7B/259MX2YbNq8QM6T9adoPO0f8jzxBw1yDmZmK5r6kEVP1mOztk20y00Xo5HawgG/yctLxoo20BDuAJGnD31TwdMuVqkv64gwzN9pQdpk3TOWy+zNg59xBfrnV8d6amZb8n2rvVvnE3yJsHb762vXM7t0nb3mzBpjtBsjZvvZ0INXCdWeuRn4UUoMZU6yCXyR2SoMzsZbx2KcXyZpnNXhvT+04M4wHhIPJW6Tqn/W4vzf5bU=,iv:ByRZhyfAoOLq7lNonbvrC8DKMmt9V3moQm1ttGoAn78=,tag:n1QXZUr4xRQO6u9uGjN5Gw==,type:str] GLANCE_VIDEO_TABS: ENC[AES256_GCM,data:NkjdaoWWMDx51tzfx5IC8bPTQhuD3eRLQIEDCLgHEfQv/d6bERnY4pntNDTIJY453z7cWBOo6JEuLyfB9p73TgwgeznufQYHPTEtCROgTNjH+Vo4Cp9wb94Ayu5bB6IqC2rFQJOmddv0s2PIV+eOqWVLil+0ZATwTpfQ+WghQ9tddtYp/yfRhXEVCBjByhuzU/jzr+spINZHyQGejWGiwF9cxPKfL47TCLMCGRYEj6GhZXw+MpV2hIENX+almQE1OjjUXFg4chH6Lw9J91ecpFZ0LoFteG7h4kTfqxI4ZPbszhl5Fs8PIez82uGGS6iQiKL7AgTcHd8TmlvQdvxgeopilzV0y5NJkUTzZ1RT+ezjbNc7GqCe4q5EDI6CUsjjKPD8zSemvVyV8CkqivS2t70Cjq/y53vWP04DmmWcVMZBDtant5X45/GX667sFjuRJyOGQBn+/dzPMKlTFzjwXFK89VN0Kvnu1I9jHfXg+/VwV+qZsNIpzrBuJgRDuXx7EBh+KF3r5Px1NyHwd9w/JDzHlofcybFPbevXFhMeUIzSB06c0gPb/g==,iv:uIkFIaapalHCkVXos8Mbhx/LaEIwJ7WuJINgSPDd2h0=,tag:bUUTo8axOdvZzAj+9DahMw==,type:str] GLANCE_REDDIT_TABS: ENC[AES256_GCM,data:QQZECOydUwA+/z6yMYngQAYxLr8zPbbusg6h4I9ANF8/KnJBx80K0XOlWcMO/lJth8M9dZAuogjiefym0E7/dFPtu5aE/XrqJLK9Nbfs8hhm8uGXkZiVIgspn3cJvImmJ8xdzv76G+va6nMAEjjZj8kx08MSrYtilDuAHN+Kc6YZ11MUBpT/q5+F2KjTrE7WrJ/iv+fpiGQOFsFJk64gsXaDLGG77pzhNOF070ft4LVjioEhhNUTvYORwfdQY6rD4+glauXYDOKRs5jpji23MtyJvLA/q4KM0+VBZEEZZeHNSA3RUs4Arpw3idy+bHYPm2SPupfhrKUrrRbHktDzXzs5JMGENzvj4e93kB7WaG1mPk2juHBbbi6btgfGOGBWsh9IrTxsypojdnom9Q==,iv:c3H87DQ1C7//G0mSiYO5ex3yX+LMebDJoLdzXPQFMpg=,tag:48f+wXY8ZxOIsB/nNkm5HQ==,type:str] +MQTT_HA_PASSWORD: ENC[AES256_GCM,data:zET6fk1csjCNFtJCys8nBFsic0Um7CVi,iv:iiXQrpyhz4o81zGsC95YFfibMdY6JUAIfWpfYBR+T+w=,tag:9/brP1ATceaAL9AOPzVA0Q==,type:str] +MQTT_RING_PASSWORD: ENC[AES256_GCM,data:FanU7jvscjrueyn8c6t+r4zbdM1qBOba,iv:a4zJgsnIaSIMc/YubI9vEao208d5ZQZ3adjObH/E6As=,tag:lgcBQNUWP/fEvSuSZwn0NA==,type:str] sops: kms: [] gcp_kms: [] @@ -143,8 +145,8 @@ sops: RGFiYUlWUUxSYWpVbzNaR0RSaUpLYzQKRssWILF29DQ57eS6Rcnz8TF4qSPrLj4Q 0coqwkg00uzmjaINDwnl9WAvXPBzSL7eEih2BKTVBRfhJjvDJ+tRWQ== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-10-22T05:12:23Z" - mac: ENC[AES256_GCM,data:j/hG/lamD+Ag3aBsxj8Y97VaIddIQ56Y1asB8VC3+pVmaUJRfyeN1Fwuth94ig2khtycDzxHwKzFKFEr6T6YL3wnVkvVYhS2+ET0ntgoCM6am186Cs05HRX4MEPhihKIynyuz1lr9M3TAB44CNQiIXIoS2mhgBDd8NusYamydLE=,iv:Xs4TPsLqsfqQafPSTCtDppn/tXyLGhjWjUEs1T7yKI4=,tag:sGqyBTUNsHVEcagDMvUgtw==,type:str] + lastmodified: "2026-10-22T20:39:23Z" + mac: ENC[AES256_GCM,data:q+5JhoBWOJ0zPcT0gHbSEj4XujKK12qrlQ+LI8/mk9re2bQWqHw16gwNPiuoadDMNuWytvhuQmVltcrYyjvw644KoLQRp3EGVzELB+aMaaEniyeyIDuv6rbMIUqhsZRX6SLIvSWD5zlOrjuP280f6s/QrGzdv0Rh6eKmaMCVfJA=,iv:5Jok8vhR2zaDM+iCKsGOQL+EP5Ac4IDa561BW1YEPs4=,tag:8AvUn6Q+tX0V2h/+EuYDqw==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.9.4 diff --git a/stacks/sensitive/README.md b/stacks/sensitive/README.md index 91c7d998..38bf5b1b 100644 --- a/stacks/sensitive/README.md +++ b/stacks/sensitive/README.md @@ -18,6 +18,8 @@ [![Actual](https://img.shields.io/badge/Actual-6B46C1?style=plastic&logo=actualbudget&logoColor=white)](https://actualbudget.org) [![Stirling-PDF](https://img.shields.io/badge/Stirling--PDF-b91c1c?style=plastic)](https://github.com/Stirling-Tools/Stirling-PDF) [![Glance](https://img.shields.io/badge/Glance-4b5563?style=plastic)](https://github.com/glanceapp/glance) +[![Mosquitto](https://img.shields.io/badge/Mosquitto-3C5280?style=plastic&logo=eclipsemosquitto&logoColor=white)](https://mosquitto.org) +[![ring-mqtt](https://img.shields.io/badge/ring--mqtt-1c9ad6?style=plastic)](https://github.com/tsightler/ring-mqtt) ADR-0008's sensitive tier — the household's password manager, photos, documents and home automation — on `trinity` (`10.0.99.40`, Winterfell / VLAN 99), the @@ -58,8 +60,10 @@ make up STACK=sensitive | actual | `actualbudget/actual-server` | *internal* (5006) | The household's budget at `https://actual.matrix.elysium`: the sync server for Actual's local-first clients, password login only, no bank sync. The fifth service beyond ADR-0008's nine, after Miniflux, Memos, Mealie and linkding, by [ADR-0062] ([#142]) | | stirling-pdf | `stirlingtools/stirling-pdf` | *internal* (8080) | The household's PDF editor at `https://pdf.matrix.elysium`: merge, split, sign, OCR, convert, so that none of it goes through a website. Keeps nothing, and its documents live only in memory ([#143], [ADR-0063]) | | glance | `glanceapp/glance` | *internal* (8080) | Two front pages. The operator's start page at `https://start.matrix.elysium`, a page per subject: the estate's state as Prometheus sees it, maintenance, feeds, video, scores and markets. And the household's page at `https://home.matrix.elysium`: what exists in the house and where, with no status, which Homepage was until [ADR-0103]. Keeps nothing ([ADR-0098], [ADR-0100]) | +| mosquitto | `eclipse-mosquitto` | *`mqtt` network only* (1883) | The MQTT broker the Ring Alarm reaches Home Assistant through. On an internal network of its own that only Home Assistant and ring-mqtt join, two users, an ACL, nothing on disk ([ADR-0104]) | +| ring-mqtt | `tsightler/ring-mqtt` | *none* | The bridge from Ring's alarm websocket to that broker: the base station, its sensors, keypads and arming, with Home Assistant discovery. Alarm devices only; the cameras stay the core Ring integration's. Signed in once by [`connect-the-ring-alarm.md`](../../docs/runbooks/connect-the-ring-alarm.md) ([ADR-0104]) | -Twenty-one services. Two are plumbing; Home Assistant and Vaultwarden are the first +Twenty-three services. Two are plumbing; Home Assistant and Vaultwarden are the first household services and the shape every later one takes; AdGuard is the one the household uses without ever knowing it; four are Immich, the service [ADR-0008] names as the price of putting the tier on Winterfell at all; three are @@ -71,8 +75,10 @@ household is meant to open for its own sake; linkding is the fourth; Actual is the household's budget, the fifth; and Stirling-PDF, the sixth, is the one that keeps nothing; Glance is two front pages, the operator's start page, the one place here that shows up and down ([ADR-0098]), and the household's -page that tells them the rest exist ([ADR-0103]). What is absent is as deliberate as -what is here: +page that tells them the rest exist ([ADR-0103]); and Mosquitto and ring-mqtt +are the Ring Alarm's way into Home Assistant ([ADR-0104]), the first service +that is plumbing for another rather than something anyone opens. What is +absent is as deliberate as what is here: - **No Prometheus, Loki or Grafana.** The lab has its own because its telemetry must never reach VLAN 99 ([ADR-0007]); this host *is* on VLAN 99, @@ -96,13 +102,15 @@ what is here: Postgres of their own — Immich's needs the vector extension and therefore a different image — and one database per service is what lets `backup-volumes.sh` attribute every volume to the one service that owns it and stop only that. -- **No Supervisor, no add-ons, no MQTT broker, no Zigbee coordinator.** Home - Assistant *Container* has no add-on store, which is why it was chosen: an - add-on is a second package manager outside `compose.yaml` and outside digest - pinning. What an add-on would have supplied becomes a pinned service here - on the day a device needs it, and today none does — Ring is cloud, the Hue - bridge is its own radio, the speakers are Wi-Fi. No USB radio means where - `trinity` sits is not this service's concern. +- **No Supervisor, no add-ons, no Zigbee coordinator.** Home Assistant + *Container* has no add-on store, which is why it was chosen: an add-on is a + second package manager outside `compose.yaml` and outside digest pinning. + What an add-on would have supplied becomes a pinned service here on the day + a device needs it. The Ring Alarm was the first, on 2026-10-22: Mosquitto + and ring-mqtt are the two services the add-on store would have installed, + pinned and hardened here instead ([ADR-0104]). Nothing else needs one. The + Hue bridge is its own radio and the speakers are Wi-Fi, and no USB radio + means where `trinity` sits is not this service's concern. - **One custom integration, and it is ours.** `home-assistant/custom_components/` holds `ring_push_guard`, a version-gated patch for `ring_doorbell` 0.9.14, whose push parser dies on Ring pushes without a `ding.id` and takes every @@ -113,7 +121,7 @@ what is here: ## Layout ```text -compose.yaml twenty-one services, one network, health-gated ordering; +compose.yaml twenty-three services, two networks, health-gated ordering; Immich's machine learning sits behind the `ml` profile Caddyfile every route the tier serves; validated in CI home-assistant/ configuration.yaml, packages/ and custom_components/, @@ -129,6 +137,9 @@ ntfy/server.yml ntfy's settings; its users and access list come from ntfy/templates/ message templates, mounted read-only stirling-pdf/smoke.sh proves the PDF engine works, not just its status endpoint; run by check_hardened_boot.sh (#143) +mosquitto/ the broker's config and ACL, mounted read-only, and a + smoke.sh that signs in as both users (ADR-0104) +ring-mqtt/smoke.sh proves ring-mqtt reads its config under its hardening household.recipients the age public keys that can open the household's copies household-age.sha256 checksums of the age release the household copy is opened with consume/ untracked: drop a scan here and Paperless-ngx imports @@ -1049,6 +1060,45 @@ docker logs sensitive-stirling-pdf 2>&1 | grep -E 'sandbox active|UnsatisfiedLin The first line should appear; the second should not. +## The Ring Alarm: Mosquitto and ring-mqtt + +The Ring Alarm's base station, sensors, keypads and arming reach Home +Assistant through these two, since [ADR-0104]. The core Ring integration +cannot see the alarm, because its library does not speak Ring's alarm +websocket. `compose.yaml` has the services and DIFFERENCE 14. What has to be +true around them is here. + +- **The broker is reachable from two services and nothing else.** It is on + `mqtt`, an internal network with no gateway, which only Home Assistant and + ring-mqtt join. Each signs in as its own user. The ACL keeps ring-mqtt to + `ring/#` and `homeassistant/#`, and anonymous clients are refused. +- **The passwords come from SOPS on every start.** `MQTT_HA_PASSWORD` and + `MQTT_RING_PASSWORD` are hashed into a tmpfs password file when the broker + starts. Home Assistant's copy is also typed into its MQTT integration once, + so a rotation changes it there too + ([`connect-the-ring-alarm.md`](../../docs/runbooks/connect-the-ring-alarm.md)). +- **ring-mqtt holds the Ring location owner's sign-in.** A shared user cannot + change settings, and Ring's newer shared role returns no devices to the + API. The refresh token is in `ring-mqtt-data`, made by hand with 2FA from + the runbook. It is not in SOPS and not backed up: Ring rotates it in use. +- **It runs as plain Node, not through its image's s6 init,** as `RENDER_UID` + on a read-only root. Its `/data/config.json` holds the broker password, so + it is written to a tmpfs at start and linked into the volume. +- **Alarm only.** `enable_cameras` is off, so ring-mqtt starts no go2rtc and + listens on nothing a client could reach. The cameras and doorbell remain + the core integration's. +- **No disarm code.** Anyone signed in to Home Assistant can arm and disarm, + as anyone the Ring app lets in can. ADR-0104 says why. + +CI boots both under their hardening. [`mosquitto/smoke.sh`](mosquitto/smoke.sh) +signs in as each user and checks the refusals and the ACL; +[`ring-mqtt/smoke.sh`](ring-mqtt/smoke.sh) checks ring-mqtt read its +configuration and is waiting for a token. On `trinity`, after a deploy: + +```bash +docker logs --since 10m sensitive-ring-mqtt 2>&1 | grep -E 'Device Discovery Complete|MQTT.*(connected|Error)' +``` + ## Backup and restore ```bash @@ -1226,6 +1276,7 @@ it matters: [ADR-0098]: ../../docs/adr/0098-add-glance-as-the-operators-start-page-beside-homepage.md [ADR-0100]: ../../docs/adr/0100-grow-glance-into-a-homelab-page-and-keep-the-operators-lists-in-sops.md [ADR-0103]: ../../docs/adr/0103-retire-homepage-and-serve-the-household-page-from-glance.md +[ADR-0104]: ../../docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md [ADR-0064]: ../../docs/adr/0064-copy-immichs-library-to-oracle-until-the-off-estate-copy-exists.md [ADR-0073]: ../../docs/adr/0073-carry-the-household-copy-on-a-drive-the-holder-keeps.md [#129]: https://github.com/Gerrrt/HomeLab/issues/129 diff --git a/stacks/sensitive/compose.yaml b/stacks/sensitive/compose.yaml index d0c58551..d30fc8b8 100644 --- a/stacks/sensitive/compose.yaml +++ b/stacks/sensitive/compose.yaml @@ -36,6 +36,9 @@ # same way (ADR-0062). # Stirling-PDF (#143) is the thirteenth, and the sixth beyond those nine: # ADR-0063 is its decision, and DIFFERENCE 13 the one new thing it does. +# Mosquitto and ring-mqtt are the fourteenth, as two services: the Ring +# Alarm's way into Home Assistant, decided by ADR-0104, and the reason for +# DIFFERENCE 14, the tier's second network. # # --------------------------------------------------------------------------- # On the comments in this file @@ -46,7 +49,7 @@ # NOT repeated here; two copies of an argument drift the same way two copies # of a config do. What IS written out here is every place this stack differs # from that one, because those are the only lines a reader cannot get from -# the sibling file. There are thirteen such places, each marked DIFFERENCE. +# the sibling file. There are fourteen such places, each marked DIFFERENCE. name: sensitive @@ -381,6 +384,8 @@ services: <<: *service-defaults image: ghcr.io/home-assistant/home-assistant:2026.10.2@sha256:33afb0659d786213264c2211f8d91c5fe87461c56eba6a3a546eb05fc71e89f4 container_name: sensitive-home-assistant + # `mqtt` as well, for the broker (DIFFERENCE 14, ADR-0104). + networks: [sensitive, mqtt] init: false # A ceiling to catch a leak, not a measurement: #114's method — median and # peak over a fortnight of real use — replaces it once the tier runs. The @@ -436,6 +441,153 @@ services: # matters. start_period: 90s + # --------------------------------------------------------------------------- + # Mosquitto and ring-mqtt — the Ring Alarm into Home Assistant (ADR-0104) + # --------------------------------------------------------------------------- + # Home Assistant's core Ring integration carries the cameras and the + # doorbell, but not the Ring Alarm: its base station, contact and motion + # sensors, keypads and arming live behind Ring's alarm websocket, which that + # integration's library does not speak. ring-mqtt does, and publishes them + # to an MQTT broker with Home Assistant discovery. Two services, then: a + # broker, and the bridge. ADR-0104 is the decision and its trade. + # + # DIFFERENCE 14: a second network, and it is internal. The broker is on + # `mqtt` alone, which has no gateway, so it can reach nothing and nothing + # but its two members can reach it: Home Assistant and ring-mqtt, which are + # on `sensitive` as well. Every other service here could otherwise open + # 1883, and the broker carries the alarm's arming commands. Each member + # also signs in as its own user, and the ACL keeps ring-mqtt to its two + # topic trees, so a compromised bridge cannot publish as anything else. + # + # Nothing is persisted. Retained discovery and state live in the broker's + # memory, and ring-mqtt republishes all of it when Home Assistant's birth + # message arrives on homeassistant/status, which it does after either side + # restarts. So the broker is read-only with no volume at all. + # + # The password file is built on every start from the environment, the + # pattern DIFFERENCE 12 set for ntfy: plaintext into a tmpfs under umask + # 077, hashed in place by `mosquitto_passwd -U` (argon2id), then the broker + # is exec'd. Changing a password in SOPS and `make up` is the rotation; Home + # Assistant's copy is entered once in its MQTT integration and must be + # changed there too. + mosquitto: + <<: *service-defaults + image: eclipse-mosquitto:2.1.2-alpine@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408 + container_name: sensitive-mosquitto + networks: [mqtt] + user: "1883:1883" + # Two clients and a few hundred retained messages; a ceiling for a leak. + mem_limit: 64m + memswap_limit: 64m + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /mosquitto/run:size=1m,uid=1883,gid=1883,mode=0700 + # mosquitto_passwd -U writes its temporary copy here. + - /tmp:size=1m + environment: + MQTT_HA_PASSWORD: ${MQTT_HA_PASSWORD:?set in secrets/sensitive.sops.yaml} + MQTT_RING_PASSWORD: ${MQTT_RING_PASSWORD:?set in secrets/sensitive.sops.yaml} + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + umask 077 + printf 'homeassistant:%s\nring-mqtt:%s\n' "$$MQTT_HA_PASSWORD" "$$MQTT_RING_PASSWORD" > /mosquitto/run/passwd + mosquitto_passwd -U /mosquitto/run/passwd + exec mosquitto -c /mosquitto/config/mosquitto.conf + volumes: + - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + - ./mosquitto/acl:/mosquitto/config/acl:ro + expose: + - "1883" + healthcheck: + # The listener answers. A login would be the stronger claim, but it + # would put a password on the healthcheck's command line, where + # `docker inspect` shows it to anyone who can run it. + test: ["CMD", "nc", "-z", "127.0.0.1", "1883"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # ring-mqtt runs as plain Node, not through the image's s6 /init. That init + # only sets RUNMODE and DEBUG and execs `ring-mqtt.js` (its services.d run + # script, read in the pinned image on 2026-10-22), but it needs root to + # start, and with BRANCH=latest or dev it would `git pull` a newer ring-mqtt + # at run time. Neither is wanted. So: the image's own Node, as RENDER_UID, + # read-only, with the two variables that init would have set. + # + # ring-mqtt reads one file, /data/config.json, and that file holds the + # broker password inside mqtt_url. It is written to a tmpfs on every start + # from the environment and linked into /data, so the password is never on + # a disk. /data itself is a volume for the one thing ring-mqtt must keep: + # ring-state.json, Ring's refresh token, which Ring rotates and ring-mqtt + # writes back. It is the Ring location owner's sign-in, held the way Home + # Assistant's own Ring entry holds it in /config/.storage, and it never + # passes through SOPS. The first one is made by hand, once, with 2FA: + # docs/runbooks/connect-the-ring-alarm.md. + # + # enable_cameras is false: the cameras and doorbell are the core + # integration's, and with it false ring-mqtt neither lists them nor starts + # its bundled go2rtc, so nothing listens on 8554. Its token web UI on 55123 + # starts only when it has no token; it is not exposed, and the runbook does + # not use it. + ring-mqtt: + <<: *service-defaults + image: tsightler/ring-mqtt:5.9.3@sha256:4a4e0db460d7023aa5851ff25491401fd23b2fd153b7269af481f0953cd6f00a + container_name: sensitive-ring-mqtt + networks: [sensitive, mqtt] + user: "${RENDER_UID:?run make render}:${RENDER_GID:?run make render}" + # A ceiling for a leak. The one number there is: 48 MiB at idle on the + # pinned image before a Ring sign-in, 2026-10-22. Re-derive once the + # alarm's devices are loaded. + mem_limit: 256m + memswap_limit: 256m + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:size=16m + - /run/ring-mqtt:size=1m,uid=${RENDER_UID:?run make render},gid=${RENDER_GID:?run make render},mode=0700 + working_dir: /app/ring-mqtt + environment: + RUNMODE: docker + DEBUG: ring-* + MQTT_RING_PASSWORD: ${MQTT_RING_PASSWORD:?set in secrets/sensitive.sops.yaml} + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + umask 077 + cat > /run/ring-mqtt/config.json <&2; exit 1; } + +pub -u homeassistant -P "${MQTT_HA_PASSWORD}" -t smoke/ha || fail "homeassistant could not publish" +pub -u ring-mqtt -P "${MQTT_RING_PASSWORD}" -t ring/smoke || fail "ring-mqtt could not publish to ring/" +pub -u ring-mqtt -P "${MQTT_RING_PASSWORD}" -t homeassistant/smoke || fail "ring-mqtt could not publish to homeassistant/" +if pub -u ring-mqtt -P wrong -t ring/smoke 2>/dev/null; then fail "a wrong password was accepted"; fi +if pub -t ring/smoke 2>/dev/null; then fail "an anonymous client was accepted"; fi + +out=/tmp/smoke-sub.txt +mosquitto_sub -h 127.0.0.1 -u homeassistant -P "${MQTT_HA_PASSWORD}" -t '#' -v -W 3 > "${out}" 2>/dev/null & +sleep 1 +pub -u ring-mqtt -P "${MQTT_RING_PASSWORD}" -t elsewhere/smoke +pub -u ring-mqtt -P "${MQTT_RING_PASSWORD}" -t ring/visible +wait || true +grep -q '^ring/visible ' "${out}" || fail "an allowed message from ring-mqtt did not arrive" +if grep -q '^elsewhere/' "${out}"; then fail "ring-mqtt published outside its ACL"; fi +rm -f "${out}" +echo "smoke: logins, refusals and the ring-mqtt ACL behave" +IN_CONTAINER diff --git a/stacks/sensitive/ring-mqtt/smoke.sh b/stacks/sensitive/ring-mqtt/smoke.sh new file mode 100755 index 00000000..72e0f15a --- /dev/null +++ b/stacks/sensitive/ring-mqtt/smoke.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# +# Prove ring-mqtt starts and reads its configuration under its hardening +# (ADR-0104). Run by scripts/check_hardened_boot.sh after the boot, with the +# container id as $1. +# +# WHY. ring-mqtt has no healthcheck (compose.yaml says why), so the boot's +# --wait passes the moment the process starts. What can go wrong under this +# compose is all at start: the config written to the tmpfs and linked into +# /data unread, the state file unwritable as RENDER_UID, or Node unable to run +# from a read-only root. Each of those stops it within seconds, so this waits, +# then reads its log. +# +# In CI there is no Ring sign-in and no internet, so the furthest a healthy +# start gets is the point where it asks for a token. That is what is checked. +set -euo pipefail + +cid="${1:?usage: smoke.sh }" + +for _ in $(seq 30); do + docker logs "${cid}" 2>&1 | grep -q 'No refresh token was found' && break + sleep 1 +done +log="$(docker logs "${cid}" 2>&1)" +fail() { echo "smoke: $*" >&2; printf '%s\n' "${log}" | tail -20 >&2; exit 1; } + +grep -q 'Detected runmode: docker' <<<"${log}" || fail "ring-mqtt did not start in docker run mode" +grep -q 'Configuration file: /data/config.json' <<<"${log}" || fail "ring-mqtt did not read /data/config.json" +grep -q 'could not be read' <<<"${log}" && fail "ring-mqtt could not read its configuration" +grep -q 'MQTT URL: mqtt://ring-mqtt:\*\*\*\*\*\*\*\*@mosquitto:1883' <<<"${log}" || fail "the configuration did not carry the broker URL" +grep -q 'No refresh token was found' <<<"${log}" || fail "ring-mqtt did not reach the point of asking for a Ring token" +[[ "$(docker inspect -f '{{.State.Running}}' "${cid}")" == true ]] || fail "ring-mqtt stopped after starting" +echo "smoke: ring-mqtt starts, reads its configuration and waits for a Ring token" From d3a115ee2a36ec4ac78474b377b5c258fd7ff2b0 Mon Sep 17 00:00:00 2001 From: Garrett Allen <98648590+Gerrrt@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:42:07 +0000 Subject: [PATCH 2/2] fix(sensitive): keep ring-mqtt's sign-in page off the sensitive network Review on the ring-mqtt PR: while ring-mqtt has no working Ring token it serves an unauthenticated sign-in page on 55123 on every interface, and on `sensitive` any service beside it could start a Ring sign-in there. ring-mqtt now joins only `mqtt` and `ring-egress`, a new network for its way out to Ring with no other member. Tested in a throwaway boot with the page up: unreachable from a container on `sensitive` at both of ring-mqtt's addresses; reachable only on `mqtt`, whose other members are the broker and Home Assistant, which already holds the same owner's Ring sign-in; and api.ring.com still reachable from ring-mqtt. DIFFERENCE 14, ADR-0104, the stack README and security.md say so. Co-Authored-By: Claude Opus 5.5 --- ...m-into-home-assistant-through-ring-mqtt.md | 20 +++++++--- docs/security.md | 7 +++- stacks/sensitive/README.md | 11 ++++-- stacks/sensitive/compose.yaml | 38 +++++++++++++------ 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md b/docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md index d07a46bb..0daf0967 100644 --- a/docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md +++ b/docs/adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md @@ -83,8 +83,15 @@ garage camera. ring-mqtt has the same constraint, for the same reason. `docs/runbooks/connect-the-ring-alarm.md`. 6. **Alarm only.** `enable_cameras` is false. The cameras and doorbell stay the core integration's, so ring-mqtt neither lists them nor starts its bundled - go2rtc, and nothing listens on 8554. Its token web UI on 55123 runs only - when it has no token, is not exposed, and is not used. + go2rtc, and nothing listens on 8554. Its token web UI on 55123 is the one + listener left, and it matters. It runs whenever ring-mqtt has no working + token, on every interface and with no authentication, and from it anyone + who can reach the port can start a Ring sign-in. So ring-mqtt is **not on + `sensitive`**. It joins `mqtt`, and `ring-egress`, a network for its way + out to Ring with no other member. The only containers that can reach + 55123 are the broker and Home Assistant, which already holds the same + owner's Ring sign-in. + The runbook signs in with `docker exec` and never uses the page. 7. **No disarm code in Home Assistant.** `disarm_code` is empty, so anyone signed in to Home Assistant can arm and disarm. They are the household, the same people the Ring app lets do it. A code would also have to sit in @@ -120,9 +127,10 @@ station. ## Consequences -- **The tier's first broker, and its second network.** `compose.yaml` marks it - DIFFERENCE 14. Anything that later wants MQTT joins `mqtt`, with a user and - an ACL entry of its own, rather than widening an existing one. +- **The tier's first broker, and two networks besides `sensitive`.** + `compose.yaml` marks them DIFFERENCE 14. Anything that later wants MQTT + joins `mqtt`, with a user and an ACL entry of its own, rather than widening + an existing one. `ring-egress` stays ring-mqtt's alone. - **Community code holds the owner's Ring sign-in.** ring-mqtt is one author's project, built on `ring-client-api`, another's. It runs with the Ring location owner's refresh token, which can do anything that owner can, @@ -131,7 +139,7 @@ station. accepted on the same terms: - pinned by digest; - non-root, read-only, with no capabilities; - - no inbound port; + - no listener the tier's other services can reach; - reachable only by Home Assistant over an authenticated, ACL'd broker. The token is listed in Ring's Control Center as `ring-mqtt-xxxxx`, where it diff --git a/docs/security.md b/docs/security.md index df353b48..a050a27a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1169,8 +1169,11 @@ this closes on. join. Each signs in as its own user, and an ACL keeps ring-mqtt to its own topics. ring-mqtt is community code holding the Ring location owner's refresh token, as Home Assistant's Ring entry does. It runs non-root and - read-only, with no inbound port, and the token can be revoked in Ring's - Control Center + read-only, and the token can be revoked in Ring's Control Center. Its one + listener, an unauthenticated sign-in page that runs while it has no working + token, is out of reach of the tier's other services: ring-mqtt is not on + `sensitive`, only on the broker's network, shared with Home Assistant, and + an egress network of its own ([ADR-0104](adr/0104-bring-the-ring-alarm-into-home-assistant-through-ring-mqtt.md)). Alloy no longer runs `privileged: true`. It never needed it: `cgroup: host` is diff --git a/stacks/sensitive/README.md b/stacks/sensitive/README.md index 38bf5b1b..31946e3e 100644 --- a/stacks/sensitive/README.md +++ b/stacks/sensitive/README.md @@ -121,7 +121,7 @@ absent is as deliberate as what is here: ## Layout ```text -compose.yaml twenty-three services, two networks, health-gated ordering; +compose.yaml twenty-three services, three networks, health-gated ordering; Immich's machine learning sits behind the `ml` profile Caddyfile every route the tier serves; validated in CI home-assistant/ configuration.yaml, packages/ and custom_components/, @@ -1084,9 +1084,12 @@ true around them is here. - **It runs as plain Node, not through its image's s6 init,** as `RENDER_UID` on a read-only root. Its `/data/config.json` holds the broker password, so it is written to a tmpfs at start and linked into the volume. -- **Alarm only.** `enable_cameras` is off, so ring-mqtt starts no go2rtc and - listens on nothing a client could reach. The cameras and doorbell remain - the core integration's. +- **Alarm only.** `enable_cameras` is off, so ring-mqtt starts no go2rtc. + The cameras and doorbell remain the core integration's. +- **ring-mqtt is not on `sensitive`.** Without a working Ring token it serves + an unauthenticated sign-in page on 55123. So it joins only `mqtt` and + `ring-egress`, its own way out to Ring with no other member. Only the broker + and Home Assistant, which holds the same Ring sign-in, can reach that page. - **No disarm code.** Anyone signed in to Home Assistant can arm and disarm, as anyone the Ring app lets in can. ADR-0104 says why. diff --git a/stacks/sensitive/compose.yaml b/stacks/sensitive/compose.yaml index d30fc8b8..27780f09 100644 --- a/stacks/sensitive/compose.yaml +++ b/stacks/sensitive/compose.yaml @@ -38,7 +38,7 @@ # ADR-0063 is its decision, and DIFFERENCE 13 the one new thing it does. # Mosquitto and ring-mqtt are the fourteenth, as two services: the Ring # Alarm's way into Home Assistant, decided by ADR-0104, and the reason for -# DIFFERENCE 14, the tier's second network. +# DIFFERENCE 14, the tier's second and third networks. # # --------------------------------------------------------------------------- # On the comments in this file @@ -451,13 +451,24 @@ services: # to an MQTT broker with Home Assistant discovery. Two services, then: a # broker, and the bridge. ADR-0104 is the decision and its trade. # - # DIFFERENCE 14: a second network, and it is internal. The broker is on - # `mqtt` alone, which has no gateway, so it can reach nothing and nothing - # but its two members can reach it: Home Assistant and ring-mqtt, which are - # on `sensitive` as well. Every other service here could otherwise open - # 1883, and the broker carries the alarm's arming commands. Each member - # also signs in as its own user, and the ACL keeps ring-mqtt to its two - # topic trees, so a compromised bridge cannot publish as anything else. + # DIFFERENCE 14: two more networks, and neither is `sensitive`. + # + # mqtt internal, no gateway. The broker is on it alone with its + # two clients, Home Assistant and ring-mqtt, so it can reach + # nothing and no other service here can open 1883. The broker + # carries the alarm's arming commands. + # ring-egress ring-mqtt's way out to Ring's cloud, and nothing else is on + # it. ring-mqtt is deliberately not on `sensitive`: while it + # has no Ring token, or its saved one fails, it serves an + # unauthenticated sign-in page on 55123 on every interface, + # and a service beside it could start a Ring sign-in of its + # own there. On this network there is no one beside it; on + # `mqtt` there are the broker and Home Assistant, which holds + # the same Ring sign-in already. + # + # Each client also signs in to the broker as its own user, and the ACL keeps + # ring-mqtt to its two topic trees, so a compromised bridge cannot publish + # as anything else. # # Nothing is persisted. Retained discovery and state live in the broker's # memory, and ring-mqtt republishes all of it when Home Assistant's birth @@ -533,13 +544,15 @@ services: # enable_cameras is false: the cameras and doorbell are the core # integration's, and with it false ring-mqtt neither lists them nor starts # its bundled go2rtc, so nothing listens on 8554. Its token web UI on 55123 - # starts only when it has no token; it is not exposed, and the runbook does - # not use it. + # starts only when it has no working token. It has no authentication, which + # is why ring-mqtt is on a network of its own (DIFFERENCE 14), and the + # runbook signs in with `docker exec` instead. ring-mqtt: <<: *service-defaults image: tsightler/ring-mqtt:5.9.3@sha256:4a4e0db460d7023aa5851ff25491401fd23b2fd153b7269af481f0953cd6f00a container_name: sensitive-ring-mqtt - networks: [sensitive, mqtt] + # Not `sensitive`: DIFFERENCE 14 says why. + networks: [ring-egress, mqtt] user: "${RENDER_UID:?run make render}:${RENDER_GID:?run make render}" # A ceiling for a leak. The one number there is: 48 MiB at idle on the # pinned image before a Ring sign-in, 2026-10-22. Re-derive once the @@ -2139,6 +2152,9 @@ networks: mqtt: driver: bridge internal: true + # DIFFERENCE 14 again: ring-mqtt's own way out, with no other member. + ring-egress: + driver: bridge volumes: caddy-data: