Skip to content

Runbook 07 — Road-warrior WireGuard (three device tiers via groups)

Goal: one WireGuard dial-in server with full-tunnel obfuscation (a connected device's traffic egresses home → ProtonVPN) and three access tiers by device class, expressed with range-based group aliases so adding a device later is zero rule edits. The endpoint is the WAN VIP ${edge.wg_admin_public}:51820 (.69) — the single deliberate inbound exception on the Comcast link (recovery path; must not depend on a VPS).

Prereqs: runbook 06 validated. Generate new keypairs for the server and each device (don't reuse keys; ideally generate on the device so the private key never leaves it).


The three tiers

Tier Group alias Range Internal access Client tunnel
admin WG_ADMINS 10.20.80.16/28 anything (incl. MGMT + firewall) — the only path into MGMT split 10.20.10.0/24 (MGMT only)
laptop WG_LAPTOPS 10.20.80.48/28 = WORKSTATIONS (SERVERS/NFS/SSO, IOT cast+print; blocked from MGMT/DMZ) + SSH(22) to SERVERS (fleet admin from the roaming laptop) full 0.0.0.0/0
phone WG_PHONES 10.20.80.32/28 minimal: Jellyfin, printers, DNS full 0.0.0.0/0

Why split tiers: a phone is the device most likely to walk off and literally can't use NFS/LDAP/Kerberos — so it gets none of it (pure attack-surface reduction; a lost phone = "someone can watch Jellyfin", not "someone has my home directory"). The laptop you manage and trust like a wired desk → full workstation. Admin is gated to MGMT and kept split-tunnel so it stays a reliable recovery path (you keep your local internet even if home's Proton is down).

Admin client AllowedIPs = MGMT only (10.20.10.0/24), NOT the whole 10.20.0.0/16. The firewall tier still permits admin → anything; this is purely what the device routes into the admin tunnel. Scoping it to MGMT lets the admin and laptop tunnels run concurrently (a common setup): the admin tunnel owns only MGMT, while everything else — SERVERS/NFS/SSO, IOT, and general internet — rides the laptop full-tunnel. With a /16 here the admin route is more specific than the laptop's 0.0.0.0/0 and silently pulls all internal traffic (incl. NFS) out the admin peer's source IP, which breaks the per-peer source the fileserver export and tier rules expect. MGMT is the admin tunnel's only unique reach anyway (everything else has another path), so MGMT-only loses nothing in practice.

Obfuscation and access are independent knobs. Full-tunnel (0.0.0.0/0 on the client) routes all the device's internet through home → Proton for privacy; the tier only governs which internal hosts the firewall lets it reach. So the phone is fully obfuscated and locked to Jellyfin+printer — no trade-off.

Laptop → SERVERS SSH(22): so a roaming laptop can administer the fleet without bringing up the admin tunnel, the laptop tier gets a firewall automation rule WG_LAPTOPS → SERVERS_NET tcp/22 (opt12/WGRW). Like the other API-added OPNsense automation rules it's codified in gitprovision/opnsense/fw-automation-rules.json + scripts/opnsense-fw.py apply (idempotent) — so it survives an OPNsense rebuild. It passes ahead of the classic block WG_LAPTOPS → RFC1918. (ICMP/ping stays blocked — only :22 is opened.)

Phone → apps-proxy :443 (phone media sync): the phone tier is otherwise locked to Jellyfin+print+DNS (a lost phone should not reach files). To let the phone's camera-roll sync client reach its server, the phone tier gets one targeted automation rule WG_PHONES → 10.20.20.6 tcp/443 (the apps-proxy) — same codified fw-automation-rules.json + opnsense-fw.py apply mechanism, passing ahead of the classic block WG_PHONES → RFC1918. This is a deliberate widening of the minimal-phone posture (a stolen phone with a live session reaches synced media until its WG peer + that session are revoked) — accepted as the cost of phone media backup. Only :443 to the one proxy IP is opened; the rest of the phone tier stays minimal. (Originally cut for the Seafile Android app; Seafile was retired 2026-08-31 and the rule stays — it is a grant to the whole apps-proxy plane, which still fronts sync.${domain}/shelf and dav.${domain}. Its description field in fw-automation-rules.json still names Seafile and is deliberately not reworded: opnsense-fw.py matches live rules by exact description, so renaming one creates a second rule and orphans the first.) (Companion rule seq 241, 2026-08-09: WG_PHONES → 10.20.20.7:443 — when the invidious override moved to the bound front door (rb06 §IOT 2b), phones would otherwise have lost that one name; same posture, one more single-IP:443 grant.)

Managing a roaming laptop over WG: reach it at its admin-peer tunnel IP (10.20.80.16), not the laptop peer — from a MGMT control node the laptop routes replies out wg-admin, so only the admin-peer source survives WireGuard's cryptokey routing (the laptop-peer path is asymmetric). inventory.ini uses .16 for atomic.


Addressing

Road-warrior subnet 10.20.80.0/24, server .1. Carve it into per-tier /28s so a new device just needs an IP in the right range — the group alias (a CIDR) already covers it:

Range Tier Example
.16/28 (.16–.31) admins admin .16
.32/28 (.32–.47) phones phone .32
.48/28 (.48–.63) laptops laptop .48

WireGuard pins each peer to its own /32, so a phone can't spoof a source in the admin range — the tiers are enforced, not just naming conventions.


1. Group aliases — Firewall → Aliases

  • WG_ADMINSNetwork(s)10.20.80.16/28
  • WG_PHONESNetwork(s)10.20.80.32/28
  • WG_LAPTOPSNetwork(s)10.20.80.48/28
  • PRINTERSHost(s) — your printer IP(s), e.g. 10.20.60.20
  • FW_PRINT_PORTSPort(s)631 9100 515 (IPP / raw-JetDirect / LPD)

2. WireGuard server (instance)

VPN → WireGuard → Instances → +: - Name roadwarrior · Private key (Generate) · Listen port 51820 - Tunnel address 10.20.80.1/24 · Disable routes: OFF (it serves the peer subnet)

Enable WireGuard (master toggle). Interfaces → Assignments: assign the wg interface → name WGRW, enable (IPv4 config = None).

3. Peers (one per device) — VPN → WireGuard → Peers

  • Name (admin / phone / laptop / …) · Public key = the device's pubkey
  • Allowed IPs = the device's /32 in the correct tier range (10.20.80.16 admin, .32 phone, .48 laptop). This server-side /32 is what the firewall matches against the group alias. (Don't confuse it with the client-side AllowedIPs in §6, which is what the device routes into the tunnel.)

