Probably works

This commit is contained in:
nick committed 2026-10-16 17:27:45 -07:00
1 parent d36e7a484a
commit b31faa5935
10 files changed
+222 -31

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
site-a.yml
site-b.yml
.env
.DS_Store
+45 -18
View File
@@ -5,18 +5,17 @@ is one TCP port on one host per side.
``` ```
ansible/ ansible/
├── inventory.ini
├── wg-relay.yml # the whole thing ├── wg-relay.yml # the whole thing
├── group_vars/all.yml # defaults ├── defaults.yml # defaults
├── host_vars/site-a.yml # Site A (Site B writes the mirror image) ├── vars/
│ ├── site-a.yml # Site A (Site B writes the mirror image)
│ └── site-b.yml
└── templates/ └── templates/
├── wg0.conf.j2 ├── wg0.conf.j2
├── socat-relay.service.j2 ├── socat-relay.service.j2
└── nftables-wgrelay.conf.j2 └── nftables-wgrelay.conf.j2
``` ```
Needs `ansible-core` and nothing else — no collections to install.
## How it works ## How it works
The relay VM never routes. `ip_forward` is pinned to 0 and the nftables The relay VM never routes. `ip_forward` is pinned to 0 and the nftables
@@ -24,7 +23,7 @@ forward chain policy is `drop`, so there is no path from the tunnel to the
LAN. The only things listening are socat processes you declared: LAN. The only things listening are socat processes you declared:
- an **inbound** relay bound to the tunnel address, forwarding to one - an **inbound** relay bound to the tunnel address, forwarding to one
`host:port` on the local LAN `host:port` on the local LAN
- an **outbound** relay bound to the LAN address, forwarding down the tunnel - an **outbound** relay bound to the LAN address, forwarding down the tunnel
Local clients point at the relay VM's LAN address and never learn a tunnel Local clients point at the relay VM's LAN address and never learn a tunnel
@@ -43,20 +42,48 @@ Each side's restrictions live entirely on its own machine. There is no
mechanism for one site to prove to the other how its half is configured — and mechanism for one site to prove to the other how its half is configured — and
none is needed, since the half that protects you is the half you control. none is needed, since the half that protects you is the half you control.
## First run ## Prerequisites
1. Run it on each side with the peer key left as the placeholder. It You can use the ansible repositories or another installation method if desired, all that matters is you have ansible
generates a keypair and prints the public key.
2. Exchange public keys, fill in `wg_peers[].public_key`, run again.
3. Point the local client at the relay VM's LAN address and port.
```bash Needs `ansible-core`, no other packages or collections are needed. The playbook will install additional required packages
ansible-playbook -i inventory.ini wg-relay.yml --check --diff
ansible-playbook -i inventory.ini wg-relay.yml ```
sudo apt update && sudo apt install ansible
``` ```
The playbook ends by asserting forwarding is off, waiting for each relay port ## Setup
to accept connections, and printing `wg show`.
The playbook and `defaults.yml` are identical at both sites. Everything that
differs lives in that site's vars file — copy `vars/site-a.yml` and edit:
- `relay_host` / `relay_user` — which box to configure. Set it to the relay
VM to run over SSH, or leave it as `localhost` to configure the machine
you're sitting on.
- `wg_address` — this side's tunnel address, as a `/32`.
- `wg_peers` — the other site's public key, endpoint and `/32`.
- `relays` — the inbound and outbound listeners.
Values in the vars file override `defaults.yml`, because `-e` outranks
everything else. Adding a site later means one more file in `vars/`; the
playbook doesn't change.
## Installation
Run it at each site against that site's vars file. No inventory file needed.
```
ansible-playbook wg-relay.yml -e @vars/site-a.yml --check --diff
ansible-playbook wg-relay.yml -e @vars/site-a.yml
```
Leave the peer's public key as the placeholder on the first run — it generates
a keypair and prints this side's public key. Exchange public keys, fill in
`wg_peers[].public_key` in each vars file, then run again. Finally, point the
local client at the relay VM's LAN address and port.
Each run ends by asserting forwarding is off, waiting for each relay port to
accept connections, and printing `wg show`.
## Notes ## Notes
@@ -65,8 +92,8 @@ to accept connections, and printing `wg show`.
- Keys live in `/etc/wireguard/private.key` (0600, root-only). To manage them - Keys live in `/etc/wireguard/private.key` (0600, root-only). To manage them
centrally instead, set `wg_private_key` from Ansible Vault and the playbook centrally instead, set `wg_private_key` from Ansible Vault and the playbook
skips generation. skips generation.
- Removing a relay from `host_vars` stops and deletes its systemd unit on the - Removing a relay from the vars file stops and deletes its systemd unit on
next run — no orphaned listeners. the next run — no orphaned listeners.
- If either uplink is PPPoE or tunnelled, drop `wg_mtu` to 1380. Sustained - If either uplink is PPPoE or tunnelled, drop `wg_mtu` to 1380. Sustained
bulk transfers are what find PMTU blackholes, and the symptom is a stall bulk transfers are what find PMTU blackholes, and the symptom is a stall
mid-transfer rather than a clean failure. mid-transfer rather than a clean failure.
+63
View File
@@ -0,0 +1,63 @@
---
# ---------------------------------------------------------------------------
# Defaults shared by every site. Do not edit this file per-site — put the
# differences in your site vars file (see vars/site-a.yml) and pass it with
# -e @vars/<site>.yml, which overrides everything here.
#
# Anything marked REQUIRED has no sensible default and must be set there.
# ---------------------------------------------------------------------------
wg_interface: wg0
wg_listen_port: 51820
# 1420 leaves room for the WireGuard header inside a 1500-byte path.
# Drop to 1380 if either side is on PPPoE or a tunnelled uplink.
wg_mtu: 1420
# REQUIRED: this box's tunnel address, as a /32. Never a /24 — the mask is
# what stops wg-quick installing a route to the whole tunnel subnet.
wg_address: ""
# Leave undefined to have the playbook generate a keypair on first run and print
# the public key. Set it (from Ansible Vault) if you manage keys centrally.
# wg_private_key: "..."
# REQUIRED: the remote site.
# allowed_ips - their /32 and nothing wider. This is the kernel's source
# filter, not just a route.
# endpoint - omit entirely if they're behind CGNAT and dial out to you.
wg_peers: []
# - name: site-b
# public_key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx="
# allowed_ips: 10.99.0.2/32
# endpoint: site-b.example.com:51820
# keepalive: 25
# REQUIRED: the socat listeners.
# direction: inbound - listens on the tunnel, forwards to your LAN
# direction: outbound - listens on your LAN, forwards down the tunnel
# source_allow - nftables source restriction for this listener
relays: []
# - name: in-site-b
# direction: inbound
# listen_address: 10.99.0.1
# listen_port: 9000
# target: 192.168.1.50:9000
# source_allow: 10.99.0.2/32
# - name: out-site-b
# direction: outbound
# listen_address: 192.168.1.10
# listen_port: 9001
# target: 10.99.0.2:9000
# source_allow: 192.168.1.50/32
# Unprivileged account the socat units run as. 'nogroup' is Debian/Ubuntu;
# on RHEL-family set both to 'nobody'.
socat_user: nobody
socat_group: nogroup
# Safety rails. Only flip these if you know why.
# forwarding_enabled: true turns this box into a router between the tunnel
# and your LAN, which defeats the point of the relay design.
wg_relay_forwarding_enabled: false
wg_relay_manage_nftables: true
+2 -6
View File
@@ -9,10 +9,8 @@
# =========================================================================== # ===========================================================================
# --- which box to configure ------------------------------------------------ # --- which box to configure ------------------------------------------------
# Set to the relay VM's address to configure it over SSH, or leave as relay_host: localhost
# localhost to configure the machine you're running this on. #relay_user: admin
relay_host: 192.168.1.10
relay_user: admin
# --- this side of the tunnel ----------------------------------------------- # --- this side of the tunnel -----------------------------------------------
wg_address: 10.99.0.1/32 wg_address: 10.99.0.1/32
@@ -22,8 +20,6 @@ wg_listen_port: 51820
wg_peers: wg_peers:
- name: site-b - name: site-b
public_key: "PASTE_SITE_B_PUBLIC_KEY_HERE" public_key: "PASTE_SITE_B_PUBLIC_KEY_HERE"
# Omit endpoint on whichever side isn't reachable from outside; the
# other side dials in and keepalives hold the path open.
endpoint: site-b.example.com:51820 endpoint: site-b.example.com:51820
allowed_ips: 10.99.0.2/32 allowed_ips: 10.99.0.2/32
keepalive: 25 keepalive: 25
+2 -2
View File
@@ -10,8 +10,8 @@
# =========================================================================== # ===========================================================================
# --- which box to configure ------------------------------------------------ # --- which box to configure ------------------------------------------------
relay_host: 10.0.0.10 relay_host: localhost
relay_user: admin #
# --- this side of the tunnel ----------------------------------------------- # --- this side of the tunnel -----------------------------------------------
wg_address: 10.99.0.2/32 wg_address: 10.99.0.2/32
+40
View File
@@ -0,0 +1,40 @@
{{ ansible_managed | comment }}
table inet wgrelay
delete table inet wgrelay
table inet wgrelay {
chain input {
type filter hook input priority 0; policy accept;
ct state established,related accept
ct state invalid drop
# --- traffic arriving over the tunnel -------------------------------
# Ping across the tunnel is useful for debugging and harmless.
iifname "{{ wg_interface }}" icmp type echo-request accept
iifname "{{ wg_interface }}" icmpv6 type echo-request accept
{% for r in relays if r.direction == 'inbound' %}
# {{ r.name }}: only {{ r.source_allow }} may reach this listener
iifname "{{ wg_interface }}" ip saddr {{ r.source_allow }} tcp dport {{ r.listen_port }} accept
{% endfor %}
# Anything else off the tunnel is not ours. This is the rule that
# makes "one port, one host" true rather than aspirational.
iifname "{{ wg_interface }}" counter drop
# --- LAN-facing relay listeners -------------------------------------
{% for r in relays if r.direction == 'outbound' %}
iifname != "{{ wg_interface }}" ip saddr {{ r.source_allow }} tcp dport {{ r.listen_port }} accept
tcp dport {{ r.listen_port }} counter drop
{% endfor %}
}
chain forward {
type filter hook forward priority 0; policy drop;
# Nothing is routed through this box, by design. Combined with
# net.ipv4.ip_forward=0 this is belt and braces.
counter
}
}
+39
View File
@@ -0,0 +1,39 @@
{{ ansible_managed | comment }}
[Unit]
Description=Relay {{ item.name }} ({{ item.listen_address }}:{{ item.listen_port }} -> {{ item.target }})
After=network-online.target wg-quick@{{ wg_interface }}.service
Wants=network-online.target
{% if item.direction == 'inbound' %}
# Inbound listeners bind to the tunnel address, which doesn't exist until
# wg-quick has brought the interface up.
BindsTo=wg-quick@{{ wg_interface }}.service
{% endif %}
[Service]
Type=simple
ExecStart=/usr/bin/socat -d \
TCP-LISTEN:{{ item.listen_port }},bind={{ item.listen_address }},fork,reuseaddr,keepalive,keepidle=60,keepintvl=10,keepcnt=5 \
TCP:{{ item.target }},keepalive,keepidle=60,keepintvl=10,keepcnt=5
# Bind may fail for a few seconds after boot while wg0 settles; just retry.
Restart=always
RestartSec=5
# socat needs no privileges beyond binding a high port.
User={{ socat_user }}
Group={{ socat_group }}
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
+24
View File
@@ -0,0 +1,24 @@
{{ ansible_managed | comment }}
# Relay tunnel. This box forwards nothing; see the sysctl and nftables
# tasks in wg-relay.yml.
[Interface]
Address = {{ wg_address }}
ListenPort = {{ wg_listen_port }}
MTU = {{ wg_mtu }}
PrivateKey = {{ wg_relay_effective_private_key }}
{% for peer in wg_peers %}
# {{ peer.name }}
[Peer]
PublicKey = {{ peer.public_key }}
AllowedIPs = {{ peer.allowed_ips }}
{% if peer.endpoint is defined and peer.endpoint %}
Endpoint = {{ peer.endpoint }}
{% endif %}
{% if peer.preshared_key is defined and peer.preshared_key %}
PresharedKey = {{ peer.preshared_key }}
{% endif %}
PersistentKeepalive = {{ peer.keepalive | default(25) }}
{% endfor %}
+3 -5
View File
@@ -8,12 +8,10 @@
# ansible-playbook wg-relay.yml -e @vars/site-a.yml --check --diff # ansible-playbook wg-relay.yml -e @vars/site-a.yml --check --diff
# ansible-playbook wg-relay.yml -e @vars/site-a.yml # ansible-playbook wg-relay.yml -e @vars/site-a.yml
# #
# No inventory file needed. Set relay_host to localhost (or leave it unset) # No inventory file needed. Set relay_host to localhost or leave it unset
# to configure the machine you're running on. Defaults come from # to configure the machine you're running on. Defaults come from
# defaults.yml; anything in the vars file wins, since -e outranks it. # defaults.yml.
# # The restrictions are enforced locally at each site.
# The restrictions are enforced locally at each site, so neither side can
# widen the other's exposure.
- name: Resolve which box to configure - name: Resolve which box to configure
hosts: localhost hosts: localhost