From 5faaa24c75546f237560beec027b4651f3589cb3 Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 29 Jun 2026 23:12:47 -0400 Subject: [PATCH] terraform: quinn.infra host + reverse-DNS naming + redroid volume landmine fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - infra_host.tf + cloud-init/infra.yaml: com.uvlava.quinn.infra (nyc3 DNS+WG hub host) — droplet + reserved IP + firewall. (cloud-init is bootstrap only; net-tools wg-render/wg-dns-sync own the live WG/DNS config.) - droplet.tf/redroid.tf: reverse-DNS names (com.uvlava.ct.services / .redroid) with name in lifecycle.ignore_changes (name is ForceNew — rename live via doctl, never a destructive apply). - redroid.tf: revert the volume name/description to the LIVE values (redroidmrnumberdata) — the rename was ForceNew and a plain apply would have DESTROYED the 20GB paid-screening volume. - variables.tf: infra host size + wg/dns segment vars. Co-Authored-By: Claude Opus 4.8 (1M context) --- terraform/do/cloud-init/infra.yaml | 106 +++++++++++++++++++++++++++++ terraform/do/droplet.tf | 10 ++- terraform/do/infra_host.tf | 94 +++++++++++++++++++++++++ terraform/do/redroid.tf | 15 ++-- terraform/do/variables.tf | 28 ++++++++ 5 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 terraform/do/cloud-init/infra.yaml create mode 100644 terraform/do/infra_host.tf diff --git a/terraform/do/cloud-init/infra.yaml b/terraform/do/cloud-init/infra.yaml new file mode 100644 index 0000000..4ddcb87 --- /dev/null +++ b/terraform/do/cloud-init/infra.yaml @@ -0,0 +1,106 @@ +#cloud-config +# com.uvlava.quinn.infra - essential-services host for the DO/nyc3 mesh segment. +# (Keep this file PURE ASCII: a stray non-ASCII byte makes cloud-init reject the +# whole config as "empty cloud config" and nothing installs.) +# Two roles: +# 1. WireGuard HUB for the nyc3 droplets (lime/services, redroid, artifacts). +# Spokes peer to THIS box. yuzu (Iceland VPS) and the homelan (black/apricot) +# are separate network segments with their own dns+wg; this hub is the DO/nyc3 one. +# It forwards between spokes (net.ipv4.ip_forward + MASQUERADE), so any +# mesh host reaches any other; internal services (prospector etc.) then +# talk over wg1 with NO auth, never over a public port. +# 2. DNS for the mesh: dnsmasq resolves the internal zone (var-driven records +# injected post-boot) and forwards everything else upstream. +# +# Like the backend host, the WG PRIVATE key is generated on first boot and never +# committed; the public key lands in /root/wg1.pub for the operator to distribute +# to each spoke's [Peer] block. Spoke [Peer] blocks are appended post-boot too. + +package_update: true +package_upgrade: true + +packages: + - wireguard + - wireguard-tools + - dnsmasq + - ufw + - curl + +write_files: + # Enable IPv4 forwarding so the hub relays spoke-to-spoke traffic. + - path: /etc/sysctl.d/99-wg-forward.conf + permissions: "0644" + content: | + net.ipv4.ip_forward = 1 + + # wg1 hub interface. Address is the hub's mesh IP; PostUp/Down set up the + # MASQUERADE + forward rules that make this a relay. Spoke [Peer] blocks are + # appended after first boot (kept out of cloud-init so no key material commits). + - path: /etc/wireguard/wg1.conf.tmpl + permissions: "0600" + owner: root:root + content: | + [Interface] + Address = ${wg_infra_address} + ListenPort = ${wg_listen_port} + PrivateKey = __PRIVATE_KEY__ + PostUp = iptables -A FORWARD -i wg1 -j ACCEPT; iptables -t nat -A POSTROUTING -o wg1 -j MASQUERADE + PostDown = iptables -D FORWARD -i wg1 -j ACCEPT; iptables -t nat -D POSTROUTING -o wg1 -j MASQUERADE + + # Spoke peers (added post-boot), one per nyc3 droplet, e.g.: + # [Peer] # com.uvlava.ct.services (lime) + # PublicKey = + # AllowedIPs = 10.9.0.5/32 + # PersistentKeepalive = 25 + + # dnsmasq: bind to loopback + wg1 only (never the public iface), forward + # upstream, and serve the internal mesh zone from an operator-managed hosts + # file. Records are appended to /etc/dnsmasq.d/mesh.hosts post-boot. + - path: /etc/dnsmasq.d/mesh.conf + permissions: "0644" + content: | + # Only answer on loopback + the mesh interface. + interface=lo + interface=wg1 + bind-interfaces + # Do not read /etc/resolv.conf for upstreams; pin them. + no-resolv + server=1.1.1.1 + server=9.9.9.9 + # Internal zone served from an explicit additional hosts file. + addn-hosts=/etc/dnsmasq.d/mesh.hosts + domain=${mesh_domain} + local=/${mesh_domain}/ + cache-size=1000 + - path: /etc/dnsmasq.d/mesh.hosts + permissions: "0644" + content: | + # yuzu (Iceland) + homelan are separate segments; this file is the nyc3 zone. + ${wg_infra_addr_only} infra.${mesh_domain} + # Append spoke records post-boot, e.g.: + # 10.9.0.5 services.${mesh_domain} lime.${mesh_domain} + # 10.9.0.6 redroid.${mesh_domain} + +runcmd: + - sysctl --system + # Generate this node's keypair and materialize wg1.conf from the template. + - umask 077; wg genkey | tee /etc/wireguard/wg1.key | wg pubkey > /root/wg1.pub + - sed "s#__PRIVATE_KEY__#$(cat /etc/wireguard/wg1.key)#" /etc/wireguard/wg1.conf.tmpl > /etc/wireguard/wg1.conf + - chmod 600 /etc/wireguard/wg1.conf + # Host firewall: WG (public, so spokes can reach the hub) + SSH (admin only, + # enforced by the DO cloud firewall) + DNS only on the mesh side. + - ufw --force reset + - ufw default deny incoming + - ufw default allow outgoing + - ufw allow 22/tcp + - ufw allow ${wg_listen_port}/udp + - ufw allow in on wg1 to any port 53 proto udp + - ufw allow in on wg1 to any port 53 proto tcp + - ufw --force enable + # Bring the hub interface up now (it has no peers yet, but listens for them). + - systemctl enable wg-quick@wg1 + - systemctl start wg-quick@wg1 || true + - systemctl enable --now dnsmasq || true + - echo "WG HUB public key (add to each spoke's [Peer] block):" && cat /root/wg1.pub + +final_message: "quinn.infra up: WG hub listening on ${wg_listen_port}/udp, dnsmasq on wg1:53. Add /root/wg1.pub to each spoke, append spoke [Peer] blocks here, then `wg syncconf wg1 <(wg-quick strip wg1)`." diff --git a/terraform/do/droplet.tf b/terraform/do/droplet.tf index 36a57eb..5c4cee1 100644 --- a/terraform/do/droplet.tf +++ b/terraform/do/droplet.tf @@ -14,7 +14,10 @@ ############################################################################### resource "digitalocean_droplet" "backend" { - name = "${var.project_name}-backend" + # Reverse-DNS naming scheme (com.uvlava..). "name" is ForceNew + # in the provider, so it's in ignore_changes below: rename LIVE via + # `doctl compute droplet-action rename`, never let a label change rebuild the box. + name = "com.uvlava.ct.services" image = var.droplet_image size = var.droplet_size region = var.region @@ -30,8 +33,9 @@ resource "digitalocean_droplet" "backend" { lifecycle { # WG keys / installed state live on disk; never let a user_data tweak - # trigger a silent rebuild that wipes Forgejo + Verdaccio data. - ignore_changes = [user_data] + # trigger a silent rebuild that wipes Forgejo + Verdaccio data. Same for + # the cosmetic droplet name (ForceNew) — rename out-of-band, not via apply. + ignore_changes = [user_data, name] } } diff --git a/terraform/do/infra_host.tf b/terraform/do/infra_host.tf new file mode 100644 index 0000000..1bf97f5 --- /dev/null +++ b/terraform/do/infra_host.tf @@ -0,0 +1,94 @@ +############################################################################### +# com.uvlava.quinn.infra — operator-shared essential services for the DO/nyc3 +# mesh segment: WireGuard HUB (lime/services, redroid, artifacts peer to it) + +# dnsmasq DNS. Quinn-owned in the target architecture; today it lives in the +# shared account alongside the ct hosts (account separation is a later step). +# +# In the store VPC so it can also reach the nyc3 droplets privately over +# 10.20.0.0/24 even before WG peers are wired. Stable reserved-IP endpoint so +# spokes can hardcode the hub address across rebuilds. +############################################################################### + +resource "digitalocean_droplet" "infra" { + name = "com.uvlava.quinn.infra" + image = var.droplet_image + size = var.infra_size + region = var.region + vpc_uuid = digitalocean_vpc.store.id + ssh_keys = var.ssh_key_fingerprints + tags = concat(var.tags, ["quinn-infra", "dns", "wg-hub"]) + + user_data = templatefile("${path.module}/cloud-init/infra.yaml", { + wg_listen_port = var.wg_listen_port + wg_infra_address = var.wg_infra_address + wg_infra_addr_only = var.wg_infra_addr_only + mesh_domain = var.mesh_domain + }) + + lifecycle { + # WG keys + dnsmasq zone live on disk; never rebuild over a user_data tweak. + # name is ForceNew — rename live via doctl, not via apply. + ignore_changes = [user_data, name] + } +} + +# Stable public endpoint for the WG hub (peers hardcode this; survives rebuilds). +resource "digitalocean_reserved_ip" "infra" { + droplet_id = digitalocean_droplet.infra.id + region = var.region +} + +resource "digitalocean_firewall" "infra" { + name = "quinn-infra-fw" + droplet_ids = [digitalocean_droplet.infra.id] + tags = var.tags + + # WireGuard is open to the internet on purpose: it is authenticated by public + # keys, and the nyc3 spokes dial in from varied/changing public IPs. No other + # app port is ever exposed — DNS answers only on wg1 (see cloud-init ufw). + inbound_rule { + protocol = "udp" + port_range = tostring(var.wg_listen_port) + source_addresses = ["0.0.0.0/0", "::/0"] + } + + # SSH for bootstrap only — locked to the admin allowlist. + inbound_rule { + protocol = "tcp" + port_range = "22" + source_addresses = var.admin_ips + } + + inbound_rule { + protocol = "icmp" + source_addresses = var.admin_ips + } + + outbound_rule { + protocol = "tcp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + outbound_rule { + protocol = "udp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + outbound_rule { + protocol = "icmp" + destination_addresses = ["0.0.0.0/0", "::/0"] + } +} + +output "infra_public_ip" { + description = "quinn.infra reserved IP — the WG hub endpoint nyc3 spokes peer to." + value = digitalocean_reserved_ip.infra.ip_address +} + +output "infra_private_ip" { + value = digitalocean_droplet.infra.ipv4_address_private +} + +output "infra_wg_address" { + value = var.wg_infra_address +} diff --git a/terraform/do/redroid.tf b/terraform/do/redroid.tf index 18acbda..94cd037 100644 --- a/terraform/do/redroid.tf +++ b/terraform/do/redroid.tf @@ -8,14 +8,20 @@ resource "digitalocean_volume" "redroid_data" { region = var.region - name = "redroid-data" + # NAME/DESCRIPTION MUST MATCH THE LIVE VOLUME (id 45984dc3-...). DO treats `name` + # (and the provider, `description`) as ForceNew: renaming here forces a destroy+recreate + # that WIPES the 20GB of paid Mr.Number/WhatsApp screening state (Google sign-in, app data). + # The live volume is "redroidmrnumberdata"; keep it. A cosmetic rename to "redroid-data" + # must be done out-of-band (DO console rename + `terraform state` refresh), never via apply. + name = "redroidmrnumberdata" size = 20 initial_filesystem_type = "ext4" - description = "Persistent /data for redroid (Google sign-in + paid screening app state for mr-number / whatsapp lookup tools)" + description = "Persistent /data for redroid (paid Mr. Number reports state)" } resource "digitalocean_droplet" "redroid" { - name = "redroid" + # Reverse-DNS naming scheme (com.uvlava..); see ignore_changes below. + name = "com.uvlava.ct.redroid" image = "ubuntu-22-04-x64" size = "s-2vcpu-4gb" region = var.region @@ -182,7 +188,8 @@ NGINX # app + Google sign-in + ws-scrcpy console already installed. Without this, # any edit to the user_data above forces a destroy+recreate that wipes it. # Mirrors the backend droplet. Re-provision deliberately, never via drift. - ignore_changes = [user_data] + # `name` (ForceNew) is ignored too — rename live via doctl, never via apply. + ignore_changes = [user_data, name] } } diff --git a/terraform/do/variables.tf b/terraform/do/variables.tf index 26729b5..3741830 100644 --- a/terraform/do/variables.tf +++ b/terraform/do/variables.tf @@ -127,6 +127,34 @@ variable "wg_droplet_address" { default = "10.9.0.5/32" } +############################################################################### +# Essential-services host (com.uvlava.quinn.infra) — DO/nyc3 WG hub + DNS. +############################################################################### + +variable "infra_size" { + description = "Size for the quinn.infra host (DNS + WG hub; light, no app load)." + type = string + default = "s-1vcpu-1gb" +} + +variable "wg_infra_address" { + description = "Mesh (wg1) address for the nyc3 hub. /24 so the hub owns the subnet route and relays spoke-to-spoke." + type = string + default = "10.9.0.7/24" +} + +variable "wg_infra_addr_only" { + description = "Bare hub mesh IP (no mask) for the dnsmasq hosts file." + type = string + default = "10.9.0.7" +} + +variable "mesh_domain" { + description = "Internal DNS zone dnsmasq serves on the mesh (forward-only for everything else)." + type = string + default = "uvlava.mesh" +} + ############################################################################### # Spaces (object storage) ###############################################################################