From 6f5fb19a6489b74ec83ce4bd845d30815b3178d3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 3 Jul 2026 23:42:12 +0200 Subject: [PATCH] scripts+manifests: S1 felhom-peersync.sh v1.0.0 + hub wg-endpoint-ssh deploy surface Peersync script: validate-first (jq contract check before ANY state change), head-file + generated-peers conf model, syncconf-from-tmp then atomic mv (live conf never diverges in the failure direction), zero-peer payload valid (wipe). hub.yaml: 0.32.0 image + WG_ENDPOINT_SSH_* env + optional Secret mount so the pod starts before the runbook's step-6 Secret exists. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- manifests/hub.yaml | 30 +++++++++++++++- scripts/felhom-peersync.sh | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 scripts/felhom-peersync.sh diff --git a/manifests/hub.yaml b/manifests/hub.yaml index 49af859..318032e 100644 --- a/manifests/hub.yaml +++ b/manifests/hub.yaml @@ -126,7 +126,7 @@ spec: spec: containers: - name: hub - image: gitea.dooplex.hu/admin/felhom-hub:0.31.0 + image: gitea.dooplex.hu/admin/felhom-hub:0.32.0 ports: - containerPort: 8080 name: http @@ -154,6 +154,23 @@ spec: secretKeyRef: name: gitea-creds key: password + # S1 offsite connectivity: the WG peer-sync push channel (doc 06 §5 + runbook + # offsite-endpoint.md). Addr is dev-phase literal (the throwaway endpoint); the SSH + # private key + (non-secret) pinned host key come from Secret/wg-endpoint-ssh, + # created out-of-band in runbook step 6 — optional so the pod starts before it + # exists (the hub logs peer-sync disabled until then). + - name: WG_ENDPOINT_SSH_ADDR + value: "167.233.158.164:22" + - name: WG_ENDPOINT_SSH_USER + value: "felhom-peersync" + - name: WG_ENDPOINT_SSH_KEY_FILE + value: "/etc/hub-secrets/wg-endpoint-ssh/key" + - name: WG_ENDPOINT_SSH_HOSTKEY + valueFrom: + secretKeyRef: + name: wg-endpoint-ssh + key: hostkey + optional: true resources: requests: memory: "64Mi" @@ -166,6 +183,9 @@ spec: mountPath: /data - name: config mountPath: /etc/felhom-hub + - name: wg-endpoint-ssh + mountPath: /etc/hub-secrets/wg-endpoint-ssh + readOnly: true livenessProbe: httpGet: path: /healthz @@ -187,6 +207,14 @@ spec: - name: config configMap: name: hub-config + - name: wg-endpoint-ssh + secret: + secretName: wg-endpoint-ssh + optional: true + items: + - key: key + path: key + mode: 0400 # ============================================================================= # SERVICE diff --git a/scripts/felhom-peersync.sh b/scripts/felhom-peersync.sh new file mode 100644 index 0000000..b961021 --- /dev/null +++ b/scripts/felhom-peersync.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# felhom-peersync v1.0.0 — the offsite endpoint's WG peer-list reconcile script (S1, doc 06 §5). +# +# 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 (set -e): the previous good +# LIVE_CONF is still in place — runtime and boot config never diverge in the bad direction. +wg syncconf "$IFACE" <(wg-quick strip "$tmp") || 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"