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>
199 lines
6.0 KiB
Terraform
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
|
|
# ---------------------------------------------------------------------------
|