Skip to content

Small office gateway

Three roles, three postures, three failure modes, one gateway. Engineering gets direct egress for speed; finance is locked behind a single VPN with a static exit IP; HR’s HRIS traffic goes through a bridge that’s invisible to SNI inspection. Every config change is in the audit log; every kill-switch trip pages the admin.

A small startup (~10 employees, two roles formalised) wants a network gateway that:

  • Routes engineering traffic direct for speed.
  • Routes finance traffic only through a kill-switched VPN tunnel so client-data never touches the open WAN.
  • Routes HR traffic through a bridge so sensitive HRIS traffic isn’t even discoverable by IP/SNI inspection.
  • Alerts the office admin on any policy violation, leak, or tunnel issue.
  • Keeps a complete audit trail of every config change for the auditor.

Either:

  • A small fanless mini-PC (4-core Intel/AMD, 4 GB RAM, 64 GB SSD), linux/amd64, install profile vps, or
  • A Raspberry Pi 4 (8 GB) in primary mode if you want WiFi AP too.

We’ll assume the mini-PC, sitting between the ISP modem and a managed L2 switch with VLAN support.

Terminal window
sudo ./scripts/install-gateway.sh --profile vps
ISP modem ── eth0 ── [mini-PC] ── eth1 ── managed switch
├── VLAN 10 engineering
├── VLAN 20 finance
├── VLAN 30 HR
└── VLAN 40 guests

The switch tags ports per role. The Gateway sees each VLAN as a separate L3 interface and applies different policies per source interface.

  • wg-finance — single tunnel to a privacy-focused provider with verified no-logs policy. Static IP at the exit (the bookkeeping SaaS allowlists it).
  • bridge-hr — bridge node on a VPS the company controls. Domains: hris.vendor.example, *.hris.vendor.example.

POST each as a JSON body to https://gateway.lan:8080/api/v1/privacy-classes. The field shapes:

{ "name": "engineering", "force_dns": true,
"ipv6_policy": "allow", "quic_policy": "allow",
"block_webrtc": false, "allow_direct_fallback": true } // direct egress is expected
{ "name": "finance", "force_dns": true,
"ipv6_policy": "block", "quic_policy": "block",
"block_webrtc": true, "allow_direct_fallback": false } // fail-closed: never direct
{ "name": "hr", "force_dns": true,
"ipv6_policy": "block", "quic_policy": "block",
"block_webrtc": true, "allow_direct_fallback": false }

System-wide under Settings → Privacy, turn on block_doh and set DNS mode to paranoid (drops TCP/853 DoT) so no client can sidestep the integrated resolver.

Each role is one VLAN-keyed policy. Multiple source_type: vlan policies on the same gateway, distinguished by source_value (the VLAN ID):

Terminal window
# Engineering — direct egress, fail-open
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"engineering","priority":100,"enabled":true,
"source_type":"vlan","source_value":"10",
"hops":[{"type":"interface","target":"eth0"}],
"fail_policy":"fail-open"
}'
# Finance — strict VPN, fail-closed (kill-switch engaged on tunnel down)
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"finance","priority":90,"enabled":true,
"source_type":"vlan","source_value":"20",
"hops":[{"type":"vpn","target":"wg-finance"}],
"fail_policy":"fail-closed"
}'
# HR's bridge-routed destinations are handled by bridge-hr automatically — the
# bridge subsystem auto-creates per-domain routing policies at priority 200
# pointing at the bridge's local SOCKS5 port. Just keep `hris.vendor.example`
# and `*.hris.vendor.example` in the bridge's `domains` list.
# Drop the rest of HR's traffic so it can't reach anything else from VLAN 30:
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"hr-drop-other","priority":80,"enabled":true,
"source_type":"vlan","source_value":"30",
"hops":[{"type":"drop"}],
"fail_policy":"fail-closed"
}'
# Guests — direct egress, fail-open (no fancy posture)
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"guests","priority":70,"enabled":true,
"source_type":"vlan","source_value":"40",
"hops":[{"type":"interface","target":"eth0"}],
"fail_policy":"fail-open"
}'
curl -X POST https://gateway.lan:8080/api/v1/routing/policies/apply \
-H "Authorization: Bearer $API_KEY"

The 17 built-in alert rules already cover everything this deployment cares about. Confirm the relevant ones are enabled and wire up the dispatch channels.

Terminal window
# Rules to leave enabled (default) for this deployment:
# tunnel-down, killswitch-engaged, dns-bypass, direct-fallback,
# drift-detected, backup_failed, leak-detected, cert-expiry-critical
# Dispatch destinations — single webhook + Telegram, applied to every fire
curl -X PUT https://gateway.lan:8080/api/v1/settings \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://hooks.example.invalid/admin",
"telegram_bot_token": "REDACTED",
"telegram_chat_id": "@office-bot"
}'
# Optional: disable a noisy rule the office doesn't care about
curl -X PUT https://gateway.lan:8080/api/v1/alerts/rules/new-device \
-H "Authorization: Bearer $API_KEY" \
-d '{"enabled": false}'
  • Audit trail captures every config change with who, what, when, before/after, and source IP. Set log.retention_days: 365 in the bootstrap config to keep a year (default is 90).
  • Daily backup pushed via SCP to a separate server (sanitized plaintext by default — for an encrypted variant with secrets, use POST /api/v1/backup/export-encrypted ad-hoc). Restore is a single gwctl backup import <file> away for plaintext; encrypted requires external decrypt first.
  • leak-matrix snapshot is part of the monthly compliance pack — proves what would have happened to traffic under any tunnel failure.
Scenario Result
Engineer streams Netflix Direct egress. Fast. No VPN overhead.
Finance opens bookkeeping SaaS Traffic goes through wg-finance — the SaaS sees the tunnel’s static IP, matches its allowlist.
wg-finance handshake stale Finance VLAN goes dark (fail-closed). Webhook fires within seconds. Admin gets paged.
HR opens HRIS Traffic goes through bridge-hr over TLS to a decoy SNI. Observer sees only ordinary HTTPS to the decoy site.
New employee joins Add device to the right VLAN port on the switch; policy applies automatically — no per-device config.
Auditor asks “what happens if VPN fails?” Show them gwctl leak-matrix. The ✗ DROP cell in the finance row is the proof.
  • Per-VLAN routing on a generic firewall (pfSense, OPNsense) requires manual nftables / pf rules per tunnel — The Gateway compiles them from policy objects.
  • Generic firewalls don’t reference-count their kill-switch — restarting a tunnel can produce a brief window of leakage.
  • Generic firewalls don’t have a structured audit log — you get syslog, you write the parser yourself.
  • Generic firewalls don’t have a leak checker that proves your posture works.

After the VLAN-keyed setup is live:

  1. Capture an audit baseline. Export the audit log monthly to a write-once destination so a compromised admin can’t quietly rewrite history. See Operations → Monthly.
  2. Schedule a quarterly restore test. A backup that doesn’t restore is just a file. See Backup → Restore.
  3. Tighten on-call. Decide which alerts should page vs notify so finance going dark wakes someone before clients notice. See On-call signals.
  4. Federate to a second site if you have one — the same VLAN→tunnel mapping applies anywhere with the same switch port topology. See Federation.
  • Routing policiessource_interface matching is what binds VLAN-tagged ports to roles.
  • Alerts & monitoring — webhook + Telegram setup for the compliance signals shown here.
  • Backup — scheduled encrypted exports with remote SFTP push.