berth init
This commit is contained in:
18
berth/ctrl/.env.example
Normal file
18
berth/ctrl/.env.example
Normal 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
58
berth/ctrl/certs.sh
Normal 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
137
berth/ctrl/check.sh
Normal 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
81
berth/ctrl/dns.sh
Normal 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
27
berth/ctrl/docs.sh
Normal 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
14
berth/ctrl/env.d/aws.env
Normal 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
17
berth/ctrl/env.d/gcp.env
Normal 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
101
berth/ctrl/estate.sh
Normal 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
63
berth/ctrl/host.sh
Normal 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
94
berth/ctrl/lib/config.sh
Normal 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
125
berth/ctrl/lib/estate.sh
Normal 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
78
berth/ctrl/ports.sh
Normal 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
33
berth/ctrl/registry.sh
Normal 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
|
||||
32
berth/ctrl/render/nginx-proxy.tmpl
Normal file
32
berth/ctrl/render/nginx-proxy.tmpl
Normal 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;
|
||||
}
|
||||
}
|
||||
23
berth/ctrl/render/nginx-static.tmpl
Normal file
23
berth/ctrl/render/nginx-static.tmpl
Normal 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;
|
||||
}
|
||||
}
|
||||
32
berth/ctrl/render/nginx-upstream.tmpl
Normal file
32
berth/ctrl/render/nginx-upstream.tmpl
Normal 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
184
berth/ctrl/selftest.sh
Normal 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
186
berth/ctrl/services.sh
Normal 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
11
berth/ctrl/versions.env
Normal 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
425
berth/ctrl/vpn.sh
Normal 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
|
||||
Reference in New Issue
Block a user