hub: S1 wgsync (pinned-SSH push + declarative reconciler) + /admin/wg API + env wiring

internal/wgsync: x/crypto/ssh client with ssh.FixedHostKey pin (no insecure
fallback), forced-command exec, ok/applied response contract; Reconciler pushes
the FULL peer list on Trigger or 5-min tick (drift repair by construction).
internal/api/wg.go: PUT/GET /admin/wg/endpoint + POST/DELETE/GET /admin/wg/peers,
global-key-only, pubkey in body (base64 vs URL), sync ok|deferred|disabled.
main.go: WG_ENDPOINT_SSH_* env wiring, disabled-with-INFO when unconfigured.
Groups B/C/D tests incl. in-process SSH server; red-proofs b/c/d run + reverted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-03 23:40:22 +02:00
parent b18f6aee1b
commit fbeeacb124
8 changed files with 1190 additions and 0 deletions
+16
View File
@@ -49,6 +49,10 @@ type Handler struct {
mailSender mailrelay.Sender
mailLimiter *mailRateLimiter
mailFromAllow map[string]bool
// S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
// disabled — mutations still persist, responses carry sync:"disabled".
wgSyncer WGSyncer
}
// SetLatestVersionProvider wires the registry version checker so the controller report ACK can
@@ -187,6 +191,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.Method == http.MethodPost && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/jobs"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/jobs")
h.handleAdminEnqueueJob(w, r, hostID)
// S1 offsite connectivity — WG endpoint record + peer registry (global key only, api/wg.go).
// DELETE carries the pubkey in the body: base64 '/'+'+' keep pubkeys out of URL paths.
case r.Method == http.MethodPut && path == "/admin/wg/endpoint":
h.handleAdminSetWGEndpoint(w, r)
case r.Method == http.MethodGet && path == "/admin/wg/endpoint":
h.handleAdminGetWGEndpoint(w, r)
case r.Method == http.MethodPost && path == "/admin/wg/peers":
h.handleAdminAddWGPeer(w, r)
case r.Method == http.MethodDelete && path == "/admin/wg/peers":
h.handleAdminDeleteWGPeer(w, r)
case r.Method == http.MethodGet && path == "/admin/wg/peers":
h.handleAdminListWGPeers(w, r)
case r.Method == http.MethodPost && path == "/event":
h.handleEvent(w, r)
case r.Method == http.MethodPost && path == "/mail":