# 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 → \): 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://: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 | | 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. ## 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.