From a9df70cde0a750d18e827ff778f1226bb2b573fd Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Mon, 14 Sep 2026 03:57:00 -0300 Subject: [PATCH] berth init --- berth/.gitignore | 21 ++ berth/Makefile | 78 +++++ berth/README.md | 250 +++++++++++++++ berth/STALE.md | 104 +++++++ berth/ctrl/.env.example | 18 ++ berth/ctrl/certs.sh | 58 ++++ berth/ctrl/check.sh | 137 +++++++++ berth/ctrl/dns.sh | 81 +++++ berth/ctrl/docs.sh | 27 ++ berth/ctrl/env.d/aws.env | 14 + berth/ctrl/env.d/gcp.env | 17 ++ berth/ctrl/estate.sh | 101 ++++++ berth/ctrl/host.sh | 63 ++++ berth/ctrl/lib/config.sh | 94 ++++++ berth/ctrl/lib/estate.sh | 125 ++++++++ berth/ctrl/ports.sh | 78 +++++ berth/ctrl/registry.sh | 33 ++ berth/ctrl/render/nginx-proxy.tmpl | 32 ++ berth/ctrl/render/nginx-static.tmpl | 23 ++ berth/ctrl/render/nginx-upstream.tmpl | 32 ++ berth/ctrl/selftest.sh | 184 +++++++++++ berth/ctrl/services.sh | 186 +++++++++++ berth/ctrl/versions.env | 11 + berth/ctrl/vpn.sh | 425 ++++++++++++++++++++++++++ berth/estate/mcrn.json | 314 +++++++++++++++++++ 25 files changed, 2506 insertions(+) create mode 100644 berth/.gitignore create mode 100644 berth/Makefile create mode 100644 berth/README.md create mode 100644 berth/STALE.md create mode 100644 berth/ctrl/.env.example create mode 100644 berth/ctrl/certs.sh create mode 100644 berth/ctrl/check.sh create mode 100644 berth/ctrl/dns.sh create mode 100644 berth/ctrl/docs.sh create mode 100644 berth/ctrl/env.d/aws.env create mode 100644 berth/ctrl/env.d/gcp.env create mode 100644 berth/ctrl/estate.sh create mode 100644 berth/ctrl/host.sh create mode 100644 berth/ctrl/lib/config.sh create mode 100644 berth/ctrl/lib/estate.sh create mode 100644 berth/ctrl/ports.sh create mode 100644 berth/ctrl/registry.sh create mode 100644 berth/ctrl/render/nginx-proxy.tmpl create mode 100644 berth/ctrl/render/nginx-static.tmpl create mode 100644 berth/ctrl/render/nginx-upstream.tmpl create mode 100644 berth/ctrl/selftest.sh create mode 100644 berth/ctrl/services.sh create mode 100644 berth/ctrl/versions.env create mode 100644 berth/ctrl/vpn.sh create mode 100644 berth/estate/mcrn.json diff --git a/berth/.gitignore b/berth/.gitignore new file mode 100644 index 0000000..4736aeb --- /dev/null +++ b/berth/.gitignore @@ -0,0 +1,21 @@ +# Machine-local config and credentials. Never committed. +ctrl/.env + +# Rendered output. Regenerate with `make services render `. +# Generated config is an artifact, not source — the estate file is the source. +# +# The path is ctrl/render/out/, NOT render/out/. A pattern containing a slash is +# anchored to the directory holding this .gitignore, so `render/out/` would mean +# berth/render/out/ — which does not exist, and the real output would have been +# committed. Caught by `git check-ignore -v`, which is the only way to be sure. +ctrl/render/out/ + +# The "default" scratch bucket: always gitignored, never versioned. +def/ + +# Key material. NEVER committed. +# +# Anchored to ctrl/ for the same reason as render/out/ above: a pattern with a +# slash resolves against this file's own directory. `git check-ignore -v` is the +# only way to confirm it, and ctrl/vpn.sh refuses to write a key until it does. +ctrl/.secrets/ diff --git a/berth/Makefile b/berth/Makefile new file mode 100644 index 0000000..fb1d9f3 --- /dev/null +++ b/berth/Makefile @@ -0,0 +1,78 @@ +# One target per ctrl/ script; the subcommand is an argument, not a second +# target: `make estate show`, not `make estate-show`. The logic lives in the +# scripts, never here. +# +# Config layers, weakest first: ctrl/versions.env < ctrl/env.d/.env < +# ctrl/.env < the environment. So `make estate plan TARGET=gcp` beats all. +# +# Every target defaults to its READ-ONLY verb, and the verbs that change a live +# estate are not reachable by a bare word. Rationale: README.md. + +ESTATE := $(or $(shell sed -n 's/^ESTATE=//p' ctrl/.env 2>/dev/null),$(shell ls estate/*.json 2>/dev/null | head -1 | xargs -r basename | sed 's/\.json$$//')) +TARGET := $(or $(shell sed -n 's/^TARGET=//p' ctrl/.env 2>/dev/null),aws) + +.PHONY: help check selftest estate services vpn dns certs host ports registry docs + +help: ## list targets + @grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 + +# ── preflight ────────────────────────────────────────────────────────────── + +check: ## is this estate coherent? reports, never fixes + bash ctrl/check.sh + +selftest: ## does berth still do what it says? exits 1 if not + bash ctrl/selftest.sh + +ports: ## port map [show|verify] (default show) + bash ctrl/ports.sh $(or $(ARGS),show) + +# ── the estate ───────────────────────────────────────────────────────────── + +estate: ## the estate [show|list|plan|apply|destroy] (default show) + bash ctrl/estate.sh $(or $(ARGS),show) + +services: ## gateway routes [list|render |deploy] (default list) + bash ctrl/services.sh $(or $(ARGS),list) + +# ── the network ──────────────────────────────────────────────────────────── + +vpn: ## overlays [list|show |check|render|keygen] (default list) + bash ctrl/vpn.sh $(or $(ARGS),list) + +# ── names and trust ──────────────────────────────────────────────────────── + +dns: ## DNS records [list|add|add-wildcard|remove] (default list) + bash ctrl/dns.sh $(or $(ARGS),list) + +certs: ## TLS [status|verify|renew|push] (default status) + bash ctrl/certs.sh $(or $(ARGS),status) + +# ── the box ──────────────────────────────────────────────────────────────── + +host: ## the remote box [status|ports|services] (default status) + bash ctrl/host.sh $(or $(ARGS),status) + +registry: ## the image registry [status] (default status) + bash ctrl/registry.sh $(or $(ARGS),status) + +# ── docs ─────────────────────────────────────────────────────────────────── + +docs: ## documentation [serve|graphs] (default serve) + bash ctrl/docs.sh $(or $(ARGS),serve) + +# ── swallowing the argument words — MUST BE LAST IN THIS FILE ────────────── +# +# Words after the target are arguments, but make reads each as a goal, so each +# gets a no-op rule. This block must come AFTER the real targets: when an +# argument names one (`make host ports`, `make vpn check`), the last definition +# wins, and it has to be the no-op. With it first, make ran both scripts. +# +# Make's "overriding recipe" warning is the swallow working as intended. +ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifneq ($(ARGS),) +$(eval $(ARGS):;@:) +# .PHONY too: some of those words name real directories (ctrl, estate, render), +# and make treats an existing directory as already built. +.PHONY: $(ARGS) +endif diff --git a/berth/README.md b/berth/README.md new file mode 100644 index 0000000..457d66f --- /dev/null +++ b/berth/README.md @@ -0,0 +1,250 @@ +# berth + +spr's deploy half. A rig is a mobile installation; a **berth** is the allocated, paid-for +place where it is moored and actually operates. Local rig → remote berth. + +```bash +make check # is this estate coherent? reports, never fixes +make estate show # the description, resolved +make vpn check # the overlay: addresses, routing, bindings, key hygiene +make services render aws +make ports verify # does the local map still agree with rig? +``` + +`rm -rf berth/` is the uninstall. + +--- + +## What berth is + +**One description of an estate, with a swappable executor.** The description is the artifact; +the tool that runs it is a rendering. + +| layer | what berth uses | note | +| --- | --- | --- | +| overlay | **WireGuard**, config generated per peer | GPL-2.0, in-kernel, and **no coordination server** | +| infra | **OpenTofu** — one executor, not a pair | plain `.tf`; `terraform` works identically | +| gateway | Caddy locally, nginx on the box | a projection with per-target rules, not a format conversion | +| pipeline | Woodpecker | Actions/GitLab reachable from the same description; not built | + +The estate's facts — domain, hosts, ports, instance size, firewall rules, services — live in +**one file every rendering reads** (`estate/.json`), rather than being restated in +one tool's language and again in another's. **Swappability is bought by the description, not +by maintaining two renderings** — a rendering you *can* produce, not one you *must* keep in +step. Two live renderings cost you every resource twice, forever, with nothing enforcing that +they agree. + +**The seam belongs in a script, not in a tool.** A tool's schema is a ceiling you do not +control. + +*(This once leaned on a specific precedent, since withdrawn — `✖ B2` in [STALE.md](STALE.md).)* + +**OpenTofu, and only OpenTofu.** Terraform has been BUSL-licensed since 2023; OpenTofu is the +CLI-compatible MPL-2.0 fork (Linux Foundation). Write plain `.tf` that runs under both; the +scripts say `tofu`, and `terraform` works identically. + +**Pipelines are the second executor axis, and are not built.** Woodpecker is the +self-hosted rendering; Actions and GitLab CI are the standards that must be reachable from +the same description. Named here so the infra seam is not designed in a way that forecloses +it. + +--- + +## The overlay is berth's network layer + +Two instances in different clouds cannot share a VPC. A WireGuard overlay gives them one flat +address space that berth owns and can reproduce on any provider — and, under a flaky +environment, a second layer beneath whatever the provider offers. + +That inverts the usual cloud pattern. Instead of a VPC with security groups and private +subnets, each instance gets a public IP, opens **only** the WireGuard port, and carries +everything else inside the tunnel. **The security boundary moves out of the provider's VPC +and into a layer that is identical on AWS, on GCP, and on a laptop behind NAT.** + +**Plain WireGuard, not Tailscale/Headscale/NetBird.** Tailscale's client is open but its +coordination plane is proprietary SaaS; Headscale and NetBird are open but add a control plane +to run. Plain WireGuard needs no server at all. + +**The cost is real and accepted:** no NAT traversal, no relay, no peer discovery. A peer behind +NAT must dial one with a public endpoint. Fine here — the instances have public IPs and the dev +box roams — but two roaming peers cannot reach each other. That is why `PersistentKeepalive` is +a checked invariant rather than a detail. + +**Keys never enter the description.** Private keys are generated on the peer that owns them +(`make vpn keygen`) into `ctrl/.secrets/` and injected only at render time; public keys live in +the estate, because a config cannot be built without them. `vpn.sh` **refuses to write** either +a key or a rendered config until `git check-ignore` confirms the path is ignored — this repo +has already been bitten once by a `.gitignore` pattern anchoring to the wrong directory. + +This also decides something about the IaC layer: **OpenTofu must never generate a WireGuard +private key**, because Terraform-lineage state stores every resource attribute in plaintext. + +--- + +## Two rules that are not style preferences + +### 1. Every default is the read-only verb + +``` +make estate -> show make certs -> status +make dns -> list make host -> status +make estate apply / destroy -> print the plan, then refuse without --yes +``` + +rig's `make cluster` defaults to `up`, because every rig verb is safe — a kind cluster is +disposable. berth's are not: `tofu destroy` costs money and takes live DNS with it. **A +tool where every verb is safe must not grow verbs that are not.** + +The failure this prevents is not hypothetical. `ppl/ctrl/certs.sh:42` is `CMD="${1:-all}"`, +so a bare `./ctrl/certs.sh` there issues a real Let's Encrypt cert, rsyncs it to the gateway, +and reloads nginx. berth's `certs` defaults to `status`. + +### 2. berth and rig share a convention, not code + +Neither imports the other. They match on **shape** — the `make ` dispatch, the +four-source config layering, the key names — and consistency is verified by +**recomputation**: `make ports verify` recomputes rig's `20000 + (cksum(name) % 200) * 10` +to check the local map, rather than sourcing rig's `lib/config.sh`. + +Copying three stable lines is the whole cost of not coupling them. A shared library would +put something outside `rig/` on rig's path, and rig's promise is that +`grep -rIn -iE 'soleprint|\bspr\b'` across it returns nothing. + +**rig is also unaware that berth exists.** `ppl/local/Caddyfile` is berth's to generate; rig +must not reference `local.ar` — its handover scrub refuses the string. + +--- + +## The gateway doctrine + +Practised across this codebase for a long time and never written down, so: written down. + +- **Caddy where routing is dynamic and config-driven** — the in-cluster gateway that + multiplexes by Host header, and the host-side `.local.ar` name→port map. +- **nginx where it is a static server or a plain long-running compose service on the box.** +- **Envoy in `mpr`** — a deliberate one-off, not a third pattern. +- **`ingress-nginx` only as a kind addon** — a different thing again from either gateway. + +### Rendering is a projection, not a format conversion + +The local Caddyfile and the box's nginx are not two spellings of the same content: + +| | local (Caddy) | cloud (nginx) | +| --- | --- | --- | +| granularity | one file | one file per vhost | +| blocks per service | one | two (`:80` redirect + `:443` server) | +| TLS | none; every address needs an explicit `:80` | one shared wildcard cert | +| upstream | `localhost:` | container name + `resolver 127.0.0.11` | +| name depth | free | constrained by the cert | +| ambiguity | most specific wins | exact, else `default_server` (= load order) | + +The `:80` is not decoration: without it Caddy 2 defaults each site to `:443` with auto-HTTPS, +which on `*.local.ar` means cert provisioning that fails and breaks the listener. The +variable upstream is not decoration either: naming the upstream in a variable forces runtime +DNS resolution, so nginx **starts when the upstream container is absent** — which is what +lets one nginx front a dozen independent compose stacks. + +Because the two disambiguate by **opposite** rules, a name set that is unambiguous locally +can be ambiguous on the box. `make check` asserts against the projection, not the source. + +### Installing generated vhosts — an order that is not optional + +1. Generated config lands in `conf.d/generated/`, **not** `conf.d/`. `ppl/ctrl/deploy.sh` + rsyncs with `--delete`; sharing a directory means one set gets erased. +2. `nginx.conf` needs a **third** include line — its `conf.d/*.conf` glob does not recurse, + which is why `conf.d/soleprint/*.conf` already needs its own. +3. That include changes **load order**, and load order decides which `:443` block catches + unmatched names. So `default.conf`'s commented-out `:443 default_server` must be restored + **first**. `make check` fails on it deliberately: it is a gate, not a warning. + +--- + +## What the checks assert, and why + +The scripts are deliberately thin on comment — the reasoning lives here. Every check below +corresponds to something that is wrong, or was wrong, in a real estate. + +### `make check` — the estate + +| assertion | the failure it catches | +| --- | --- | +| every service name is covered by an issued cert SAN | **a wildcard matches exactly one label.** `*.d.com` covers `a.d.com` but not `a.b.d.com`, which needs its own SAN. Surfaces otherwise as a browser TLS warning, far from its cause | +| a `:443 default_server` exists | DNS and the cert are wildcard but nginx matches `server_name` exactly, so without one the fallback for any unknown name is whichever vhost loads first — alphabetically, by accident | +| firewall rules and listeners agree | a rule allowing a port nothing listens on is **dead config**; a service no compose file declares is **undocumented state**. Neither is visible from one side alone, which is why the inventory has two halves | +| `HOST` is an ssh alias, never a hostname | there is no `Host ` block, so a bare hostname falls through to the global defaults, ssh offers every key in the agent in turn, and `MaxAuthTries` (6) trips with *"Too many authentication failures"* before reaching the right one. The aliases set `IdentitiesOnly yes` | + +### `make vpn check` — the overlay + +| assertion | the failure it catches | +| --- | --- | +| peer addresses unique and inside the subnet | a duplicate is a silent misroute, never an error | +| AllowedIPs do not overlap | AllowedIPs is **cryptokey routing** — the route table and the ACL at once. Overlapping ranges resolve to the last match, so an overlap is both a misroute and an unintended grant | +| something carries `PersistentKeepalive` if anything roams | without it a NAT mapping expires and the tunnel works only while traffic flows outward — *"works sometimes"*, the hardest failure to read. Note it is **not** a property of the roaming peer's own entry: the roaming machine sets it on the entry for the peer it **dials** | +| the listen port is in the firewall | otherwise no peer can be dialed at all | +| no private key in the description | public keys are *also* 44-char base64, so the shape proves nothing. The real assertions are **no field named `priv*`** and **no key outside a `public_key` field** | +| overlay-reached services bind a reachable address | **a tunnel cannot reach loopback.** A service on `127.0.0.1` is unreachable over the overlay; one on `0.0.0.0` is reachable but also exposed to the whole LAN | + +### Capturing the overlay + +```bash +sudo wg show | make vpn capture --write +``` + +`wg show` has three forms and **only the first is safe**: + +| form | safe | why | +| --- | --- | --- | +| `wg show` | **yes** | prints `private key: (hidden)` | +| `wg show dump` | **no** | field 1 of the first line *is* the private key | +| `wg showconf ` | **no** | prints `PrivateKey=` outright | + +`capture` refuses the latter two by shape. It matches peers by **allowed-ips address, not +public key** — the keys are exactly what is missing at that point — and **drops a roaming +peer's endpoint in the parser**, since that value is a home ISP address and a roaming peer +has no stable endpoint anyway. + +## Layout + +``` +berth/ +├── Makefile one target per ctrl/ script; the verb is an argument +├── STALE.md withdrawn assumptions, each with a check that runs +├── estate/.json THE ARTIFACT — one description, many renderings +└── ctrl/ + ├── check.sh reports and instructs; never fixes + ├── estate.sh show | list | plan | apply --yes | destroy --yes + ├── services.sh list | render | deploy + ├── ports.sh show | verify (the rig coincidence check) + ├── dns.sh certs.sh host.sh registry.sh docs.sh + ├── versions.env pinned toolchain (weakest layer) + ├── env.d/.env provider shape: aws | gcp + ├── .env.example -> ctrl/.env, machine-local (gitignored) + ├── lib/config.sh the four-layer load, from rig + ├── lib/estate.sh reading and projecting the estate + └── render/*.tmpl nginx vhost shapes, substituted with sed +``` + +Config layers, weakest first: `versions.env` → `env.d/.env` → `ctrl/.env` → the +caller's environment. So `make estate plan TARGET=gcp` beats everything. + +**Identity is explicit — the inversion of rig.** rig derives its name from its folder so that +copies never collide. berth refuses to guess, because a deployment has exactly one production +and a wrong guess acts on the wrong estate. `ESTATE` names a file; the only convenience is +that a single `estate/*.json` is used without being asked for. + +**`python3`, not `jq`.** rig ships a pinned static `jq` because its floor is "docker and +nothing else" on a machine it does not control. berth's floor is already higher, so +`python3` is a dependency it *has* rather than one it *adds* — the same reasoning by which +rig chose `sed` over `envsubst`. + +--- + +## Status + +**B0 (this) is the shape.** `estate/mcrn.json` is marked `UNVERIFIED`: it records what the +repos *claim*, because `ppl/infra/` was written and never applied — no `~/.pulumi`, no +`venv`, no stack state, files dated `mar 6`. B1's read-only inventory is what replaces those +claims with observations. Until then, `estate plan` and `estate apply` refuse: there is +nothing truthful to compare against yet. + +`make check` currently fails on two real things — see `def/plans/36.0/berth.md`. diff --git a/berth/STALE.md b/berth/STALE.md new file mode 100644 index 0000000..4432f1f --- /dev/null +++ b/berth/STALE.md @@ -0,0 +1,104 @@ +# berth — withdrawn assumptions + +**Everything in this file is no longer true.** + +It exists so the live docs stay short and so a withdrawn assumption cannot quietly return: +each entry carries a **check**, and `ctrl/selftest.sh` runs every one of them. A retraction +that is only prose is a retraction nobody re-reads. + +Kept rather than deleted for the reason the docgen thread already wrote down — +*a requirement that disappears without explanation comes back.* + +### For agents + +- **Do not restate these** in a plan, a README or a comment. One line pointing here is enough. +- **Ids are stable.** Cite `✖ B3`; do not re-explain it. +- **When you withdraw an assumption, move it here** — quoted claim, where it came from, what + superseded it and why, what changed in the code, and a check that proves it is gone. +- **Only withdrawn things belong here.** A warning that is still actionable is a live rule, + however historical it sounds, and stays where it is. + +--- + +**✖ B1 — "Pulumi is the source in spr, and Terraform must match the config."** +*(INDEX §5, carried from 35.3)* Withdrawn 2026-09-12. berth uses **OpenTofu and only +OpenTofu**. The rule assumed *open source* and *industry standard* pull apart — Terraform +being BUSL, Pulumi being the open alternative. OpenTofu is both: the standard language, plain +Terraform-compatible HCL, under MPL-2.0. Swappability was never bought by keeping two +renderings; it is bought by `estate/*.json` being the artifact — a rendering you *can* +produce, not one you *must* maintain. +**Gone from:** `ctrl/versions.env` (no `PULUMI_VERSION`), `ctrl/estate.sh` (`plan` runs one +executor), `ctrl/lib/config.sh` (`PULUMI_STACK` → `TOFU_WORKSPACE`), `ctrl/check.sh` +(toolchain list). +**Deliberately kept:** `README.md` and `estate/mcrn.json` both record that `ppl/infra/` was +written and never applied — *"no `~/.pulumi`, no venv, no stack state"*. That is a historical +fact about the estate, not a live dependency. +**Check:** no `pulumi` in `ctrl/` or `Makefile`. + +**✖ B2 — "ctlptl's rejection is the precedent for *the seam belongs in a script, not a tool*."** +*(INDEX §5, `berth/README.md`)* Withdrawn 2026-09-12. **ctlptl was reinstated** — pinned in +`rig/ctrl/versions.env` at v0.9.4 — and had been removed for the wrong reason. The rule may +still hold; it now has to stand on its own reasoning rather than that example. +**Gone from:** `README.md` — the argument is stated directly, with no borrowed evidence. +**Check:** `ctlptl` appears nowhere in berth. + +**✖ B3 — "`wg show` is safe; `wg showconf` is not."** *(my own note, 2026-09-12)* Incomplete, +and the gap is the dangerous one. There are **three** forms, and `wg show dump` puts the +**private key in field 1 of the first line**. Stated as a two-way distinction, the `dump` form +reads as safe. +**Now:** `wg show` plain is safe; `dump` and `showconf` are not. `ctrl/vpn.sh capture` refuses +the latter two **by shape**, rather than parsing around them. +**Check:** `vpn.sh` names all three forms, and `capture` rejects both unsafe ones. + +**✖ B4 — "A roaming peer must set `PersistentKeepalive` on its own entry."** +*(`ctrl/vpn.sh`, first draft)* Wrong side, and wrong in the direction that looks fine: +`PersistentKeepalive` is set per-peer in a config, so the roaming machine sets it on the entry +for the peer it **dials**. The original check would have **warned on a correctly configured +overlay**. +**Now:** checked once per overlay — if anything roams, some peer entry must carry a keepalive. +**Check:** the invariant is not keyed on the roaming peer's own `keepalive` field. + +**✖ B5 — "The Makefile's pass-through block goes near the top, with the other variables."** +*(`Makefile`, inherited from rig's layout)* Withdrawn 2026-09-12. When a subcommand **names a +real target**, make has two recipes for it and the **last definition wins** — so with the block +first, `make host ports` ran `ctrl/host.sh ports` *and* `ctrl/ports.sh ports`, the second +failing because `ports` is not one of its verbs. Same for `make host services`, `make vpn +check`, `make vpn show estate`. +**Now:** the `$(eval $(ARGS):;@:)` block is **last in the file**, so the no-op wins and the +word is swallowed — which is what an argument is. Make's *"overriding recipe"* warning is the +swallow working. +**Check:** every colliding invocation dispatches to exactly one script. + +**✖ B6 — "A base64 key in the description can be caught by its shape."** +*(`ctrl/vpn.sh check`, first draft)* WireGuard **public** keys are also 44-char base64 and +legitimately live in the estate, so shape alone proves nothing and would flag correct data. +**Now:** two assertions instead — **no field named `priv*`**, and **no base64 key outside a +`public_key` field**. +**Check:** the estate's public keys do not trip the secret check. + +**✖ B7 — "`network.wireguard` is where the overlay is described."** *(`estate/mcrn.json`)* +Superseded 2026-09-12: WireGuard is berth's network layer, not one service's transport, so it +is a top-level `vpn` block with named overlays and peers. `ctrl/check.sh` and +`ctrl/registry.sh` were repointed. +**Gone from:** the estate schema — a `wireguard_moved` tombstone marks the old key. +**Check:** nothing reads `network.wireguard.*`. + +**✖ B8 — "berth and rig are related through their first uses."** *(early framing)* Withdrawn: +**rig and berth are peers — neither depends on the other.** They match on shape (dispatch, +config layering, key names) and consistency is verified by **recomputation**, never by +dependency. A shared library would put something outside `rig/` on rig's path. +**Check:** berth imports nothing from rig; `ports.sh` recomputes the port formula and agrees +with rig's golden values. + +**✖ B9 — "`langfuse.mcrn.ar` is an exception that cannot be generated."** +*(`estate/mcrn.json`, `raw: true`)* Withdrawn 2026-09-14. It was filed as the one route a +template could not express — a static `upstream{}` to a WireGuard address, with no `resolver` +and no `set $var`. It was not an exception; it was **the first instance of the general case**. +Those three properties are not three decisions, they are one: *this service is reached by +address on the overlay, not by name on the docker network.* Naming that decision — +`placement` — makes the file renderable. +**Gone from:** `estate/mcrn.json` — `lng` and `langfuse` were **two entries for one socket** +and are now one service with `placement: local`, `peer: nrft`, and a `local_host` for the +name it answers to locally. `raw` is dropped. +**Check:** the generated vhost matches the hand-written one, normalised for comments and +whitespace — proof against a live route rather than an assertion. diff --git a/berth/ctrl/.env.example b/berth/ctrl/.env.example new file mode 100644 index 0000000..15cf4fd --- /dev/null +++ b/berth/ctrl/.env.example @@ -0,0 +1,18 @@ +# Machine-local config. Copy to ctrl/.env (gitignored) and edit. +# +# The estate's FACTS live in estate/.json. +# The provider's SHAPE lives in ctrl/env.d/.env. +# This file is only what differs between machines, plus credentials. + +# ESTATE is required when estate/ holds more than one file. +# ESTATE=mcrn +# TARGET=aws + +# ssh ALIASES, never hostnames — check.sh refuses a value containing a dot. +# HOST=mcrn # app user, no sudo +# HOST_ADMIN=mcrn-admin # sudo, only where genuinely required + +# Credentials: names only, never values. The secrets stay in ~/.aws and +# ~/.config/gcloud where their own tooling manages them. +# AWS_PROFILE=default +# GCP_PROJECT= diff --git a/berth/ctrl/certs.sh b/berth/ctrl/certs.sh new file mode 100644 index 0000000..be9c50d --- /dev/null +++ b/berth/ctrl/certs.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# The wildcard TLS cert for the gateway. +# +# Usage: +# ./certs.sh status # SANs issued vs SANs the services need +# ./certs.sh verify # inspect the cert served on :443 +# ./certs.sh renew # refuses: issues a real cert +# ./certs.sh push # refuses: ships to a live gateway + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +status() { + local issued; issued=$(estate_get "certs.issued" | python3 -c 'import json,sys +try: print("\n".join(json.load(sys.stdin))) +except Exception: pass') + echo "issued SANs (estate/${ESTATE}.json: certs.issued):" + echo "$issued" | sed 's/^/ /' + echo + echo "SANs the services NEED (derived from services[]):" + estate_sans | sed 's/^/ /' + echo + local missing=0 s + while IFS= read -r s; do + [ -z "$s" ] && continue + grep -qxF "$s" <<< "$issued" || { echo "MISSING: $s"; missing=1; } + done < <(estate_sans) + [ "$missing" = 0 ] && echo "the issued cert covers every derived name." + echo + echo "certbot image: $(eval echo "\$$CERTBOT_IMAGE_VAR") provider: $DNS_PROVIDER" +} + +verify() { + echo "would run:" + echo " echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null \\" + echo " | openssl x509 -noout -dates -ext subjectAltName" + echo + echo "read-only against a live host — announce and approve first (§7)." +} + +refuse() { + echo "REFUSING: '$1' acts on a live cert and a live gateway." >&2 + echo " renew issues a real Let's Encrypt cert (rate-limited)." >&2 + echo " push rsyncs to the gateway and reloads nginx." >&2 + echo " Neither runs without explicit approval." >&2 + exit 1 +} + +case "${1:-status}" in + status) status ;; + verify) verify ;; + renew|push) refuse "$1" ;; + *) echo "usage: $0 [status|verify|renew|push]" >&2; exit 1 ;; +esac diff --git a/berth/ctrl/check.sh b/berth/ctrl/check.sh new file mode 100644 index 0000000..f7e9a88 --- /dev/null +++ b/berth/ctrl/check.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Is this estate coherent? Reports and instructs; never fixes. +# +# Takes no subcommand — there is one question to ask. +# Every check corresponds to something wrong in the estate today. + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +WORST=0 +note() { echo " $*"; } +warn() { echo " WARN $*"; [ "$WORST" -lt 1 ] && WORST=1; return 0; } +bad() { echo " FAIL $*"; WORST=2; return 0; } + +echo "estate: $ESTATE target: $TARGET domain: $DOMAIN" +echo + +# 1. cert coverage: a wildcard matches exactly one label. +echo "certs — does the cert cover every name the services serve?" +issued=$(estate_get "certs.issued" | python3 -c 'import json,sys +try: print("\n".join(json.load(sys.stdin))) +except Exception: pass') +if [ -z "$issued" ]; then + warn "no certs.issued in the estate file — cannot check coverage." +else + while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do + [ -z "$host" ] && continue + fqdn="${host}.${DOMAIN}" + # A '*' host stands for "any single label here" — check the deepest + # name it can produce, which is the one that fails. + probe="$fqdn" + case "$host" in \*.*) probe="anyroom.${host#\*.}.${DOMAIN}" ;; esac + covered="" + while IFS= read -r san; do + [ -z "$san" ] && continue + if san_covers "$probe" "$san"; then covered=1; break; fi + done <<< "$issued" + if [ -z "$covered" ]; then + bad "$name: '$probe' is covered by NO issued SAN" + note " issued: $(echo "$issued" | tr '\n' ' ')" + note " a wildcard matches exactly ONE label — reissue with" + note " -d '*.${host#\*.}.${DOMAIN}' or move the name one level up" + fi + done < <(estate_services "$TARGET") + [ "$WORST" -lt 2 ] && note "every service name is covered." +fi +echo + +# 2. unmatched names: DNS and the cert are wildcard, nginx is exact, so +# without a :443 default_server the fallback is whichever vhost loads first. +echo "gateway — is there a deliberate answer for unmatched names?" +DEFAULT_CONF="${PPL_DIR:-$HOME/wdir/semester/ppl}/gateway/nginx/conf.d/default.conf" +if [ ! -f "$DEFAULT_CONF" ]; then + note "ppl not on this machine at $DEFAULT_CONF — skipped." +elif grep -qE '^\s*listen\s+443.*default_server' "$DEFAULT_CONF"; then + note "default.conf has a :443 default_server." +else + bad "default.conf has NO :443 default_server." + note " Unmatched names fall through to the first-loaded vhost." + note " This is a PREREQUISITE for generating any config: adding a" + note " generated include changes load order, and load order is what" + note " currently decides the fallback." +fi +echo + +# 3. a rule allowing a port nothing listens on is dead config; a service no +# compose file declares is undocumented state. Needs both halves to see. +echo "firewall — rules against listeners" +estate_get "firewall" | python3 -c ' +import json,sys +try: fw = json.load(sys.stdin) +except Exception: fw = [] +for r in fw: + n = r.get("note") + print(" %-6s %-5s %s" % (r["port"], r.get("proto","tcp"), r.get("desc",""))) + if n: print(" UNRESOLVED: " + n) +' +note "listener side: unknown until captured (ss -ltnp over ssh $HOST)." +wg=$(estate_get "vpn._status") +[ -n "$wg" ] && warn "overlay: not fully captured — see 'make vpn check'" && \ + note " 10.8.0.1 carries the registry and woodpecker gRPC;" && \ + note " 10.8.0.2 backs langfuse. No 51820/udp rule, nothing creates" && \ + note " the interface — a fresh box cannot start the gateway compose." +note "overlay detail: make vpn show estate" +echo + +# 4. ssh aliases, never hostnames: a bare hostname offers every agent key and +# trips MaxAuthTries before reaching the right one. +echo "ssh — aliases, never hostnames" +for var in HOST HOST_ADMIN; do + val="${!var:-}" + if [ -z "$val" ]; then + warn "$var is unset." + elif [[ "$val" == *.* ]]; then + bad "$var='$val' looks like a hostname, not a ~/.ssh/config alias." + note " A bare hostname trips MaxAuthTries before reaching the key." + elif [ -f "$HOME/.ssh/config" ] && grep -qiE "^\s*Host\s+.*\b${val}\b" "$HOME/.ssh/config"; then + note "$var=$val — Host block present." + else + warn "$var='$val' has no matching Host block in ~/.ssh/config." + fi +done +for f in "$HOME/wdir/semester/ppl/ctrl/.env"; do + [ -f "$f" ] || continue + if grep -qE '^SERVER=.*\.' "$f"; then + warn "$f sets SERVER to a hostname, not an alias — every ppl script inherits it." + fi +done +echo + +# ── 5. toolchain ─────────────────────────────────────────────────────────── +echo "toolchain" +for t in python3 "$TOFU_BIN" aws gcloud ssh rsync wg; do + if command -v "$t" >/dev/null 2>&1; then + note "$(printf '%-8s' "$t") present" + else + note "$(printf '%-8s' "$t") MISSING — $(case $t in + tofu) echo 'blocks: estate plan/apply; terraform works identically' ;; + wg) echo 'blocks: vpn keygen and the overlay checks' ;; + aws) echo 'blocks: the control-plane inventory, dns on route53' ;; + gcloud) echo 'blocks: the gcp estate' ;; + *) echo 'blocks: most things' ;; + esac)" + fi +done +echo + +case "$WORST" in + 0) echo "OK" ;; + 1) echo "OK, with warnings" ;; + 2) echo "PROBLEMS FOUND — see FAIL lines above" ;; +esac +exit 0 diff --git a/berth/ctrl/dns.sh b/berth/ctrl/dns.sh new file mode 100644 index 0000000..e6a6834 --- /dev/null +++ b/berth/ctrl/dns.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# DNS records, over whichever provider the target names. +# +# Usage: +# ./dns.sh list +# ./dns.sh add # . -> +# ./dns.sh add-wildcard +# ./dns.sh remove +# +# Read-only verbs announce and wait; mutating ones refuse. + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +announce() { + echo "would run:" + printf ' %s\n' "$*" +} + +list() { + case "$DNS_PROVIDER" in + route53) + announce "aws route53 list-resource-record-sets --hosted-zone-id $AWS_HOSTED_ZONE_ID" \ + "--query 'ResourceRecordSets[].[Name,Type,TTL,ResourceRecords[0].Value]' --output table" + ;; + google) + announce "gcloud dns record-sets list --zone=${ESTATE}-zone --project=${GCP_PROJECT:-}" + ;; + *) echo "unknown DNS_PROVIDER: $DNS_PROVIDER" >&2; exit 1 ;; + esac + echo + echo "read-only, but NOT run: each batch is announced and" + echo "waited on. Approve it and it runs." +} + +mutate() { + local verb="$1" sub="${2:-}" + [ -z "$sub" ] && { echo "usage: $0 $verb " >&2; exit 1; } + local name + case "$verb" in + add) name="${sub}.${DOMAIN}" ;; + add-wildcard) name="*.${sub}.${DOMAIN}" ;; + remove) name="${sub}.${DOMAIN}" ;; + esac + echo "$verb: $name -> $DOMAIN (provider: $DNS_PROVIDER)" + echo + + # The wildcard-depth rule again, applied BEFORE the record is created rather + # than discovered in a browser afterwards. + if [ "$verb" = "add" ]; then + local issued; issued=$(estate_get "certs.issued" | python3 -c 'import json,sys +try: print("\n".join(json.load(sys.stdin))) +except Exception: pass') + local covered="" + while IFS= read -r san; do + [ -z "$san" ] && continue + san_covers "$name" "$san" && { covered=1; break; } + done <<< "$issued" + [ -z "$covered" ] && { + echo "WARNING: '$name' is covered by no issued SAN." >&2 + echo " A wildcard matches ONE label. The record would" >&2 + echo " resolve and then fail TLS. Reissue the cert first." >&2 + echo >&2 + } + fi + + echo "REFUSING: this changes live DNS." >&2 + echo " Nothing is created, modified or deleted on any account without" >&2 + echo " explicit approval for that specific action." >&2 + exit 1 +} + +case "${1:-list}" in + list) list ;; + add|add-wildcard|remove) mutate "$@" ;; + *) echo "usage: $0 [list|add |add-wildcard |remove ]" >&2; exit 1 ;; +esac diff --git a/berth/ctrl/docs.sh b/berth/ctrl/docs.sh new file mode 100644 index 0000000..710d18f --- /dev/null +++ b/berth/ctrl/docs.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Documentation. +# +# Usage: +# ./docs.sh serve|graphs + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +case "${1:-serve}" in + serve) + echo "berth has no doc server yet — the README is the documentation." + echo " $(cd .. && pwd)/README.md" + echo + echo "the estate, resolved: make estate show" + ;; + graphs) + echo "not implemented. The estate file is the graph's source; a" + echo "renderer belongs with docgen/graphgen, which is another thread's" + echo "another thread's — so this is a handoff, not a stub to fill in here." + ;; + *) echo "usage: $0 [serve|graphs]" >&2; exit 1 ;; +esac diff --git a/berth/ctrl/env.d/aws.env b/berth/ctrl/env.d/aws.env new file mode 100644 index 0000000..3d7505f --- /dev/null +++ b/berth/ctrl/env.d/aws.env @@ -0,0 +1,14 @@ +# Target: AWS — the estate that actually runs today (mcrn.ar). +TARGET_NAME=aws +CLOUD=aws +REGION=us-east-1 +INSTANCE_TYPE=t3.small + +# The Route53 hosted zone. It ALREADY EXISTS and is reused, never created — +# see estate.sh's refusal to create a zone on this target. +AWS_HOSTED_ZONE_ID=Z02279903503ZMIB5FC1N +SSH_KEY_NAME=mcrn + +# certbot's DNS-01 plugin for this provider. +CERTBOT_IMAGE_VAR=CERTBOT_AWS_IMAGE +DNS_PROVIDER=route53 diff --git a/berth/ctrl/env.d/gcp.env b/berth/ctrl/env.d/gcp.env new file mode 100644 index 0000000..9aeae0d --- /dev/null +++ b/berth/ctrl/env.d/gcp.env @@ -0,0 +1,17 @@ +# Target: GCP — the replica, on its own domain. +# +# The domain differs from AWS's on purpose: this target CREATES a DNS zone +# where aws reuses one, so a shared domain would create a second authoritative +# zone and break live DNS. The domain lives in estate/nrft.json, not here. +TARGET_NAME=gcp +CLOUD=gcp +REGION=us-central1 +INSTANCE_TYPE=e2-small + +GCP_PROJECT= +GCP_ZONE=us-central1-a + +# Delegation order: create the zone and let it answer first, then set the +# nameservers at the registrar — it validates that they respond. +CERTBOT_IMAGE_VAR=CERTBOT_GCP_IMAGE +DNS_PROVIDER=google diff --git a/berth/ctrl/estate.sh b/berth/ctrl/estate.sh new file mode 100644 index 0000000..a85aa29 --- /dev/null +++ b/berth/ctrl/estate.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# The estate: what it is, what would change, and — behind a gate — changing it. +# +# Usage: +# ./estate.sh # show +# ./estate.sh list # every estate/*.json +# ./estate.sh plan # tofu plan, read-only +# ./estate.sh apply --yes # refuses without --yes +# ./estate.sh destroy --yes +# +# Why the default is read-only: ../README.md + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +show() { + echo "estate: $ESTATE ($ESTATE_FILE)" + echo "target: $TARGET (cloud=$CLOUD region=$REGION)" + echo "domain: $DOMAIN" + echo "host: $HOST (sudo: $HOST_ADMIN)" + echo "workspace: $TOFU_WORKSPACE" + echo + local status; status=$(estate_get "_meta.status") + [ -n "$status" ] && echo " !! $status" && echo + + echo "services in scope for '$TARGET':" + local name host up kind raw + while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do + [ -z "$name" ] && continue + printf ' %-12s %-14s %-24s %s%s\n' \ + "$name" "${host:--}" "${up:--}" "$kind" \ + "$([ -n "$raw" ] && echo ' [hand-written]')" + done < <(estate_services "$TARGET") + + echo + echo "cert SANs (derived, not listed):" + estate_sans | sed 's/^/ /' +} + +list() { + local f n + printf '%-12s %-16s %s\n' ESTATE DOMAIN STATUS + for f in ../estate/*.json; do + [ -f "$f" ] || continue + n=$(basename "$f" .json) + printf '%-12s %-16s %s%s\n' "$n" \ + "$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("domain",""))' "$f")" \ + "$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("_meta",{}).get("status",""))' "$f")" \ + "$([ "$n" = "$ESTATE" ] && echo ' <- this one')" + done +} + +# Read-only. Meaningful only once state is imported: against empty state, +# plan reports "create N resources", which is not drift. +plan() { + echo "== $TOFU_BIN plan ==" + if ! command -v "$TOFU_BIN" >/dev/null; then + echo " $TOFU_BIN not installed — skipped." >&2 + echo " OpenTofu is the MPL-2.0 fork; 'terraform' works identically." >&2 + else + echo " would run: $TOFU_BIN plan -var-file=<(estate)" + fi + echo + echo "NOTE: not wired up yet, and that is the point. tofu plan against empty" + echo " state reports \"create N resources\" — which is not drift, it is an" + echo " empty state. It becomes the check that proves the description" + echo " matches reality only once state is IMPORTED from the inventory." + echo " ppl/infra/ describes an aspiration: it was never applied." +} + +# The gate. Two things have to be true: --yes present, AND the plan shown first. +require_yes() { + local verb="$1"; shift + local yes="" + for a in "$@"; do [ "$a" = "--yes" ] && yes=1; done + if [ -z "$yes" ]; then + echo "refusing to $verb without --yes." >&2 + echo >&2 + echo " $verb changes a live, billable estate and can take DNS with it." >&2 + echo " Read the plan first: make estate plan" >&2 + echo " Then: ./ctrl/estate.sh $verb --yes" >&2 + exit 1 + fi + echo "refusing to $verb: not implemented, and deliberately so." >&2 + echo " The executor is not wired up, and nothing is imported yet, so" >&2 + echo " there is nothing truthful to apply." >&2 + exit 1 +} + +case "${1:-show}" in + show) show ;; + list) list ;; + plan) plan ;; + apply) shift; require_yes apply "$@" ;; + destroy) shift; require_yes destroy "$@" ;; + *) echo "usage: $0 [show|list|plan|apply --yes|destroy --yes]" >&2; exit 1 ;; +esac diff --git a/berth/ctrl/host.sh b/berth/ctrl/host.sh new file mode 100644 index 0000000..52172aa --- /dev/null +++ b/berth/ctrl/host.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# The remote box — the other half of the inventory. +# +# Usage: +# ./host.sh status|ports|services +# +# Announces what it would run over `ssh $HOST` and does not run it. Always the +# ssh alias, never a hostname: there is no `Host mcrn.ar` block, so a bare +# hostname offers every agent key and trips MaxAuthTries. + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +guard_alias() { + if [[ "$HOST" == *.* ]]; then + echo "HOST='$HOST' is a hostname, not a ~/.ssh/config alias. Refusing." >&2 + exit 1 + fi +} + +announce_batch() { + echo "would run, over 'ssh $HOST':" + printf ' %s\n' "$@" + echo + echo "read-only, and NOT run. Announce-first applies to EACH batch, not" + echo "once per session — so the commands can be read and" + echo "learned rather than scrolled past." +} + +guard_alias +case "${1:-status}" in + status) + announce_batch \ + "uname -a; uptime; df -h /" \ + "docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}'" \ + "docker network inspect gateway --format '{{range .Containers}}{{.Name}} {{end}}'" \ + "systemctl list-units --type=service --state=running --no-pager" \ + "systemctl list-timers --no-pager" + echo + echo "sudo-only, over 'ssh $HOST_ADMIN' and only where genuinely needed:" + echo " wg show # the WireGuard peers that exist nowhere in the tree" + ;; + ports) + announce_batch "ss -ltnp" + echo "the estate declares these firewall rules:" + estate_get "firewall" | python3 -c ' +import json,sys +for r in json.load(sys.stdin): + print(" %-6s %-5s %s" % (r["port"], r.get("proto","tcp"), r.get("desc","")))' + ;; + services) + announce_batch "docker compose -f ~/ppl/gateway/docker-compose.yml ps" + echo "the estate declares $(estate_services "$TARGET" | grep -c . ) service(s) for target '$TARGET'." + echo "the gateway compose declares 8. The difference is sibling repos'" + echo "stacks joining the shared 'gateway' network — intended design, but" + echo "nothing in the tree lists it. The inventory produces that list." + ;; + *) echo "usage: $0 [status|ports|services]" >&2; exit 1 ;; +esac diff --git a/berth/ctrl/lib/config.sh b/berth/ctrl/lib/config.sh new file mode 100644 index 0000000..16b6fd4 --- /dev/null +++ b/berth/ctrl/lib/config.sh @@ -0,0 +1,94 @@ +# Shared config loading. Sourced, never executed. Run from ctrl/. +# +# Precedence, weakest first: +# ctrl/versions.env pinned toolchain (committed) +# ctrl/env.d/.env provider shape: aws|gcp (committed) +# ctrl/.env machine-local + secrets (gitignored) +# the caller's env `make estate plan TARGET=gcp` (always wins) + +CONFIG_OVERRIDABLE="TARGET ESTATE CLOUD REGION INSTANCE_TYPE + HOST HOST_ADMIN AWS_PROFILE AWS_HOSTED_ZONE_ID + GCP_PROJECT GCP_ZONE TOFU_WORKSPACE" + +# rig's port formula, reproduced rather than imported. cksum because it is +# POSIX and gives the same value on every machine. Used to verify, not allocate. +derive_port_base() { + local h; h=$(printf '%s' "$1" | cksum | awk '{print $1}') + echo $((20000 + (h % 200) * 10)) +} + +_config_restore() { + local line + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line" + fi + done <<< "$1" + # A while loop returns its last body command's status; the trailing empty + # line would otherwise make this return 1 and trip `set -e` in the caller. + return 0 +} + +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + # ${!k+x} distinguishes "set but empty" from "unset" — an explicit + # FOO= on the command line is a real choice and must survive. + if [ -n "${!k+x}" ]; then + saved+="$k=$(printf '%q' "${!k}")"$'\n' + fi + done + + set -a + source ./versions.env + [ -f ./.env ] && source ./.env + set +a + + # Re-apply overrides now so TARGET is the caller's before we pick the file. + _config_restore "$saved" + + local target="${TARGET:-aws}" + if [ ! -f "./env.d/${target}.env" ]; then + echo "no such target: env.d/${target}.env" >&2 + echo "available: $(ls env.d/*.env 2>/dev/null | xargs -n1 basename | sed 's/\.env$//' | tr '\n' ' ')" >&2 + exit 1 + fi + + set -a + source "./env.d/${target}.env" + [ -f ./.env ] && source ./.env + set +a + + _config_restore "$saved" + + TARGET="$target" + + # Identity is explicit: berth never guesses which estate it is acting on. + # The one convenience: a single estate/*.json is used without being asked. + if [ -z "${ESTATE:-}" ]; then + local n; n=$(ls ../estate/*.json 2>/dev/null | wc -l) + if [ "$n" = "1" ]; then + ESTATE=$(basename "$(ls ../estate/*.json)" .json) + else + echo "ESTATE is not set and estate/ holds $n candidates — refusing to guess." >&2 + echo "available: $(ls ../estate/*.json 2>/dev/null | xargs -n1 basename | sed 's/\.json$//' | tr '\n' ' ')" >&2 + echo "set it: make estate show ESTATE=, or ESTATE= in ctrl/.env" >&2 + exit 1 + fi + fi + + ESTATE_FILE="../estate/${ESTATE}.json" + if [ ! -f "$ESTATE_FILE" ]; then + echo "no such estate: estate/${ESTATE}.json" >&2 + echo "available: $(ls ../estate/*.json 2>/dev/null | xargs -n1 basename | sed 's/\.json$//' | tr '\n' ' ')" >&2 + exit 1 + fi + + # Facts come from the estate file, never restated in a target env. + DOMAIN=$(estate_get "domain") + HOST="${HOST:-$(estate_get "host")}" + HOST_ADMIN="${HOST_ADMIN:-$(estate_get "host_admin")}" + + # Workspace == target, so the two can never mean different things. + TOFU_WORKSPACE="${TOFU_WORKSPACE:-$TARGET}" +} diff --git a/berth/ctrl/lib/estate.sh b/berth/ctrl/lib/estate.sh new file mode 100644 index 0000000..213cbe7 --- /dev/null +++ b/berth/ctrl/lib/estate.sh @@ -0,0 +1,125 @@ +# Reading and projecting estate/.json. Sourced, never executed. +# +# python3 rather than jq: berth's floor already includes python3, so it is a +# dependency berth has rather than one it adds. + +estate_get() { + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +for k in sys.argv[2].split("."): + if isinstance(d, list): + try: k = int(k) + except ValueError: sys.exit(0) + try: d = d[k] + except Exception: sys.exit(0) +print("" if d is None else d if isinstance(d, str) else json.dumps(d)) +' "$ESTATE_FILE" "$1" +} + +# Services in scope for one target. A service names its targets; absent = all. +# Fields are US-separated (0x1f), not tab: tab is IFS whitespace, so bash +# collapses a run of them and an empty field would shift every later column. +estate_services() { + local target="${1:-$TARGET}" + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +target = sys.argv[2] +for s in d.get("services", []): + tg = s.get("targets") + if tg is not None and target not in tg: + continue + print("\x1f".join([ + s.get("name", ""), + s.get("host", ""), + str(s.get(target + "_upstream", s.get("upstream", "")) or ""), + s.get("kind", "proxy"), + "raw" if s.get("raw") else "", + s.get("placement", "box"), + s.get("peer", ""), + str(s.get("port", "") or ""), + s.get("local_host", s.get("host", "")), + ])) +' "$ESTATE_FILE" "$target" +} + +# The SAN list the services need, derived — never a literal list. +estate_sans() { + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +domain = d["domain"] +sans = [domain] +depths = set() +for s in d.get("services", []): + h = s.get("host", "") + if not h: + continue + # A wildcard matches exactly ONE label. "git" needs *.domain; "dlt.spr" + # needs *.spr.domain. The parent of the leaf is what has to be covered. + parent = h.split(".", 1)[1] if "." in h else "" + depths.add(parent) +for p in sorted(depths): + sans.append("*." + (p + "." if p else "") + domain) +for s in sans: + print(s) +' "$ESTATE_FILE" +} + +# Is covered by ? A wildcard matches exactly one label. +san_covers() { + local fqdn="$1" san="$2" + [ "$fqdn" = "$san" ] && return 0 + case "$san" in + \*.*) + local suffix="${san#\*.}" + # Must end in .suffix AND have exactly one extra label. + case "$fqdn" in + *".$suffix") [ "${fqdn%".$suffix"}" = "${fqdn%%.*}" ] && return 0 ;; + esac + ;; + esac + return 1 +} + +# ── the overlay ──────────────────────────────────────────────────────────── + +# Every overlay name, one per line. +overlay_names() { + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +for n in d.get("vpn", {}).get("overlays", {}): + print(n) +' "$ESTATE_FILE" +} + +# Peers of one overlay, US-separated: +# name, address, role, endpoint, public_key, allowed_ips, keepalive +overlay_peers() { + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +ov = d.get("vpn", {}).get("overlays", {}).get(sys.argv[2], {}) +for name, p in ov.get("peers", {}).items(): + print("\x1f".join(str(x) if x is not None else "" for x in [ + name, p.get("address"), p.get("role"), p.get("endpoint"), + p.get("public_key"), p.get("allowed_ips"), p.get("keepalive"), + ])) +' "$ESTATE_FILE" "$1" +} + +overlay_get() { estate_get "vpn.overlays.$1.$2"; } + +# Is an address inside a CIDR? Pure python so there is no ipcalc dependency — +# berth's floor already includes python3 because the IaC side needs it. +addr_in_subnet() { + python3 -c ' +import ipaddress, sys +try: + sys.exit(0 if ipaddress.ip_address(sys.argv[1]) in ipaddress.ip_network(sys.argv[2], strict=False) else 1) +except ValueError: + sys.exit(2) +' "$1" "$2" +} diff --git a/berth/ctrl/ports.sh b/berth/ctrl/ports.sh new file mode 100644 index 0000000..b62b8f9 --- /dev/null +++ b/berth/ctrl/ports.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# The local port map, and whether it still agrees with rig. +# +# Usage: +# ./ports.sh show # DERIVED / ACTIVE / SOURCE +# ./ports.sh verify # recompute rig's formula, report drift +# +# berth recomputes rig's port formula rather than importing it, so neither +# depends on the other. See ../README.md. + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +# namehostlocal_port for everything that has one. +_local_ports() { + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +for s in d.get("services", []): + p = s.get("local_port") + if p: + print("\x1f".join([s.get("name",""), s.get("host",""), str(p)])) +' "$ESTATE_FILE" +} + +show() { + local ld; ld=$(estate_get "local_domain"); : "${ld:=local.ar}" + printf '%-12s %-22s %-8s %-8s %s\n' NAME ADDRESS ACTIVE DERIVED SOURCE + local name host port base + while IFS=$'\x1f' read -r name host port; do + [ -z "$name" ] && continue + base=$(derive_port_base "$host") + if [ "$port" = "$base" ]; then + printf '%-12s %-22s %-8s %-8s %s\n' "$name" "${host}.${ld}" "$port" "$base" "derived" + else + printf '%-12s %-22s %-8s %-8s %s\n' "$name" "${host}.${ld}" "$port" "$base" "override" + fi + done < <(_local_ports) + echo + echo "DERIVED is what rig's formula gives for that name. ACTIVE is what the" + echo "estate records. 'override' is not an error — most of these were never" + echo "rigs. 'make ports verify' says which ones should have matched." +} + +verify() { + local name host port base rc=0 checked=0 + while IFS=$'\x1f' read -r name host port; do + [ -z "$name" ] && continue + # Only 20000-21999 is rig's to predict; anything else was never derived. + if [ "$port" -lt 20000 ] || [ "$port" -gt 21999 ]; then + continue + fi + checked=$((checked + 1)) + base=$(derive_port_base "$host") + if [ "$port" != "$base" ]; then + echo "DRIFT $name (${host}): estate says $port, rig's formula gives $base" + echo " either the rig pinned HTTP_PORT in its ctrl/.env, or the" + echo " folder was renamed. The Caddy map is stale either way." + rc=1 + else + echo "ok $name (${host}): $port" + fi + done < <(_local_ports) + echo + echo "checked $checked rig-shaped port(s) in 20000-21999." + [ "$rc" = 0 ] && echo "no drift." || echo "drift found — regenerate with 'make services render local'." + return 0 +} + +case "${1:-show}" in + show) show ;; + verify) verify ;; + *) echo "usage: $0 [show|verify]" >&2; exit 1 ;; +esac diff --git a/berth/ctrl/registry.sh b/berth/ctrl/registry.sh new file mode 100644 index 0000000..d1fc54f --- /dev/null +++ b/berth/ctrl/registry.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# The image registry — remote, and reachable only over the overlay. +# +# Usage: +# ./registry.sh status +# +# One verb: berth reports on a registry running on someone else's box. Starting +# and stopping it is that box's business. + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +status() { + local wg_server; wg_server=$(overlay_get estate "peers.box.address") + echo "registry: registry.${DOMAIN} (public pull via /v2/)" + echo "push: ${wg_server}:5000 (WireGuard-only, not in the firewall)" + echo + echo "would run:" + echo " curl -s https://registry.${DOMAIN}/v2/_catalog" + echo + echo "NOTE: the push endpoint binds ${wg_server}, and $(estate_get 'vpn._status')." + echo " A freshly-provisioned box cannot start the gateway compose file" + echo " at all, because that bind fails." +} + +case "${1:-status}" in + status) status ;; + *) echo "usage: $0 [status]" >&2; exit 1 ;; +esac diff --git a/berth/ctrl/render/nginx-proxy.tmpl b/berth/ctrl/render/nginx-proxy.tmpl new file mode 100644 index 0000000..e3f979e --- /dev/null +++ b/berth/ctrl/render/nginx-proxy.tmpl @@ -0,0 +1,32 @@ +# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit. +# Edit the estate file and re-run: make services render aws + +server { + listen 80; + server_name ${FQDN}; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name ${FQDN}; + + ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem; + + # Docker's embedded DNS. Naming the upstream in a VARIABLE forces runtime + # resolution, so nginx STARTS even when the upstream container is absent. + # With a literal proxy_pass, one stopped container takes the whole gateway + # down at reload — which is what makes one nginx able to front a dozen + # independent compose stacks. + resolver 127.0.0.11 valid=30s; + + location / { + set $upstream_${NAME} ${UPSTREAM_HOST}; + proxy_pass http://$upstream_${NAME}:${UPSTREAM_PORT}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/berth/ctrl/render/nginx-static.tmpl b/berth/ctrl/render/nginx-static.tmpl new file mode 100644 index 0000000..87303e6 --- /dev/null +++ b/berth/ctrl/render/nginx-static.tmpl @@ -0,0 +1,23 @@ +# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit. +# Edit the estate file and re-run: make services render aws + +server { + listen 80; + server_name ${FQDN}; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name ${FQDN}; + + ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem; + + root /usr/share/nginx/html/${NAME}; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/berth/ctrl/render/nginx-upstream.tmpl b/berth/ctrl/render/nginx-upstream.tmpl new file mode 100644 index 0000000..f480085 --- /dev/null +++ b/berth/ctrl/render/nginx-upstream.tmpl @@ -0,0 +1,32 @@ +# ${NAME} — GENERATED by berth from estate/${ESTATE}.json. Do not edit. +# Placement: ${PLACEMENT} (${PEER}) — reached over the overlay, not the docker network. + +upstream ${NAME}_backend { + server ${UPSTREAM_HOST}:${UPSTREAM_PORT}; +} + +server { + listen 80; + server_name ${FQDN}; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name ${FQDN}; + + ssl_certificate /etc/nginx/certs/live/${DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/${DOMAIN}/privkey.pem; + + # No `resolver`, and no `set $var` indirection — deliberately. Those exist so + # nginx starts when a CONTAINER is absent; this upstream is a literal address + # on the overlay, which needs no DNS at all. The three properties are one + # decision, and placement is what decides them. + location / { + proxy_pass http://${NAME}_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/berth/ctrl/selftest.sh b/berth/ctrl/selftest.sh new file mode 100644 index 0000000..cc98099 --- /dev/null +++ b/berth/ctrl/selftest.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# What berth has settled, and what it has withdrawn, written down as assertions. +# +# Two halves: +# - decisions that hold. Failing one means "you are about to undo this". +# - every entry in ../STALE.md. Failing one means a withdrawn assumption came +# back. That is the half that makes STALE.md an audit surface and not an +# archive — a retraction nobody re-reads is a retraction that decays. +# +# Scope: no cloud, no ssh, no sudo, no network. Cheap enough to actually run. +# `make check` reports on the world and never fails; this exits 1, like rig's. +# +# Usage: make selftest (or: bash ctrl/selftest.sh) +set -uo pipefail # NOT -e: one failing check must not abort the rest +cd "$(dirname "$0")" + +source ./lib/config.sh + +rc=0 +passed=0 +check() { # name, expected, actual + if [ "$2" = "$3" ]; then + printf ' ok %s\n' "$1" + passed=$((passed + 1)) + else + printf ' FAIL %s\n expected: %s\n got: %s\n' "$1" "$2" "$3" + rc=1 + fi +} +note() { printf '\n%s\n' "$1"; } +skip() { printf ' skip %s (%s)\n' "$1" "$2"; } + +# An absence check must not match the files that RECORD the absence. STALE.md +# names every withdrawn thing by definition, and this file names them again to +# assert them — so both are excluded, or every check fails on itself. rig hits +# the same wall and assembles its pattern from fragments for the same reason. +NOSELF="--exclude=selftest.sh --exclude=STALE.md" +absent() { grep -rIl $NOSELF "$@" 2>/dev/null | wc -l; } + +# A throwaway estate, for the checks that have to run berth rather than read it. +TMP_ESTATE=_selftest +cleanup() { rm -f "../estate/${TMP_ESTATE}.json"; } +trap cleanup EXIT + +note "the withdrawn assumptions — ../STALE.md, one check each" + +# B1 — Pulumi. The two surviving mentions are historical fact about ppl/infra +# and live in README.md and the estate, not in anything that runs. +check "B1 no pulumi in the code" "0" "$(absent -i pulumi . ../Makefile)" + +# B2 — the ctlptl precedent. Withdrawn; the argument stands on its own now. +check "B2 the withdrawn precedent is cited nowhere" "0" "$(absent -i ctlptl ..)" + +# B3 — `wg show dump` leaks the private key in field 1. Stating only +# show-vs-showconf makes the dump form read as safe. +check "B3 all three wg forms are named" "yes" \ + "$(grep -q 'dump' vpn.sh && grep -q 'showconf' vpn.sh && echo yes || echo no)" +check "B3 capture refuses showconf-shaped input" "1" \ + "$(printf '[Interface]\nPrivateKey = x\n' | bash vpn.sh capture >/dev/null 2>&1; echo $?)" +check "B3 capture refuses dump-shaped input" "1" \ + "$(printf 'priv\tpub\t51820\toff\n' | bash vpn.sh capture >/dev/null 2>&1; echo $?)" + +# B4 — keepalive belongs to the peer that DIALS, not the one that roams. The +# first version warned on a correctly configured overlay, so the check is run +# against one: hub carries the keepalive, nrft roams. +python3 - <<'PY' +import json, collections +d = json.load(open("../estate/mcrn.json"), object_pairs_hook=collections.OrderedDict) +d["vpn"]["overlays"]["estate"]["peers"]["box"]["keepalive"] = 25 +json.dump(d, open("../estate/_selftest.json", "w"), indent=2, ensure_ascii=False) +PY +check "B4 a correct overlay raises no keepalive warning" "0" \ + "$(ESTATE=$TMP_ESTATE bash vpn.sh check 2>/dev/null | grep -ci 'no peer entry carries')" + +# B6 — public keys are 44-char base64 too, so shape alone would flag correct +# data. Same fixture, with a real-shaped public key on a peer. +python3 - <<'PY' +import base64, collections, json, os +d = json.load(open("../estate/_selftest.json"), object_pairs_hook=collections.OrderedDict) +d["vpn"]["overlays"]["estate"]["peers"]["box"]["public_key"] = base64.b64encode(os.urandom(32)).decode() +json.dump(d, open("../estate/_selftest.json", "w"), indent=2, ensure_ascii=False) +PY +check "B6 a public key does not trip the secret check" "0" \ + "$(ESTATE=$TMP_ESTATE bash vpn.sh check 2>/dev/null | grep -c 'FAIL.*key')" + +cleanup # the fixture is done with; two estate files would make load_config + # refuse to guess below, which is right but reads as a config failure + +# B5 — the pass-through block must be LAST, or a subcommand that names a real +# target runs that target too. Checked through make, not by reading the file. +note "B5 a subcommand that names a target dispatches once" +for combo in "host ports" "host services" "vpn check" "vpn show estate" "estate show"; do + check " make $combo" "1" \ + "$(cd .. && make -n $combo 2>/dev/null | grep -c 'bash ctrl/')" +done + +# B7 — the overlay moved out of network.wireguard into a top-level vpn block. +check "B7 nothing reads network.wireguard" "0" "$(absent 'network\.wireguard' .)" + +# B8 — peers, not relatives. berth sources nothing from rig. +check "B8 berth sources nothing from rig" "0" "$(absent -E 'rig/ctrl|\.\./rig' .)" + +# B9 — langfuse was filed as an exception a template could not express. It was +# the general case. The proof is a live route: render it and diff against the +# hand-written file, normalised for comments and whitespace. +LIVE=/home/mariano/wdir/semester/ppl/gateway/nginx/conf.d/langfuse.conf +if [ -f "$LIVE" ]; then + norm() { sed -e 's/#.*//' -e 's/[[:space:]]\+/ /g' -e 's/^ //' -e 's/ $//' -e '/^$/d' "$1"; } + bash services.sh render aws >/dev/null 2>&1 + check "B9 the generated vhost reproduces the live one" "same" \ + "$(diff -q <(norm ./render/out/aws/langfuse.conf) <(norm "$LIVE") >/dev/null 2>&1 \ + && echo same || echo different)" +else + skip "B9 generated vhost matches the live one" "ppl not on this machine" +fi + +note "the safety contract — berth's verbs are not all safe" + +check "estate defaults to show" "show" "$(cd .. && make -n estate 2>/dev/null | grep -oE 'estate\.sh [a-z]+' | awk '{print $2}')" +check "certs defaults to status" "status" "$(cd .. && make -n certs 2>/dev/null | grep -oE 'certs\.sh [a-z]+' | awk '{print $2}')" +check "dns defaults to list" "list" "$(cd .. && make -n dns 2>/dev/null | grep -oE 'dns\.sh [a-z]+' | awk '{print $2}')" +check "vpn defaults to list" "list" "$(cd .. && make -n vpn 2>/dev/null | grep -oE 'vpn\.sh [a-z]+' | awk '{print $2}')" + +for verb in apply destroy; do + check "estate $verb refuses without --yes" "1" \ + "$(bash estate.sh "$verb" >/dev/null 2>&1; echo $?)" +done +for verb in renew push; do + check "certs $verb refuses" "1" \ + "$(bash certs.sh "$verb" >/dev/null 2>&1; echo $?)" +done +check "dns add refuses to change live DNS" "1" \ + "$(bash dns.sh add selftest >/dev/null 2>&1; echo $?)" +check "vpn up refuses without --yes" "1" \ + "$(bash vpn.sh up >/dev/null 2>&1; echo $?)" + +note "config — the caller's env beats the files" + +# Generated from CONFIG_OVERRIDABLE, so a new key enrols itself. +test_value() { + case "$1" in + TARGET) echo "gcp" ;; + ESTATE) echo "mcrn" ;; + *) echo "selftest-sentinel" ;; + esac +} +for key in $CONFIG_OVERRIDABLE; do + want="$(test_value "$key")" + got="$(export "$key=$want"; load_config >/dev/null 2>&1; echo "${!key}")" + check " caller's $key wins" "$want" "$got" +done + +note "rig agreement — recomputed, never imported" + +# rig pins these same constants in its own selftest. Both arrive at them from +# the same formula with no shared code, which is the coupling rule made testable. +check "derive_port_base rig" "20310" "$(derive_port_base rig)" +check "derive_port_base foo" "21690" "$(derive_port_base foo)" +check "derive_port_base my-proj" "21030" "$(derive_port_base my-proj)" + +note "containment — berth writes nothing outside berth/" + +check "no tracked change outside berth/" "0" \ + "$(cd ../.. && git status --porcelain 2>/dev/null | grep -vc '^.. berth/')" +check "generated output is ignored" "yes" \ + "$(cd .. && git check-ignore -q ctrl/render/out && echo yes || echo no)" +# A trailing-slash pattern matches directories only, so ask about a path +# inside it rather than the (not-yet-existing) directory itself. +check "key material is ignored" "yes" \ + "$(cd .. && git check-ignore -q ctrl/.secrets/vpn/any.key && echo yes || echo no)" + +note "every STALE entry has a check here" + +# Not "$0": line 15 cd's into this script's directory, so a relative $0 no +# longer resolves. After the cd the file is simply selftest.sh. +# Ids are counted wherever they appear — B5's sits in a note(), not a check name. +entries="$(grep -c '^\*\*✖ B' ../STALE.md)" +checked="$(grep -oE '\bB[1-9][0-9]?\b' selftest.sh | sort -u | wc -l)" +check "STALE.md entries are all covered" "$entries" "$checked" + +printf '\n%d passed' "$passed" +[ "$rc" -ne 0 ] && printf ', SOME FAILED' +printf '\n' +exit "$rc" diff --git a/berth/ctrl/services.sh b/berth/ctrl/services.sh new file mode 100644 index 0000000..a7bcf1a --- /dev/null +++ b/berth/ctrl/services.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# Gateway routes, projected from the estate onto one target. +# +# Usage: +# ./services.sh # list +# ./services.sh render aws # -> render/out/aws/*.conf (nginx vhosts) +# ./services.sh render local # -> render/out/local/Caddyfile +# ./services.sh deploy # refuses; ppl/ctrl/deploy.sh ships config +# +# Each target is a projection with its own rules, not a format conversion. +# The nine axes they disagree on, and the install order: ../README.md + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +OUT_ROOT="./render/out" + +list() { + printf '%-12s %-16s %-22s %-9s %s\n' NAME FQDN UPSTREAM PLACEMENT SOURCE + local name host up kind raw + while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do + [ -z "$name" ] && continue + # A placed service has no literal `upstream` — it is derived from the + # peer's overlay address, so show what it actually resolves to. + local shown="$up" + case "$placement" in + local|instance) shown="$(overlay_get estate "peers.${peer}.address"):${port}" ;; + esac + printf '%-12s %-16s %-22s %-9s %s\n' \ + "$name" "${host}.${DOMAIN}" "${shown:--}" "$placement" \ + "$([ -n "$raw" ] && echo 'hand-written' || echo 'generated')" + done < <(estate_services "$TARGET") + echo + echo "hand-written entries are NOT generated and NOT overwritten." + echo "run 'make estate show' to see why each one is an exception." +} + +render_cloud() { + # Two statements: `local a="$1" b="$a"` expands all arguments before any + # assignment, so $a would still be unset. + local target="$1" + local out="$OUT_ROOT/$target" + rm -rf "$out"; mkdir -p "$out" + local name host up kind raw uhost uport n=0 skipped=0 + + while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do + [ -z "$name" ] && continue + if [ -n "$raw" ]; then + skipped=$((skipped + 1)) + continue + fi + # Placement picks the rendering. A container on the estate's own network + # is reached by NAME through docker's resolver; anything on the overlay + # is reached by ADDRESS and needs no DNS. That is one decision, not the + # three properties (upstream{}, no resolver, no set $var) it produces. + local tmpl + case "$placement" in + local|instance) + tmpl=./render/nginx-upstream.tmpl + uhost="$(overlay_get estate "peers.${peer}.address")" + uport="$port" + if [ -z "$uhost" ]; then + echo " ! $name: placement '$placement' names peer '$peer', which has no address" >&2 + continue + fi + ;; + hosted) + echo " ! $name: placement 'hosted' is declared but not rendered yet" >&2 + continue + ;; + *) + if [ "$kind" = "static" ]; then + tmpl=./render/nginx-static.tmpl + uhost=""; uport="" + else + tmpl=./render/nginx-proxy.tmpl + uhost="${up%%:*}"; uport="${up##*:}" + fi + ;; + esac + sed -e "s|\${NAME}|${name}|g" \ + -e "s|\${ESTATE}|${ESTATE}|g" \ + -e "s|\${FQDN}|${host}.${DOMAIN}|g" \ + -e "s|\${DOMAIN}|${DOMAIN}|g" \ + -e "s|\${PLACEMENT}|${placement}|g" \ + -e "s|\${PEER}|${peer}|g" \ + -e "s|\${UPSTREAM_HOST}|${uhost}|g" \ + -e "s|\${UPSTREAM_PORT}|${uport}|g" \ + "$tmpl" > "$out/${name}.conf" + n=$((n + 1)) + done < <(estate_services "$target") + + echo "wrote $n vhost(s) to $out/ ($skipped hand-written, left alone)" + cat < "$out/Caddyfile" + + echo "wrote $out/Caddyfile" + echo + echo "rig is not consulted and does not know this exists — its handover" + echo "scrub refuses the string '${ld}'. Where a port belongs to a rig," + echo "'make ports verify' RECOMPUTES rig's formula to check it rather than" + echo "importing rig's code. Convention, verified; not a dependency." +} + +case "${1:-list}" in + list) list ;; + render) + shift + # `case "${1:-X}"` defaults the match but leaves $1 empty. + t="${1:-$TARGET}" + case "$t" in + local) render_local ;; + aws|gcp) render_cloud "$t" ;; + *) echo "usage: $0 render [aws|gcp|local]" >&2; exit 1 ;; + esac + ;; + deploy) + echo "berth does not ship config; ppl/ctrl/deploy.sh does." >&2 + echo " berth's half is the DESCRIPTION and the render. Shipping is" >&2 + echo " rsync + compose against a live box, and it belongs where the" >&2 + echo " credentials are: berth is the tool, ppl is the estate that" >&2 + echo " holds the secrets." >&2 + exit 1 + ;; + *) echo "usage: $0 [list|render [aws|gcp|local]|deploy]" >&2; exit 1 ;; +esac diff --git a/berth/ctrl/versions.env b/berth/ctrl/versions.env new file mode 100644 index 0000000..63c7f1b --- /dev/null +++ b/berth/ctrl/versions.env @@ -0,0 +1,11 @@ +# Pinned toolchain. Committed. The weakest config layer. + +# The infra executor. One, not a pair — see README.md. +# This pin is a placeholder; set it from `tofu version` once installed. +TOFU_VERSION=1.9.0 +TOFU_BIN=tofu + +# certbot runs as a throwaway container so the DNS plugin's credentials never +# have to be installed on this machine. +CERTBOT_AWS_IMAGE=certbot/dns-route53:latest +CERTBOT_GCP_IMAGE=certbot/dns-google:latest diff --git a/berth/ctrl/vpn.sh b/berth/ctrl/vpn.sh new file mode 100644 index 0000000..504b7a9 --- /dev/null +++ b/berth/ctrl/vpn.sh @@ -0,0 +1,425 @@ +#!/usr/bin/env bash +# Overlays — WireGuard as berth's network layer. +# +# Usage: +# ./vpn.sh # list +# ./vpn.sh show # topology +# ./vpn.sh check # invariants +# ./vpn.sh render # peer's wg0.conf -> render/out/vpn/ +# ./vpn.sh keygen # keypair -> .secrets/; prints only the public key +# ./vpn.sh up|down --yes # refuses; the host operates its own tunnel +# +# sudo wg show | ./vpn.sh capture [--write] +# +# `wg show` is the only safe form: `wg show dump` puts the private key in +# field 1, and `wg showconf` prints it outright. capture refuses both. +# +# Rationale, topology and key handling: ../README.md + +set -euo pipefail +cd "$(dirname "$0")" + +source ./lib/config.sh +source ./lib/estate.sh +load_config + +SECRETS_DIR="./.secrets/vpn" +OUT_DIR="./render/out/vpn" + +WORST=0 +note() { echo " $*"; } +warn() { echo " WARN $*"; [ "$WORST" -lt 1 ] && WORST=1; return 0; } +bad() { echo " FAIL $*"; WORST=2; return 0; } + +# Which addresses belong to THIS machine, so checks can distinguish what they +# can actually see from what needs capturing elsewhere. +my_overlay_addrs() { ip -4 -o addr show 2>/dev/null | awk '{split($4,a,"/"); print a[1]}'; } +is_me() { my_overlay_addrs | grep -qxF "$1"; } + +list() { + local n sub port peers + for n in $(overlay_names); do + sub=$(overlay_get "$n" subnet) + port=$(overlay_get "$n" listen_port) + peers=$(overlay_peers "$n" | grep -c . || true) + printf '%-10s %-16s port %-7s %s peer(s)\n' "$n" "$sub" "$port" "$peers" + note "$(overlay_get "$n" purpose)" + done + local st; st=$(estate_get "vpn._status") + [ -n "$st" ] && { echo; echo " !! $st"; } +} + +show() { + local ov="${1:-}" + [ -z "$ov" ] && { echo "usage: $0 show " >&2; exit 1; } + overlay_names | grep -qxF "$ov" || { + echo "no such overlay: $ov" >&2 + echo "available: $(overlay_names | tr '\n' ' ')" >&2; exit 1; } + + echo "overlay: $ov subnet $(overlay_get "$ov" subnet) udp/$(overlay_get "$ov" listen_port)" + echo + printf '%-8s %-12s %-9s %-22s %s\n' PEER ADDRESS ROLE ENDPOINT PUBKEY + local name addr role ep pk aips ka + while IFS=$'\x1f' read -r name addr role ep pk aips ka; do + [ -z "$name" ] && continue + printf '%-8s %-12s %-9s %-22s %s%s\n' \ + "$name" "$addr" "$role" "${ep:-—}" "${pk:-—}" \ + "$(is_me "$addr" && echo ' <- this machine')" + done < <(overlay_peers "$ov") +} + +check() { + local ov name addr role ep pk aips ka + for ov in $(overlay_names); do + local sub port + sub=$(overlay_get "$ov" subnet); port=$(overlay_get "$ov" listen_port) + echo "overlay '$ov' — $sub udp/$port" + + # 1. addresses: unique, and inside the subnet. Two peers sharing an + # address is a silent misroute, never an error message. + local addrs; addrs=$(overlay_peers "$ov" | cut -d$'\x1f' -f2 | grep -v '^$' || true) + local dupes; dupes=$(echo "$addrs" | sort | uniq -d) + [ -n "$dupes" ] && bad "duplicate peer addresses: $(echo "$dupes" | tr '\n' ' ')" + while IFS= read -r a; do + [ -z "$a" ] && continue + addr_in_subnet "$a" "$sub" || bad "$a is outside $sub" + done <<< "$addrs" + + while IFS=$'\x1f' read -r name addr role ep pk aips ka; do + [ -z "$name" ] && continue + + # AllowedIPs is cryptokey routing — route table and ACL at once. + case "$aips" in + *0.0.0.0/0*) warn "$name: AllowedIPs includes 0.0.0.0/0 — full-tunnel. Deliberate?" ;; + esac + + # A peer with no endpoint cannot be dialed; it must initiate. + if [ -z "$ep" ] && [ "$role" != "roaming" ]; then + warn "$name: role '$role' but no endpoint — nothing can dial it." + fi + # Keepalive is NOT on the roaming peer's own entry — it is set on + # the entry for the peer it dials. Checked per-overlay below. + [ -z "$pk" ] && note "$name: public_key not captured yet" + done < <(overlay_peers "$ov") + + # If anything roams, some peer entry must carry a keepalive. + if overlay_peers "$ov" | cut -d$'\x1f' -f3 | grep -qx roaming; then + if ! overlay_peers "$ov" | cut -d$'\x1f' -f7 | grep -qE '^[0-9]+$'; then + warn "a peer roams but no peer entry carries PersistentKeepalive" + note " the roaming side sets it on the entry for the peer it dials;" + note " without it the NAT mapping expires and the tunnel works only" + note " while traffic flows outward — 'works sometimes'" + else + note "keepalive present on the dialed peer." + fi + fi + + # 4. the listen port must be open wherever a peer is dialable. + local fwports; fwports=$(estate_get "firewall" | python3 -c ' +import json,sys +try: print(" ".join(str(r.get("port")) for r in json.load(sys.stdin))) +except Exception: pass') + case " $fwports " in + *" $port "*) note "udp/$port present in the firewall description." ;; + *) bad "udp/$port is in no firewall rule — no peer could be dialed." ;; + esac + echo + done + + # Public keys are also 44-char base64, so shape alone proves nothing. The + # assertions are: no field named private, no key outside a public_key field. + echo "secrets — the description must never carry a private key" + local leaked + leaked=$(python3 - estate/../../estate/*.json <<'PY' 2>/dev/null || true +import json, re, sys, glob +KEY = re.compile(r'^[A-Za-z0-9+/]{43}=$') +bad = [] +for f in glob.glob("../estate/*.json"): + def walk(node, path): + if isinstance(node, dict): + for k, v in node.items(): + if re.search(r'priv', k, re.I): + bad.append(f"{f}: field '{'.'.join(path+[k])}' is named private") + walk(v, path + [k]) + elif isinstance(node, list): + for i, v in enumerate(node): walk(v, path + [str(i)]) + elif isinstance(node, str) and KEY.match(node): + if not path or 'public' not in path[-1]: + bad.append(f"{f}: base64 key at '{'.'.join(path)}' is not a public_key field") + walk(json.load(open(f)), []) +print("\n".join(bad)) +PY +) + if [ -n "$leaked" ]; then + echo "$leaked" | while IFS= read -r l; do [ -n "$l" ] && bad "$l"; done + else + note "clean — no private-named field, no stray key material." + fi + echo + + # A service reached over the overlay must bind an address the tunnel can + # reach. Loopback cannot be reached through a tunnel. + echo "bindings — services reached over the overlay must bind a reachable address" + local checked=0 + while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do + [ -z "$up" ] && continue + local uhost="${up%%:*}" uport="${up##*:}" + addr_in_subnet "$uhost" "$(overlay_get estate subnet)" 2>/dev/null || continue + checked=$((checked + 1)) + if is_me "$uhost"; then + local binds; binds=$(ss -ltn 2>/dev/null | awk -v p=":$uport\$" '$4 ~ p {print $4}') + if [ -z "$binds" ]; then + bad "$name: nothing listens on :$uport here, but $uhost:$uport is its upstream" + elif echo "$binds" | grep -q '^127\.0\.0\.1:'; then + bad "$name: :$uport binds 127.0.0.1 — unreachable over the overlay" + note " the tunnel cannot reach loopback; bind 0.0.0.0 or $uhost" + else + note "$name: :$uport binds $(echo "$binds" | tr '\n' ' ')— reachable" + echo "$binds" | grep -q '^0\.0\.0\.0:' && \ + note " (0.0.0.0 also exposes it to the LAN; $uhost alone would be tighter)" + fi + else + note "$name: upstream $uhost is another peer — needs capture there" + fi + done < <(estate_services "$TARGET") + [ "$checked" = 0 ] && note "no service currently has an overlay address as its upstream." + echo + + case "$WORST" in + 0) echo "OK" ;; + 1) echo "OK, with warnings" ;; + 2) echo "PROBLEMS FOUND — see FAIL lines above" ;; + esac + return 0 +} + +# A .gitignore pattern containing a slash anchors to its own directory, so the +# only way to know a path is ignored is to ask git. +assert_ignored() { + local path="$1" + if ! git check-ignore -q "$path" 2>/dev/null; then + echo "REFUSING: '$path' is not gitignored." >&2 + echo " Writing key material there would stage it on the next 'git add'." >&2 + echo " Verify with: git check-ignore -v $path" >&2 + exit 1 + fi +} + +keygen() { + local peer="${1:-}" + [ -z "$peer" ] && { echo "usage: $0 keygen " >&2; exit 1; } + command -v wg >/dev/null || { echo "wg not installed." >&2; exit 1; } + + mkdir -p "$SECRETS_DIR" + assert_ignored "$SECRETS_DIR" + local kf="$SECRETS_DIR/${peer}.key" + [ -e "$kf" ] && { echo "REFUSING: $kf exists. Delete it deliberately to rotate." >&2; exit 1; } + + ( umask 077; wg genkey > "$kf" ) + echo "private key -> $kf (0600, gitignored, never leaves this machine)" + echo + echo "public key for the estate description:" + echo " $(wg pubkey < "$kf")" + echo + echo "Paste that into estate/*.json under vpn.overlays..peers.${peer}.public_key." + echo "The private key stays here and is injected only at render time." +} + +render() { + local peer="${1:-}" + [ -z "$peer" ] && { echo "usage: $0 render " >&2; exit 1; } + mkdir -p "$OUT_DIR" + assert_ignored "$OUT_DIR" + + local ov=estate + local found="" + local name addr role ep pk aips ka + while IFS=$'\x1f' read -r name addr role ep pk aips ka; do + [ "$name" = "$peer" ] && found=1 && break + done < <(overlay_peers "$ov") + [ -z "$found" ] && { echo "no such peer '$peer' in overlay '$ov'" >&2; exit 1; } + + local missing="" + while IFS=$'\x1f' read -r name addr role ep pk aips ka; do + [ -z "$pk" ] && missing="$missing $name" + done < <(overlay_peers "$ov") + if [ -n "$missing" ]; then + echo "REFUSING to render: public keys not captured for:$missing" >&2 + echo " A config without every peer's public key is a config that silently" >&2 + echo " drops those peers. Capture first: sudo wg show | $0 capture --write" >&2 + exit 1 + fi + echo "would write $OUT_DIR/${peer}.conf (all keys present)" +} + +# Reads `wg show` on stdin. Peers match by allowed-ips address, not public key, +# because the keys are what is missing. A roaming peer's endpoint is a home +# address and has no stable value — dropped in the parser, not just unused. +capture() { + local write="" + for a in "$@"; do [ "$a" = "--write" ] && write=1; done + + local input; input=$(cat) + if [ -z "$input" ]; then + echo "nothing on stdin." >&2 + echo " run: sudo wg show | $0 capture" >&2 + exit 1 + fi + # Refuse the unsafe forms outright rather than parsing around them. + if printf '%s' "$input" | grep -qiE '^\s*PrivateKey\s*=|^\[Interface\]'; then + echo "REFUSING: this looks like 'wg showconf' output — it contains a PRIVATE KEY." >&2 + echo " Use 'sudo wg show' (plain). It prints 'private key: (hidden)'." >&2 + exit 1 + fi + if ! printf '%s' "$input" | grep -q 'interface:'; then + echo "REFUSING: this does not look like 'wg show' output." >&2 + echo " If it was 'wg show dump': that form's first field IS the" >&2 + echo " private key. Use 'sudo wg show' with no subcommand." >&2 + exit 1 + fi + + WRITE="$write" INPUT="$input" python3 - "$ESTATE_FILE" <<'PYCAP' +import collections, json, os, re, sys + +text = os.environ["INPUT"] +write = os.environ.get("WRITE") == "1" +path = sys.argv[1] + +iface, peers, cur = {}, [], None +for line in text.splitlines(): + st = line.strip() + if st.startswith("interface:"): + cur = iface; cur["name"] = st.split(":", 1)[1].strip(); continue + if st.startswith("peer:"): + cur = {"public_key": st.split(":", 1)[1].strip()}; peers.append(cur); continue + if cur is None or ":" not in st: + continue + k, v = st.split(":", 1) + k, v = k.strip().lower(), v.strip() + if k == "private key": + continue # never recorded, whatever it says + if k == "public key": cur["public_key"] = v + elif k == "listening port": cur["listen_port"] = v + elif k == "allowed ips": cur["allowed_ips"] = v + elif k == "endpoint": cur["endpoint"] = v + elif k == "persistent keepalive": + m = re.search(r"(\d+)", v) + if m: cur["keepalive"] = int(m.group(1)) + +d = json.load(open(path), object_pairs_hook=collections.OrderedDict) +ov = d["vpn"]["overlays"]["estate"] + +# address -> peer name, from what the estate already declares +by_addr = {p["address"]: n for n, p in ov["peers"].items() if p.get("address")} +# this machine's own wg address, so the interface block lands on the right peer +me = None +for n, p in ov["peers"].items(): + if p.get("address") and os.popen( + "ip -4 -o addr show 2>/dev/null | awk '{split($4,a,\"/\"); print a[1]}'" + ).read().split().count(p["address"]): + me = n + +changes = [] +conflicts = [] +staged = {} +def setf(peer, field, val, why=""): + p = ov["peers"][peer] + if val is None or p.get(field) == val: + return + # Two values for one field means the input is from another machine. + prev = staged.get((peer, field)) + if prev is not None and prev != val: + conflicts.append((peer, field, prev, val)) + return + staged[(peer, field)] = val + changes.append((peer, field, p.get(field), val, why)) + if write: + p[field] = val + +if me and iface.get("public_key"): + setf(me, "public_key", iface["public_key"], "(this machine's interface)") + +for pr in peers: + addrs = [a.split("/")[0] for a in pr.get("allowed_ips", "").split(",") if a.strip()] + name = next((by_addr[a] for a in addrs if a in by_addr), None) + if not name: + changes.append(("?", "UNMATCHED", None, + "allowed_ips=%s key=%s" % (pr.get("allowed_ips"), pr["public_key"][:12] + "..."), + "no estate peer has this address")) + continue + setf(name, "public_key", pr.get("public_key")) + setf(name, "allowed_ips", pr.get("allowed_ips")) + setf(name, "keepalive", pr.get("keepalive")) + # endpoint: recorded ONLY for a non-roaming peer. For a roaming one the + # value is a home ISP address and is deliberately dropped here. + if pr.get("endpoint"): + if ov["peers"][name].get("role") == "roaming": + changes.append((name, "endpoint", None, "(dropped: roaming peer)", + "a home address is the one sensitive field; roaming peers have no stable endpoint")) + else: + setf(name, "endpoint", pr["endpoint"]) + +if iface.get("listen_port"): + try: + lp = int(iface["listen_port"]) + if ov.get("listen_port") != lp: + changes.append(("(overlay)", "listen_port", ov.get("listen_port"), lp, "")) + if write: ov["listen_port"] = lp + except ValueError: + pass + +if conflicts: + print("REFUSING: the same field was reported twice with different values.\n") + for peer, field, a, b in conflicts: + print(" %s.%s: %s vs %s" % (peer, field, a, b)) + print("\nThis usually means `wg show` output from one machine was piped into") + print("capture on another. Run capture on the machine the output came from.") + raise SystemExit(1) + +if not changes: + print("nothing to record — the estate already matches what wg reports.") +else: + print("%-9s %-12s %-22s %s" % ("PEER", "FIELD", "WAS", "WOULD BE")) + for peer, field, was, val, why in changes: + print("%-9s %-12s %-22s %s" % (peer, field, was if was is not None else "—", val)) + if why: print(" %s" % why) + +if write: + still = [n for n, p in ov["peers"].items() if not p.get("public_key")] + if not still: + d["vpn"]["_status"] = ("CAPTURED %s — public keys, allowed-ips and keepalive read from " + "`wg show`. Roaming endpoints deliberately not recorded." + % __import__("datetime").date.today()) + json.dump(d, open(path, "w"), indent=2, ensure_ascii=False) + open(path, "a").write("\n") + print("\nwritten to %s" % path) +else: + print("\nnothing written. Add --write to record it.") +PYCAP +} + +refuse() { + local verb="$1"; shift + local yes="" + for a in "$@"; do [ "$a" = "--yes" ] && yes=1; done + [ -z "$yes" ] && { + echo "refusing to $verb without --yes." >&2 + echo " $verb changes live networking — it can cut the path this session" >&2 + echo " is reaching the estate through. Read 'make vpn check' first." >&2 + exit 1; } + echo "refusing to $verb: not implemented. Bringing a tunnel up or down is" >&2 + echo " the host's business, and the live one is systemd-managed" >&2 + echo " (wg-quick@wg0). berth describes and renders; it does not operate." >&2 + exit 1 +} + +case "${1:-list}" in + list) list ;; + show) shift; show "${1:-}" ;; + check) check ;; + render) shift; render "${1:-}" ;; + keygen) shift; keygen "${1:-}" ;; + capture) shift; capture "$@" ;; + up|down) v="$1"; shift; refuse "$v" "$@" ;; + *) echo "usage: $0 [list|show |check|render |keygen |capture [--write]|up --yes|down --yes]" >&2; exit 1 ;; +esac diff --git a/berth/estate/mcrn.json b/berth/estate/mcrn.json new file mode 100644 index 0000000..9bb7e93 --- /dev/null +++ b/berth/estate/mcrn.json @@ -0,0 +1,314 @@ +{ + "_meta": { + "status": "UNVERIFIED — derived from the repos, not from the estate", + "why": "ppl/infra/ was written and never applied: no ~/.pulumi, no infra/venv, no stack state, files dated 'mar 6'. The estate was built in the console and the IaC is aspirational. B1's inventory is what replaces these values with observed ones; until it runs, every field here is a CLAIM.", + "never_record": "credential values. Resource ids and settings only. nova's gateway secret is deliberately absent from this file even though it is committed in plaintext in ppl/gateway/nginx/conf.d/nova.conf — see services[].raw.", + "sources": [ + "ppl/infra/__main__.py", + "ppl/ctrl/dns.sh", + "ppl/ctrl/certs.sh", + "ppl/gateway/docker-compose.yml", + "ppl/gateway/nginx/conf.d/", + "ppl/local/Caddyfile" + ], + "placement": { + "box": "a container on the estate's own docker network — upstream is the container name", + "local": "a rig cluster on a peer, reached over the overlay — upstream is that peer's address", + "instance": "a dedicated cloud instance on the overlay — same rendering as `local`", + "hosted": "a managed endpoint. Declared so moving to one is a one-line change; unused.", + "_why": "A service says WHERE it runs. How it is reached follows from that, and the three properties of a static-upstream vhost — upstream{}, no resolver, no set $var — are one decision rather than three." + } + }, + "domain": "mcrn.ar", + "local_domain": "local.ar", + "host": "mcrn", + "host_admin": "mcrn-admin", + "instance": { + "type": "t3.small", + "disk_gb": 30, + "disk_type": "gp3", + "image": "debian-12", + "user": "mariano" + }, + "firewall": [ + { + "port": 22, + "proto": "tcp", + "desc": "SSH" + }, + { + "port": 80, + "proto": "tcp", + "desc": "HTTP" + }, + { + "port": 443, + "proto": "tcp", + "desc": "HTTPS" + }, + { + "port": 3022, + "proto": "tcp", + "desc": "Gitea SSH", + "note": "compose maps 3022:22 but GITEA__server__SSH_PORT=22, so gitea advertises :22 in clone URLs while listening on :3022. B1 confirms which is real." + }, + { + "port": 51820, + "proto": "udp", + "desc": "WireGuard", + "note": "ABSENT from ppl/infra/__main__.py's four rules — but the tunnel is live (ping 10.8.0.1 succeeds), so the real security group must already allow it. The code therefore does not describe the estate. Confirm in V1." + } + ], + "network": { + "docker_network": "gateway", + "docker_network_note": "A fixed, externally-joinable bridge name. Every unrelated app stack on the box joins it so nginx can resolve them by container name. This is why the gateway compose declares 8 services while nginx routes 20+ hostnames.", + "wireguard_moved": "superseded by the top-level `vpn` block" + }, + "vpn": { + "_status": "PARTIAL — addresses and subnet verified from the live wg0 interface on nrft. Peer public keys, endpoints, allowed-ips and keepalive still need `wg show` capture on both ends (V1). Nulls below are unknowns, not defaults.", + "_never_record": "private keys. `wg show` prints 'private key: (hidden)' and is the safe capture command. `wg showconf` dumps PrivateKey= in clear — never use it.", + "overlays": { + "estate": { + "purpose": "Connects the estate's machines across clouds without a shared VPC, and carries everything that does not need to be publicly reachable.", + "subnet": "10.8.0.0/24", + "listen_port": 51820, + "peers": { + "box": { + "address": "10.8.0.1", + "role": "hub", + "note": "mcrn.ar. Has a public IP, so it is the peer others dial. Carries the registry (:5000) and woodpecker's gRPC (:9000), both bound to this address and therefore overlay-only.", + "endpoint": null, + "public_key": null, + "allowed_ips": null + }, + "nrft": { + "address": "10.8.0.2", + "role": "roaming", + "note": "The dev box. Behind NAT, so it must initiate and needs PersistentKeepalive. Verified: wg0 UP at 10.8.0.2/24, ping 10.8.0.1 0% loss at 153ms.", + "endpoint": null, + "public_key": null, + "allowed_ips": null, + "keepalive": null + } + } + } + } + }, + "databases": [ + "gitea", + "woodpecker", + "umami" + ], + "certs": { + "issued": [ + "mcrn.ar", + "*.mcrn.ar", + "*.spr.mcrn.ar" + ], + "issued_source": "ppl/ctrl/certs.sh:92 — the -d flags passed to certbot", + "note": "What the cert ACTUALLY covers. estate_sans() derives what the services NEED. check.sh compares the two; the difference is the finding, not a restatement." + }, + "services": [ + { + "name": "gitea", + "host": "git", + "upstream": "gitea:3000", + "targets": [ + "aws" + ] + }, + { + "name": "woodpecker", + "host": "ci", + "upstream": "woodpecker-server:8000", + "targets": [ + "aws" + ] + }, + { + "name": "registry", + "host": "registry", + "upstream": "registry:5000", + "targets": [ + "aws" + ] + }, + { + "name": "umami", + "host": "analytics", + "upstream": "umami:3000", + "targets": [ + "aws" + ] + }, + { + "name": "docserve", + "host": "docs", + "upstream": "docserve:8020", + "targets": [ + "aws" + ] + }, + { + "name": "ghost", + "host": "notes", + "upstream": "ghost:2368", + "targets": [ + "aws" + ] + }, + { + "name": "deskmeter", + "host": "deskmeter", + "upstream": "dmweb:10000", + "targets": [ + "aws" + ], + "local_port": 10000 + }, + { + "name": "sysmonstm", + "host": "sysmonstm", + "upstream": "sysmonstm-edge:8080", + "targets": [ + "aws" + ], + "local_port": 8020 + }, + { + "name": "malvalava", + "host": "malvalava", + "upstream": "mlvclean-frontend:80", + "targets": [ + "aws" + ], + "local_port": 30090 + }, + { + "name": "soleprint", + "host": "soleprint", + "upstream": "soleprint:8000", + "targets": [ + "aws" + ], + "local_port": 12000 + }, + { + "name": "dlt", + "host": "dlt.spr", + "upstream": "dlt_spr:8000", + "targets": [ + "aws" + ] + }, + { + "name": "sample", + "host": "sample.spr", + "upstream": "sample_spr:8000", + "targets": [ + "aws" + ] + }, + { + "name": "mariano", + "host": "mariano", + "kind": "static", + "targets": [ + "aws" + ] + }, + { + "name": "rigui", + "host": "rig", + "kind": "static", + "targets": [ + "aws" + ], + "local_port": 20310 + }, + { + "name": "unt", + "host": "unt", + "targets": [ + "local" + ], + "local_port": 8040 + }, + { + "name": "mpr", + "host": "mpr", + "targets": [ + "local" + ], + "local_port": 30080 + }, + { + "name": "nvi", + "host": "nvi", + "targets": [ + "local" + ], + "local_port": 8060 + }, + { + "name": "eth", + "host": "eth", + "targets": [ + "local" + ], + "local_port": 8050 + }, + { + "name": "amar", + "host": "amar", + "targets": [ + "local" + ], + "local_port": 8030 + }, + { + "name": "nova", + "host": "nova", + "upstream": "nova-ui:80", + "targets": [ + "aws" + ], + "raw": true, + "raw_why": "Gated on an X-Gateway-Secret header whose value is committed in plaintext. The value is NOT recorded here. Worse: stellarair.conf proxies to the SAME nova-ui:80 upstream WITHOUT the check, so the gate is bypassable by hostname. Stays hand-written until that is decided." + }, + { + "name": "stellarair", + "host": "stellarair", + "upstream": "nova-ui:80", + "targets": [ + "aws" + ], + "raw": true, + "raw_why": "See nova. Same upstream, no header gate." + }, + { + "name": "langfuse", + "host": "langfuse", + "local_host": "lng", + "placement": "local", + "peer": "nrft", + "port": 3000, + "targets": [ + "aws", + "local" + ], + "local_port": 3000, + "note": "One service, one socket, two names. It was two entries with one flagged `raw`; placement is what made the exception expressible, so it is generated now." + }, + { + "name": "legacy", + "host": "*.soleprint", + "upstream": "soleprint:8000", + "targets": [ + "aws" + ], + "raw": true, + "raw_why": "A regex server_name with a named capture plus sub_filter injection — not expressible as a template. ALSO BROKEN: its /api/, /admin/, /static/ and / blocks proxy to 127.0.0.1, i.e. inside the nginx container where nothing listens, so every legacy room 502s. Only /wrapper/ uses the correct container-name form." + } + ] +}