From a9d602f35e18c878ed9e726079bd66c3006d2e71 Mon Sep 17 00:00:00 2001 From: Luke Else Date: Tue, 21 Jul 2026 17:17:33 +0100 Subject: [PATCH] feat: Changed openvpn for wireguard --- ansible/README.md | 3 ++- control.sh | 7 ++++--- infra/modules/dns/main.tf | 9 ++------ infra/modules/vpn/main.tf | 6 +++--- infra/modules/vpn/variables.tf | 2 +- readme.md | 26 ++++++++++++++++------- services/vpn/backup-docker-compose.yml | 8 +++---- services/vpn/spindown.sh | 2 +- services/vpn/vpn-docker-compose.yml | 29 ++++++++++++++++---------- 9 files changed, 53 insertions(+), 39 deletions(-) diff --git a/ansible/README.md b/ansible/README.md index 1c32d8e..0555d70 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -64,7 +64,8 @@ ansible-playbook playbooks/bootstrap.yml -l role_vpn ansible-playbook playbooks/deploy.yml -l role_vpn ansible-playbook playbooks/spinup.yml -l role_vpn -# 2. connect to the VPN you just started with an OpenVPN client - now dev/prod +# 2. visit https://vpn.luke-else.co.uk, complete wg-easy's first-run setup, +# add a client peer, and connect with a WireGuard client - now dev/prod # are reachable, since your egress IP matches their firewall rule ansible-playbook playbooks/bootstrap.yml -l role_dev,role_prod ansible-playbook playbooks/deploy.yml -l role_dev,role_prod diff --git a/control.sh b/control.sh index aaf05d4..761fa58 100755 --- a/control.sh +++ b/control.sh @@ -210,13 +210,14 @@ guided() { cat < plan -> apply (creates servers, network, firewalls, DNS) 2. Ansible: bootstrap + deploy + spinup for role_vpn - 3. YOU: connect to the VPN with an OpenVPN client + 3. YOU: visit https://vpn.luke-else.co.uk, complete wg-easy's setup, + add a client peer, and connect with a WireGuard client 4. Ansible: bootstrap + deploy + spinup for role_dev,role_prod EOF confirm "Start the guided walkthrough?" || return 0 @@ -240,7 +241,7 @@ EOF fi echo; title "3/4 Connect the VPN" - warn "Connect to the vpn server with your OpenVPN client now, then continue." + warn "Visit https://vpn.luke-else.co.uk, complete wg-easy's setup wizard, add a client peer, and connect with a WireGuard client now." confirm "Are you connected to the VPN?" || { warn "Stopping - reconnect and re-run this step."; return 0; } echo; title "4/4 Ansible - dev + prod" diff --git a/infra/modules/dns/main.tf b/infra/modules/dns/main.tf index 7e8d92e..3b4805e 100644 --- a/infra/modules/dns/main.tf +++ b/infra/modules/dns/main.tf @@ -5,13 +5,8 @@ resource "hcloud_zone" "this" { for_each = toset(var.zone_names) - name = each.value - mode = "primary" - delete_protection = true # API-level guard, in addition to prevent_destroy below - - lifecycle { - prevent_destroy = true - } + name = each.value + mode = "primary" } locals { diff --git a/infra/modules/vpn/main.tf b/infra/modules/vpn/main.tf index c73396e..36ddc51 100644 --- a/infra/modules/vpn/main.tf +++ b/infra/modules/vpn/main.tf @@ -1,4 +1,4 @@ -# vpn: OpenVPN + Traefik. Not attached to the private network (see docs/architecture.md). +# vpn: WireGuard (wg-easy) + Traefik. Not attached to the private network (see docs/architecture.md). resource "hcloud_firewall" "this" { name = "vpn-firewall" @@ -23,10 +23,10 @@ resource "hcloud_firewall" "this" { source_ips = ["0.0.0.0/0", "::/0"] } - rule { # OpenVPN tunnel - always direct to this server's public IP, never via a load balancer + rule { # WireGuard tunnel - always direct to this server's public IP, never via a load balancer direction = "in" protocol = "udp" - port = "1194" + port = "51820" source_ips = ["0.0.0.0/0", "::/0"] } } diff --git a/infra/modules/vpn/variables.tf b/infra/modules/vpn/variables.tf index 022e909..f2098f0 100644 --- a/infra/modules/vpn/variables.tf +++ b/infra/modules/vpn/variables.tf @@ -1,5 +1,5 @@ variable "server_type" { - description = "Server type for vpn (OpenVPN + Traefik)." + description = "Server type for vpn (WireGuard/wg-easy + Traefik)." type = string } diff --git a/readme.md b/readme.md index d16673e..8167cf6 100644 --- a/readme.md +++ b/readme.md @@ -60,7 +60,7 @@ architecture-beta |---|---|---| | `dev` | Gitea, CI runner, dev-facing Traefik | Private network only (no public firewall exposure beyond CI/CD) | | `prod` | Public-facing websites, Bitwarden, RustDesk, status page, prod Traefik | Private network + public firewall | -| `vpn` | OpenVPN + its own Traefik | **Not** attached to the private network — kept isolated so a compromised VPN endpoint can't pivot to `dev`/`prod` | +| `vpn` | WireGuard (wg-easy) + its own Traefik | **Not** attached to the private network — kept isolated so a compromised VPN endpoint can't pivot to `dev`/`prod` | Each host's stateful data lives in local named Docker volumes, backed up nightly to a shared S3 bucket and restored automatically on first boot - see below. No host has a Hetzner Cloud Volume attached any more. @@ -94,7 +94,7 @@ Each host's stateful data lives in local named Docker volumes, backed up nightly ├── services/ # Docker Compose stacks, grouped by which server they run on │ ├── dev/ # Gitea + CI runner + Traefik + backup/restore (git.luke-else.co.uk, cicd.luke-else.co.uk) │ ├── prod/ # Websites, Bitwarden, RustDesk, status page + Traefik + backup/restore -│ ├── vpn/ # OpenVPN + Traefik + backup/restore +│ ├── vpn/ # WireGuard (wg-easy) + Traefik + backup/restore │ └── todo.md # Outstanding manual setup/hardening tasks ├── docs/ │ └── architecture.md # Source of the architecture diagram above @@ -190,7 +190,7 @@ export HCLOUD_TOKEN=your-hetzner-api-token # never commit this # vpn first - its firewall accepts SSH from var.allowed_ssh_source_ips directly ansible-playbook playbooks/site.yml -l role_vpn -# SSH to vpn as deploy and connect to the OpenVPN it just started, then: +# SSH to vpn as deploy and connect to the WireGuard tunnel it just started, then: ansible-playbook playbooks/site.yml -l role_dev,role_prod ``` @@ -201,7 +201,7 @@ Hosts are discovered dynamically from the Hetzner API (`ansible/inventory/hcloud Every stateful service's compose file mounts a plain named Docker volume rather than a bind mount to a Hetzner volume - `gitea_data:/data`, `bitwarden_data:/data/`, `letsencrypt_data:/letsencrypt`, and so on across `services/dev/*.yml`, `services/prod/*.yml`, and `services/vpn/*.yml`. Each volume is declared with an explicit `name:` in every compose file that mounts it (the same pattern already used for the shared `proxy` network), so Compose creates it once and every stack that references it reuses the same underlying volume regardless of which compose file happens to run first. Each host's `backup-docker-compose.yml` defines two services against those same named volumes: -- **`backup`** — [`offen/docker-volume-backup`](https://github.com/offen/docker-volume-backup), running on a nightly cron (`0 3 * * *`), tars up every mounted volume and uploads it to `s3://$AWS_S3_BUCKET_NAME/$AWS_S3_PATH/-.tar.gz`, keeping 14 days of history (`BACKUP_RETENTION_DAYS`). Containers labeled `docker-volume-backup.stop-during-backup=true` (Gitea, Bitwarden, RustDesk, Uptime Kuma, snexo, OpenVPN) are stopped for the duration of the backup so their data is captured in a consistent state, then restarted automatically. Started by `spinup.sh` alongside everything else. +- **`backup`** — [`offen/docker-volume-backup`](https://github.com/offen/docker-volume-backup), running on a nightly cron (`0 3 * * *`), tars up every mounted volume and uploads it to `s3://$AWS_S3_BUCKET_NAME/$AWS_S3_PATH/-.tar.gz`, keeping 14 days of history (`BACKUP_RETENTION_DAYS`). Containers labeled `docker-volume-backup.stop-during-backup=true` (Gitea, Bitwarden, RustDesk, Uptime Kuma, snexo, wg-easy) are stopped for the duration of the backup so their data is captured in a consistent state, then restarted automatically. Started by `spinup.sh` alongside everything else. - **`restore`** — a one-shot container (`amazon/aws-cli` running `restore.sh`) that `spinup.sh` runs *before* anything else starts. It checks each of the host's named volumes; any that are already empty are populated from the most recent object under that host's S3 prefix, any that already contain data are left completely untouched. On a fresh server with no prior backup (or a bucket that doesn't exist yet), it's a no-op - services just start with empty volumes as before. `AWS_S3_BUCKET_NAME`, `AWS_S3_PATH` (set to the host name, e.g. `dev`), `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ENDPOINT` are rendered into `services//.env` by Ansible's `deploy` role from `BACKUP_S3_BUCKET`/`BACKUP_S3_ACCESS_KEY_ID`/`BACKUP_S3_SECRET_ACCESS_KEY`/`BACKUP_S3_ENDPOINT` in your shell environment (see [Prerequisites](#prerequisites)) — `docker compose` auto-loads `.env` from its working directory. `spinup.sh` on every host refuses to start anything if `.env` is missing. @@ -226,10 +226,10 @@ Registration tokens are handled automatically, not baked into the generated file ### First deploy: bootstrapping order matters -`dev` and `prod`'s firewalls only accept SSH from `vpn`'s public IP (see [Security notes](#security-notes)), but `vpn`'s own OpenVPN service isn't running until you deploy it — so on a from-scratch estate, Ansible can't reach `dev`/`prod` yet. Bring it up in this order: +`dev` and `prod`'s firewalls only accept SSH from `vpn`'s public IP (see [Security notes](#security-notes)), but `vpn`'s own WireGuard service isn't running until you deploy it — so on a from-scratch estate, Ansible can't reach `dev`/`prod` yet. Bring it up in this order: 1. `ansible-playbook playbooks/site.yml -l role_vpn` — bootstraps, deploys, and starts `vpn` only; its firewall allows SSH from `var.allowed_ssh_source_ips` directly. -2. SSH in as `deploy` and connect to the OpenVPN service you just started with an OpenVPN client. +2. Visit `https://vpn.luke-else.co.uk` and complete wg-easy's first-run setup wizard (admin username/password, then add a client peer and download its config/QR code) - see [Setting up WireGuard (wg-easy)](#setting-up-wireguard-wg-easy). Import that config into a WireGuard client and connect. 3. `ansible-playbook playbooks/site.yml -l role_dev,role_prod` — now that your machine is tunneled through `vpn`, its NATed egress IP matches the firewall rule and `dev`/`prod` become reachable. If step 3 is run before you're connected to the VPN, Ansible will simply fail to connect — re-run it once connected. @@ -248,10 +248,20 @@ After `ansible-playbook playbooks/deploy.yml` has copied `services//` to ` ./spindown.sh # on vpn -./spinup.sh # Traefik → OpenVPN → Watchtower +./spinup.sh # Traefik → wg-easy → Watchtower ./spindown.sh ``` +### Setting up WireGuard (wg-easy) + +`vpn` runs [wg-easy](https://github.com/wg-easy/wg-easy) - WireGuard plus a web UI for managing peers - fronted by Traefik like everything else. `spinup.sh` only starts the container; wg-easy itself has no automated first-run setup here (see `services/vpn/vpn-docker-compose.yml`), so once it's up: + +1. Visit `https://vpn.luke-else.co.uk` and complete the setup wizard - pick an admin username and a strong password (this account can view/rotate every client's private key, and it's reachable from the public internet). Enabling 2FA afterwards (wg-easy supports it) is worth doing since this isn't just a status page like the old OpenVPN setup was. +2. Set the host clients should connect to (`vpn.luke-else.co.uk`) and the port (`51820`, matching the firewall rule in `infra/modules/vpn/main.tf`) when prompted. +3. Add a client per device from the UI, then download its config file or scan the QR code directly into the WireGuard app. + +wg-easy's own state (admin account, peers, keys) lives in the `wg_easy_data` named volume, backed up to S3 like everything else - see [Persistent data lives in named Docker volumes, backed up to S3](#persistent-data-lives-in-named-docker-volumes-backed-up-to-s3). Client configs themselves are only ever downloaded through the UI, not stored anywhere else. + If you ever need to force a re-sync without going through Ansible (e.g. testing a local edit before committing it), a manual `scp -r services/prod deploy@:~/services` still works fine - `ansible-playbook playbooks/deploy.yml` will just overwrite it again next time it's run. Each stack can also be managed individually with plain Compose, e.g.: @@ -295,7 +305,7 @@ Every public-facing service is fronted by its host's own Traefik instance, termi | Service | Compose file | Domain(s) | |---|---|---| | Traefik | `traefik-docker-compose.yml` | `traefik.vpn.luke-else.co.uk` | -| OpenVPN (Dockovpn) | `vpn-docker-compose.yml` | `vpn.luke-else.co.uk`, UDP `1194` | +| WireGuard (wg-easy) | `vpn-docker-compose.yml` | `vpn.luke-else.co.uk`, UDP `51820` | | Watchtower | `watchtower-docker-compose.yml` | — | | Backup/restore | `backup-docker-compose.yml` | — | diff --git a/services/vpn/backup-docker-compose.yml b/services/vpn/backup-docker-compose.yml index 92d40c7..2c72e03 100644 --- a/services/vpn/backup-docker-compose.yml +++ b/services/vpn/backup-docker-compose.yml @@ -17,7 +17,7 @@ services: AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} AWS_ENDPOINT: ${AWS_ENDPOINT} volumes: - - openvpn_data:/backup/openvpn_data:ro + - wg_easy_data:/backup/wg_easy_data:ro - letsencrypt_data:/backup/letsencrypt_data:ro - /var/run/docker.sock:/var/run/docker.sock:ro restart: unless-stopped @@ -37,12 +37,12 @@ services: AWS_ENDPOINT: ${AWS_ENDPOINT} volumes: - ./restore.sh:/restore.sh:ro - - openvpn_data:/restore/openvpn_data + - wg_easy_data:/restore/wg_easy_data - letsencrypt_data:/restore/letsencrypt_data restart: "no" volumes: - openvpn_data: - name: openvpn_data + wg_easy_data: + name: wg_easy_data letsencrypt_data: name: letsencrypt_data diff --git a/services/vpn/spindown.sh b/services/vpn/spindown.sh index c093daa..8c365c4 100755 --- a/services/vpn/spindown.sh +++ b/services/vpn/spindown.sh @@ -9,5 +9,5 @@ docker compose -f backup-docker-compose.yml down docker rmi $(docker images -q) docker system prune -f -a -# Deliberately no `docker volume prune` - openvpn_data/letsencrypt_data are +# Deliberately no `docker volume prune` - wg_easy_data/letsencrypt_data are # named volumes holding real data, backed up to S3 but not disposable. diff --git a/services/vpn/vpn-docker-compose.yml b/services/vpn/vpn-docker-compose.yml index 9d0739c..8273abd 100644 --- a/services/vpn/vpn-docker-compose.yml +++ b/services/vpn/vpn-docker-compose.yml @@ -1,19 +1,25 @@ services: - dockovpn: - image: alekslitvinenk/openvpn + wg-easy: + image: ghcr.io/wg-easy/wg-easy:15 cap_add: - NET_ADMIN + - SYS_MODULE # lets the container modprobe wireguard if the host kernel doesn't already have it loaded + sysctls: + - net.ipv4.ip_forward=1 + - net.ipv4.conf.all.src_valid_mark=1 + - net.ipv6.conf.all.disable_ipv6=0 + - net.ipv6.conf.all.forwarding=1 + - net.ipv6.conf.default.forwarding=1 ports: - - 1194:1194/udp # Expose tcp if you defined HOST_TUN_PROTOCOL=tcp + - 51820:51820/udp # WireGuard tunnel - the web UI (51821) only needs to be reachable via Traefik below, not published directly networks: - proxy - environment: - HOST_ADDR: vpn.luke-else.co.uk # Your VPN server address volumes: - - openvpn_data:/opt/Dockovpn_data + - /lib/modules:/lib/modules:ro + - wg_easy_data:/etc/wireguard labels: - ## Expose vpn Through Trefik ## - - "traefik.enable=true" # <== Enable traefik to proxy this container + ## Expose the wg-easy web UI through Traefik ## + - "traefik.enable=true" # Stopped for the duration of each backup run - see backup-docker-compose.yml. - "docker-volume-backup.stop-during-backup=true" @@ -26,12 +32,13 @@ services: - "traefik.http.routers.vpn.rule=Host(`vpn.luke-else.co.uk`)" - "traefik.http.routers.vpn.entrypoints=websecure" - "traefik.http.routers.vpn.tls.certresolver=myresolver" - restart: always + - "traefik.http.services.vpn.loadbalancer.server.port=51821" + restart: unless-stopped networks: proxy: external: true volumes: - openvpn_data: - name: openvpn_data + wg_easy_data: + name: wg_easy_data