Files
soleprint/berth/README.md
2026-09-14 03:57:00 -03:00

13 KiB

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.

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/<name>.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.)

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 <noun> <verb> 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:<port> 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 <domain> 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

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 <if> dump no field 1 of the first line is the private key
wg showconf <if> 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/<name>.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 <aws|gcp|local> | 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/<target>.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.envenv.d/<target>.envctrl/.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.