diff --git a/ansible/README.md b/ansible/README.md index f0ff35a..1c32d8e 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -14,16 +14,19 @@ to S3, not on a Hetzner volume - see `../readme.md`. - `inventory/hcloud.yml` - dynamic inventory, queries the Hetzner Cloud API directly (not Terraform state) and groups servers by their `role` label (`role_dev`, `role_prod`, `role_vpn` - set in `infra/modules//main.tf`). -- `group_vars/` - `all.yml` (shared: `deploy_user`, SSH key path, - `backup_s3_*` S3 backup credentials), `role_dev.yml` / `role_prod.yml` / - `role_vpn.yml` (per-role vars, e.g. `service_group`). - `roles/bootstrap/` - Docker install, `deploy` user creation, SSH hardening, unattended-upgrades. Replaces `infra/scripts/bootstrap.sh.tftpl`. - `roles/deploy/` - copies `services//` to the server and renders `.env` (S3 backup credentials, see `../readme.md`) and, for `dev`, `Runners/docker-compose.yml`. - `playbooks/` - `bootstrap.yml`, `deploy.yml`, `spinup.yml`, `spindown.yml`, - and `site.yml` (all three in order). + `site.yml` (all three in order), and `group_vars/` (`all.yml` - shared: + `deploy_user`, SSH key path, `backup_s3_*` S3 backup credentials; + `role_dev.yml` / `role_prod.yml` / `role_vpn.yml` - per-role vars, e.g. + `service_group`). `group_vars/` lives here rather than at the `ansible/` + root because Ansible only auto-loads `group_vars/`/`host_vars/` from + directories adjacent to the playbook file or the inventory source - not + from an arbitrary sibling directory. ## Prerequisites @@ -41,7 +44,7 @@ export BACKUP_S3_ENDPOINT=your-s3-endpoint # omit for real AWS S3 persists your answers to `../.control.env` (gitignored) so you don't have to re-export them every session. -`deploy_user` (see `group_vars/all.yml`) defaults to `deploy` - override with +`deploy_user` (see `playbooks/group_vars/all.yml`) defaults to `deploy` - override with the `DEPLOY_USER` env var if you want a different non-root user created on each host. `ansible_ssh_private_key_file` (same file) defaults to `~/.ssh/id_ed25519` - override with `ANSIBLE_SSH_PRIVATE_KEY_FILE` if you use a diff --git a/ansible/group_vars/all.yml b/ansible/playbooks/group_vars/all.yml similarity index 100% rename from ansible/group_vars/all.yml rename to ansible/playbooks/group_vars/all.yml diff --git a/ansible/group_vars/role_dev.yml b/ansible/playbooks/group_vars/role_dev.yml similarity index 100% rename from ansible/group_vars/role_dev.yml rename to ansible/playbooks/group_vars/role_dev.yml diff --git a/ansible/group_vars/role_prod.yml b/ansible/playbooks/group_vars/role_prod.yml similarity index 100% rename from ansible/group_vars/role_prod.yml rename to ansible/playbooks/group_vars/role_prod.yml diff --git a/ansible/group_vars/role_vpn.yml b/ansible/playbooks/group_vars/role_vpn.yml similarity index 100% rename from ansible/group_vars/role_vpn.yml rename to ansible/playbooks/group_vars/role_vpn.yml diff --git a/infra/main.tf b/infra/main.tf index fd6d20b..161587a 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -31,6 +31,17 @@ locals { { zone = "luke-else.co.uk", name = "vpn", value = module.vpn.ipv4 }, { zone = "luke-else.co.uk", name = "traefik.vpn", value = module.vpn.ipv4 }, ] + + # Microsoft 365 mail records for luke-else.co.uk - carried over from the + # domain's old DNS provider (pre-Hetzner), not previously applied here, so + # these are genuinely new to the Hetzner zone rather than something to + # import. + mail_records = [ + { zone = "luke-else.co.uk", name = "@", type = "MX", value = "0 lukeelse-co-uk01b.mail.protection.outlook.com." }, + { zone = "luke-else.co.uk", name = "@", type = "TXT", value = "\"v=spf1 include:spf.protection.outlook.com -all\"" }, + { zone = "luke-else.co.uk", name = "@", type = "TXT", value = "\"google-site-verification=aKLk5BATowKUwNbFcU5zyfu-AIyCW9W9PBStePRjH3U\"" }, + { zone = "luke-else.co.uk", name = "autodiscover", type = "CNAME", value = "autodiscover.outlook.com." }, + ] } module "network" { @@ -87,5 +98,5 @@ module "dns" { source = "./modules/dns" zone_names = var.dns_zones - records = local.dns_records + records = concat(local.dns_records, local.mail_records) } diff --git a/infra/modules/dns/main.tf b/infra/modules/dns/main.tf index 08952ee..7e8d92e 100644 --- a/infra/modules/dns/main.tf +++ b/infra/modules/dns/main.tf @@ -14,15 +14,29 @@ resource "hcloud_zone" "this" { } } -resource "hcloud_zone_rrset" "a" { - for_each = { for r in var.records : "${r.zone}/${r.name}" => r } +locals { + # Group flat var.records entries by zone/name/type into one set of values + # each, since a Hetzner RRSet is keyed on (zone, name, type) and can hold + # several values (e.g. an SPF and a google-site-verification TXT both live + # under the same "@" TXT RRSet). + rrsets = { + for key, items in { for r in var.records : "${r.zone}/${r.name}/${r.type}" => r... } : key => { + zone = items[0].zone + name = items[0].name + type = items[0].type + ttl = items[0].ttl + values = [for i in items : i.value] + } + } +} + +resource "hcloud_zone_rrset" "this" { + for_each = local.rrsets zone = hcloud_zone.this[each.value.zone].id name = each.value.name - type = "A" + type = each.value.type ttl = each.value.ttl - records = [ - { value = each.value.value } - ] + records = [for v in each.value.values : { value = v }] } diff --git a/infra/modules/dns/moved.tf b/infra/modules/dns/moved.tf new file mode 100644 index 0000000..8bfb840 --- /dev/null +++ b/infra/modules/dns/moved.tf @@ -0,0 +1,60 @@ +# Generalizing hcloud_zone_rrset.a (for_each keyed "zone/name", A records +# only) into hcloud_zone_rrset.this (keyed "zone/name/type", any record type) +# changed every existing instance's resource address. Without these, `tofu +# apply` would destroy and recreate every A record already in state instead of +# just adding the new mail records - these moved blocks make it a no-op rename +# for anything that already existed. Safe to delete once applied and merged. +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/@"] + to = hcloud_zone_rrset.this["luke-else.co.uk/@/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/dev"] + to = hcloud_zone_rrset.this["luke-else.co.uk/dev/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/metarius"] + to = hcloud_zone_rrset.this["luke-else.co.uk/metarius/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/traefik"] + to = hcloud_zone_rrset.this["luke-else.co.uk/traefik/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/status"] + to = hcloud_zone_rrset.this["luke-else.co.uk/status/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/bitwarden"] + to = hcloud_zone_rrset.this["luke-else.co.uk/bitwarden/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/rd"] + to = hcloud_zone_rrset.this["luke-else.co.uk/rd/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/git"] + to = hcloud_zone_rrset.this["luke-else.co.uk/git/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/traefik.cicd"] + to = hcloud_zone_rrset.this["luke-else.co.uk/traefik.cicd/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/vpn"] + to = hcloud_zone_rrset.this["luke-else.co.uk/vpn/A"] +} + +moved { + from = hcloud_zone_rrset.a["luke-else.co.uk/traefik.vpn"] + to = hcloud_zone_rrset.this["luke-else.co.uk/traefik.vpn/A"] +} diff --git a/infra/modules/dns/variables.tf b/infra/modules/dns/variables.tf index 7f8265e..7de9e57 100644 --- a/infra/modules/dns/variables.tf +++ b/infra/modules/dns/variables.tf @@ -4,11 +4,20 @@ variable "zone_names" { } variable "records" { - description = "A records to create across the zones in var.zone_names. Use name = \"@\" for the zone apex." + description = <<-EOT + Records to create across the zones in var.zone_names. Use name = "@" for + the zone apex. Entries sharing the same zone/name/type become a single + Hetzner RRSet with multiple values (e.g. two TXT strings under the same + name) - Hetzner has no concept of separate same-type records otherwise. + For MX, embed the priority in value using standard zonefile syntax (e.g. + "10 mail.example.com."); Hetzner's API has no separate priority field. + For TXT, wrap value in escaped double quotes (e.g. "\"v=spf1 ...\""). + EOT type = list(object({ zone = string name = string value = string + type = optional(string, "A") ttl = optional(number, 300) })) default = [] diff --git a/infra/variables.tf b/infra/variables.tf index e974430..c8c811f 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -13,25 +13,25 @@ variable "network_zone" { variable "server_image" { description = "OS image used for all servers." type = string - default = "ubuntu-24.04" + default = "ubuntu-26.04" } variable "dev_server_type" { description = "Server type for dev" type = string - default = "cx22" + default = "cx23" } variable "prod_server_type" { description = "Server type for prod" type = string - default = "cx22" + default = "cx23" } variable "vpn_server_type" { description = "Server type for vpn" type = string - default = "cx22" + default = "cx23" } variable "network_ip_range" { diff --git a/readme.md b/readme.md index d2bb5a7..d16673e 100644 --- a/readme.md +++ b/readme.md @@ -85,11 +85,12 @@ Each host's stateful data lives in local named Docker volumes, backed up nightly │ └── dns/ # Hetzner DNS zones + A records for every domain in var.dns_zones ├── ansible/ # Ansible: bootstraps each server and deploys/starts services// onto it │ ├── inventory/hcloud.yml # dynamic inventory — queries the Hetzner API, groups by the role label above -│ ├── group_vars/ # deploy_user, service_group, backup_s3_*, etc. │ ├── roles/ │ │ ├── bootstrap/ # Docker, deploy user, sshd hardening, unattended-upgrades │ │ └── deploy/ # copies services//, renders .env (S3 backup credentials) + Runners/docker-compose.yml -│ └── playbooks/ # bootstrap.yml, deploy.yml, spinup.yml, spindown.yml, site.yml +│ └── playbooks/ # bootstrap.yml, deploy.yml, spinup.yml, spindown.yml, site.yml, +│ └── group_vars/ # deploy_user, service_group, backup_s3_*, etc. - must sit next to +│ # the playbooks for Ansible to auto-load it (see ansible/README.md) ├── 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 @@ -159,13 +160,23 @@ OpenTofu never connects to the servers over SSH — no provisioners, no `bootstr ### Managing DNS -`module.dns` (in `infra/modules/dns`) creates one Hetzner DNS zone per domain in `var.dns_zones` (default: `luke-else.co.uk`, `divine-couture.co.uk`, `snexo.co.uk`) and an `hcloud_zone_rrset` A record for every hostname currently referenced by a Traefik `Host()` rule anywhere under `services/` — kept in sync with `local.dns_records` in `infra/main.tf`, pointed at whichever of `dev`/`prod`/`vpn` actually serves it. Zones have `prevent_destroy = true`, matching the volumes — losing one deletes every record in it. +`module.dns` (in `infra/modules/dns`) creates one Hetzner DNS zone per domain in `var.dns_zones` (default: `luke-else.co.uk`, `divine-couture.co.uk`, `snexo.co.uk`) and an `hcloud_zone_rrset` per record in `var.records` — kept in sync with `local.dns_records` (A records, one per hostname currently referenced by a Traefik `Host()` rule anywhere under `services/`, pointed at whichever of `dev`/`prod`/`vpn` actually serves it) and `local.mail_records` (see below) in `infra/main.tf`. Zones have `prevent_destroy = true`, matching the volumes — losing one deletes every record in it. Creating the zone doesn't make Hetzner authoritative for the domain by itself: you still need to point that domain's NS records at Hetzner's nameservers at whichever registrar it's registered through. Run `tofu output dns_nameservers` after applying to get the exact nameservers per domain, and set those as the domain's NS records at the registrar. Propagation can take a while depending on the registrar and the domain's previous NS TTL. Adding a new subdomain: add an entry to `local.dns_records` in `infra/main.tf` (zone, name, and the target `module..ipv4`) and re-run `tofu apply` — don't hand-create records in the Hetzner console, they'll drift from state. Note `name = "@"` is Hetzner's convention for a zone's apex record (e.g. bare `snexo.co.uk`), not an empty string. -Only A records for IPv4 are managed here — none of the modules currently track servers' IPv6 addresses, so AAAA records aren't generated even though the firewalls already allow IPv6 traffic. +Only A records for IPv4 are managed for hosts — none of the modules currently track servers' IPv6 addresses, so AAAA records aren't generated even though the firewalls already allow IPv6 traffic. + +`var.records` isn't limited to A records: each entry takes an optional `type` (`A` by default, or `MX`/`TXT`/`CNAME`/etc.), and entries sharing the same `zone`/`name`/`type` are merged into a single Hetzner RRSet with multiple values (needed for e.g. two TXT strings under the same name) - see `infra/modules/dns/variables.tf` for the exact value format each type expects (MX embeds the priority in the value string, TXT values need escaped double quotes). + +#### Mail records (Microsoft 365) + +`local.mail_records` in `infra/main.tf` tracks the DNS records Microsoft 365 needs for `luke-else.co.uk` - the MX record, the SPF and Google-site-verification TXT records (merged into one `@` TXT RRSet), and the `autodiscover` CNAME. These carried over from the domain's DNS provider prior to Hetzner and were never applied to the Hetzner zone, so they're genuinely new records here (not something to `tofu import` - nothing to collide with). + +**Not yet included:** a DMARC policy (`_dmarc` TXT) and DKIM signing (two `selector1._domainkey`/`selector2._domainkey` CNAMEs). DKIM's targets are generated per-tenant by Microsoft 365 (Defender portal → Email & collaboration → Policies & rules → DKIM) and can't be guessed - enable DKIM there first, then add the two CNAMEs it gives you to `local.mail_records`. + +`infra/modules/dns/moved.tf` re-points every pre-existing A record at its new resource address, since generalizing the module to support non-A record types changed how they're keyed internally - without it, `tofu apply` would destroy and recreate every A record for no functional reason. It's a one-time migration aid: run `tofu plan` first to confirm the only real changes are the new mail records being created, then the file can be deleted after a successful apply. ## Bootstrapping and deploying with Ansible (`ansible/`) @@ -310,5 +321,5 @@ Then reopen the repo in VS Code with the Dev Containers extension. - SSH to `dev` and `prod` is restricted to the `vpn` server's own public IP — you must be tunneled into the VPN to reach them over SSH. 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 own egress IP(s). - `vpn` is intentionally excluded from the private network so that a compromised VPN endpoint cannot reach `dev` or `prod` directly over it — SSH access still works because the VPN's egress traffic is NATed through its own public IP. - Firewalls are allowlists scoped per host in `infra/modules//main.tf` — only ports actually used by that host's compose stacks (plus the cross-host private network range) are open. -- Every host's [Ansible bootstrap role](ansible/roles/bootstrap/tasks/main.yml) disables SSH password authentication, restricts root login to key-only, and creates a separate sudo user (`deploy_user`, see `ansible/group_vars/all.yml`) for day-to-day access. +- Every host's [Ansible bootstrap role](ansible/roles/bootstrap/tasks/main.yml) disables SSH password authentication, restricts root login to key-only, and creates a separate sudo user (`deploy_user`, see `ansible/playbooks/group_vars/all.yml`) for day-to-day access. - DNS zones (`modules/dns`) carry both Hetzner's own `delete_protection` and Terraform's `prevent_destroy` — losing a zone takes every record in it with it, including for `divine-couture.co.uk` and `snexo.co.uk`, not just `luke-else.co.uk`. diff --git a/services/dev/backup-docker-compose.yml b/services/dev/backup-docker-compose.yml index c9a18e1..26b676a 100644 --- a/services/dev/backup-docker-compose.yml +++ b/services/dev/backup-docker-compose.yml @@ -26,7 +26,7 @@ services: # Run explicitly by spinup.sh before the services that own these volumes # start - not started by `docker compose up`. restore: - image: amazon/aws-cli:2 + image: amazon/aws-cli:latest container_name: backup-restore entrypoint: ["/bin/bash", "/restore.sh"] environment: diff --git a/services/prod/backup-docker-compose.yml b/services/prod/backup-docker-compose.yml index 7e996f0..7263c2b 100644 --- a/services/prod/backup-docker-compose.yml +++ b/services/prod/backup-docker-compose.yml @@ -31,7 +31,7 @@ services: # Run explicitly by spinup.sh before the services that own these volumes # start - not started by `docker compose up`. restore: - image: amazon/aws-cli:2 + image: amazon/aws-cli:latest container_name: backup-restore entrypoint: [ "/bin/bash", "/restore.sh" ] environment: diff --git a/services/vpn/backup-docker-compose.yml b/services/vpn/backup-docker-compose.yml index 0c2ae8e..92d40c7 100644 --- a/services/vpn/backup-docker-compose.yml +++ b/services/vpn/backup-docker-compose.yml @@ -26,7 +26,7 @@ services: # Run explicitly by spinup.sh before the services that own these volumes # start - not started by `docker compose up`. restore: - image: amazon/aws-cli:2 + image: amazon/aws-cli:latest container_name: backup-restore entrypoint: ["/bin/bash", "/restore.sh"] environment: diff --git a/services/vpn/vpn-docker-compose.yml b/services/vpn/vpn-docker-compose.yml index f78e1af..9d0739c 100644 --- a/services/vpn/vpn-docker-compose.yml +++ b/services/vpn/vpn-docker-compose.yml @@ -2,13 +2,15 @@ services: dockovpn: image: alekslitvinenk/openvpn cap_add: - - NET_ADMIN + - NET_ADMIN ports: - - 1194:1194/udp # Expose tcp if you defined HOST_TUN_PROTOCOL=tcp + - 1194:1194/udp # Expose tcp if you defined HOST_TUN_PROTOCOL=tcp + networks: + - proxy environment: - HOST_ADDR: vpn.luke-else.co.uk # Your VPN server address + HOST_ADDR: vpn.luke-else.co.uk # Your VPN server address volumes: - - openvpn_data:/opt/Dockovpn_data + - openvpn_data:/opt/Dockovpn_data labels: ## Expose vpn Through Trefik ## - "traefik.enable=true" # <== Enable traefik to proxy this container @@ -26,6 +28,10 @@ services: - "traefik.http.routers.vpn.tls.certresolver=myresolver" restart: always +networks: + proxy: + external: true + volumes: openvpn_data: - name: openvpn_data \ No newline at end of file + name: openvpn_data