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 (Seafile media sync): the phone tier is otherwise locked to Jellyfin+print+DNS (a lost phone should not reach files). To let the Seafile Android app auto-upload the camera roll, the phone tier gets one targeted automation rule WG_PHONES → 10.20.20.6 tcp/443 (the apps-proxy that fronts files.${domain}) — 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 Seafile session reaches synced media until its WG peer + Seafile 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.

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 — but they sit in different zones:

  • 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 → WORKSTATIONS (KODI_BOX = 10.20.40.101). The Kodi box is a trusted stationary client, so it lives in WORKSTATIONS and reaches media via the WS rules (runbook 06 §WS): JELLYFIN:8096 + TVHEADEND:9981/9982 (rule 2a), NFS media on 2049 (rule 2), and its Invidious add-on on the lone host-scoped KODI_BOX → INVIDIOUS:3000 WS→DMZ exception (rule 2c).

Control from a phone (Kore app / "play to Kodi") works at home over the trusted wifi; nothing in this runbook applies to either device (they're not WG peers).

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.

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).