terraform: manage DNS for asxp.io and czsk.it

The zones were edited by hand in the console, and it showed: dozens of
records for servers that no longer existed, a CNAME and a TXT record
missing their trailing dot, and a 24 h default TTL that made today's
XMPP move linger in resolver caches.

Now both zones live in the asxpio project, so describe them here,
adopted with import blocks (the first apply imported 87 and changed
nothing but protection). Our servers' records reference hcloud_server
instead of literal IPs. Every record set is change-protected except
those of the test XMPP servers, and both zones are delete-protected.

Assisted-by: Claude:opus-5-5
This commit is contained in:
Sergei Poljanski 2026-11-12 22:46:53 +04:00
commit 32836d5d53
2 changed files with 172 additions and 33 deletions

View file

@ -27,6 +27,18 @@ day-to-day configuration is in `../ansible`.
- asxpio-backups-repl (fsn1)
## DNS
`dns.tf` holds the zones asxp.io and czsk.it (Hetzner DNS, this project)
and every record set except SOA and NS. Our servers' records reference
`hcloud_server`, so moving a service is one edit.
All record sets are change-protected except the test XMPP servers'
(`prosody`, `ejabberd`, `anotherim`); both zones are delete-protected.
To change a protected record set, apply twice: first with the set
excluded from protection, then with the new value. Hand edits in the
console show up as drift in `terraform plan`.
## Network
- VPC: 10.10.0.0/20

View file

