Document the LAN-only ntfy decision and the WireGuard split tunnel

Records the networking decision behind Phase 6b's arrival notifications so it
doesn't get re-derived later.

ntfy stays LAN-only: identity -> ntfy is a container-to-container call that never
reaches OPNsense, and remote delivery rides a WireGuard split tunnel routing only
the smart-home VLAN. No DMZ, no port forward, no certificates, no inter-VLAN rule.

Both alternatives were weighed and rejected, with reasons recorded:
- DMZ + port forward buys only "notifications without the VPN connected," at the
  cost of a public listener, TLS upkeep, and mandatory ntfy auth — an exposed
  topic leaks "the house is empty right now".
- DMZ + NAT reflection does avoid a new inter-VLAN rule (the smart-home VLAN's
  existing outbound-WAN allow already covers traffic to your own public IP), but
  makes a purely internal path depend on the ISP and external DNS being up, so
  two devices metres apart stop talking during a WAN outage. CGNAT breaks it
  outright, and the rule it avoids was the safe direction anyway.

New §2.1 covers the split tunnel itself, including the trap that motivated it:
AllowedIPs must be the actual VLAN prefix, never 192.168.0.0/16, which collides
with typical café/hotel LANs and breaks the phone's connectivity there. Plus
always-on VPN yes / lockdown no, and no DNS= so queries aren't pulled home.

