feat: Limited rustdesk to vpn IP only

This commit is contained in:
2026-08-06 21:06:50 +01:00
parent 0add4d3221
commit eef5c568bb
11 changed files with 39 additions and 39 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ public IP, so you must bring `vpn` up and connect to it before `dev`/`prod`
are reachable at all. are reachable at all.
```sh ```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/bootstrap.yml -l role_vpn
ansible-playbook playbooks/deploy.yml -l role_vpn ansible-playbook playbooks/deploy.yml -l role_vpn
ansible-playbook playbooks/spinup.yml -l role_vpn ansible-playbook playbooks/spinup.yml -l role_vpn
+4 -4
View File
@@ -8,7 +8,7 @@ locals {
# SSH to dev/prod is only permitted from the vpn server's public IP: admins must # 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. # 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 # 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"] vpn_ssh_source_ips = ["${module.vpn.ipv4}/32"]
# IDs for every named key in var.ssh_key_names - installed on every server. # IDs for every named key in var.ssh_key_names - installed on every server.
@@ -62,7 +62,7 @@ module "dev" {
ssh_key_ids = local.ssh_key_ids ssh_key_ids = local.ssh_key_ids
network_id = module.network.id network_id = module.network.id
private_ip = var.dev_private_ip private_ip = var.dev_private_ip
allowed_ssh_source_ips = local.vpn_ssh_source_ips allowed_source_ips = local.vpn_ssh_source_ips
network_ip_range = var.network_ip_range network_ip_range = var.network_ip_range
# module.network.id alone doesn't guarantee the subnet exists yet, and a server # module.network.id alone doesn't guarantee the subnet exists yet, and a server
@@ -79,7 +79,7 @@ module "prod" {
ssh_key_ids = local.ssh_key_ids ssh_key_ids = local.ssh_key_ids
network_id = module.network.id network_id = module.network.id
private_ip = var.prod_private_ip private_ip = var.prod_private_ip
allowed_ssh_source_ips = local.vpn_ssh_source_ips allowed_source_ips = local.vpn_ssh_source_ips
network_ip_range = var.network_ip_range network_ip_range = var.network_ip_range
depends_on = [module.network] depends_on = [module.network]
@@ -92,7 +92,7 @@ module "vpn" {
image = var.server_image image = var.server_image
location = var.location location = var.location
ssh_key_ids = local.ssh_key_ids ssh_key_ids = local.ssh_key_ids
allowed_ssh_source_ips = var.allowed_ssh_source_ips allowed_source_ips = var.allowed_source_ips
} }
module "dns" { module "dns" {
+1 -1
View File
@@ -8,7 +8,7 @@ resource "hcloud_firewall" "this" {
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "22" port = "22"
source_ips = var.allowed_ssh_source_ips source_ips = var.allowed_source_ips
} }
rule { # gitea ssh rule { # gitea ssh
+1 -1
View File
@@ -28,7 +28,7 @@ variable "private_ip" {
type = string 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." description = "CIDRs allowed to reach port 22 on dev. Set by the root module to the vpn server's public IP."
type = list(string) type = list(string)
} }
+6 -6
View File
@@ -6,7 +6,7 @@ resource "hcloud_firewall" "this" {
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "22" port = "22"
source_ips = var.allowed_ssh_source_ips source_ips = var.allowed_source_ips
} }
rule { # Traefik http/https rule { # Traefik http/https
@@ -27,35 +27,35 @@ resource "hcloud_firewall" "this" {
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "21115" port = "21115"
source_ips = ["0.0.0.0/0", "::/0"] source_ips = var.allowed_source_ips
} }
rule { rule {
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "21116" port = "21116"
source_ips = ["0.0.0.0/0", "::/0"] source_ips = var.allowed_source_ips
} }
rule { rule {
direction = "in" direction = "in"
protocol = "udp" protocol = "udp"
port = "21116" port = "21116"
source_ips = ["0.0.0.0/0", "::/0"] source_ips = var.allowed_source_ips
} }
rule { # rustdesk hbbr rule { # rustdesk hbbr
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "21117" port = "21117"
source_ips = ["0.0.0.0/0", "::/0"] source_ips = var.allowed_source_ips
} }
rule { rule {
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "21119" port = "21119"
source_ips = ["0.0.0.0/0", "::/0"] source_ips = var.allowed_source_ips
} }
rule { # traffic from over the private network rule { # traffic from over the private network
+1 -1
View File
@@ -28,7 +28,7 @@ variable "private_ip" {
type = string 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." description = "CIDRs allowed to reach port 22 on prod. Set by the root module to the vpn server's public IP."
type = list(string) type = list(string)
} }
+1 -1
View File
@@ -6,7 +6,7 @@ resource "hcloud_firewall" "this" {
direction = "in" direction = "in"
protocol = "tcp" protocol = "tcp"
port = "22" port = "22"
source_ips = var.allowed_ssh_source_ips source_ips = var.allowed_source_ips
} }
rule { # Traefik http/https rule { # Traefik http/https
+1 -1
View File
@@ -18,7 +18,7 @@ variable "ssh_key_ids" {
type = list(string) type = list(string)
} }
variable "allowed_ssh_source_ips" { variable "allowed_source_ips" {
description = "CIDRs allowed to reach port 22 on vpn." description = "CIDRs allowed to reach port 22 on vpn."
type = list(string) type = list(string)
} }
+1 -1
View File
@@ -14,5 +14,5 @@ ssh_key_names = ["luke-else@PC", "luke-else@laptop"]
# dev_server_type = "cx23" # dev_server_type = "cx23"
# prod_server_type = "cx23" # prod_server_type = "cx23"
# vpn_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"] # dns_zones = ["luke-else.co.uk"]
+1 -1
View File
@@ -63,7 +63,7 @@ variable "ssh_key_names" {
type = list(string) 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)." 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) type = list(string)
default = ["0.0.0.0/0", "::/0"] default = ["0.0.0.0/0", "::/0"]
+3 -3
View File
@@ -63,7 +63,7 @@ architecture-beta
| `prod` | Public websites, Bitwarden, RustDesk, status page, prod Traefik | Private network + public firewall | | `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` | | `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/<host>/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/<host>/main.tf`) opening only the ports its own compose stacks use, plus SSH restricted to `var.allowed_source_ips`.
## Repository layout ## Repository layout
@@ -167,7 +167,7 @@ cd ansible
ansible-galaxy collection install -r requirements.yml ansible-galaxy collection install -r requirements.yml
export HCLOUD_TOKEN=your-hetzner-api-token # never commit this 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 ansible-playbook playbooks/site.yml -l role_vpn
# SSH to vpn as deploy and connect to the WireGuard tunnel it just started, then: # 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 ## 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/<host>/.env` (mode `0600`, gitignored, rendered by Ansible). - 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/<host>/.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/<host>/main.tf`) — only ports actually used by that host's compose stacks are open. - Firewalls are per-host allowlists (`infra/modules/<host>/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. - 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. - 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.