4. WAN endpoint — the .69 VIP

  • Interfaces → Virtual IPs → +: mode IP Alias, interface WAN, address ${edge.wg_admin_public}/32 (your routed public .69; the ISP routes the block to OPNsense, so it's a /32). .65–.68 stay reserved.
  • Firewall → Rules → WAN → +: pass · proto UDP · src any · dst ${edge.wg_admin_public} · dst port 51820. The only inbound on the Comcast link.

5. WGRW firewall rules (per tier, by group alias)

Firewall → Rules → WGRW, top-down. The tier ranges are disjoint and WG pins each peer's source, so order between tiers doesn't matter; order within a tier does (specific allows → block RFC1918 → internet → kill-switch).

admin 1. Pass: src WG_ADMINSany — full admin (incl. MGMT + firewall GUI/SSH).

laptops (= WORKSTATIONS) 2. Pass TCP/UDP: src WG_LAPTOPSfw ports FW_PORTS_STD (DNS/NTP/GUI) 3. Pass: src WG_LAPTOPSSERVERS_NET ports IPA_PORTS (identity/NFS) — default gw 4. Pass: src WG_LAPTOPSIOT net (cast/print) — default gw 5. Block: src WG_LAPTOPSRFC1918 (isolate MGMT/DMZ/other) 6. Block: TCP/UDP 853 (DoT) 7. Pass: src WG_LAPTOPSVPN_BYPASS — gw WAN_GW 8. Pass: src WG_LAPTOPSany — gw PROTONVPN_GRP 9. Block: src WG_LAPTOPSany (kill-switch)

phones (minimal) 10. Pass TCP: src WG_PHONESJELLYFIN port 8096 11. Pass TCP: src WG_PHONESPRINTERS port FW_PRINT_PORTS (IPP 631 / raw 9100 / LPD 515) 12. Pass TCP/UDP: src WG_PHONESfw port 53 (DNS) 13. Block: src WG_PHONESRFC1918 (everything else internal) 14. Pass: src WG_PHONESVPN_BYPASS — gw WAN_GW 15. Pass: src WG_PHONESany — gw PROTONVPN_GRP 16. Block: src WG_PHONESany (kill-switch)

block RFC1918 before the internet rule on both client tiers is what keeps "→ any via Proton" matching only real internet, not internal hosts (same pattern as the zone rules in runbook 06). The group name PROTONVPN_GRP is whatever yours is actually called — the live build uses PROTONVPN_LB (runbook 05).

6. Client configs

Endpoint = ${edge.wg_admin_public}:51820, DNS = 10.20.80.1 (firewall Unbound), PersistentKeepalive = 25 on all. Differ only by Address + AllowedIPs:

Tier Address Client AllowedIPs
laptop 10.20.80.48/32 0.0.0.0/0 (full tunnel — obfuscation)
phone 10.20.80.32/32 0.0.0.0/0 (full tunnel — always-on)
admin 10.20.80.16/32 10.20.10.0/24 (split — MGMT only; keeps local internet, and composes with the laptop tunnel when both are up — see §The three tiers)

7. Printer (reservation + the discovery caveat)

The printer is a DHCP reservation in the IOT static range (10.20.60.2–.99): its MAC → 10.20.60.20, and that IP goes in PRINTERS. Reservation (not static-on-device) because it's a leaf you want centrally managed + DNS-registered, and it's not rebuild-critical — see the static-vs-reservation rule in runbook 04.

mDNS discovery does not cross the tunnel, so a phone on the tunnel won't auto-find the printer. Add it by IP on the phone (10.20.60.20). Print jobs are unicast to the printer on FW_PRINT_PORTS (IPP 631 / raw 9100 / LPD 515 — rule 11), so whatever add-by-IP method the client picks works. (iOS AirPrint is discovery-only with no add-by-IP — use a third-party print app there. Android adds by IP natively.)

Adding devices later (the range payoff)

  • New phone → create a WG peer with any IP in .33–.47. Done — it inherits the phone tier; no rule or alias edit.
  • New laptop.49–.63. New admin device.17–.31.
  • New printer → add its IP to PRINTERS + a DHCP reservation; phones → PRINTERS:FW_PRINT_PORTS already covers it.

Not road-warrior: home media clients (Roku, LibreELEC)

Roku and LibreELEC/Kodi are stationary home media clients, not road-warrior peers — and both live in IOT:

  • Roku → IOT. It plays via Jellyfin → already covered by the IOT net → JELLYFIN:8096 rule, plus IOT internet (Proton) for channel updates/metadata. Treat it as untrusted; IOT isolation is exactly right.
  • LibreELEC/Kodi → IOT (KODI_BOX = 10.20.60.100, Kea-reserved). Stationary, but it runs untrusted third-party plugins — and WORKSTATIONS membership grants effective read of every sec=sys NFS home, so it fails the trust test regardless of being stationary (decision 2026-08-02). It reaches media via the IOT rules (runbook 06 §IOT): JELLYFIN:8096 (rule 2) + the host-scoped KODI_BOX → TVHEADEND:9981-9982 Live-TV exception (rule 2a) + the host-scoped KODI_BOX → INVIDIOUS:3000 backend-direct exception (rule 2b; the add-on targets http://10.20.30.70:3000). Deliberately no NFS and no apps-proxy path (the name-based Tier-2 plane stays unreachable).

Control from a phone (Kore app): phones live in GUEST with always-on WG, so Kore reaches Kodi over the road-warrior tunnel — the WG_PHONES → KODI_BOX:8080 automation rule (seq 542, TCP JSON-RPC). In Kore, add the host manually (10.20.60.100, port 8080, the Kodi web-interface user/pass) — mDNS does not cross WireGuard, so auto-discovery will never find it. On the box: Settings → Services → Control → Allow remote control via HTTP + set a username/password (and Allow remote control from applications on other systems). EventServer (UDP 9777) is NOT opened — Kore's remote works fully over JSON-RPC; open it later only if something genuinely needs it.

Firewall sshd hardening (key-only + MGMT-scoped)

There is no public SSH — the only WAN inbound is WG (.69:51820); you SSH the firewall over the admin tunnel (or on-LAN) at its internal IP 10.20.10.1. Harden the firewall's own sshd (System → Settings → Administration, or config.xml <system><ssh> + configctl openssh restart):

  • Password login OFF (PasswordAuthentication no) — key-only. (Default here.)
  • Login group admins / wheel only.
  • Listen Interfaces = MGMT only (<interfaces>opt1</interfaces>): sshd binds just 10.20.10.1:22 instead of 0.0.0.0 + [::], so it no longer listens on SERVERS/DMZ/WORKSTATIONS/IOT/GUEST or IPv6. The WG-admin peer still reaches it because its packets are destined to 10.20.10.1 (routed in over the tunnel). Verify: sshd -T | grep listenaddress shows only 10.20.10.1:22 (+ loopback).
  • Root login: OPNsense's shell-admin account is root, and there is no non-root admin user, so root SSH stays enabled (key-only). "No root" would need a non-root admin + os-sudo; better realized later via the fleet SSH-CA (trust the user CA on OPNsense, map a cert principal) for short-lived, auditable, no-static-key access — deferred to the SSH-CA phase.

Fallback: reaching a Tier-2 UI with no VPN session (scripts/lab-browse.sh)

The VPN is the way in. But there are moments when it is not available and a web UI is still needed — a laptop whose WireGuard peer has not been re-added yet, a borrowed machine, a client that cannot bring the tunnel up while you are debugging the tunnel. What is usually still available in those moments is SSH to some host that is already inside.

scripts/lab-browse.sh borrows that host's DNS and routing for exactly one browser session:

scripts/lab-browse.sh <ssh-host> <url>

<ssh-host> is whatever your own ssh config already understands; the script adds nothing to it and hardcodes nothing about this deployment. It opens a SOCKS tunnel, launches a throwaway browser profile pointed through it, and tears the tunnel down when the browser exits — including on Ctrl-C, on a kill, and when the terminal closes.

This is a fallback, not a second access path. It grants exactly what the SSH credential already grants, for as long as a browser window is open, and it is not a substitute for the per-peer VPN rules above — those are what the access model is actually built on.

Three details that are easy to get wrong by hand and are the reason this is a script:

  • --user-data-dir is load-bearing. With a Chromium already running, a fresh invocation hands the URL to the existing process and silently ignores every other flag — an unproxied tab, no error, no tunnel in the path. A separate profile forces a separate instance.
  • --host-resolver-rules stops the browser resolving the name locally first, which off the lab both fails and leaks the query to whatever resolver the machine happens to use. With it, the name is resolved at the far end of the tunnel.
  • A random high port, and ExitOnForwardFailure=yes. A fixed port collides with anything already holding it; without the option, a collision yields a live SSH session with no forwarding — a failure that looks exactly like success.

The one gap, stated plainly: SIGKILL on the script leaves the tunnel up, because no trap can run. Sweep one up with pkill -f 'ssh -N -D'. Everything short of that is covered.

Validation

  • [ ] firewall sshd: sshd -T | grep -E 'listenaddress|passwordauth' → only 10.20.10.1:22 (+ loopback), passwordauthentication no.
  • [ ] VPN → WireGuard → Status: roadwarrior up; admin/phone/laptop handshake and get their /32.
  • [ ] phone (full tunnel): ipinfo.io shows Proton; reaches Jellyfin + the printer-by-IP; cannot reach SERVERS broadly, MGMT, or DMZ.
  • [ ] laptop (full tunnel): full WORKSTATIONS access (NFS/SSO + IOT) + Proton internet; blocked from MGMT/DMZ.
  • [ ] admin (split): reaches MGMT + firewall GUI/SSH; keeps local internet.
  • [ ] External scan of ${edge.wg_admin_public}: only UDP 51820 responds (and silently — WG gives nothing to an unauthenticated scanner); nothing else inbound.
  • [ ] From INSIDE the house (not just cellular): a peer on home wifi completes a handshake and passes traffic. See the trap below — this is the case that breaks silently.

Troubleshooting: tunnel connects but passes nothing (rx: 0)

Client says connected, tx climbs, rx stays 0, and on the firewall the peer is stale with an old handshake. If it works on cellular but not on home wifi, it is the endpoint hairpin — not the peer, the key, or the rules:

  1. Firewall → Diagnostics → Log, filter 51820. The signature is a block every ~5 s (the WG retry cadence) from the firewall's private WAN transit address to ${edge.wg_admin_public}:51820, labelled "Block private networks from WAN".
  2. Cause: ${edge.wg_admin_public} is in VPN_BYPASS (usually because the whole public /29 was added rather than individual addresses). Bypass routes an internal peer's handshake out WAN, outbound NAT rewrites the source to the private transit IP, and since .69 is a VIP on that same interface it loops back in and hits anti-spoofing.
  3. Fix: remove the WG endpoint from VPN_BYPASS (list the bypassed addresses individually — meet + the edges). It then egresses via Proton and re-enters from a public IP.
  4. Confirm: the peer's endpoint in VPN → WireGuard → Status shows a Proton exit address, handshake age drops to seconds, rx climbs, and the 51820 blocks stop.

Peers away from home are unaffected throughout, which is why this hides: it will not reproduce from ctrl or over cellular. Full write-up: runbook 05 §6 + runbook 48.

Then → runbook 08 (mDNS reflector).