193 lines
14 KiB
Markdown
193 lines
14 KiB
Markdown
# 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 |
|
|
| 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.
|