From eef5c568bb53af80d3d0b23f94aa005f4bbcd2aa Mon Sep 17 00:00:00 2001 From: Luke Else Date: Thu, 6 Aug 2026 21:06:50 +0100 Subject: [PATCH] feat: Limited rustdesk to vpn IP only --- ansible/README.md | 2 +- infra/main.tf | 44 ++++++++++++++++----------------- infra/modules/dev/main.tf | 2 +- infra/modules/dev/variables.tf | 2 +- infra/modules/prod/main.tf | 12 ++++----- infra/modules/prod/variables.tf | 2 +- infra/modules/vpn/main.tf | 2 +- infra/modules/vpn/variables.tf | 2 +- infra/terraform.tfvars.example | 2 +- infra/variables.tf | 2 +- readme.md | 6 ++--- 11 files changed, 39 insertions(+), 39 deletions(-) diff --git a/ansible/README.md b/ansible/README.md index 0555d70..c1f3666 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -59,7 +59,7 @@ public IP, so you must bring `vpn` up and connect to it before `dev`/`prod` are reachable at all. ```sh -# 1. vpn first - its firewall accepts SSH from var.allowed_ssh_source_ips directly +# 1. vpn first - its firewall accepts SSH from var.allowed_source_ips directly ansible-playbook playbooks/bootstrap.yml -l role_vpn ansible-playbook playbooks/deploy.yml -l role_vpn ansible-playbook playbooks/spinup.yml -l role_vpn diff --git a/infra/main.tf b/infra/main.tf index 9c529c2..7a2ee8d 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -8,7 +8,7 @@ locals { # SSH to dev/prod is only permitted from the vpn server's public IP: admins must # tunnel through the VPN first, whose egress traffic is then sourced from this IP. # vpn itself can't require tunneling through itself, so it keeps - # var.allowed_ssh_source_ips for direct SSH access. + # var.allowed_source_ips for direct SSH access. vpn_ssh_source_ips = ["${module.vpn.ipv4}/32"] # IDs for every named key in var.ssh_key_names - installed on every server. @@ -56,14 +56,14 @@ module "network" { module "dev" { source = "./modules/dev" - server_type = var.dev_server_type - image = var.server_image - location = var.location - ssh_key_ids = local.ssh_key_ids - network_id = module.network.id - private_ip = var.dev_private_ip - allowed_ssh_source_ips = local.vpn_ssh_source_ips - network_ip_range = var.network_ip_range + server_type = var.dev_server_type + image = var.server_image + location = var.location + ssh_key_ids = local.ssh_key_ids + network_id = module.network.id + private_ip = var.dev_private_ip + allowed_source_ips = local.vpn_ssh_source_ips + network_ip_range = var.network_ip_range # module.network.id alone doesn't guarantee the subnet exists yet, and a server # can't join a network before it has a subnet. @@ -73,14 +73,14 @@ module "dev" { module "prod" { source = "./modules/prod" - server_type = var.prod_server_type - image = var.server_image - location = var.location - ssh_key_ids = local.ssh_key_ids - network_id = module.network.id - private_ip = var.prod_private_ip - allowed_ssh_source_ips = local.vpn_ssh_source_ips - network_ip_range = var.network_ip_range + server_type = var.prod_server_type + image = var.server_image + location = var.location + ssh_key_ids = local.ssh_key_ids + network_id = module.network.id + private_ip = var.prod_private_ip + allowed_source_ips = local.vpn_ssh_source_ips + network_ip_range = var.network_ip_range depends_on = [module.network] } @@ -88,11 +88,11 @@ module "prod" { module "vpn" { source = "./modules/vpn" - server_type = var.vpn_server_type - image = var.server_image - location = var.location - ssh_key_ids = local.ssh_key_ids - allowed_ssh_source_ips = var.allowed_ssh_source_ips + server_type = var.vpn_server_type + image = var.server_image + location = var.location + ssh_key_ids = local.ssh_key_ids + allowed_source_ips = var.allowed_source_ips } module "dns" { diff --git a/infra/modules/dev/main.tf b/infra/modules/dev/main.tf index 9f9517f..7231f3b 100644 --- a/infra/modules/dev/main.tf +++ b/infra/modules/dev/main.tf @@ -8,7 +8,7 @@ resource "hcloud_firewall" "this" { direction = "in" protocol = "tcp" port = "22" - source_ips = var.allowed_ssh_source_ips + source_ips = var.allowed_source_ips } rule { # gitea ssh diff --git a/infra/modules/dev/variables.tf b/infra/modules/dev/variables.tf index a9d7899..e1cedc8 100644 --- a/infra/modules/dev/variables.tf +++ b/infra/modules/dev/variables.tf @@ -28,7 +28,7 @@ variable "private_ip" { type = string } -variable "allowed_ssh_source_ips" { +variable "allowed_source_ips" { description = "CIDRs allowed to reach port 22 on dev. Set by the root module to the vpn server's public IP." type = list(string) } diff --git a/infra/modules/prod/main.tf b/infra/modules/prod/main.tf index 931c902..0acf96a 100644 --- a/infra/modules/prod/main.tf +++ b/infra/modules/prod/main.tf @@ -6,7 +6,7 @@ resource "hcloud_firewall" "this" { direction = "in" protocol = "tcp" port = "22" - source_ips = var.allowed_ssh_source_ips + source_ips = var.allowed_source_ips } rule { # Traefik http/https @@ -27,35 +27,35 @@ resource "hcloud_firewall" "this" { direction = "in" protocol = "tcp" port = "21115" - source_ips = ["0.0.0.0/0", "::/0"] + source_ips = var.allowed_source_ips } rule { direction = "in" protocol = "tcp" port = "21116" - source_ips = ["0.0.0.0/0", "::/0"] + source_ips = var.allowed_source_ips } rule { direction = "in" protocol = "udp" port = "21116" - source_ips = ["0.0.0.0/0", "::/0"] + source_ips = var.allowed_source_ips } rule { # rustdesk hbbr direction = "in" protocol = "tcp" port = "21117" - source_ips = ["0.0.0.0/0", "::/0"] + source_ips = var.allowed_source_ips } rule { direction = "in" protocol = "tcp" port = "21119" - source_ips = ["0.0.0.0/0", "::/0"] + source_ips = var.allowed_source_ips } rule { # traffic from over the private network diff --git a/infra/modules/prod/variables.tf b/infra/modules/prod/variables.tf index 860bb14..4aabbe2 100644 --- a/infra/modules/prod/variables.tf +++ b/infra/modules/prod/variables.tf @@ -28,7 +28,7 @@ variable "private_ip" { type = string } -variable "allowed_ssh_source_ips" { +variable "allowed_source_ips" { description = "CIDRs allowed to reach port 22 on prod. Set by the root module to the vpn server's public IP." type = list(string) } diff --git a/infra/modules/vpn/main.tf b/infra/modules/vpn/main.tf index d7e5ef3..d29ea3f 100644 --- a/infra/modules/vpn/main.tf +++ b/infra/modules/vpn/main.tf @@ -6,7 +6,7 @@ resource "hcloud_firewall" "this" { direction = "in" protocol = "tcp" port = "22" - source_ips = var.allowed_ssh_source_ips + source_ips = var.allowed_source_ips } rule { # Traefik http/https diff --git a/infra/modules/vpn/variables.tf b/infra/modules/vpn/variables.tf index f2098f0..956e890 100644 --- a/infra/modules/vpn/variables.tf +++ b/infra/modules/vpn/variables.tf @@ -18,7 +18,7 @@ variable "ssh_key_ids" { type = list(string) } -variable "allowed_ssh_source_ips" { +variable "allowed_source_ips" { description = "CIDRs allowed to reach port 22 on vpn." type = list(string) } diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example index e59d991..a6bc1b3 100644 --- a/infra/terraform.tfvars.example +++ b/infra/terraform.tfvars.example @@ -14,5 +14,5 @@ ssh_key_names = ["luke-else@PC", "luke-else@laptop"] # dev_server_type = "cx23" # prod_server_type = "cx23" # vpn_server_type = "cx23" -# allowed_ssh_source_ips = ["0.0.0.0/0"] +# allowed_source_ips = ["0.0.0.0/0"] # dns_zones = ["luke-else.co.uk"] \ No newline at end of file diff --git a/infra/variables.tf b/infra/variables.tf index 2acc995..9890405 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -63,7 +63,7 @@ variable "ssh_key_names" { type = list(string) } -variable "allowed_ssh_source_ips" { +variable "allowed_source_ips" { description = "CIDRs allowed to reach port 22 on the vpn server. Narrow this to your own IP(s) once known. dev and prod don't use this - their SSH is restricted to the vpn server's own public IP instead (see infra/main.tf)." type = list(string) default = ["0.0.0.0/0", "::/0"] diff --git a/readme.md b/readme.md index 042d5bb..d1f9502 100644 --- a/readme.md +++ b/readme.md @@ -63,7 +63,7 @@ architecture-beta | `prod` | Public websites, Bitwarden, RustDesk, status page, prod Traefik | Private network + public firewall | | `vpn` | WireGuard (wg-easy) + its own Traefik | **Not** on the private network — isolated so a compromised VPN endpoint can't pivot to `dev`/`prod` | -`dev` and `prod` share a private Hetzner network (`10.0.1.0/24` by default) so CI/CD on `dev` can reach `prod` without exposing that traffic publicly. Each server has its own Hetzner Cloud Firewall (`infra/modules//main.tf`) opening only the ports its own compose stacks use, plus SSH restricted to `var.allowed_ssh_source_ips`. +`dev` and `prod` share a private Hetzner network (`10.0.1.0/24` by default) so CI/CD on `dev` can reach `prod` without exposing that traffic publicly. Each server has its own Hetzner Cloud Firewall (`infra/modules//main.tf`) opening only the ports its own compose stacks use, plus SSH restricted to `var.allowed_source_ips`. ## Repository layout @@ -167,7 +167,7 @@ cd ansible ansible-galaxy collection install -r requirements.yml export HCLOUD_TOKEN=your-hetzner-api-token # never commit this -# vpn first - its firewall accepts SSH from var.allowed_ssh_source_ips directly +# vpn first - its firewall accepts SSH from var.allowed_source_ips directly ansible-playbook playbooks/site.yml -l role_vpn # SSH to vpn as deploy and connect to the WireGuard tunnel it just started, then: @@ -272,7 +272,7 @@ Then reopen the repo in VS Code with the Dev Containers extension. ## Security notes - Real secrets (`HCLOUD_TOKEN`, `BACKUP_S3_*`, `*.tfvars`, your SSH private key) must never be committed — see `.gitignore`. S3 credentials land in each host's `services//.env` (mode `0600`, gitignored, rendered by Ansible). -- SSH to `dev`/`prod` is restricted to `vpn`'s own public IP — you must be tunneled into the VPN to reach them. SSH to `vpn` itself is gated by `var.allowed_ssh_source_ips`; narrow this from the default `0.0.0.0/0` once you know your egress IP(s). +- SSH to `dev`/`prod` is restricted to `vpn`'s own public IP — you must be tunneled into the VPN to reach them. SSH to `vpn` itself is gated by `var.allowed_source_ips`; narrow this from the default `0.0.0.0/0` once you know your egress IP(s). - Firewalls are per-host allowlists (`infra/modules//main.tf`) — only ports actually used by that host's compose stacks are open. - Ansible's bootstrap role disables SSH password auth, restricts root login to key-only, and creates a separate sudo user (`deploy_user`) for day-to-day access. - DNS zones carry both Hetzner's `delete_protection` and Terraform's `prevent_destroy` — losing a zone takes every record in it with it, across all three managed domains.