#!/usr/bin/env bash # felhom-peersync v1.0.1 — the offsite endpoint's WG peer-list reconcile script (S1, doc 06 §5). # # v1.0.1 (S2): `wg-quick strip` moved OUT of process substitution — `<(...)` hides the inner # exit code, so a strip failure (e.g. corrupt head file) could feed syncconf empty/partial input # that WIPES the live peer set while the script exits 0. Strip now writes a temp file and its # failure aborts before wg is ever invoked. # # Runs as the SSH forced command for the hub's `felhom-peersync` user (via sudo — see # documentation/runbooks/offsite-endpoint.md step 5). Reads the hub's declarative payload on # stdin, VALIDATES FIRST (any failure exits 1 before touching anything), then applies the FULL # peer list with `wg syncconf` (exact-match: adds missing peers, removes absent ones, never # bounces the interface) and only after a successful apply persists the conf atomically. # # Payload contract (version 1): # {"version":1,"interface":"wg0","peers":[{"pubkey":"<44b64>","allowed_ip":"10.77.0.x/32"}]} # Response on stdout: {"status":"ok","applied":} # # One script, one job: there is NO second mode. It never reads or prints the WG private key — # /etc/wireguard/wg0.conf.head (the [Interface] section, including PrivateKey) is only ever # concatenated. Do NOT replace the head-file model with `wg-quick save` (nondeterministic; would # rewrite the whole conf from runtime state). set -euo pipefail CONF_DIR=/etc/wireguard HEAD_FILE="$CONF_DIR/wg0.conf.head" LIVE_CONF="$CONF_DIR/wg0.conf" IFACE=wg0 err() { echo "felhom-peersync: ERROR: $*" >&2 exit 1 } command -v jq >/dev/null || err "jq is required" command -v wg >/dev/null || err "wireguard-tools is required" [ -r "$HEAD_FILE" ] || err "missing $HEAD_FILE" # 1. Read stdin capped at 1 MiB. A truncated (oversized) payload fails JSON validation below. payload=$(head -c 1048576) [ -n "$payload" ] || err "empty payload" # 2. Validate EVERYTHING before touching any state. `all` is true on an empty peers array, so a # zero-peer payload (wipe the list) is valid by design. The endpoint's own 10.77.0.1 must # never appear as a peer allowed_ip. jq -e ' (.version == 1) and (.interface == "wg0") and ((.peers | type) == "array") and ([.peers[] | (.pubkey | type) == "string" and (.pubkey | test("^[A-Za-z0-9+/]{43}=$"))] | all) and ([.peers[] | (.allowed_ip | type) == "string" and (.allowed_ip | test("^10\\.77\\.0\\.[0-9]{1,3}/32$")) and (.allowed_ip != "10.77.0.1/32")] | all) ' >/dev/null <<<"$payload" || err "payload failed validation (version/interface/pubkey/allowed_ip)" # 3. Generate the candidate conf in a tmp dir ON THE SAME FILESYSTEM (atomic mv later). Values # are written into the file by jq/cat only — never interpolated into a command line. tmpdir=$(mktemp -d "$CONF_DIR/.peersync.XXXXXX") trap 'rm -rf "$tmpdir"' EXIT tmp="$tmpdir/wg0.conf" (umask 077; cat "$HEAD_FILE" > "$tmp") jq -r '.peers[] | "\n[Peer]\nPublicKey = \(.pubkey)\nAllowedIPs = \(.allowed_ip)"' \ <<<"$payload" >> "$tmp" chmod 600 "$tmp" # 4. Apply from the TMP file first. On failure we exit here: the previous good LIVE_CONF is # still in place — runtime and boot config never diverge in the bad direction. Strip runs as # its own step (NOT process substitution, which would swallow its exit code — v1.0.1). wg-quick strip "$tmp" > "$tmpdir/stripped" || err "wg-quick strip failed; live state untouched" wg syncconf "$IFACE" "$tmpdir/stripped" || err "wg syncconf failed; live conf untouched" # 5. Persist only after a successful apply (same-fs mv = atomic). mv "$tmp" "$LIVE_CONF" # 6. Report. applied=$(jq '.peers | length' <<<"$payload") printf '{"status":"ok","applied":%d}\n' "$applied"