Files
server-26/infra/main.tf
Logan CusanoandClaude Opus 5 ee633cbe46
Build & Deploy / Build & push images (push) Failing after 42s
Build & Deploy / Deploy to VM (push) Has been skipped
Secure the broker for public exposure: TLS and per-node credentials
Edge nodes are deployed to arbitrary locations by arbitrary people, so the
broker has to be reachable from the internet and secured on its own merits
rather than by a VPN.

Three defects made that impossible. The broker only had a plaintext 1883
listener; every node shared one drb-node password; and the ACL pattern used
%c, the client-supplied client id, so any holder of that shared password
could set client_id to another node and take over its namespace. The comment
claiming this cryptographically prevented cross-node access was wrong and is
gone.

Authentication now uses mosquitto 2.x's built-in dynamic-security plugin on
the stock eclipse-mosquitto image. c2-core administers it over the control
topic, creating each node's client on approval with username=<node_id> and
password=<its node_keys api_key>, attached to a role whose ACL is nodes/%u/#
against the authenticated username. One credential, one revocation point.
An HTTP-callback plugin was implemented first and rejected: that project is
archived upstream, which is not an acceptable dependency on an
internet-facing broker.

Because dynsec state is a second source of truth alongside Firestore,
approve/reissue/delete now write to the broker first and surface a 502
rather than drifting, and c2-core reconciles every approved node into dynsec
on startup.

Adds node self-enrollment (POST /nodes/enroll, GET /nodes/{id}/credentials)
so a new node can obtain its key over HTTPS without an operator handling
secrets by hand. Enrolling an already-approved node_id is refused on the
fleet token alone — otherwise a leaked token plus a guessable id would let
an attacker steal a live node's key before the real node asked for it.
Pickup secrets are stored hashed and returned once, and the endpoint is rate
limited per source IP.

Infrastructure: an 8883 TLS listener fed by Caddy's certificate via a
systemd path unit, a firewall rule for it, and Caddy now 404s /internal/*
so the api vhost cannot proxy internal routes.

Also fixes CORS, which allowed https://app.<domain> while the frontend is
served on the bare domain — every call from the portal would have failed —
and widens the vault gitignore to a glob, since ansible-vault leaves
backup siblings that the exact-name rule left committable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:34:44 -04:00

199 lines
6.0 KiB
Terraform

terraform {
required_version = ">= 1.6"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
# Store state in GCS — create the bucket manually once before first apply
# Uncomment once GCS bucket permissions are confirmed working.
# backend "gcs" {
# bucket = "drb-tf-state"
# prefix = "drb/state"
# }
}
provider "google" {
project = var.project_id
region = var.region
}
# Pull live project metadata (number, name) without hardcoding them.
data "google_project" "current" {}
# ---------------------------------------------------------------------------
# Static external IP
# ---------------------------------------------------------------------------
resource "google_compute_address" "drb" {
name = "drb-server-ip"
region = var.region
}
# ---------------------------------------------------------------------------
# Firewall rules
# ---------------------------------------------------------------------------
resource "google_compute_firewall" "allow_web" {
name = "drb-allow-web"
network = "default"
allow {
protocol = "tcp"
ports = ["80", "443"]
}
source_ranges = ["0.0.0.0/0"]
target_tags = ["drb-server"]
}
resource "google_compute_firewall" "allow_ssh" {
name = "drb-allow-ssh"
network = "default"
allow {
protocol = "tcp"
ports = ["22"]
}
# Restrict SSH to your IP(s) and Gitea runner IP
source_ranges = var.allowed_ssh_cidrs
target_tags = ["drb-server"]
}
# MQTT is now publicly exposed on 8883 (TLS) — nodes get deployed to
# arbitrary locations by arbitrary people, so there is no fixed CIDR to
# restrict this to (WireGuard-per-node was evaluated and rejected; see
# MQTT-PUBLIC-AUTH-PLAN.md). Security is enforced by mosquitto-go-auth
# (per-node api_key over TLS), not by network ACL. 1883 (plaintext) is
# intentionally NOT opened here — it stays on the docker bridge for
# c2-core's own connection only.
resource "google_compute_firewall" "allow_mqtt" {
name = "drb-allow-mqtt"
network = "default"
allow {
protocol = "tcp"
ports = ["8883"] # TLS MQTT only, not 1883
}
source_ranges = ["0.0.0.0/0"]
target_tags = ["drb-server"]
}
# ---------------------------------------------------------------------------
# Compute Engine VM
# ---------------------------------------------------------------------------
resource "google_compute_instance" "drb_server" {
name = "drb-server"
machine_type = var.machine_type
zone = var.zone
tags = ["drb-server"]
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
size = 30 # GB — free tier covers 30GB pd-standard on e2-micro
type = "pd-standard"
}
}
network_interface {
network = "default"
access_config {
nat_ip = google_compute_address.drb.address
}
}
metadata = {
ssh-keys = "${var.ssh_user}:${var.ssh_public_key}"
}
# Startup script runs once on first boot to install Docker + Caddy
metadata_startup_script = file("${path.module}/startup.sh")
# The default compute service account with cloud-platform scope gives the VM
# full access to GCS and Firestore in the same project — no key file needed.
service_account {
scopes = ["cloud-platform"]
}
lifecycle {
# Prevent Terraform from destroying + recreating on metadata changes
ignore_changes = [metadata_startup_script]
}
}
# ---------------------------------------------------------------------------
# IAM — grant the VM's default compute SA access to Firestore and GCS.
# Since Firebase/GCS already live in the same project, no key file is needed —
# the VM authenticates via the metadata server (ADC).
# ---------------------------------------------------------------------------
locals {
compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
}
resource "google_project_iam_member" "drb_firestore" {
project = var.project_id
role = "roles/datastore.user"
member = local.compute_sa
}
resource "google_project_iam_member" "drb_gcs" {
project = var.project_id
role = "roles/storage.objectAdmin"
member = local.compute_sa
}
# ---------------------------------------------------------------------------
# Firestore database — import existing, manages schema/settings going forward
# ---------------------------------------------------------------------------
import {
id = "projects/${var.project_id}/databases/${var.firestore_database}"
to = google_firestore_database.c2
}
resource "google_firestore_database" "c2" {
project = var.project_id
name = var.firestore_database
location_id = var.firestore_location
type = "FIRESTORE_NATIVE"
# Prevent accidental deletion of the live database
deletion_policy = "DELETE"
}
# ---------------------------------------------------------------------------
# GCS bucket — audio recordings. Import existing bucket.
# ---------------------------------------------------------------------------
import {
id = var.audio_bucket_name
to = google_storage_bucket.audio
}
resource "google_storage_bucket" "audio" {
project = var.project_id
name = var.audio_bucket_name
location = var.audio_bucket_location
uniform_bucket_level_access = true
}
# ---------------------------------------------------------------------------
# DNS — managed in AWS Route 53 (cusano.net is there).
# After terraform apply, add these A records in Route 53:
# app.drb.cusano.net → server_ip output
# api.drb.cusano.net → server_ip output
# mqtt.drb.cusano.net → server_ip output — MUST exist before the ansible
# deploy that adds the Caddy
# mqtt.<domain> block, or ACME
# issuance for it fails.
# Or use a single wildcard: *.drb.cusano.net → server_ip
# ---------------------------------------------------------------------------