berth init
This commit is contained in:
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