early commit
This commit is contained in:
4 files changed
+499
No files matched your search
@@ -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.
|
||||
+48
@@ -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
|
||||
+44
@@ -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
|
||||
+324
@@ -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
|
||||
Reference in new issue
Block a user