Adds the missing reconciler piece: render each host's /etc/wireguard/wg1.conf
from data/mesh-hosts.json (WG config was previously hand-built).
- mesh.segments maps <segment> -> {hub, endpoint, dns_host, dns_listen}; hosts
carry `segment` + `wg_pubkey` (public key only). iceland(yuzu) and nyc3(citron)
are independent stars. Legacy single-hub (mesh.hub) still works as fallback.
- bin/wg-render: --keygen/--pubkey bootstrap, --dry-run/--whoami inspect,
--apply installs + `wg syncconf` (idempotent, rollback). Hub gets a [Peer] per
spoke + ip_forward/MASQUERADE; spoke gets one [Peer] = its hub. WG_RENDER_SELF
override for tests/ops.
- bin/wg-dns-sync: segment-aware listen — a segment's dns_host binds its own
dns_listen (citron serves nyc3 on 10.9.0.7; apricot unchanged on 10.9.0.2).
- Registers citron (com.uvlava.quinn.infra, nyc3 hub) + nyc3 keys for lime;
carries the com.uvlava.ct.* DO-name aliases. Tests cover hub/spoke/dns.
(data/mesh-hosts.json also carries pre-existing working-tree normalization:
literal em-dash -> — escapes and expanded alias arrays.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
11 KiB
Bash
Executable file
216 lines
11 KiB
Bash
Executable file
#!/bin/sh
|
|
# wg-render — render THIS host's /etc/wireguard/wg1.conf from data/mesh-hosts.json.
|
|
#
|
|
# net-tools' missing piece: SSH (host-apply), /etc/hosts + mesh DNS
|
|
# (mesh-hosts-render / wg-dns-sync) were already reconciler-owned; the WireGuard
|
|
# config was not (set up by hand). This renders it from the one source of truth.
|
|
#
|
|
# MULTI-SEGMENT HUB MODEL
|
|
# A segment is a hub + its spokes. mesh.segments maps <segment> -> { hub,
|
|
# endpoint, dns_host, dns_listen }. Each host carries `segment` (which segment
|
|
# it belongs to) and `wg_pubkey` (its public key — NEVER the private key).
|
|
# - The segment's HUB renders [Interface] (+ ip_forward/MASQUERADE PostUp) and a
|
|
# [Peer] for every spoke in its segment (AllowedIPs = spoke/32).
|
|
# - A SPOKE renders [Interface] + a single [Peer] = its segment hub
|
|
# (AllowedIPs = mesh cidr, Endpoint = segment endpoint, keepalive).
|
|
# yuzu (Iceland) and citron (nyc3) are independent segments — no cross-segment
|
|
# routing unless a hub is also listed as another segment's spoke.
|
|
#
|
|
# BACKWARD COMPATIBLE: if mesh.segments is absent, falls back to the legacy single
|
|
# hub (mesh.hub / mesh.hub_endpoint) and treats every non-hub host as its spoke.
|
|
#
|
|
# The PRIVATE key is read from /etc/wireguard/wg1.key (generated on the box, never
|
|
# in the repo). Bootstrap a fresh host with `wg-render --keygen` which generates
|
|
# the key and prints the PUBLIC key to paste into the host's wg_pubkey field.
|
|
#
|
|
# Usage:
|
|
# wg-render # --dry-run : print this host's wg1.conf (default)
|
|
# wg-render --dry-run # same, explicit
|
|
# wg-render --apply # install /etc/wireguard/wg1.conf + `wg syncconf` (root)
|
|
# wg-render --keygen # ensure /etc/wireguard/wg1.key exists; print pubkey
|
|
# wg-render --pubkey # print this host's public key (from the private key)
|
|
# wg-render --whoami # print self name + segment + role (hub|spoke)
|
|
#
|
|
# Exit codes: 0 ok/no-op · 1 bad input/deps · 2 need root · 3 wg failed (rolled back)
|
|
|
|
set -eu
|
|
|
|
mode=dry-run
|
|
case "${1:-}" in
|
|
""|--dry-run) mode=dry-run ;;
|
|
--apply) mode=apply ;;
|
|
--keygen) mode=keygen ;;
|
|
--pubkey) mode=pubkey ;;
|
|
--whoami) mode=whoami ;;
|
|
*) echo "wg-render: unknown arg '$1'" >&2; exit 1 ;;
|
|
esac
|
|
|
|
# --- locate data file (symlink-resolving walk, matches the other renderers) -----
|
|
self_path=$0
|
|
while [ -L "$self_path" ]; do
|
|
link=$(readlink "$self_path")
|
|
case $link in /*) self_path=$link ;; *) self_path=$(dirname "$self_path")/$link ;; esac
|
|
done
|
|
root=$(cd "$(dirname "$self_path")" && pwd)
|
|
while [ "$root" != "/" ] && [ ! -f "$root/data/mesh-hosts.json" ]; do root=$(dirname "$root"); done
|
|
data_file="$root/data/mesh-hosts.json"
|
|
[ -f "$data_file" ] || { echo "wg-render: cannot locate data/mesh-hosts.json" >&2; exit 1; }
|
|
command -v jq >/dev/null || { echo "wg-render: jq not installed" >&2; exit 1; }
|
|
jq empty "$data_file" || { echo "wg-render: invalid JSON in $data_file" >&2; exit 1; }
|
|
|
|
WG_DIR=/etc/wireguard
|
|
KEY_FILE="$WG_DIR/wg1.key"
|
|
CONF_FILE="$WG_DIR/wg1.conf"
|
|
iface=$(jq -r '.mesh.interface // "wg1"' "$data_file")
|
|
cidr=$(jq -r '.mesh.cidr // "10.9.0.0/24"' "$data_file")
|
|
port=$(jq -r '.mesh.segments | (.. | .endpoint? // empty)' "$data_file" 2>/dev/null | head -1 | sed "s/.*://" )
|
|
[ -n "${port:-}" ] || port=$(jq -r '(.mesh.hub_endpoint // "x:51820") | split(":")[1]' "$data_file")
|
|
[ -n "$port" ] || port=51820
|
|
|
|
# --- key helpers ---------------------------------------------------------------
|
|
ensure_key() {
|
|
command -v wg >/dev/null || { echo "wg-render: wireguard-tools (wg) not installed" >&2; exit 1; }
|
|
if [ ! -f "$KEY_FILE" ]; then
|
|
need_root "create $KEY_FILE"
|
|
$SUDO mkdir -p "$WG_DIR"; $SUDO chmod 700 "$WG_DIR"
|
|
umask 077
|
|
wg genkey | $SUDO tee "$KEY_FILE" >/dev/null
|
|
$SUDO chmod 600 "$KEY_FILE"
|
|
echo "wg-render: generated $KEY_FILE" >&2
|
|
fi
|
|
}
|
|
pubkey_of_self() {
|
|
[ -f "$KEY_FILE" ] || { echo "wg-render: no $KEY_FILE (run --keygen first)" >&2; exit 1; }
|
|
$SUDO cat "$KEY_FILE" 2>/dev/null | wg pubkey
|
|
}
|
|
|
|
SUDO=
|
|
need_root() {
|
|
[ "$(id -u)" -eq 0 ] && return 0
|
|
if command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then SUDO="sudo"; return 0; fi
|
|
echo "wg-render: need root to $1 (run with sudo)" >&2; exit 2
|
|
}
|
|
|
|
# --- identify self (name/alias or any local IPv4 incl. wg) ---------------------
|
|
short=$(hostname 2>/dev/null | cut -d. -f1); [ -n "$short" ] || short=$(uname -n | cut -d. -f1)
|
|
if command -v ip >/dev/null 2>&1; then
|
|
local_ips=$(ip -o -4 addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
|
|
else
|
|
local_ips=$(ifconfig 2>/dev/null | awk '/inet /{print $2}')
|
|
fi
|
|
ips_json=$(printf '%s\n' $local_ips | jq -R . | jq -s .)
|
|
# WG_RENDER_SELF forces the self identity (tests + deliberate ops override).
|
|
if [ -n "${WG_RENDER_SELF:-}" ]; then
|
|
self=$(jq -r --arg h "$WG_RENDER_SELF" '[.hosts[] | select(.name==$h or ((.aliases//[])|index($h))) | .name] | first // empty' "$data_file")
|
|
[ -n "$self" ] || { echo "wg-render: WG_RENDER_SELF='$WG_RENDER_SELF' not in mesh-hosts.json" >&2; exit 1; }
|
|
else
|
|
self=$(jq -r --arg h "$short" --argjson ips "$ips_json" '
|
|
[ .hosts[] | . as $x
|
|
| select(($x.name==$h) or (($x.aliases//[])|index($h)) or ($x.wg!=null and ($ips|index($x.wg)))
|
|
or ($x.lan!=null and ($ips|index($x.lan))) )
|
|
| $x.name ] | first // empty' "$data_file")
|
|
fi
|
|
[ -n "$self" ] || { echo "wg-render: cannot identify this host (short=$short ips=$local_ips) in mesh-hosts.json" >&2; exit 1; }
|
|
|
|
# Resolve self's segment + the hub for it, with legacy fallback.
|
|
self_seg=$(jq -r --arg s "$self" '.hosts[] | select(.name==$s) | .segment // empty' "$data_file")
|
|
if [ -z "$self_seg" ]; then
|
|
# Legacy single-hub: synthesize a default segment from mesh.hub.
|
|
seg_hub=$(jq -r '.mesh.hub // empty' "$data_file")
|
|
seg_ep=$(jq -r '.mesh.hub_endpoint // empty' "$data_file")
|
|
seg_members_filter='.hosts[]'
|
|
else
|
|
seg_hub=$(jq -r --arg g "$self_seg" '.mesh.segments[$g].hub // empty' "$data_file")
|
|
seg_ep=$(jq -r --arg g "$self_seg" '.mesh.segments[$g].endpoint // empty' "$data_file")
|
|
seg_members_filter='.hosts[] | select((.segment // "") == $SEG)'
|
|
fi
|
|
[ -n "$seg_hub" ] || { echo "wg-render: no hub resolved for self=$self segment=${self_seg:-<legacy>}" >&2; exit 1; }
|
|
[ "$self" = "$seg_hub" ] && role=hub || role=spoke
|
|
|
|
self_wg=$(jq -r --arg s "$self" '.hosts[] | select(.name==$s) | .wg' "$data_file")
|
|
self_addr_cidr="${self_wg}/$( [ "$role" = hub ] && echo 24 || echo 32 )"
|
|
|
|
if [ "$mode" = "whoami" ]; then
|
|
printf '%s segment=%s role=%s hub=%s endpoint=%s\n' \
|
|
"$self" "${self_seg:-<legacy>}" "$role" "$seg_hub" "${seg_ep:-?}"
|
|
exit 0
|
|
fi
|
|
if [ "$mode" = "keygen" ]; then ensure_key; pubkey_of_self; exit 0; fi
|
|
if [ "$mode" = "pubkey" ]; then pubkey_of_self; exit 0; fi
|
|
|
|
# --- render wg1.conf -----------------------------------------------------------
|
|
# The private key is substituted from $KEY_FILE at install time, not embedded in
|
|
# dry-run output (which prints a placeholder so logs never leak it).
|
|
render_conf() {
|
|
privkey_repr=$1
|
|
when=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "?")
|
|
printf '# Generated by net-tools/bin/wg-render — DO NOT EDIT MANUALLY\n'
|
|
printf '# Edit data/mesh-hosts.json (segments + wg_pubkey) and re-run wg-render --apply.\n'
|
|
printf '# self: %s segment: %s role: %s rendered_at: %s\n\n' \
|
|
"$self" "${self_seg:-<legacy>}" "$role" "$when"
|
|
printf '[Interface]\n'
|
|
printf 'Address = %s\n' "$self_addr_cidr"
|
|
printf 'ListenPort = %s\n' "$port"
|
|
printf 'PrivateKey = %s\n' "$privkey_repr"
|
|
if [ "$role" = hub ]; then
|
|
printf 'PostUp = sysctl -w net.ipv4.ip_forward=1; iptables -A FORWARD -i %s -j ACCEPT; iptables -t nat -A POSTROUTING -o %s -j MASQUERADE\n' "$iface" "$iface"
|
|
printf 'PostDown = iptables -D FORWARD -i %s -j ACCEPT; iptables -t nat -D POSTROUTING -o %s -j MASQUERADE\n' "$iface" "$iface"
|
|
fi
|
|
printf '\n'
|
|
|
|
if [ "$role" = hub ]; then
|
|
# One [Peer] per spoke in this segment that has a published pubkey.
|
|
jq -r --arg SEG "${self_seg:-}" --arg SELF "$self" "
|
|
${seg_members_filter}
|
|
| select(.name != \$SELF)
|
|
| select(.wg_pubkey != null and .wg_pubkey != \"\")
|
|
| \"# \(.name)\n[Peer]\nPublicKey = \(.wg_pubkey)\nAllowedIPs = \(.wg)/32\n\"
|
|
" "$data_file"
|
|
# Warn (to stderr) about spokes still missing a key.
|
|
miss=$(jq -r --arg SEG "${self_seg:-}" --arg SELF "$self" "
|
|
${seg_members_filter} | select(.name!=\$SELF) | select((.wg_pubkey//\"\")==\"\") | .name" "$data_file" | tr '\n' ' ')
|
|
[ -n "$(echo "$miss" | tr -d ' ')" ] && echo "wg-render: NOTE spokes without wg_pubkey (not peered): $miss" >&2
|
|
else
|
|
# Single [Peer] = the segment hub.
|
|
hub_pub=$(jq -r --arg H "$seg_hub" '.hosts[] | select(.name==$H) | .wg_pubkey // empty' "$data_file")
|
|
[ -n "$hub_pub" ] || { echo "wg-render: hub $seg_hub has no wg_pubkey in mesh-hosts.json — cannot render spoke peer" >&2; exit 1; }
|
|
printf '# hub: %s\n[Peer]\nPublicKey = %s\nEndpoint = %s\nAllowedIPs = %s\nPersistentKeepalive = 25\n' \
|
|
"$seg_hub" "$hub_pub" "$seg_ep" "$cidr"
|
|
fi
|
|
}
|
|
|
|
if [ "$mode" = "dry-run" ]; then
|
|
render_conf "<PrivateKey from $KEY_FILE at apply>"
|
|
exit 0
|
|
fi
|
|
|
|
# --apply
|
|
ensure_key
|
|
need_root "write $CONF_FILE"
|
|
priv=$($SUDO cat "$KEY_FILE")
|
|
tmp=$(mktemp "${TMPDIR:-/tmp}/wg1.conf.XXXXXX"); trap 'rm -f "$tmp"' EXIT
|
|
render_conf "$priv" > "$tmp"
|
|
chmod 600 "$tmp"
|
|
|
|
if [ -f "$CONF_FILE" ] && cmp -s "$tmp" "$CONF_FILE"; then
|
|
echo "wg-render: $CONF_FILE already up to date for $self ($role/${self_seg:-legacy})"
|
|
exit 0
|
|
fi
|
|
[ -f "$CONF_FILE" ] && $SUDO cp "$CONF_FILE" "$CONF_FILE.netbak"
|
|
$SUDO cp "$tmp" "$CONF_FILE"; $SUDO chmod 600 "$CONF_FILE"
|
|
echo "wg-render: wrote $CONF_FILE for $self ($role/${self_seg:-legacy})"
|
|
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
$SUDO systemctl enable "wg-quick@${iface}" >/dev/null 2>&1 || true
|
|
if $SUDO systemctl is-active "wg-quick@${iface}" >/dev/null 2>&1; then
|
|
# Live update without dropping the tunnel.
|
|
if $SUDO sh -c "wg syncconf $iface <(wg-quick strip $iface)" 2>/dev/null; then
|
|
echo "wg-render: $iface syncconf applied"
|
|
else
|
|
$SUDO systemctl restart "wg-quick@${iface}" || { echo "wg-render: $iface restart failed — rolling back" >&2; [ -f "$CONF_FILE.netbak" ] && $SUDO cp "$CONF_FILE.netbak" "$CONF_FILE"; $SUDO systemctl restart "wg-quick@${iface}" || true; exit 3; }
|
|
fi
|
|
else
|
|
$SUDO systemctl start "wg-quick@${iface}" || { echo "wg-render: $iface start failed — rolling back" >&2; [ -f "$CONF_FILE.netbak" ] && $SUDO cp "$CONF_FILE.netbak" "$CONF_FILE"; exit 3; }
|
|
echo "wg-render: $iface started"
|
|
fi
|
|
fi
|