The household uses no Apple devices, which removes the only forcing function for
exposure (ntfy's iOS app needs APNs and therefore an upstream relay). Open
decision #37 closes as a household decision; the Apple case is kept as a
footnote in case that ever changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
digest-per-person-and-agendas
Amir Alexander Abdelbaki 2026-07-31 12:42:52 +02:00
parent 0caa21fc42
commit 15e6715469
4 changed files with 76 additions and 26 deletions

View File

@ -93,11 +93,11 @@ chores/ Presence/calendar-driven household chore nudging +
- [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes — built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md` - [ ] Kitchen/fridge display + `pantry-vision` (`hosts/kitchen-display/`, `pantry-vision/`) — hold a grocery item up to the camera, an Ollama vision model proposes what it is and roughly how long it keeps, a human confirms (never auto-committed) before it's written into Grocy stock; the display then shows inventory sorted by soonest-to-expire, groceries running low, and Grocy's recipes — built and wired into `setup-container-host.sh` (`ENABLE_PANTRY_VISION`, off by default), **nothing run against a real camera, vision model, or Grocy instance** — the Grocy API call shapes in particular are written from documentation only, see `pantry-vision/README.md` and `hosts/kitchen-display/README.md`
- [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as `<name>`" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline**`TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md` - [ ] `identity` + door panel (`identity/`, `hosts/door-panel/`) — the person <-> BLE-identifier registry: "register me as `<name>`" by voice or touchscreen, multi-phone support (multiple identifiers per person), anti-spoofing (only allowlisted IRK-resolved/fixed-tag entities are ever accepted as candidates, never a raw MAC), device-less people (a "no device" flag plus a hand-operated Home/Away toggle — the concrete case: a grandmother without a smartphone), and an anonymous "Guest" path. Backs `hosts/door-panel/`'s weather+clothing/who's-home/groceries-running-low dashboard and `hosts/kitchen-display/`'s "Show registration" screen — built and wired into `setup-container-host.sh` (`ENABLE_IDENTITY`, off by default), **nothing run against a real HA instance, real Private BLE Device entities, or a real voice pipeline**`TRUSTED_ENTITY_PREFIXES` above all needs checking against Developer Tools -> States, see `identity/README.md` and `hosts/door-panel/README.md`
- [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and owns the per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, plus chore assignment) consumed by `chores/`, see `identity/README.md` - [ ] `identity` also corroborates presence from Frigate face recognition (Phase 20, Tapo pan/tilt cameras) — an OR-ed-in second signal only, **never** a registration signal; and owns the per-person chore-system settings (`chore_exempt`, `chore_reminder_style`, plus chore assignment) consumed by `chores/`, see `identity/README.md`
- [ ] `identity`'s admin panel (`identity/frontend/admin.html`, Phase 6b) — managing people/guests: edit every field, **nicknames** (an input alias only — `/resolve` accepts them, but the assistant always speaks the real `speak_name`), **visit history** sampled from `/presence` plus a derived "who was home with whom" view, **"select all that last visited before `<date>`"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), chore assignment, and **opt-in arrival push notifications** ("tell me when someone gets home", via the self-hosted ntfy this stack already runs — `identity` itself never touches the WAN; getting the push while you're *away* is `docs/network-integration.md`'s WireGuard answer, and iOS is a documented exception). Deliberately **not** a kiosk page and not linked from any wall panel. Covered by API-level tests; **never opened in a real browser**, and `DEPARTURE_GRACE_SECONDS` is an untuned guess — see `identity/README.md` - [ ] `identity`'s admin panel (`identity/frontend/admin.html`, Phase 6b) — managing people/guests: edit every field, **nicknames** (an input alias only — `/resolve` accepts them, but the assistant always speaks the real `speak_name`), **visit history** sampled from `/presence` plus a derived "who was home with whom" view, **"select all that last visited before `<date>`"** pruning (the filter selects, a human confirms the exact list, the filter is never re-run at delete time), **per-device rights** for self-entry via a smart lock (`identity` only ever *answers* `GET /device-access` — HA asks and HA acts, deny is the default), chore assignment, and **opt-in arrival push notifications** ("tell me when someone gets home", via the self-hosted ntfy this stack already runs — `identity` itself never touches the WAN; ntfy stays LAN-only and remote delivery rides a WireGuard split tunnel — see `docs/network-integration.md` §2.2 for why a DMZ/port-forward was weighed and rejected). Deliberately **not** a kiosk page and not linked from any wall panel. Covered by API-level tests; **never opened in a real browser**, and `DEPARTURE_GRACE_SECONDS` is an untuned guess — see `identity/README.md`
- [ ] `trash-calendar` + `transit` (Phase 19, Kennelbach AT trash pickup + Vorarlberg public transit) — built and wired into `setup-container-host.sh` (`ENABLE_TRASH_CALENDAR`/`ENABLE_TRANSIT`/`ENABLE_TRIP_PLANNING`, all off by default), **nothing run against a live ICS feed, a live GTFS feed, or a real OpenTripPlanner instance** — trip planning also needs a manually-built OTP graph this repo does not build for you, see `trash-calendar/README.md` and `transit/README.md`'s "Route planning scope" - [ ] `trash-calendar` + `transit` (Phase 19, Kennelbach AT trash pickup + Vorarlberg public transit) — built and wired into `setup-container-host.sh` (`ENABLE_TRASH_CALENDAR`/`ENABLE_TRANSIT`/`ENABLE_TRIP_PLANNING`, all off by default), **nothing run against a live ICS feed, a live GTFS feed, or a real OpenTripPlanner instance** — trip planning also needs a manually-built OTP graph this repo does not build for you, see `trash-calendar/README.md` and `transit/README.md`'s "Route planning scope"
- [ ] `chores` (Phase 20) — presence/calendar-driven household chore nudging: "I don't care who does it, as long as it gets done" — prefers whoever's been assigned a chore in `identity`'s admin panel but falls through to whoever's actually home rather than waiting (`CHORE_ASSIGNMENT_STRICT` flips that), redirects to someone else if a chore goes neglected, keeps a passive fairness tally that never feeds back into who gets nudged, and camera-checks trash bins/dishes/litter via Frigate + an Ollama vision model. **Litter remains the exception to everything** — it ignores both chore-exemption and assignment, because cleaning up what you left out was never a task anyone could be assigned. Built and wired into `setup-container-host.sh` (`ENABLE_CHORES`, off by default, every-2-hours systemd timer), **no Tapo camera hardware chosen and nothing run against real hardware**, see `chores/README.md` - [ ] `chores` (Phase 20) — presence/calendar-driven household chore nudging: "I don't care who does it, as long as it gets done" — prefers whoever's been assigned a chore in `identity`'s admin panel but falls through to whoever's actually home rather than waiting (`CHORE_ASSIGNMENT_STRICT` flips that), redirects to someone else if a chore goes neglected, keeps a passive fairness tally that never feeds back into who gets nudged, and camera-checks trash bins/dishes/litter via Frigate + an Ollama vision model. **Litter remains the exception to everything** — it ignores both chore-exemption and assignment, because cleaning up what you left out was never a task anyone could be assigned. Built and wired into `setup-container-host.sh` (`ENABLE_CHORES`, off by default, every-2-hours systemd timer), **no Tapo camera hardware chosen and nothing run against real hardware**, see `chores/README.md`
- [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31 - [ ] Music Assistant (optional, additive multi-room audio) — wired into `setup-container-host.sh` (`ENABLE_MUSIC_ASSISTANT`, off by default), **its default port is an unverified guess that collides with `PANTRY_VISION_PORT`** if both are enabled together, see `docs/project-plan.md` open decision #31
- [ ] `docs/network-integration.md` (OPNsense VLAN segmentation + why nothing here should be port-forwarded to the WAN) — written, not run against a real OPNsense instance - [ ] `docs/network-integration.md` (OPNsense VLAN segmentation, the WireGuard split tunnel that carries arrival notifications, and why nothing here — ntfy included — gets port-forwarded to the WAN) — written, not run against a real OPNsense instance
## Quick start ## Quick start

View File

@ -48,6 +48,56 @@ side per new service this repo adds later.
If you don't need remote access at all, skip this section entirely — every service If you don't need remote access at all, skip this section entirely — every service
in this stack works purely on the LAN with zero WAN configuration. in this stack works purely on the LAN with zero WAN configuration.
### 2.1 Split tunnel — route the smart-home VLAN, nothing else
`AllowedIPs` on the **client** profile is both the route table and WireGuard's crypto
ACL, so it alone decides what enters the tunnel. Route the smart-home VLAN and leave
everything else on the phone's own connection:
```ini
[Peer]
AllowedIPs = 192.168.30.0/24 # the smart-home VLAN, adjust to your real subnet
# NOT 0.0.0.0/0 (sends all your traffic home for no reason)
# NOT 192.168.0.0/16 — see the collision warning below
```
**Never route the whole `192.168.0.0/16`.** Practically every café, hotel and hotspot
LAN lives on `192.168.0.0/24` or `192.168.1.0/24`; routing the entire /16 makes the
phone send *that* network's own gateway down the tunnel, and you lose connectivity on
that Wi-Fi altogether. Route only your actual VLAN prefix — and if your home LAN is
itself on `192.168.1.0/24`, put the smart-home VLAN somewhere unlikely
(`192.168.73.0/24`, say) so the clash can't arise in the first place.
Two client-side settings that are easy to get wrong together:
- **Android "always-on VPN": on. "Block connections without VPN" (lockdown): OFF.**
Lockdown drops everything not traversing the tunnel, which with a split tunnel kills
all non-`192.168.x` traffic on the device.
- **Leave `DNS =` unset.** Reach services by LAN IP (`http://192.168.30.x:8090` for
ntfy). No internal DNS needed, and your DNS queries don't get pulled home while
you're out.
This is just §3's rule 4 (route WireGuard peers into the smart-home VLAN, not Trusted
LAN) expressed on the client side.
## 2.2 Decided: ntfy stays LAN-only, no DMZ, no port forward
`identity`'s arrival notifications (Phase 6b — "tell me when someone gets home") were
the first thing in this project with a real argument for WAN exposure, so the
alternatives were weighed properly and **rejected**:
| Option | Verdict |
|---|---|
| **ntfy stays in the compose stack, reached over WireGuard** | **Chosen.** `identity → ntfy` is a container-to-container call that never reaches OPNsense at all — no firewall rule, no DMZ, no certificates. ntfy's Android app does instant delivery straight to a LAN server, so it works at home with nothing configured and away over the split tunnel above |
| ntfy in a DMZ, port-forwarded | Rejected. Buys only "notifications without the VPN connected," at the cost of a public listener, TLS/reverse-proxy upkeep, and mandatory ntfy auth — an exposed topic leaks *"the house is empty right now"*, which is materially worse than the usual leak of a self-hosted service |
| ntfy in a DMZ, reached from the smart-home VLAN by **NAT reflection** (hairpin) instead of an inter-VLAN rule | Rejected. It does avoid a new rule — the smart-home VLAN's existing blanket outbound-WAN allow already covers traffic to your own public IP — but it makes a purely internal notification path depend on the **ISP and external DNS being up**, so two devices metres apart stop talking during a WAN outage. CGNAT breaks it outright, and the rule it avoids (`smart-home → DMZ:443`, one host, one port, outbound) was the *safe* direction anyway; `DMZ → smart-home` stays denied either way |
**The household uses no Apple devices**, which removes the one genuine forcing
function for exposure: ntfy's iOS app can only be woken by Apple's APNs, so a
self-hosted server would need `upstream-base-url` relaying through ntfy.sh — WAN
egress through a third party, in an otherwise fully-local stack. Android needs none of
that. Revisit this table only if an Apple device ever joins the household.
## 3. VLAN segmentation — not a DMZ, a blast-radius boundary ## 3. VLAN segmentation — not a DMZ, a blast-radius boundary
A traditional DMZ exists to host something the WAN needs to reach. Nothing here A traditional DMZ exists to host something the WAN needs to reach. Nothing here
@ -110,7 +160,7 @@ column shows which are opt-in vs. always-on with the base stack.
| 8080 | zigbee2mqtt frontend | Yes | none by default — VERIFY you've set `frontend.auth` if this VLAN isn't fully trusted | | 8080 | zigbee2mqtt frontend | Yes | none by default — VERIFY you've set `frontend.auth` if this VLAN isn't fully trusted |
| 1880 | Node-RED | Yes | Node-RED's own login (if enabled) | | 1880 | Node-RED | Yes | Node-RED's own login (if enabled) |
| 3000 | Homepage dashboard | `ENABLE_HOMEPAGE` (default on) | none | | 3000 | Homepage dashboard | `ENABLE_HOMEPAGE` (default on) | none |
| 8090 | ntfy | `ENABLE_NTFY` (default on) | ntfy's own auth (if configured) — off by default, treat the topic name as the only barrier until you set one | | 8090 | ntfy | `ENABLE_NTFY` (default on) | ntfy's own auth (if configured) — off by default, treat the topic name as the only barrier until you set one. Carries `identity`'s arrival notifications ("X just got home"), so an exposed topic leaks when the house is empty — this is the reason §2.2 keeps it LAN-only. Reach it from a phone at `http://<container-host>:8090`, at home or over the split tunnel |
| 9000 | Portainer | `ENABLE_PORTAINER` (default on) | Portainer's own login | | 9000 | Portainer | `ENABLE_PORTAINER` (default on) | Portainer's own login |
| 445 | Gallery SMB share | `ENABLE_GALLERY_SMB` (default off) | SMB username/password you set (`GALLERY_SMB_USERNAME`/`PASSWORD`) | | 445 | Gallery SMB share | `ENABLE_GALLERY_SMB` (default off) | SMB username/password you set (`GALLERY_SMB_USERNAME`/`PASSWORD`) |
| 9925 | Mealie | `ENABLE_MEALIE` (default off) | Mealie's own login | | 9925 | Mealie | `ENABLE_MEALIE` (default off) | Mealie's own login |