@ -1,34 +1,161 @@
# Hetzner Cloud DNS Management
# =================================
# DNS for asxp.io and czsk.it (Hetzner DNS, project asxpio). Every record
# set of both zones is here except SOA and NS, which Hetzner manages.
#
# DNS zones and records are now managed by Terraform.
#
# Existing Zones:
# - asxp.io (ID: 61604) - 34 records
# - czsk.it (ID: 61606) - 7 records
# - childfr.ee (ID: 61605) - 13 records
#
# To manage DNS records:
# Use the 'hcloud_record' resource in Terraform.
#
# Key records to update during migration:
# - asxp.io: @, git, builds, upload, onitama, proxy, xmpp, turn, photos
# - czsk.it: @
# - childfr.ee: @
# Keys are "<name>/<type>"; "@" is the apex. Records of our own servers
# point at the server resources, so moving a service is one edit here.
# Every record set has Hetzner's change protection except those of the
# test servers in local.unprotected: a protected set cannot be changed or
# deleted until protection is lifted, so a change to one is two applies
# (set protection off for it, then edit). Both zones are delete-protected.
#resource "hcloud_zone" "czsk_it" {
# name = "czsk.it"
# mode = "primary"
# labels = {
# managed_by = "terraform"
# }
#}
locals {
ip = {
ops_v4 = hcloud_server.ops.ipv4_address
ops_v6 = hcloud_server.ops.ipv6_address
services_v4 = hcloud_server.services.ipv4_address
services_v6 = hcloud_server.services.ipv6_address
}
#resource "hcloud_zone_rrset" "apex_a" {
# zone = hcloud_zone.czsk_it.name
# name = "test"
# type = "A"
# records = [
# { value = "127.0.0.1", comment = "test" },
# ]
#}
zones = {
"asxp.io" = { ttl = 3600, delete_protection = true }
"czsk.it" = { ttl = 86400, delete_protection = true }
}
# Test XMPP servers (prosody, ejabberd, anotherim) and their subdomains
unprotected = "(^|\\.)(prosody|ejabberd|anotherim)$"
dns = {
"asxp.io" = {
"@/A" = { records = [local.ip.ops_v4] }
"anotherim/A" = { records = ["178.105.241.125"] }
"blog/A" = { records = [local.ip.ops_v4], ttl = 86400 }
"conference/A" = { records = [local.ip.services_v4], ttl = 300 }
"conference.anotherim/A" = { records = ["178.105.241.125"] }
"conference.ejabberd/A" = { records = ["178.105.240.144"] }
"conference.prosody/A" = { records = ["178.105.250.162"] }
"conference.xmpp/A" = { records = [local.ip.services_v4], ttl = 300 }
"ejabberd/A" = { records = ["178.105.240.144"] }
"mc/A" = { records = [local.ip.services_v4] }
"prosody/A" = { records = ["178.105.250.162"] }
"proxy.anotherim/A" = { records = ["178.105.241.125"] }
"proxy.ejabberd/A" = { records = ["178.105.240.144"] }
"proxy.prosody/A" = { records = ["178.105.250.162"] }
"pubsub.anotherim/A" = { records = ["178.105.241.125"] }
"pubsub.ejabberd/A" = { records = ["178.105.240.144"] }
"pubsub.prosody/A" = { records = ["178.105.250.162"] }
"s3/A" = { records = [local.ip.ops_v4] }
"traefik/A" = { records = [local.ip.ops_v4] }
"upload.ejabberd/A" = { records = ["178.105.240.144"] }
"upload.prosody/A" = { records = ["178.105.250.162"] }
"www/A" = { records = [local.ip.ops_v4] }
"xmpp/A" = { records = [local.ip.services_v4], ttl = 300 }
"@/AAAA" = { records = [local.ip.ops_v6] }
"anotherim/AAAA" = { records = ["2a01:4f8:c01e:27d9::1"] }
"blog/AAAA" = { records = [local.ip.ops_v6], ttl = 86400 }
"conference/AAAA" = { records = [local.ip.services_v6], ttl = 300 }
"conference.anotherim/AAAA" = { records = ["2a01:4f8:c01e:27d9::1"] }
"conference.ejabberd/AAAA" = { records = ["2a01:4f8:c01e:11b6::1"] }
"conference.prosody/AAAA" = { records = ["2a01:4f8:c01e:2891::1"] }
"conference.xmpp/AAAA" = { records = [local.ip.services_v6], ttl = 300 }
"ejabberd/AAAA" = { records = ["2a01:4f8:c01e:11b6::1"] }
"prosody/AAAA" = { records = ["2a01:4f8:c01e:2891::1"] }
"proxy.anotherim/AAAA" = { records = ["2a01:4f8:c01e:27d9::1"] }
"proxy.ejabberd/AAAA" = { records = ["2a01:4f8:c01e:11b6::1"] }
"proxy.prosody/AAAA" = { records = ["2a01:4f8:c01e:2891::1"] }
"pubsub.anotherim/AAAA" = { records = ["2a01:4f8:c01e:27d9::1"] }
"pubsub.ejabberd/AAAA" = { records = ["2a01:4f8:c01e:11b6::1"] }
"pubsub.prosody/AAAA" = { records = ["2a01:4f8:c01e:2891::1"] }
"s3/AAAA" = { records = [local.ip.ops_v6] }
"upload.ejabberd/AAAA" = { records = ["2a01:4f8:c01e:11b6::1"] }
"upload.prosody/AAAA" = { records = ["2a01:4f8:c01e:2891::1"] }
"www/AAAA" = { records = [local.ip.ops_v6] }
"xmpp/AAAA" = { records = [local.ip.services_v6], ttl = 300 }
"fm1._domainkey/CNAME" = { records = ["fm1.asxp.io.dkim.fmhosted.com."] }
"fm2._domainkey/CNAME" = { records = ["fm2.asxp.io.dkim.fmhosted.com."] }
"fm3._domainkey/CNAME" = { records = ["fm3.asxp.io.dkim.fmhosted.com."] }
"git/CNAME" = { records = ["git.czsk.it."], ttl = 86400 }
"mail/CNAME" = { records = ["mail.fastmail.com."] }
"@/MX" = { records = ["10 in1-smtp.messagingengine.com.", "20 in2-smtp.messagingengine.com."] }
"@/TXT" = { records = ["\"google-site-verification=H1sj22-DZ-4uW_Ri7z18RDKXo50bPwJXqIT1SyjthZg\"", "\"nfd=5777245\"", "\"v=spf1 include:spf.messagingengine.com ?all\""] }
"_discord/TXT" = { records = ["\"dh=8d39ced1e23afda4492219ee2456a81d209fecaa\""] }
"_dmarc/TXT" = { records = ["\"v=DMARC1;p=reject;rua=mailto:tr@asxp.io;ruf=mailto:tr@asxp.io;rf=afrf;pct=100\""] }
"mail._domainkey/TXT" = { records = ["\"k=rsa; t=s; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0X0brpvueSc7IiGZA2Qx zu12CWjeSN7/SquxrZM+afjU2If9BL5MHiE9iza/k6HgVnvc7exfMUdCHeLPUJJr WUIgCpYUR6ogJF0pe402zltYBExxjJFIMASAXuhTi0Gs4nc7fcfM56zSLckmJpO9 6jMrZPM7x3BxCeJt79j+4NLegClP8d5eRk1aByz9rujRYE\" \" rSJzaUcHuR47uEE5wz bSbY64hUadaSduBuD/OnAy0F3iqMSzVV5KNzDnAyWD4V7QcEWK7GAF9nuj3KhhS9 QLSHydPw/CCrhrXVjcprDDpfDG8dQw1JPfYPKs8h+Z44SGFw1ZxvqYIUmQ6mYrxv uwIDAQAB\""] }
"_stun._tcp/SRV" = { records = ["0 5 3478 xmpp.asxp.io."] }
"_stun._tcp.anotherim/SRV" = { records = ["0 5 3478 anotherim.asxp.io."] }
"_stun._tcp.ejabberd/SRV" = { records = ["0 5 3478 ejabberd.asxp.io."] }
"_stun._udp/SRV" = { records = ["0 5 3478 xmpp.asxp.io."] }
"_stun._udp.anotherim/SRV" = { records = ["0 5 3478 anotherim.asxp.io."] }
"_stun._udp.ejabberd/SRV" = { records = ["0 5 3478 ejabberd.asxp.io."] }
"_turn._tcp/SRV" = { records = ["0 5 3478 xmpp.asxp.io."] }
"_turn._tcp.anotherim/SRV" = { records = ["0 5 3478 anotherim.asxp.io."] }
"_turn._tcp.ejabberd/SRV" = { records = ["0 5 3478 ejabberd.asxp.io."] }
"_turn._udp/SRV" = { records = ["0 5 3478 xmpp.asxp.io."] }
"_turn._udp.anotherim/SRV" = { records = ["0 5 3478 anotherim.asxp.io."] }
"_turn._udp.ejabberd/SRV" = { records = ["0 5 3478 ejabberd.asxp.io."] }
"_xmpp-client._tcp/SRV" = { records = ["0 5 5222 xmpp.asxp.io."], ttl = 3600 }
"_xmpp-client._tcp.anotherim/SRV" = { records = ["0 5 5222 anotherim.asxp.io."] }
"_xmpp-client._tcp.ejabberd/SRV" = { records = ["0 5 5222 ejabberd.asxp.io."] }
"_xmpp-client._tcp.prosody/SRV" = { records = ["0 5 5222 prosody.asxp.io."] }
"_xmpp-server._tcp/SRV" = { records = ["0 5 5269 xmpp.asxp.io."], ttl = 3600 }
"_xmpp-server._tcp.anotherim/SRV" = { records = ["0 5 5269 anotherim.asxp.io."] }
"_xmpp-server._tcp.conference/SRV" = { records = ["0 5 5269 xmpp.asxp.io."], ttl = 3600 }
"_xmpp-server._tcp.conference.anotherim/SRV" = { records = ["0 5 5269 anotherim.asxp.io."] }
"_xmpp-server._tcp.conference.ejabberd/SRV" = { records = ["0 5 5269 ejabberd.asxp.io."] }
"_xmpp-server._tcp.conference.prosody/SRV" = { records = ["0 5 5269 prosody.asxp.io."] }
"_xmpp-server._tcp.ejabberd/SRV" = { records = ["0 5 5269 ejabberd.asxp.io."] }
"_xmpp-server._tcp.prosody/SRV" = { records = ["0 5 5269 prosody.asxp.io."] }
"_xmpps-client._tcp/SRV" = { records = ["0 5 5223 xmpp.asxp.io."], ttl = 3600 }
"_xmpps-client._tcp.anotherim/SRV" = { records = ["0 5 5223 anotherim.asxp.io."] }
"_xmpps-client._tcp.ejabberd/SRV" = { records = ["0 5 5223 ejabberd.asxp.io."] }
"_xmpps-client._tcp.prosody/SRV" = { records = ["0 5 5223 prosody.asxp.io."] }
},
"czsk.it" = {
"@/A" = { records = [local.ip.ops_v4] }
"www/A" = { records = [local.ip.ops_v4] }
"@/AAAA" = { records = [local.ip.ops_v6] }
}
}
# "<zone>/<name>/<type>" => record set; the key is also the import ID
rrsets = merge([
for zone, sets in local.dns : {
for key, set in sets : "${zone}/${key}" => merge(set, {
zone = zone
name = split("/", key)[0]
type = split("/", key)[1]
})
}
]...)
}
resource "hcloud_zone" "zone" {
for_each = local.zones
name = each.key
mode = "primary"
ttl = each.value.ttl
delete_protection = each.value.delete_protection
}
resource "hcloud_zone_rrset" "rr" {
for_each = local.rrsets
zone = hcloud_zone.zone[each.value.zone].name
name = each.value.name
type = each.value.type
ttl = try(each.value.ttl, null)
change_protection = !can(regex(local.unprotected, each.value.name))
records = [for value in each.value.records : { value = value }]
}
# Adopted from the existing zones on 2026-11-12. Harmless once in state;
# remove after the first apply if you like.
import {
for_each = local.zones
to = hcloud_zone.zone[each.key]
id = each.key
}
import {
for_each = local.rrsets
to = hcloud_zone_rrset.rr[each.key]
id = each.key
}