# Network integration — OPNsense, VLANs, and whether to port-forward anything

**Short answer: don't port-forward any of this to the WAN. Nothing in this repo
needs to be reachable from the public internet, and forwarding it would trade a
huge amount of security for very little.** If you want to reach the household
stack while away from home, the answer is a VPN back into your own network, not
opening ports on the firewall. The rest of this document explains why, and how to
segment things internally with VLANs so a compromised IoT device (cameras
especially — see Phase 5/20's "zero WAN egress" guardrail) can't reach the rest of
your network either.

This is guidance, not automation — **nothing under this repo touches your OPNsense
config**, same convention as this project's HA-integration catalog entries
(`docs/project-plan.md` §2): you apply this by hand in the OPNsense web UI.

## 1. Why no port forward

Every custom service this repo builds (`identity`, `pantry-vision`, `transit`,
`admin-canvas`, `digest-web`/`admin-web`/`pantry-web`/`identity-web`, `chores`,
`trash-calendar`) is a small stdlib-`http.server`/nginx process, bearer-token
gated where it needs to be, but **none of it was built or hardened with "reachable
from the raw internet" as a threat model** — no rate limiting, no WAF, no
DDoS/abuse handling, no security audit. Home Assistant, Grocy, Frigate, Node-RED,
Portainer, Netdata, and every other off-the-shelf piece of the stack are the same
story: capable, actively maintained software, but not written or configured here
with public exposure in mind. Port-forwarding any of them turns "a bug in one of
these" into "a bug reachable by the entire internet, scanning for it constantly."
None of them need to be reachable from the internet in the first place — every
real use case (checking the dashboard from work, registering a guest while
out, planning a trip) is solved just as well by a VPN.

## 2. Remote access: WireGuard, not forwarded HTTP ports

OPNsense ships a WireGuard implementation (Instances/Peers under VPN → WireGuard,
built in since 22.1 — no separate package needed on current OPNsense). Set up one
instance on the firewall and a peer per device you want remote access from (your
phone, a laptop). The only thing that ever needs forwarding is **one UDP port**
for WireGuard itself (commonly 51820, but pick anything free) — and that's a
categorically different exposure than forwarding this stack's HTTP services
directly: WireGuard silently drops any packet that isn't from an already-paired,
cryptographically-authenticated peer, so an internet-wide scanner sees nothing to
attack at all, versus a live HTTP endpoint answering every request that reaches it.
Once connected, your phone/laptop is (virtually) on your LAN/VLAN and reaches
`identity`/`pantry-vision`/HA/etc. at their normal LAN IPs — no per-service
forwarding, no bearer tokens exposed to the WAN, nothing else to configure network-
side per new service this repo adds later.

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.

### 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

A traditional DMZ exists to host something the WAN needs to reach. Nothing here
needs that (§1), so "do I need a DMZ" isn't really the right question — the useful
question is **whether an IoT device on this network should be able to reach your
laptop, NAS, or anything else you actually care about if it's ever compromised.**
Zigbee/RuView nodes, ESPHome BLE proxies, and especially the Tapo cameras (Phase
20 — IP cameras have a genuinely bad industry-wide security track record) are the
class of device this matters most for. The fix is a dedicated VLAN, not a DMZ.

A reasonable split for this project's device inventory:

| VLAN | What goes on it | Internet access | Reaches trusted LAN? |
|---|---|---|---|
| **Trusted LAN** (existing) | Your own laptops/desktops, phones (when not on the VPN) | Yes | — |
| **Smart-home VLAN** | The container host, kiosks/thin-clients/touch panels, RuView nodes, ESPHome BLE proxies, Zigbee coordinator | Yes (needed: Ollama model pulls if not fully pre-cached, container image pulls, NTP, and any digest-engine ingestion sources — mail/Telegram/Discord/news feeds/FRED/Stooq all reach out to real external APIs, see `digest-engine/README.md`) | No, by default — see below |
| **Camera VLAN** | Tapo pan/tilt cameras (Phase 20), the existing peephole cam (Phase 5) | **No — block entirely** | No |

Rules to actually write in OPNsense (Firewall → Rules → \<VLAN interface\>):

1. **Inter-VLAN default-deny.** OPNsense's default "allow all outbound, block
   inbound from other interfaces" behavior already gets you most of the way —
   just don't add a blanket "Smart-home VLAN → Trusted LAN, any/any" rule. Add
   narrow allow rules only for what's actually needed (e.g. if you want to browse
   to Home Assistant from a trusted-LAN laptop without a VPN, that's a Trusted
   LAN → Smart-home VLAN rule scoped to port 8123, not the reverse direction).
