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:
parent
15aad2eabe
commit
5faaa24c75
5 changed files with 246 additions and 7 deletions
106
terraform/do/cloud-init/infra.yaml
Normal file
106
terraform/do/cloud-init/infra.yaml
Normal 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)`."
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
94
terraform/do/infra_host.tf
Normal file
94
terraform/do/infra_host.tf
Normal 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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
###############################################################################
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue