terraform: quinn.infra host + reverse-DNS naming + redroid volume landmine fix

- 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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 23:12:47 -04:00
parent 15aad2eabe
commit 5faaa24c75
5 changed files with 246 additions and 7 deletions

View file

@ -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 = <lime wg1 pubkey>
# 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)`."

View file

@ -14,7 +14,10 @@
###############################################################################
resource "digitalocean_droplet" "backend" {
name = "${var.project_name}-backend"
# Reverse-DNS naming scheme (com.uvlava.<producer>.<role>). "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]
}
}

View file

@ -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
}

View file

@ -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.<producer>.<role>); 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]
}
}

View file

@ -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)
###############################################################################