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 whole10.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/16here the admin route is more specific than the laptop's0.0.0.0/0and 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 git —provision/opnsense/fw-automation-rules.json+scripts/opnsense-fw.py apply(idempotent) — so it survives an OPNsense rebuild. It passes ahead of the classicblock 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 frontsfiles.${domain}) — same codifiedfw-automation-rules.json+opnsense-fw.py applymechanism, passing ahead of the classicblock 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 outwg-admin, so only the admin-peer source survives WireGuard's cryptokey routing (the laptop-peer path is asymmetric).inventory.iniuses.16foratomic.
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_ADMINS— Network(s) —10.20.80.16/28WG_PHONES— Network(s) —10.20.80.32/28WG_LAPTOPS— Network(s) —10.20.80.48/28PRINTERS— Host(s) — your printer IP(s), e.g.10.20.60.20FW_PRINT_PORTS— Port(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
/32in the correct tier range (10.20.80.16admin,.32phone,.48laptop). This server-side/32is what the firewall matches against the group alias. (Don't confuse it with the client-sideAllowedIPsin §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–.68stay reserved. - Firewall → Rules → WAN → +: pass · proto UDP · src
any· dst${edge.wg_admin_public}· dst port51820. 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_ADMINS → any — full admin (incl. MGMT + firewall GUI/SSH).
laptops (= WORKSTATIONS)
2. Pass TCP/UDP: src WG_LAPTOPS → fw ports FW_PORTS_STD (DNS/NTP/GUI)
3. Pass: src WG_LAPTOPS → SERVERS_NET ports IPA_PORTS (identity/NFS) — default gw
4. Pass: src WG_LAPTOPS → IOT net (cast/print) — default gw
5. Block: src WG_LAPTOPS → RFC1918 (isolate MGMT/DMZ/other)
6. Block: TCP/UDP 853 (DoT)
7. Pass: src WG_LAPTOPS → VPN_BYPASS — gw WAN_GW
8. Pass: src WG_LAPTOPS → any — gw PROTONVPN_GRP
9. Block: src WG_LAPTOPS → any (kill-switch)
phones (minimal)
10. Pass TCP: src WG_PHONES → JELLYFIN port 8096
11. Pass TCP: src WG_PHONES → PRINTERS port FW_PRINT_PORTS (IPP 631 / raw 9100 / LPD 515)
12. Pass TCP/UDP: src WG_PHONES → fw port 53 (DNS)
13. Block: src WG_PHONES → RFC1918 (everything else internal)
14. Pass: src WG_PHONES → VPN_BYPASS — gw WAN_GW
15. Pass: src WG_PHONES → any — gw PROTONVPN_GRP
16. Block: src WG_PHONES → any (kill-switch)
block RFC1918before 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 namePROTONVPN_GRPis whatever yours is actually called — the live build usesPROTONVPN_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_PORTSalready 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:8096rule, 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 on2049(rule 2), and its Invidious add-on on the lone host-scopedKODI_BOX → INVIDIOUS:3000WS→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/wheelonly. - Listen Interfaces = MGMT only (
<interfaces>opt1</interfaces>): sshd binds just10.20.10.1:22instead of0.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 to10.20.10.1(routed in over the tunnel). Verify:sshd -T | grep listenaddressshows only10.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'→ only10.20.10.1:22(+ loopback),passwordauthentication no. - [ ] VPN → WireGuard → Status:
roadwarriorup; admin/phone/laptop handshake and get their/32. - [ ] phone (full tunnel):
ipinfo.ioshows 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:
- 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". - Cause:
${edge.wg_admin_public}is inVPN_BYPASS(usually because the whole public/29was 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.69is a VIP on that same interface it loops back in and hits anti-spoofing. - 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. - Confirm: the peer's endpoint in VPN → WireGuard → Status shows a Proton exit address,
handshake age drops to seconds,
rxclimbs, and the51820blocks 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).