View File

@ -885,6 +885,6 @@ These need a decision before their respective implementation steps can be built
34. **`identity`'s `DEPARTURE_GRACE_SECONDS` default (15 min) is an untuned guess at how much a real Private BLE Device setup flaps** (new, Phase 6b) — too low and one evening at home is recorded as several separate "visits," too high and a quick trip out never registers. The entire usefulness of the visit history and the co-presence view rests on this number, and nobody has watched a real BLE presence entity over a day to pick it. First thing to check once `GET /visits` has real data in it. 34. **`identity`'s `DEPARTURE_GRACE_SECONDS` default (15 min) is an untuned guess at how much a real Private BLE Device setup flaps** (new, Phase 6b) — too low and one evening at home is recorded as several separate "visits," too high and a quick trip out never registers. The entire usefulness of the visit history and the co-presence view rests on this number, and nobody has watched a real BLE presence entity over a day to pick it. First thing to check once `GET /visits` has real data in it.
35. **The device-rights HA wiring is a worked example, not a tested one, and this is the one place where getting it wrong opens a door** (new, Phase 6b) — `identity` only ever *answers* `GET /device-access`; an HA automation that calls `lock.unlock` outside the `condition: template` guard in `identity/README.md`'s example would unlock regardless of that answer, and nothing on this side can detect or prevent that. Same "documented shape, not verified" caveat as #21, with a materially worse failure mode. 35. **The device-rights HA wiring is a worked example, not a tested one, and this is the one place where getting it wrong opens a door** (new, Phase 6b) — `identity` only ever *answers* `GET /device-access`; an HA automation that calls `lock.unlock` outside the `condition: template` guard in `identity/README.md`'s example would unlock regardless of that answer, and nothing on this side can detect or prevent that. Same "documented shape, not verified" caveat as #21, with a materially worse failure mode.
36. **The admin panel has never been opened in a real browser** (new, Phase 6b) — every endpoint it calls is covered by API-level tests, but the page itself (the `<dialog>`-based person editor, the prune checkbox flow) has only been checked for syntax. `showModal()` needs a reasonably current browser; the door panel's Chromium qualifies, something older might not. 36. **The admin panel has never been opened in a real browser** (new, Phase 6b) — every endpoint it calls is covered by API-level tests, but the page itself (the `<dialog>`-based person editor, the prune checkbox flow) has only been checked for syntax. `showModal()` needs a reasonably current browser; the door panel's Chromium qualifies, something older might not.
37. **iOS cannot receive `identity`'s arrival notifications without WAN egress through a third party** (new, Phase 6b) — ntfy's iOS app depends on Apple's APNs, so a self-hosted server must relay via `upstream-base-url` (i.e. through ntfy.sh) for iOS devices to be woken, *even on the home Wi-Fi*. Android needs none of that (instant delivery straight to the LAN server), and a WireGuard-connected phone reaches the LAN server from anywhere. This is an Apple architecture constraint, not something this repo can engineer around, but it turns "who gets arrival notifications" into a household-platform decision that should be made consciously rather than discovered. Nothing here has been delivered to a real phone of either kind yet. 37. ~~iOS cannot receive `identity`'s arrival notifications without WAN egress through a third party~~**household decision made** (Phase 6b): **no Apple devices are used here**, which removes the only forcing function for exposing ntfy at all. ntfy therefore **stays LAN-only** — no DMZ, no port forward, no NAT-reflection hairpin; `identity → ntfy` is a container-to-container call that never reaches OPNsense, and remote delivery rides a **WireGuard split tunnel** routing only the smart-home VLAN. The full comparison (including why the hairpin variant, which avoids an inter-VLAN rule, was rejected for making an internal path depend on the ISP) is recorded in `docs/network-integration.md` §2.2 so it doesn't get re-litigated. **Still genuinely open**: nothing has been delivered to a real phone yet, and the split tunnel's `AllowedIPs` is the fiddly part — routing all of `192.168.0.0/16` would collide with typical café/hotel LANs and break the phone's connectivity there. Revisit §2.2 only if an Apple device ever joins the household.
38. **Both of `chores`' shipped container URLs pointed at `127.0.0.1` and could never have worked** (found while wiring Phase 6b's arrival notifications) — inside the `chores` container that address is the chores container itself, not the host. `NTFY_URL=http://127.0.0.1:8090` meant every nudge failed to send, and `IDENTITY_URL=http://127.0.0.1:8097` meant `_presence()` never reached identity at all, so **`chores` would have done nothing whatsoever** — and silently, since both failure paths log and continue by design (a deliberate never-block-on-a-dependency choice that here hid a total outage). Corrected in the template to `http://ntfy` and `http://identity:8097` (compose-bridge DNS; ntfy on its internal port 80, not the 8090 published to the LAN). **An already-deployed `/opt/smart-home/chores/chores.env` still has the old values** — templates are copied once at setup and never re-synced, so existing installs need this edited by hand. The broader lesson worth acting on: several services fail soft on an unreachable dependency, which means a misconfigured address produces silence rather than an error — nothing in this repo currently distinguishes "nothing to do" from "never reached the thing that would have told me". 38. **Both of `chores`' shipped container URLs pointed at `127.0.0.1` and could never have worked** (found while wiring Phase 6b's arrival notifications) — inside the `chores` container that address is the chores container itself, not the host. `NTFY_URL=http://127.0.0.1:8090` meant every nudge failed to send, and `IDENTITY_URL=http://127.0.0.1:8097` meant `_presence()` never reached identity at all, so **`chores` would have done nothing whatsoever** — and silently, since both failure paths log and continue by design (a deliberate never-block-on-a-dependency choice that here hid a total outage). Corrected in the template to `http://ntfy` and `http://identity:8097` (compose-bridge DNS; ntfy on its internal port 80, not the 8090 published to the LAN). **An already-deployed `/opt/smart-home/chores/chores.env` still has the old values** — templates are copied once at setup and never re-synced, so existing installs need this edited by hand. The broader lesson worth acting on: several services fail soft on an unreachable dependency, which means a misconfigured address produces silence rather than an error — nothing in this repo currently distinguishes "nothing to do" from "never reached the thing that would have told me".
39. **Nothing enforces that voice/TTS consumers actually read `speak_name` rather than `nickname`** (new, Phase 6b) — the field exists, is documented, and `chores/` uses it, but a future HA intent script that reaches for the friendlier-looking `nickname` would break the "the assistant uses real names" rule silently. Worth checking whenever a new consumer of `/presence` or `/resolve` is written. 39. **Nothing enforces that voice/TTS consumers actually read `speak_name` rather than `nickname`** (new, Phase 6b) — the field exists, is documented, and `chores/` uses it, but a future HA intent script that reaches for the friendlier-looking `nickname` would break the "the assistant uses real names" rule silently. Worth checking whenever a new consumer of `/presence` or `/resolve` is written.

