berth init

This commit is contained in:
2026-09-14 03:57:00 -03:00
parent 2d9bc9289c
commit a9df70cde0
25 changed files with 2506 additions and 0 deletions

18
berth/ctrl/.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Machine-local config. Copy to ctrl/.env (gitignored) and edit.
#
# The estate's FACTS live in estate/<name>.json.
# The provider's SHAPE lives in ctrl/env.d/<target>.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=

58
berth/ctrl/certs.sh Normal file
View File

@@ -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

137
berth/ctrl/check.sh Normal file
View File

@@ -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

81
berth/ctrl/dns.sh Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# DNS records, over whichever provider the target names.
#
# Usage:
# ./dns.sh list
# ./dns.sh add <subdomain> # <sub>.<domain> -> <domain>
# ./dns.sh add-wildcard <sub>
# ./dns.sh remove <subdomain>
#
# 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:-<unset>}"
;;
*) 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 <subdomain>" >&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 <sub>|add-wildcard <sub>|remove <sub>]" >&2; exit 1 ;;
esac

27
berth/ctrl/docs.sh Normal file
View File

@@ -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

14
berth/ctrl/env.d/aws.env Normal file
View File

@@ -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

17
berth/ctrl/env.d/gcp.env Normal file
View File

@@ -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

101
berth/ctrl/estate.sh Normal file
View File

@@ -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

63
berth/ctrl/host.sh Normal file
View File

@@ -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

94
berth/ctrl/lib/config.sh Normal file
View File

@@ -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/<target>.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=<name>, 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}"
}

125
berth/ctrl/lib/estate.sh Normal file
View File

@@ -0,0 +1,125 @@
# Reading and projecting estate/<name>.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 <fqdn> covered by <san>? 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"
}

78
berth/ctrl/ports.sh Normal file
View File

@@ -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
# name<US>host<US>local_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

33
berth/ctrl/registry.sh Normal file
View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

184
berth/ctrl/selftest.sh Normal file
View File

@@ -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 <if> 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"

186
berth/ctrl/services.sh Normal file
View File

@@ -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 <<EONOTE
TO INSTALL THESE, THREE THINGS MUST HAPPEN IN THIS ORDER — and the order is the
whole reason this is not a one-liner:
1. These land in conf.d/generated/, NOT conf.d/. ppl/ctrl/deploy.sh rsyncs the
gateway with --delete; generated and hand-written config sharing one
directory means one of them gets erased.
2. nginx.conf needs a THIRD include line. Its conf.d/*.conf glob does not
recurse — which is exactly why conf.d/soleprint/*.conf already needs its
own line at nginx.conf:28-30.
3. That new 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 this
today, deliberately — it is a gate, not a warning.
EONOTE
}
render_local() {
local out="$OUT_ROOT/local"; mkdir -p "$out"
local ld; ld=$(estate_get "local_domain")
: "${ld:=local.ar}"
local name host up kind raw port drift=0
{
cat <<EOH
# GENERATED by berth from estate/${ESTATE}.json. Do not edit.
# Regenerate: make services render local
#
# Install: sudo ln -sf \$PWD/Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy
# All *.${ld} resolve to 127.0.0.1 via dnsmasq.
#
# Every site address carries an explicit :80. Without it Caddy 2 defaults to
# :443 with auto-HTTPS, which on *.${ld} means cert provisioning attempts that
# fail and break the listener. Plain HTTP only on this host.
#
# Caddy matches the MOST SPECIFIC site address, not the first — the opposite of
# nginx, which matches exactly and otherwise falls to default_server. A name set
# that is unambiguous here can be ambiguous on the box.
EOH
while IFS=$'\x1f' read -r name host up kind raw placement peer port lhost; do
[ -z "$name" ] && continue
port=$(python3 -c '
import json,sys
d=json.load(open(sys.argv[1]))
for s in d.get("services",[]):
if s.get("name")==sys.argv[2]:
print(s.get("local_port") or ""); break
' "$ESTATE_FILE" "$name")
[ -z "$port" ] && continue
echo
echo "${lhost}.${ld}:80, *.${lhost}.${ld}:80 {"
echo " reverse_proxy localhost:${port}"
echo "}"
done < <(estate_services local)
} > "$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

11
berth/ctrl/versions.env Normal file
View File

@@ -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

425
berth/ctrl/vpn.sh Normal file
View File

@@ -0,0 +1,425 @@
#!/usr/bin/env bash
# Overlays — WireGuard as berth's network layer.
#
# Usage:
# ./vpn.sh # list
# ./vpn.sh show <overlay> # topology
# ./vpn.sh check # invariants
# ./vpn.sh render <peer> # peer's wg0.conf -> render/out/vpn/
# ./vpn.sh keygen <peer> # 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 <if> 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 <overlay>" >&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 <peer>" >&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.<ov>.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 <peer>" >&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 <if> 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 <ov>|check|render <peer>|keygen <peer>|capture [--write]|up --yes|down --yes]" >&2; exit 1 ;;
esac