From 236a3b6b8e1d4c958e5d69ef2b8c3f54adb5f893 Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:32:08 +0800 Subject: [PATCH] fix(selfhost): open Grafana's port for --profile observability, opt-in terraform/main.tf's firewall opened 22, 80, 443/tcp, 443/udp and 8787, but nothing for 3000. docker-compose.yml's grafana service (--profile observability) publishes 3000:3000 on the host, so an operator following main.tf's own documented flow -- provision the VPS, then run docker compose --profile observability up -d -- ended up with Grafana bound to the public interface and no path through the cloud firewall to reach it, not even from their own IP. The failure mode is a timed-out connection with no explanation. Add the rule gated behind a new var.expose_grafana (bool, default false) rather than opening it unconditionally: the observability profile is itself opt-in, so a default-open port for a service most operators never start would widen the attack surface for nothing. When enabled the rule is scoped to var.admin_ip_allowlist, matching port 8787's existing pattern -- never 0.0.0.0/0 like the public Caddy ports. Left at the default, Grafana stays reachable over an SSH tunnel. The new terraform/README.md (this module had none, unlike the miner module) documents both paths, including the ssh -L command, alongside what gets provisioned and the deploy steps main.tf's header already described. The test locks the invariants a syntax check can't see -- the port is opt-in and can never be public -- and pins the rule to the compose service it exists for, mirroring test/unit/miner-terraform-module.test.ts. Closes #5818 --- terraform/README.md | 80 +++++++++++++++++++ terraform/main.tf | 14 ++++ terraform/variables.tf | 6 ++ .../root-terraform-grafana-firewall.test.ts | 53 ++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 terraform/README.md create mode 100644 test/unit/root-terraform-grafana-firewall.test.ts diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000000..0973ba7012 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,80 @@ +# ORB self-host server — Terraform module + +Provisions a single Hetzner Cloud VPS for the **ORB** self-host stack (the persistent HTTP review service), with +Docker + Docker Compose pre-installed via cloud-init and a persistent volume mounted at `/data` for the SQLite DB +and Litestream WAL segments. + +It is **not** the [`packages/loopover-miner/terraform/`](../packages/loopover-miner/terraform/) module, which +provisions a fleet-mode AMS miner host and exposes no public endpoints. This module serves public HTTP(S), so its +firewall opens the Caddy ports to the internet and keeps everything else admin-scoped. + +## What it creates + +| Resource | Purpose | +| -------------------------- | ---------------------------------------------------------------------------------------------- | +| `hcloud_server` | One Ubuntu 24.04 VM (`server_type` default `cx22` = 2 vCPU / 4 GB, sufficient for <50 reviews/day) | +| `hcloud_firewall` | Inbound 22 (admin), 80 + 443/tcp + 443/udp (public, Caddy), 8787 (admin), 3000 (admin, opt-in) | +| `hcloud_volume` (+ attach) | Persistent ext4 volume mounted at `/data` so the DB and WAL survive re-provisioning | +| `hcloud_ssh_key` | Your SSH public key, for access | + +## Prerequisites + +- [Terraform](https://developer.hashicorp.com/terraform/install) `>= 1.6` +- A Hetzner Cloud project + API token (console.hetzner.cloud → Security → API Tokens) +- An SSH key pair + +## Usage + +```sh +cd terraform + +export TF_VAR_hcloud_token="…" # or set it in a *.tfvars file (never commit it) +terraform init +terraform plan -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" +terraform apply -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" +``` + +Useful variables (see [`variables.tf`](variables.tf) for all): `server_type`, `location`, `volume_size_gb`, +`admin_ip_allowlist` (restrict this to your IP in production), `expose_grafana`. + +## After apply — start the stack + +The module provisions the **host**; you finish setup over SSH (secrets never live in Terraform state): + +1. `terraform output ssh_command` → SSH in. +2. Clone the repo and copy [`../.env.example`](../.env.example) → `.env` — it is the exhaustive reference for + every variable the stack reads. +3. `docker compose up -d` (or `docker compose --profile postgres --profile caddy up -d`). + +## Reaching Grafana (`--profile observability`) + +`docker compose --profile observability up -d` publishes Grafana on the host at `3000:3000`. Because the +observability profile is itself opt-in, the firewall does **not** open port 3000 by default — a default-open port +for a service most operators never start would widen the attack surface for nothing. Pick one: + +**SSH tunnel (default, nothing to change).** Keep 3000 closed and forward it over your existing SSH access: + +```sh +ssh -L 3000:localhost:3000 ubuntu@$(terraform output -raw server_ipv4) +# then browse http://localhost:3000 +``` + +**Open the port to your own IP.** Set `expose_grafana = true` and re-apply. The rule is always scoped to +`admin_ip_allowlist` — never `0.0.0.0/0` like the public Caddy ports — so restrict that allowlist to your own +IP(s) first, or you will publish Grafana to the internet: + +```sh +terraform apply \ + -var "ssh_public_key=$(cat ~/.ssh/id_ed25519.pub)" \ + -var "expose_grafana=true" \ + -var 'admin_ip_allowlist=["203.0.113.4/32"]' +``` + +## Outputs + +| Output | Description | +| --------------- | -------------------------------------- | +| `server_ipv4` | Public IPv4 of the server | +| `server_ipv6` | Public IPv6 of the server | +| `ssh_command` | Ready-to-run SSH command | +| `volume_device` | Block device path for the data volume | diff --git a/terraform/main.tf b/terraform/main.tf index 66b09b0480..e7f14ed61d 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -66,6 +66,20 @@ resource "hcloud_firewall" "gittensory" { port = "8787" source_ips = var.admin_ip_allowlist } + + # Grafana (`docker compose --profile observability`, which publishes 3000:3000 on the host). Opt-in via + # var.expose_grafana because the observability profile is itself opt-in — a default-open port for a service + # most operators never start would widen the attack surface for nothing. Left off, Grafana stays reachable + # over an SSH tunnel (see README.md). Allowlist-scoped like 8787, never 0.0.0.0/0 like the Caddy ports. + dynamic "rule" { + for_each = var.expose_grafana ? [1] : [] + content { + direction = "in" + protocol = "tcp" + port = "3000" + source_ips = var.admin_ip_allowlist + } + } } # ── Persistent volume for /data (SQLite DB + Litestream WAL) ────────────────── diff --git a/terraform/variables.tf b/terraform/variables.tf index 3d1941c3d8..7d36bde50e 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -32,3 +32,9 @@ variable "admin_ip_allowlist" { type = list(string) default = ["0.0.0.0/0", "::/0"] } + +variable "expose_grafana" { + description = "Open Grafana's port (3000) to admin_ip_allowlist for `docker compose --profile observability`. Defaults to false: observability is itself an opt-in profile, and Grafana is otherwise reachable over an SSH tunnel (see README.md). Never opened publicly — the rule is always allowlist-scoped." + type = bool + default = false +} diff --git a/test/unit/root-terraform-grafana-firewall.test.ts b/test/unit/root-terraform-grafana-firewall.test.ts new file mode 100644 index 0000000000..dea9da1813 --- /dev/null +++ b/test/unit/root-terraform-grafana-firewall.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +// Static structural checks for the root ORB Terraform module's Grafana exposure (#5818). docker-compose.yml's +// `grafana` service (--profile observability) publishes 3000:3000 on the host, but the firewall had no rule for +// it — not even an admin-scoped one — so an operator following main.tf's own documented flow (provision, then +// `docker compose --profile observability up -d`) got a timed-out connection with no explanation. These lock in +// the SAFETY-CRITICAL invariants a `terraform validate` can't see: the port is opt-in, and it can never be +// opened to the public. Mirrors the pattern in test/unit/miner-terraform-module.test.ts. + +const DIR = "terraform"; +const mainTf = readFileSync(`${DIR}/main.tf`, "utf8"); +const variablesTf = readFileSync(`${DIR}/variables.tf`, "utf8"); +const readme = readFileSync(`${DIR}/README.md`, "utf8"); +const dockerCompose = readFileSync("docker-compose.yml", "utf8"); + +/** The `dynamic "rule"` block that gates Grafana's port, body included. */ +const grafanaRule = /dynamic\s+"rule"\s*\{[\s\S]*?for_each\s*=\s*var\.expose_grafana[\s\S]*?\n {2}\}/.exec(mainTf)?.[0] ?? ""; + +describe("root Terraform module — Grafana firewall (#5818)", () => { + it("still matches the compose service it exists for: grafana publishes 3000 under the observability profile", () => { + // If this drifts, the firewall rule below is guarding the wrong port. + expect(dockerCompose).toMatch(/grafana:[\s\S]*?profiles:\s*\["observability"\]/); + expect(dockerCompose).toMatch(/grafana:[\s\S]*?ports:[\s\S]*?"3000:3000"/); + }); + + it("opens Grafana's port 3000, gated by var.expose_grafana", () => { + expect(grafanaRule, "a dynamic rule gated on var.expose_grafana must exist").not.toBe(""); + expect(grafanaRule).toMatch(/port\s*=\s*"3000"/); + expect(grafanaRule).toMatch(/protocol\s*=\s*"tcp"/); + expect(grafanaRule).toMatch(/direction\s*=\s*"in"/); + }); + + it("INVARIANT: Grafana's port is admin-allowlist-scoped — never opened to the public like the Caddy ports", () => { + expect(grafanaRule).toMatch(/source_ips\s*=\s*var\.admin_ip_allowlist/); + expect(grafanaRule).not.toMatch(/0\.0\.0\.0\/0/); + expect(grafanaRule).not.toMatch(/::\/0/); + }); + + it("INVARIANT: exposure is opt-in — expose_grafana is a bool defaulting to false", () => { + const variable = /variable\s+"expose_grafana"\s*\{[\s\S]*?\n\}/.exec(variablesTf)?.[0] ?? ""; + expect(variable, "expose_grafana must be declared").not.toBe(""); + expect(variable).toMatch(/type\s*=\s*bool/); + expect(variable).toMatch(/default\s*=\s*false/); + expect(variable).toMatch(/description\s*=/); // every var in this file documents itself + }); + + it("documents both access paths, including a runnable SSH-tunnel command for the closed default", () => { + expect(readme).toMatch(/ssh -L 3000:localhost:3000/); + expect(readme).toContain("expose_grafana"); + }); +});