From 2658d7403d5686812136b8f4dcb815fbcb14d9e7 Mon Sep 17 00:00:00 2001 From: Chewbaccalakis Date: Thu, 16 Jul 2026 11:44:08 -0700 Subject: [PATCH] travel router --- README.md | 1 + ansible/group_vars/vpnhub.example.yml | 10 +++++ ansible/site.yml | 18 +++++--- ansible/templates/env.j2 | 1 + docker-compose.yml | 4 +- docs/SITE-SIDE.md | 11 +++-- docs/TRAVEL-ROUTERS.md | 65 +++++++++++++++++++++++++++ host/rules.v4.template | 4 ++ host/wg0.conf.example | 11 ++++- technitium/configure.sh | 21 ++++++++- 10 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 docs/TRAVEL-ROUTERS.md diff --git a/README.md b/README.md index d5407a2..e1bcad5 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Oswald↔Trochalakis tunnel — this box never carries it. | Oswald LAN | `192.168.7.0/24` | AH-OSWLDC | | WG transfer | `172.31.255.0/24` | hub .1, Troch .2, Oswald .3 | | OpenVPN clients | `172.31.8.0/24` | routed (not NATed) to both sites | +| WG travel routers | `172.31.9.0/24` | GL.iNet roaming peers, hub .1 ([docs](docs/TRAVEL-ROUTERS.md)) | Public endpoint: `vpn.atlashorizon.net` — only 22/tcp (SSH), 51820/udp (WireGuard) and 1194/udp (OpenVPN) are open. RADIUS binds loopback only; diff --git a/ansible/group_vars/vpnhub.example.yml b/ansible/group_vars/vpnhub.example.yml index 3650f93..ecf821d 100644 --- a/ansible/group_vars/vpnhub.example.yml +++ b/ansible/group_vars/vpnhub.example.yml @@ -39,6 +39,16 @@ technitium_admin_password: "" # "" to disable registration. vpn_dns_zone: roam.atlashorizon.net +# ── Travel routers (GL.iNet etc.) as roaming WireGuard peers ── +# Each gets a /32 from 172.31.9.0/24 (hub is 172.31.9.1). Generate the +# keypair on the router (or wg genkey) and paste the PUBLIC key here. +# The init job also registers . A + PTR records. +wg_travel_peers: [] +#wg_travel_peers: +# - { name: glinet-1, public_key: "xxxx=", ip: 172.31.9.11 } +# - { name: glinet-2, public_key: "yyyy=", ip: 172.31.9.12 } +# - { name: glinet-3, public_key: "zzzz=", ip: 172.31.9.13 } + # ── Optional ── # Pin for deterministic Duo updates (e.g. "6.5.1"); bumping it forces a # refetch of the tarball on the next rebuild. diff --git a/ansible/site.yml b/ansible/site.yml index 96e6408..d770045 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -219,15 +219,23 @@ ansible.builtin.debug: msg: "RackNerd WG public key: {{ wg_pubkey.stdout }}" - - name: Deploy wg0.conf (rendered from host/wg0.conf.example) + - name: Deploy wg0.conf (rendered from host/wg0.conf.example + travel peers) tags: [host, wireguard] ansible.builtin.copy: dest: /etc/wireguard/wg0.conf mode: "0600" - content: "{{ lookup('file', playbook_dir ~ '/../host/wg0.conf.example') - | replace('', wg_key_raw.content | b64decode | trim) - | replace('', troch_wg_public_key | default('', true)) - | replace('', oswald_wg_public_key | default('', true)) }}\n" + content: | + {{ lookup('file', playbook_dir ~ '/../host/wg0.conf.example') + | replace('', wg_key_raw.content | b64decode | trim) + | replace('', troch_wg_public_key | default('', true)) + | replace('', oswald_wg_public_key | default('', true)) }} + {% for p in wg_travel_peers | default([]) %} + + # ── Travel router: {{ p.name }} (roaming /32, appended by Ansible) ── + [Peer] + PublicKey = {{ p.public_key }} + AllowedIPs = {{ p.ip }}/32 + {% endfor %} no_log: true notify: restart wireguard diff --git a/ansible/templates/env.j2 b/ansible/templates/env.j2 index 3ac7381..e424a16 100644 --- a/ansible/templates/env.j2 +++ b/ansible/templates/env.j2 @@ -19,6 +19,7 @@ RADIUS_SECRET={{ radius_secret }} TECHNITIUM_ADMIN_PASSWORD={{ technitium_admin_password }} VPN_DNS_ZONE={{ vpn_dns_zone | default('roam.atlashorizon.net') }} +VPN_TRAVEL_PEERS={% for p in wg_travel_peers | default([]) %}{{ p.name }}:{{ p.ip }}{{ "," if not loop.last else "" }}{% endfor %} DUO_AUTHPROXY_VERSION={{ duo_authproxy_version | default('latest') }} diff --git a/docker-compose.yml b/docker-compose.yml index 7fb566f..be9fc9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,9 @@ services: - DNS_SERVER_ADMIN_PASSWORD=${TECHNITIUM_ADMIN_PASSWORD} - DNS_SERVER_WEB_SERVICE_HTTP_PORT=5380 - DNS_SERVER_RECURSION=UseSpecifiedNetworkACL - - DNS_SERVER_RECURSION_NETWORK_ACL=127.0.0.0/8,172.31.8.0/24,172.31.255.0/24,192.168.20.0/24,192.168.7.0/24 + # NOTE: seeded on first start only — on an existing deploy, add new + # networks in the console instead (Settings -> Recursion). + - DNS_SERVER_RECURSION_NETWORK_ACL=127.0.0.0/8,172.31.8.0/24,172.31.9.0/24,172.31.255.0/24,192.168.20.0/24,192.168.7.0/24 volumes: - ${TECHNITIUM_DIR:-./technitium}/config:/etc/dns restart: unless-stopped diff --git a/docs/SITE-SIDE.md b/docs/SITE-SIDE.md index 329d0c9..6156245 100644 --- a/docs/SITE-SIDE.md +++ b/docs/SITE-SIDE.md @@ -13,7 +13,8 @@ Add the RackNerd peer to each site's existing WG config. The sites [Peer] # RackNerd hub PublicKey = # printed by host/setup.sh Endpoint = vpn.atlashorizon.net:51820 -AllowedIPs = 172.31.255.1/32, 172.31.8.0/24 +# .8 = OpenVPN clients, .9 = travel routers (docs/TRAVEL-ROUTERS.md) +AllowedIPs = 172.31.255.1/32, 172.31.8.0/24, 172.31.9.0/24 PersistentKeepalive = 25 ``` @@ -35,6 +36,7 @@ LAN router (or directly on the DCs): | Destination | Via | |------------------|------------------------| | `172.31.8.0/24` | site WireGuard box | +| `172.31.9.0/24` | site WireGuard box | | `172.31.255.1/32`| site WireGuard box | Verify no asymmetry: `tracert 172.31.8.10` from a DC must leave via the @@ -42,9 +44,10 @@ local WG box → RackNerd, never via the other site. ## 3. Windows Firewall / segmentation on the DCs -Allow from **`172.31.8.0/24`** (roaming clients, real source IPs): standard -AD client ports — DNS 53, Kerberos 88/464, LDAP 389/636, SMB 445, RPC 135 + -49152-65535, NTP 123, GC 3268/3269. +Allow from **`172.31.8.0/24`** (OpenVPN clients, real source IPs) and +**`172.31.9.0/24`** (travel routers): standard AD client ports — DNS 53, +Kerberos 88/464, LDAP 389/636, SMB 445, RPC 135 + 49152-65535, NTP 123, +GC 3268/3269. Allow from **`172.31.255.1/32`** (the RackNerd host itself): - TCP/UDP 53 → AHDC01, AHDC03, AH-OSWLDC (Technitium forwarder queries) diff --git a/docs/TRAVEL-ROUTERS.md b/docs/TRAVEL-ROUTERS.md new file mode 100644 index 0000000..9c942c1 --- /dev/null +++ b/docs/TRAVEL-ROUTERS.md @@ -0,0 +1,65 @@ +# Travel routers (GL.iNet) as roaming WireGuard peers + +The three GL.iNet routers connect to the hub as always-on WireGuard +clients from `172.31.9.0/24` (hub = `172.31.9.1`). They are roaming +*clients*, not sites: they initiate to the hub, carry no LAN routes of +their own on the hub side, and NAT their attached devices behind their +single WG address — so the DCs see one IP per router. + +Security model (be aware): unlike OpenVPN users there is **no Duo** here — +possession of the router's private key is the whole credential. Each +router has its own keypair; if one is lost, remove its entry from +`wg_travel_peers` and re-run the playbook — that revokes it completely. + +## Hub side (all via Ansible) + +1. Generate a keypair per router — in the GL.iNet UI (it shows the public + key) or manually: `wg genkey | tee priv | wg pubkey`. +2. Fill `wg_travel_peers` in `group_vars/vpnhub.yml`: + ```yaml + wg_travel_peers: + - { name: glinet-1, public_key: "xxxx=", ip: 172.31.9.11 } + - { name: glinet-2, public_key: "yyyy=", ip: 172.31.9.12 } + - { name: glinet-3, public_key: "zzzz=", ip: 172.31.9.13 } + ``` +3. `ansible-playbook site.yml` — appends the peers to wg0.conf (restarts + wg0: brief blip for all tunnels), adds the firewall forwards, and + registers `.roam.atlashorizon.net` A + PTR records. +4. **Existing deploys only**: add `172.31.9.0/24` to the Technitium + recursion ACL by hand (console → Settings → Recursion) — that setting + was seeded at first boot and isn't re-read from the compose env. + +## Router side (paste into the GL.iNet WireGuard client UI) + +```ini +[Interface] +PrivateKey = +Address = 172.31.9.11/24 # .12 / .13 for the others +DNS = 172.31.9.1 # Technitium — AD names resolve + +[Peer] +PublicKey = # printed by setup/playbook +Endpoint = vpn.atlashorizon.net:51820 +# Split tunnel: both sites + the hub nets. Use 0.0.0.0/0 instead for +# full tunnel (all hotel traffic exits via the VPS). +AllowedIPs = 192.168.20.0/24, 192.168.7.0/24, 172.31.9.0/24, 172.31.255.1/32 +PersistentKeepalive = 25 +``` + +## Site side (once, both sites — same drill as the OpenVPN subnet) + +- Add `172.31.9.0/24` to the RackNerd peer's AllowedIPs in each site's + WG config. +- Return route: `172.31.9.0/24` via the site WG box (LAN router or DCs). +- DC/Windows firewall: allow AD ports from `172.31.9.0/24`. +- Optional: AD Sites & Services subnet object for `172.31.9.0/24`. + +## Verify + +```bash +wg show wg0 # router peers: recent handshakes +ping 172.31.9.11 # from the hub +dig @127.0.0.1 -x 172.31.9.11 +short # glinet-1.roam.atlashorizon.net. +# from a device behind the router: +# ping ahdc01.ad.atlashorizon.net (resolves via 172.31.9.1, routes via hub) +``` diff --git a/host/rules.v4.template b/host/rules.v4.template index 0fbf9e1..6c24887 100644 --- a/host/rules.v4.template +++ b/host/rules.v4.template @@ -43,6 +43,10 @@ -A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 192.168.7.0/24 -j ACCEPT -A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 172.31.255.0/24 -j ACCEPT +# Travel routers (roaming WG peers, wg0 -> wg0) -> both sites +-A FORWARD -i wg0 -o wg0 -s 172.31.9.0/24 -d 192.168.20.0/24 -j ACCEPT +-A FORWARD -i wg0 -o wg0 -s 172.31.9.0/24 -d 192.168.7.0/24 -j ACCEPT + # Site -> client NEW connections (e.g. RDP/RSAT from a DC to a roaming # laptop). Off by default; uncomment if you need it. #-A FORWARD -i wg0 -o tun0 -d 172.31.8.0/24 -j ACCEPT diff --git a/host/wg0.conf.example b/host/wg0.conf.example index 90af1cf..f6ca8d9 100644 --- a/host/wg0.conf.example +++ b/host/wg0.conf.example @@ -3,7 +3,8 @@ # paste in each site's public key afterwards. [Interface] -Address = 172.31.255.1/24 +# .255 = site transfer net, .9 = roaming WG peers (travel routers) +Address = 172.31.255.1/24, 172.31.9.1/24 ListenPort = 51820 PrivateKey = # wg-quick auto-adds routes for each peer's AllowedIPs — that is exactly @@ -25,3 +26,11 @@ AllowedIPs = 172.31.255.3/32, 192.168.7.0/24 # Deliberately NOT listing the other site's LAN under either peer: # Oswald<->Trochalakis traffic must stay on their direct tunnel, never # hairpin through this box. + +# ── Travel routers (roaming /32 peers from 172.31.9.0/24) ── +# The Ansible deploy appends these from the wg_travel_peers list in +# group_vars. For manual setups, copy this block per router: +#[Peer] # glinet-1 +#PublicKey = +#AllowedIPs = 172.31.9.11/32 +# (no Endpoint — routers initiate and keep alive from behind NAT) diff --git a/technitium/configure.sh b/technitium/configure.sh index 60a6afd..261e7ac 100644 --- a/technitium/configure.sh +++ b/technitium/configure.sh @@ -93,7 +93,26 @@ ensure_primary_zone() { if [ -n "${VPN_DNS_ZONE:-}" ]; then ensure_primary_zone "$VPN_DNS_ZONE" - ensure_primary_zone "8.31.172.in-addr.arpa" + ensure_primary_zone "8.31.172.in-addr.arpa" # OpenVPN clients + ensure_primary_zone "9.31.172.in-addr.arpa" # travel routers +fi + +# Static A + PTR for travel routers (VPN_TRAVEL_PEERS="name:ip,name:ip"). +# overwrite=true keeps this idempotent across re-runs. +if [ -n "${VPN_TRAVEL_PEERS:-}" ] && [ -n "${VPN_DNS_ZONE:-}" ]; then + echo "$VPN_TRAVEL_PEERS" | tr ',' '\n' | while IFS=: read -r name ip; do + [ -n "$name" ] && [ -n "$ip" ] || continue + echo "registering travel router $name -> $ip" + curl -sf -H "$AUTH" -G "$API/zones/records/add" \ + --data-urlencode "domain=$name.$VPN_DNS_ZONE" \ + --data-urlencode "zone=$VPN_DNS_ZONE" \ + --data-urlencode "type=A" \ + --data-urlencode "ttl=3600" \ + --data-urlencode "ipAddress=$ip" \ + --data-urlencode "overwrite=true" \ + --data-urlencode "ptr=true" \ + --data-urlencode "createPtrZone=true" | api_ok + done fi echo "Technitium AD forwarder zones OK."