2. **Camera VLAN: block outbound to the WAN entirely**, and block Camera VLAN →
   every other VLAN except the one narrow path Frigate needs (RTSP/API from the
   container host's own interface, since Frigate is what actually pulls camera
   streams — the camera itself never needs to *initiate* anything toward the
   container host, only *receive* the stream pull, so even that path can often be
   a single allow rule scoped to the container host's IP and the camera's RTSP
   port). This is the same invariant this project's own testing checklist already
   states for the peephole cam (`docs/project-plan.md`'s Phase 5 guardrail, "are
   cameras verified to have zero WAN egress?") — it applies identically to every
   Tapo camera added in Phase 20.
3. **Smart-home VLAN → Trusted LAN: deny by default**, add narrow exceptions only
   if you have a real reason (e.g. Nextcloud/CalDAV living on a trusted-LAN
   machine rather than inside this stack — then just that one host:port, not the
   whole VLAN).
4. **WireGuard peers land wherever you configure the tunnel's allowed-IPs to
   route** — typically you'd route a WireGuard peer into the Smart-home VLAN
   (so your phone, once connected, can reach `identity`/`pantry-vision`/HA the
   same way it would sitting on that VLAN at home), not into Trusted LAN.

None of this is automated by anything in this repo — the container host's own
network interface/VLAN tagging, and every rule above, is configured by hand in
OPNsense, same "nothing under this repo builds the HA/network side" convention as
the rest of this project's integration catalog entries.

## 4. Full port inventory (LAN-only — none of these get forwarded, see §1)

Every port `setup-container-host.sh` publishes, current as of Phase 20. `ENABLE_*`
column shows which are opt-in vs. always-on with the base stack.

| Port | Service | Always on? | Auth |
|---|---|---|---|
| 8123 | Home Assistant | Yes | HA's own login |
| 1883 | Mosquitto (MQTT) | Yes | MQTT username/password (`mosquitto/config`) |
| 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) |
| 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. 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 |
| 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 |
| 5000, 8554, 8555 | Frigate (web UI, RTSP, WebRTC) | Yes (Phase 5+) | Frigate's own login for the web UI; RTSP/WebRTC unauthenticated on the LAN by Frigate's own design — this is exactly why the camera VLAN's isolation (§3) matters, not the port itself |
| 9283 | Grocy | Yes (Phase 1/7) | Grocy's own login |
| 8091 | digest-web | `ENABLE_DIGEST_ENGINE` | none — read-only rendered output, no credentials in it by design |
| 8094 | admin-web | `ENABLE_ADMIN_CANVAS` | none — same reasoning as digest-web |
| 8095 | pantry-vision | `ENABLE_PANTRY_VISION` | bearer token (`PANTRY_VISION_TOKEN`) |
| 8096 | pantry-web | `ENABLE_PANTRY_VISION` | none |
| 8097 | identity | `ENABLE_IDENTITY` | bearer token (`IDENTITY_TOKEN`) |
| 8098 | identity-web | `ENABLE_IDENTITY` | none |
| 80, 443 | Caddy reverse proxy | `ENABLE_PROXY` (default off) | none itself — it fronts `identity`/`identity-web`, whose own bearer token still applies. **This is the one service whose whole job is TLS**: the admin panel's URL carries `IDENTITY_TOKEN`, and on plain HTTP that credential is readable by anything on this VLAN. Still LAN-only — HTTPS here is about the local wire, not about exposure; §1's no-port-forward rule is unchanged |
| 8099 | transit | `ENABLE_TRANSIT` | bearer token (`TRANSIT_TOKEN`) |
| 8100 | OpenTripPlanner (OTP) | `ENABLE_TRIP_PLANNING` | none — OTP has no built-in auth; this is why it's only ever called server-side by `transit`, never exposed to a kiosk browser directly |
| ~8095 (VERIFY) | Music Assistant | `ENABLE_MUSIC_ASSISTANT` | Music Assistant's own auth (if configured) — **also flagged as an assumed/unverified port that collides with `PANTRY_VISION_PORT`, see `docs/project-plan.md` open decision #31** — resolve the actual port before relying on this table for it |

`admin-canvas` (port 8092) is deliberately **not** in this table — it has no
`ports:` mapping in the generated compose file at all, reachable only from other
containers on the compose network (i.e. Home Assistant), by design.

### On the LLM host (a separate machine — `hosts/llm-host/`)

| Port | Service | Auth |
|---|---|---|
| 11434 | Ollama | **None whatsoever.** Not a token, not a password — and the API is not read-only: it can pull and **delete** models as well as generate. Anyone who can reach this port can do all of it |

That row is the strongest case in this table for §3's VLAN segmentation and §1's no-
port-forward rule. It is also why the LLM host belongs on the smart-home VLAN rather
than the trusted LAN: it accepts unauthenticated instructions from anything that can
reach it, which is exactly the property you want blast-radius boundaries around.

## 5. What's still unverified here

This entire document was written against `setup-container-host.sh`'s current
`ports:` mappings, not tested against a real OPNsense instance — the VLAN/firewall
rule guidance in §3 is standard practice for this class of network, not something
run against your specific hardware/interface names. Confirm your OPNsense version
actually ships WireGuard where you expect (VPN menu) before planning around it,
and treat the port table in §4 as something to re-check against a live
`docker compose ps` after deployment, not a permanent guarantee — a new
`ENABLE_*` service added to the script later needs a new row here too.
