#!/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 -> { 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 } # --keygen/--pubkey are host-local (generate/print this box's key) and must work # BEFORE the host is registered in mesh-hosts.json — that's the bootstrap order # (keygen -> paste pubkey into mesh-hosts -> apply). So handle them before the # self-detection below, which would otherwise fail for an unregistered host. if [ "$mode" = "keygen" ]; then ensure_key; pubkey_of_self; exit 0; fi if [ "$mode" = "pubkey" ]; then pubkey_of_self; exit 0; fi # --- 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:-}" >&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:-}" "$role" "$seg_hub" "${seg_ep:-?}" 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:-}" "$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. Use an if-block, not # `[ ... ] && echo`: the latter returns 1 when the test is false, which # under `set -e` (apply path: render_conf > tmp) aborts the whole render. miss=$(jq -r --arg SEG "${self_seg:-}" --arg SELF "$self" " ${seg_members_filter} | select(.name!=\$SELF) | select((.wg_pubkey//\"\")==\"\") | .name" "$data_file" | tr '\n' ' ') if [ -n "$(echo "$miss" | tr -d ' ')" ]; then echo "wg-render: NOTE spokes without wg_pubkey (not peered): $miss" >&2 fi 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 "" 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. `wg syncconf` needs a stripped # conf file; build it with a temp file (POSIX) — NOT bash <() process # substitution, since this script runs under /bin/sh (dash on Ubuntu). strip_tmp=$(mktemp "${TMPDIR:-/tmp}/wg1.strip.XXXXXX") if $SUDO wg-quick strip "$iface" > "$strip_tmp" 2>/dev/null && $SUDO wg syncconf "$iface" "$strip_tmp" 2>/dev/null; then echo "wg-render: $iface syncconf applied"; rm -f "$strip_tmp" else rm -f "$strip_tmp" $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