Files
soleprint/berth/ctrl/check.sh
2026-09-14 07:28:50 -03:00

154 lines
6.1 KiB
Bash

#!/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)."
# Ask the structure, not the prose: the condition is "does a peer still lack a
# public key", not "is there a _status string". _status is ALWAYS non-empty —
# capture rewrites it to "CAPTURED ..." — so testing it for emptiness pinned
# this warning on permanently, including after the capture it asks for.
uncaptured=$(estate_get "vpn.overlays.estate.peers" 2>/dev/null | python3 -c '
import json, sys
try:
peers = json.load(sys.stdin)
except Exception:
sys.exit(0)
print(" ".join(n for n, p in peers.items() if not p.get("public_key")))
' 2>/dev/null)
if [ -n "$uncaptured" ]; then
warn "overlay: public keys not captured for:$uncaptured — see 'make vpn check'"
note " 10.8.0.1 carries the registry and woodpecker gRPC;"
note " 10.8.0.2 backs langfuse. Nothing in the tree creates the"
note " interface — a fresh box cannot start the gateway compose."
note " capture with: sudo wg show | make vpn capture --write"
else
note "overlay: $(estate_get 'vpn._status')"
fi
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