View File

@ -233,22 +233,24 @@ through the door and then notice nothing happened.
### Getting the push while you're actually away ### Getting the push while you're actually away
**`identity` never touches the WAN.** It POSTs to the self-hosted ntfy this stack **`identity` never touches the WAN.** It POSTs to the self-hosted ntfy this stack
already runs for `chores` (`setup-container-host.sh`'s `ENABLE_NTFY`, published on already runs for `chores` (`setup-container-host.sh`'s `ENABLE_NTFY`) — one container
`:8090`) — one container to another over the LAN. Whether the message then reaches to another on the compose network, never even reaching the firewall.
your phone is a **network** question, and it's the one that decides whether this
feature is useful, since "did they get home while I was out?" is most of the point:
| Where your phone is | What's needed | Getting the message onto a phone is a **network** question, and it's settled in
|---|---| `docs/network-integration.md` §2.2: **ntfy stays LAN-only.** At home, ntfy's Android
| On the home Wi-Fi, Android | Nothing. The ntfy Android app holds a connection straight to your self-hosted server ("instant delivery") — no Google services, no WAN | app holds a connection straight to it ("instant delivery" — no Google services, no
| Away from home | **WireGuard back into your own network**`docs/network-integration.md`'s existing answer, unchanged. On the VPN your phone reaches LAN ntfy exactly as if it were at home | WAN). Away, a **WireGuard split tunnel** routing just the smart-home VLAN (§2.1)
| Away, without a VPN | Would need ntfy exposed to the WAN — **which `docs/network-integration.md` explicitly tells you not to do**, or ntfy's `upstream-base-url` relay, which puts a third party in the path | reaches it exactly as if you were sitting at home. No DMZ, no port forward, no
| iOS, anywhere | ntfy's iOS app depends on Apple's APNs, so a self-hosted server needs `upstream-base-url` set to relay through ntfy.sh — **WAN egress and a third party**, even on your own Wi-Fi. An Android-only or VPN-only household avoids this entirely | certificates, no firewall rule.
The honest summary: **Android + WireGuard keeps this fully local and works anywhere; That doc records why exposing ntfy in a DMZ — with or without NAT reflection — was
iOS cannot, because of Apple's push architecture, not because of anything here.** weighed and rejected, so the reasoning doesn't have to be re-derived later.
Neither path has been tested against a real phone from this repo — see the
verification list below. > Apple footnote, for completeness only: this household uses no Apple devices. If one
> ever joins, note that ntfy's iOS app can only be woken via Apple's APNs, so a
> self-hosted server would need `upstream-base-url` relaying through ntfy.sh — real WAN
> egress through a third party, even on your own Wi-Fi. That would reopen §2.2's
> decision. Android needs none of it.
## Pruning: the filter selects, the human deletes ## Pruning: the filter selects, the human deletes
@ -536,14 +538,12 @@ no way to send an `Authorization` header.
12. **Arrival pushes have never been delivered to a real phone from here** — the 12. **Arrival pushes have never been delivered to a real phone from here** — the
notification logic is covered by tests (subscription, opt-out, dedup, the notification logic is covered by tests (subscription, opt-out, dedup, the
startup-baseline guard), but nothing has been sent through a real ntfy server to a startup-baseline guard), but nothing has been sent through a real ntfy server to a
real device. The two things to check first: that the ntfy **Android** app's real device. Two things to check: that the ntfy **Android** app's instant delivery
instant-delivery against a self-hosted LAN server behaves as its docs describe, and against a self-hosted LAN server behaves as its docs describe, and that a phone on
that a **WireGuard**-connected phone reaches `NTFY_URL` while away (both per the the **WireGuard split tunnel** (`docs/network-integration.md` §2.1) actually reaches
table above). **iOS is expected not to work fully locally at all** — ntfy's iOS app `NTFY_URL` from outside. The split tunnel is the more likely of the two to need
needs APNs, so a self-hosted server has to relay via `upstream-base-url`, which is fiddling — get `AllowedIPs` wrong and it fails either silently or by breaking the
real WAN egress through a third party and directly contrary to this project's phone's connectivity on café Wi-Fi.
local-first default. That's an Apple constraint, not something this repo can fix,
but it should be a conscious choice rather than a surprise.
13. **The device-rights HA wiring above is a worked example, not a tested one** — same 13. **The device-rights HA wiring above is a worked example, not a tested one** — same
caveat as the voice-registration YAML. The consequence of getting it wrong is worse caveat as the voice-registration YAML. The consequence of getting it wrong is worse
here than anywhere else in this repo: an automation that calls `lock.unlock` here than anywhere else in this repo: an automation that calls `lock.unlock`