diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..6a066ce Binary files /dev/null and b/.DS_Store differ diff --git a/.git_ignore b/.git_ignore new file mode 100644 index 0000000..c623e0d --- /dev/null +++ b/.git_ignore @@ -0,0 +1,4 @@ +site-a.yml +site-b.yml +.env +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 0635851..c1292a7 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,17 @@ is one TCP port on one host per side. ``` ansible/ -├── inventory.ini ├── wg-relay.yml # the whole thing -├── group_vars/all.yml # defaults -├── host_vars/site-a.yml # Site A (Site B writes the mirror image) +├── defaults.yml # defaults +├── vars/ +│ ├── site-a.yml # Site A (Site B writes the mirror image) +│ └── site-b.yml └── templates/ ├── wg0.conf.j2 ├── socat-relay.service.j2 └── nftables-wgrelay.conf.j2 ``` -Needs `ansible-core` and nothing else — no collections to install. - ## How it works 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: - 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 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 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 - 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. +You can use the ansible repositories or another installation method if desired, all that matters is you have ansible -```bash -ansible-playbook -i inventory.ini wg-relay.yml --check --diff -ansible-playbook -i inventory.ini wg-relay.yml +Needs `ansible-core`, no other packages or collections are needed. The playbook will install additional required packages + +``` +sudo apt update && sudo apt install ansible ``` -The playbook ends by asserting forwarding is off, waiting for each relay port -to accept connections, and printing `wg show`. +## Setup + +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 @@ -65,8 +92,8 @@ to accept connections, and printing `wg show`. - 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 skips generation. -- Removing a relay from `host_vars` stops and deletes its systemd unit on the - next run — no orphaned listeners. +- Removing a relay from the vars file stops and deletes its systemd unit on + the next run — no orphaned listeners. - 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 mid-transfer rather than a clean failure. diff --git a/defaults.yml b/defaults.yml new file mode 100644 index 0000000..83c6907 --- /dev/null +++ b/defaults.yml @@ -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/.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 \ No newline at end of file diff --git a/site-a.yml b/site-a-example.yml similarity index 84% rename from site-a.yml rename to site-a-example.yml index 347dbd4..c129fcd 100644 --- a/site-a.yml +++ b/site-a-example.yml @@ -9,10 +9,8 @@ # =========================================================================== # --- which box to configure ------------------------------------------------ -# Set to the relay VM's address to configure it over SSH, or leave as -# localhost to configure the machine you're running this on. -relay_host: 192.168.1.10 -relay_user: admin +relay_host: localhost +#relay_user: admin # --- this side of the tunnel ----------------------------------------------- wg_address: 10.99.0.1/32 @@ -22,8 +20,6 @@ wg_listen_port: 51820 wg_peers: - name: site-b 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 allowed_ips: 10.99.0.2/32 keepalive: 25 diff --git a/site-b.yml b/site-b-example.yml similarity index 97% rename from site-b.yml rename to site-b-example.yml index b95f87f..bfc737e 100644 --- a/site-b.yml +++ b/site-b-example.yml @@ -10,8 +10,8 @@ # =========================================================================== # --- which box to configure ------------------------------------------------ -relay_host: 10.0.0.10 -relay_user: admin +relay_host: localhost +# # --- this side of the tunnel ----------------------------------------------- wg_address: 10.99.0.2/32 diff --git a/templates/nftables-wgrelay.conf.j2 b/templates/nftables-wgrelay.conf.j2 new file mode 100644 index 0000000..91ef40c --- /dev/null +++ b/templates/nftables-wgrelay.conf.j2 @@ -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 + } +} diff --git a/templates/socat-relay.service.j2 b/templates/socat-relay.service.j2 new file mode 100644 index 0000000..4742638 --- /dev/null +++ b/templates/socat-relay.service.j2 @@ -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 diff --git a/templates/wg0.conf.j2 b/templates/wg0.conf.j2 new file mode 100644 index 0000000..adc2f34 --- /dev/null +++ b/templates/wg0.conf.j2 @@ -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 %} diff --git a/wg-relay.yml b/wg-relay.yml index a179601..2e46b7c 100644 --- a/wg-relay.yml +++ b/wg-relay.yml @@ -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 # -# 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 -# defaults.yml; anything in the vars file wins, since -e outranks it. -# -# The restrictions are enforced locally at each site, so neither side can -# widen the other's exposure. +# defaults.yml. +# The restrictions are enforced locally at each site. - name: Resolve which box to configure hosts: localhost