feat: Changed openvpn for wireguard
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
+4
-3
@@ -210,13 +210,14 @@ guided() {
|
||||
cat <<EOF
|
||||
This walks the whole bring-up in the order the firewalls require. dev/prod only
|
||||
accept SSH from the vpn server's public IP, so vpn must exist and you must be
|
||||
connected to it (OpenVPN) before dev/prod are reachable.
|
||||
connected to it (WireGuard, via wg-easy) before dev/prod are reachable.
|
||||
|
||||
Steps, in order:
|
||||
0. Configuration: variables + terraform.tfvars (skip if already done)
|
||||
1. OpenTofu: init -> 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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<host>-<timestamp>.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/<host>-<timestamp>.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/<host>/.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/<host>/` 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@<prod_ipv4>:~/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` | — |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user