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

479 lines
20 KiB
Bash

#!/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
# Resolve placement first: a placed service's upstream is derived, not
# literal, so reading `up` alone skips it and this invariant goes quiet.
up="$(service_upstream "$up" "$placement" "$peer" "$port")" || true
[ -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="" as_peer=""
while [ $# -gt 0 ]; do
case "$1" in
--write) write=1 ;;
--as) shift; as_peer="${1:-}"
[ -z "$as_peer" ] && { echo "--as needs a peer name" >&2; exit 1; } ;;
*) echo "capture: unknown argument '$1'" >&2; exit 1 ;;
esac
shift
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" AS_PEER="$as_peer" INPUT="$input" python3 - "$ESTATE_FILE" <<'PYCAP'
import collections, ipaddress, 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")}
by_key = {p["public_key"]: n for n, p in ov["peers"].items() if p.get("public_key")}
subnet = ipaddress.ip_network(ov["subnet"]) if ov.get("subnet") else None
hubs = [n for n, p in ov["peers"].items() if p.get("role") == "hub"]
# Whose interface block is this? `--as` names it explicitly, and that is the only
# thing that works for output captured over ssh: the addresses on THIS machine
# say nothing about the machine the output came from.
as_peer = os.environ.get("AS_PEER") or ""
if as_peer:
if as_peer not in ov["peers"]:
print("no peer named %r in this overlay. known: %s"
% (as_peer, ", ".join(ov["peers"])))
raise SystemExit(1)
me = as_peer
else:
me = None
local = os.popen(
"ip -4 -o addr show 2>/dev/null | awk '{split($4,a,\"/\"); print a[1]}'"
).read().split()
for n, p in ov["peers"].items():
if p.get("address") and p["address"] in local:
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)")
def match(pr):
# 1. The public key IS the identity. Use it whenever the estate knows it.
n = by_key.get(pr["public_key"])
if n:
return n
nets = [a.strip() for a in pr.get("allowed_ips", "").split(",") if a.strip()]
# 2. An allowed-ip that is a declared peer address — the ordinary spoke case.
for a in nets:
if a.split("/")[0] in by_addr:
return by_addr[a.split("/")[0]]
# 3. A peer routing the WHOLE overlay is the hub seen from a spoke. Its
# allowed_ips is the subnet itself, so no single address ever matches it.
if subnet and len(hubs) == 1:
for a in nets:
try:
if ipaddress.ip_network(a, strict=False).supernet_of(subnet):
return hubs[0]
except ValueError:
continue
return None
for pr in peers:
name = match(pr)
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 key, this address, or this route"))
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 me and iface.get("listen_port"):
try:
lp = int(iface["listen_port"])
except ValueError:
lp = None
if lp is not None:
# A listen port belongs to the PEER, not to the overlay. A roaming peer's
# is an ephemeral source port chosen by the kernel; writing it to the
# overlay would rename the port the firewall rule is checked against.
setf(me, "listen_port", lp)
if ov["peers"][me].get("role") == "hub" and ov.get("listen_port") != lp:
changes.append(("(overlay)", "listen_port", ov.get("listen_port"), lp,
"the hub's port is the overlay's port"))
if write:
ov["listen_port"] = lp
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 [--as <peer>] [--write]|up --yes|down --yes]" >&2; exit 1 ;;
esac