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