diff --git a/README.md b/README.md index e69de29..0635851 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,83 @@ +# wg-relay + +A WireGuard tunnel between two sites where the only thing reachable across it +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) +└── 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 +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 +- 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 +exists, so nothing on them needs reconfiguring if the relay changes. + +Three layers have to agree before a packet gets anywhere: + +1. **WireGuard** — `AllowedIPs` is a `/32`, which the kernel enforces as a + source filter. A peer that forges a source address outside its own `/32` + is dropped before any userspace sees it. +2. **nftables** — the tunnel interface ends in an explicit `drop`; only the + declared relay ports, from the declared source, get through. +3. **socat** — forwards to exactly one `host:port`, running as `nobody`. + +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 + +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. + +```bash +ansible-playbook -i inventory.ini wg-relay.yml --check --diff +ansible-playbook -i inventory.ini wg-relay.yml +``` + +The playbook ends by asserting forwarding is off, waiting for each relay port +to accept connections, and printing `wg show`. + +## Notes + +- The `wg0.conf` task is `no_log: true` because the file contains the private + key; `--diff` won't show it changing. That's deliberate. +- 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. +- 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. +- `socat_group: nogroup` is Debian/Ubuntu. On RHEL-family set it to `nobody`. +- Whichever side is behind CGNAT should omit `endpoint` and let the other side + dial in; `PersistentKeepalive` holds the path open. If *both* sides are + behind CGNAT, neither can reach the other directly and this design doesn't + apply as-is. +- The relay is TCP only. A UDP service needs `UDP-LISTEN`/`UDP` in the socat + unit and a matching `udp dport` rule in the nftables template. +- Protocols that embed their own hostname or redirect clients to a second + address (anything doing virtual-host-style routing) need that address to + resolve on the client side too — a relay changes the address clients + connect to, and some protocols notice. \ No newline at end of file diff --git a/site-a.yml b/site-a.yml new file mode 100644 index 0000000..347dbd4 --- /dev/null +++ b/site-a.yml @@ -0,0 +1,48 @@ +--- +# =========================================================================== +# Site A. Everything that differs between sites lives in this file. +# +# ansible-playbook wg-relay.yml -e @vars/site-a.yml +# +# Site B's file is the mirror image: swap the two tunnel addresses, point +# the targets at its own service, and list Site A as the peer. +# =========================================================================== + +# --- 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 + +# --- this side of the tunnel ----------------------------------------------- +wg_address: 10.99.0.1/32 +wg_listen_port: 51820 + +# --- the other side -------------------------------------------------------- +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 + +# --- what is reachable, in each direction ---------------------------------- +relays: + # Site B reaching the service on Site A's LAN. + - name: in-site-b + direction: inbound + listen_address: 10.99.0.1 # this box's tunnel address + listen_port: 9000 + target: 192.168.1.50:9000 # the one host:port Site B may reach + source_allow: 10.99.0.2/32 # Site B's tunnel address only + + # Site A reaching the service on Site B's LAN. Local clients point at + # this box's LAN address and never need to know a tunnel exists. + - name: out-site-b + direction: outbound + listen_address: 192.168.1.10 # this box's LAN address + listen_port: 9001 + target: 10.99.0.2:9000 # Site B's inbound relay + source_allow: 192.168.1.50/32 # the one local client allowed to use it \ No newline at end of file diff --git a/site-b.yml b/site-b.yml new file mode 100644 index 0000000..b95f87f --- /dev/null +++ b/site-b.yml @@ -0,0 +1,44 @@ +--- +# =========================================================================== +# Site B — the mirror image of vars/site-a.yml. Run this at Site B only: +# +# ansible-playbook wg-relay.yml -e @vars/site-b.yml +# +# Note the LAN addresses here are Site B's own; they have no relationship to +# the ones in site-a.yml and the two sites may well use the same subnet. +# Only the 10.99.0.x tunnel addresses have to agree between the files. +# =========================================================================== + +# --- which box to configure ------------------------------------------------ +relay_host: 10.0.0.10 +relay_user: admin + +# --- this side of the tunnel ----------------------------------------------- +wg_address: 10.99.0.2/32 +wg_listen_port: 51820 + +# --- the other side -------------------------------------------------------- +wg_peers: + - name: site-a + public_key: "PASTE_SITE_A_PUBLIC_KEY_HERE" + endpoint: site-a.example.com:51820 + allowed_ips: 10.99.0.1/32 + keepalive: 25 + +# --- what is reachable, in each direction ---------------------------------- +relays: + # Site A reaching the service on Site B's LAN. + - name: in-site-a + direction: inbound + listen_address: 10.99.0.2 # this box's tunnel address + listen_port: 9000 + target: 10.0.0.50:9000 # the one host:port Site A may reach + source_allow: 10.99.0.1/32 # Site A's tunnel address only + + # Site B reaching the service on Site A's LAN. + - name: out-site-a + direction: outbound + listen_address: 10.0.0.10 # this box's LAN address + listen_port: 9001 + target: 10.99.0.1:9000 # Site A's inbound relay + source_allow: 10.0.0.50/32 # the one local client allowed to use it \ No newline at end of file diff --git a/wg-relay.yml b/wg-relay.yml new file mode 100644 index 0000000..a179601 --- /dev/null +++ b/wg-relay.yml @@ -0,0 +1,324 @@ +--- +# WireGuard relay box: a tunnel to one peer, plus socat listeners that are +# the only thing reachable across it. No IP forwarding, ever. +# +# One playbook, one vars file per site. Everything that differs between +# sites lives in the vars file, including which box to configure: +# +# 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) +# 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. + +- name: Resolve which box to configure + hosts: localhost + gather_facts: false + tasks: + - name: A vars file was supplied + ansible.builtin.assert: + that: relays is defined and wg_address is defined + fail_msg: >- + No site vars found. Run with -e @vars/site-a.yml (copy + vars/site-a.yml and edit it for each site). + quiet: true + + - name: Target the relay named in the vars file + ansible.builtin.add_host: + name: "{{ relay_host | default('localhost') }}" + groups: wg_relay_target + ansible_user: "{{ relay_user | default(omit) }}" + ansible_connection: >- + {{ 'local' + if relay_host | default('localhost') in ['localhost', '127.0.0.1'] + else 'ssh' }} + changed_when: false + +- name: Configure WireGuard relay + hosts: wg_relay_target + become: true + + vars_files: + - defaults.yml + + handlers: + - name: Restart wireguard + ansible.builtin.systemd: + name: "wg-quick@{{ wg_interface }}" + state: restarted + listen: restart wireguard + + - name: Reload nftables + ansible.builtin.command: + cmd: nft -f /etc/nftables.conf + changed_when: true + listen: reload nftables + + - name: Apply sysctl + ansible.builtin.command: + cmd: sysctl --system + changed_when: true + listen: apply sysctl + + tasks: + + # -- preflight ---------------------------------------------------------- + + - name: Required variables are set + ansible.builtin.assert: + that: + - wg_address | length > 0 + - wg_address is search('/32$') + - wg_peers | length > 0 + - relays | length > 0 + fail_msg: >- + Set wg_address (as a /32), wg_peers and relays in your site vars + file. A /32 matters: a wider mask makes wg-quick route the whole + tunnel subnet into this box. + quiet: true + + - name: Peer AllowedIPs are single hosts + ansible.builtin.assert: + that: item.allowed_ips is search('/32$') + fail_msg: >- + Peer '{{ item.name }}' has AllowedIPs {{ item.allowed_ips }}. + AllowedIPs is the kernel's source filter, not just a route — widen + it and that peer can spoof any address inside the range. + quiet: true + loop: "{{ wg_peers }}" + loop_control: + label: "{{ item.name }}" + + - name: Relay names are unique + ansible.builtin.assert: + that: (relays | map(attribute='name') | list | unique | length) == (relays | length) + fail_msg: "Two relays share a name; each one becomes a systemd unit." + quiet: true + + - name: Install packages + ansible.builtin.package: + name: + - wireguard-tools + - socat + - nftables + state: present + + # -- keys --------------------------------------------------------------- + + - name: Create /etc/wireguard + ansible.builtin.file: + path: /etc/wireguard + state: directory + owner: root + group: root + mode: "0700" + + - name: Look for an existing private key + ansible.builtin.stat: + path: /etc/wireguard/private.key + register: wg_privkey_stat + + - name: Generate a private key on first run + ansible.builtin.shell: + cmd: set -o pipefail && umask 077 && wg genkey > /etc/wireguard/private.key + executable: /bin/bash + creates: /etc/wireguard/private.key + when: + - not wg_privkey_stat.stat.exists + - wg_private_key is not defined + + - name: Read the private key back + ansible.builtin.slurp: + src: /etc/wireguard/private.key + register: wg_privkey_file + when: wg_private_key is not defined + no_log: true + + - name: Settle on which private key to template + ansible.builtin.set_fact: + wg_relay_effective_private_key: >- + {{ wg_private_key if wg_private_key is defined + else (wg_privkey_file.content | b64decode | trim) }} + no_log: true + + - name: Derive the public key + ansible.builtin.shell: + cmd: set -o pipefail && printf '%s' "$WGKEY" | wg pubkey + executable: /bin/bash + environment: + WGKEY: "{{ wg_relay_effective_private_key }}" + register: wg_pubkey + changed_when: false + check_mode: false + no_log: true + + - name: Public key to send to the other site + ansible.builtin.debug: + msg: "{{ inventory_hostname }} public key: {{ wg_pubkey.stdout }}" + + # -- no routing --------------------------------------------------------- + + # Deliberately ansible.builtin only — no collections to install, so this + # playbook runs on a bare `pip install ansible-core`. + - name: Pin forwarding off at boot + ansible.builtin.copy: + dest: /etc/sysctl.d/99-wg-relay.conf + owner: root + group: root + mode: "0644" + content: | + # Managed by wg-relay.yml. This box relays, it does not route. + net.ipv4.ip_forward = 0 + net.ipv6.conf.all.forwarding = 0 + when: not wg_relay_forwarding_enabled + notify: apply sysctl + + - name: Apply sysctl now + ansible.builtin.meta: flush_handlers + + # -- tunnel ------------------------------------------------------------- + + - name: Write the WireGuard config + ansible.builtin.template: + src: wg0.conf.j2 + dest: "/etc/wireguard/{{ wg_interface }}.conf" + owner: root + group: root + mode: "0600" + no_log: true + notify: restart wireguard + + - name: Enable and start the tunnel + ansible.builtin.systemd: + name: "wg-quick@{{ wg_interface }}" + enabled: true + state: started + daemon_reload: true + + # -- relays ------------------------------------------------------------- + + - name: Write a systemd unit per relay + ansible.builtin.template: + src: socat-relay.service.j2 + dest: "/etc/systemd/system/wg-relay-{{ item.name }}.service" + owner: root + group: root + mode: "0644" + loop: "{{ relays }}" + loop_control: + label: "{{ item.name }}" + register: relay_units + + - name: Find relay units we no longer define + ansible.builtin.find: + paths: /etc/systemd/system + patterns: "wg-relay-*.service" + register: existing_units + + - name: Stop and remove stale relay units + block: + - name: Stop stale unit + ansible.builtin.systemd: + name: "{{ item }}" + state: stopped + enabled: false + loop: "{{ stale_units }}" + + - name: Delete stale unit file + ansible.builtin.file: + path: "/etc/systemd/system/{{ item }}" + state: absent + loop: "{{ stale_units }}" + vars: + stale_units: >- + {{ existing_units.files | map(attribute='path') | map('basename') | list + | difference(relays | map(attribute='name') + | map('regex_replace', '^(.*)$', 'wg-relay-\1.service') | list) }} + when: stale_units | length > 0 + + - name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + when: relay_units is changed + + - name: Enable and start each relay + ansible.builtin.systemd: + name: "wg-relay-{{ item.name }}.service" + enabled: true + state: "{{ 'restarted' if relay_units is changed else 'started' }}" + loop: "{{ relays }}" + loop_control: + label: "{{ item.name }}" + + # -- firewall ----------------------------------------------------------- + + - name: Write the nftables ruleset + ansible.builtin.template: + src: nftables-wgrelay.conf.j2 + dest: /etc/nftables.d/wgrelay.nft + owner: root + group: root + mode: "0644" + validate: "nft --check --file %s" + when: wg_relay_manage_nftables + notify: reload nftables + + - name: Make sure /etc/nftables.conf includes our file + ansible.builtin.lineinfile: + path: /etc/nftables.conf + line: 'include "/etc/nftables.d/*.nft"' + create: true + owner: root + group: root + mode: "0644" + when: wg_relay_manage_nftables + notify: reload nftables + + - name: Enable nftables at boot + ansible.builtin.systemd: + name: nftables + enabled: true + state: started + when: wg_relay_manage_nftables + + - name: Apply pending changes now + ansible.builtin.meta: flush_handlers + + # -- verify ------------------------------------------------------------- + + - name: Confirm forwarding is off + ansible.builtin.slurp: + src: /proc/sys/net/ipv4/ip_forward + register: fwd_check + when: not wg_relay_forwarding_enabled + + - name: Fail if the kernel would route between tunnel and LAN + ansible.builtin.assert: + that: (fwd_check.content | b64decode | trim) == "0" + fail_msg: "ip_forward is on — this box would route, not relay." + quiet: true + when: not wg_relay_forwarding_enabled + + - name: Confirm each relay is listening + ansible.builtin.wait_for: + host: "{{ item.listen_address }}" + port: "{{ item.listen_port }}" + timeout: 20 + loop: "{{ relays }}" + loop_control: + label: "{{ item.name }}" + + - name: Tunnel status + ansible.builtin.command: + cmd: wg show {{ wg_interface }} + register: wg_status + changed_when: false + check_mode: false + + - name: Show tunnel status + ansible.builtin.debug: + var: wg_status.stdout_lines \ No newline at end of file