479 lines
20 KiB
Bash
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
|