feat(hub): operator OOB peer + oob_peer_ip desired-state merge (H1 Part 1)

store.SetOperatorOOBPeer/GetOperatorOOBPeer (empty-host_id wg_peers row, explicit
/32, validated in-subnet/not-reserved/not-taken, last-write-wins rotation).
PUT/GET /admin/wg/operator-peer (global key). mergeWireguard adds oob_peer_ip when
an operator peer exists (absent = byte-identical). BumpAllHostGenerations forces
fleet re-fetch. Non-hollow tests both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 22:08:06 +02:00
parent a3ee93e97e
commit 0ec7555126
6 changed files with 342 additions and 1 deletions
+84 -1
View File
@@ -309,7 +309,7 @@ func (h *Handler) mergeWireguard(hostID, desired string) string {
h.logger.Printf("[ERROR] wg merge %s: stored desired_json unparsable: %v (serving unmerged)", hostID, err)
return desired
}
doc["wireguard"] = map[string]interface{}{
wgBlock := map[string]interface{}{
"endpoint": map[string]interface{}{
"dns_name": ep.DNSName,
"wg_port": ep.WGPort,
@@ -319,6 +319,16 @@ func (h *Handler) mergeWireguard(hostID, desired string) string {
"pubkey": peer.Pubkey,
"assigned_ip": peer.AssignedIP + "/32",
}
// H1 [OF-1]: if a fleet operator OOB peer is registered, deliver its tunnel /32 as oob_peer_ip so
// the agent RENDERS it into wg-felhom AllowedIPs (survives self-heal). Absent → key omitted →
// byte-identical pass-through for pre-H1 fleets. A lookup failure is fail-safe (serve without OOB,
// never break the control channel).
if op, oerr := h.store.GetOperatorOOBPeer(); oerr != nil {
h.logger.Printf("[WARN] wg merge %s: operator OOB peer lookup failed: %v (serving without oob_peer_ip)", hostID, oerr)
} else if op != nil {
wgBlock["oob_peer_ip"] = op.AssignedIP // bare IPv4, e.g. "10.77.0.250"
}
doc["wireguard"] = wgBlock
out, err := json.Marshal(doc)
if err != nil {
h.logger.Printf("[ERROR] wg merge %s: re-marshal: %v (serving unmerged)", hostID, err)
@@ -409,3 +419,76 @@ func (h *Handler) handleAdminListWGPeers(w http.ResponseWriter, r *http.Request)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"peers": out})
}
// handleAdminSetOperatorPeer — PUT /admin/wg/operator-peer (global key). Registers/rotates the fleet
// operator OOB peer at an EXPLICIT /32 (so the endpoint's static forward chain can hardcode it),
// pushes the full peer list to the endpoint (so the operator's handshake is accepted), and bumps
// EVERY host's generation so agents re-fetch + re-render oob_peer_ip into wg-felhom AllowedIPs (H1).
func (h *Handler) handleAdminSetOperatorPeer(w http.ResponseWriter, r *http.Request) {
_, _, isGlobal, ok := h.checkAuthHost(r)
if !ok || !isGlobal {
http.Error(w, "Forbidden: global key required", http.StatusForbidden)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
var req struct {
Pubkey string `json:"pubkey"`
AssignedIP string `json:"assigned_ip"` // bare IPv4, e.g. "10.77.0.250"
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid payload: body must be JSON", http.StatusBadRequest)
return
}
if err := validateWGPubkey(req.Pubkey); err != nil {
http.Error(w, "Invalid payload: "+err.Error(), http.StatusBadRequest)
return
}
if err := h.store.SetOperatorOOBPeer(req.Pubkey, req.AssignedIP); err != nil {
if err == store.ErrWGEndpointUnset {
http.Error(w, "wg endpoint not configured", http.StatusConflict)
return
}
http.Error(w, "Invalid operator peer: "+err.Error(), http.StatusBadRequest)
return
}
syncStatus := h.syncAfterMutation(r.Context())
bumped, berr := h.store.BumpAllHostGenerations()
if berr != nil {
h.logger.Printf("[WARN] operator peer set but generation bump failed: %v", berr)
}
h.logger.Printf("[INFO] operator OOB peer set: %s -> %s/32 (sync=%s, %d host generations bumped)",
req.Pubkey, req.AssignedIP, syncStatus, bumped)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"pubkey": req.Pubkey, "assigned_ip": req.AssignedIP + "/32", "sync": syncStatus, "hosts_bumped": bumped,
})
}
// handleAdminGetOperatorPeer — GET /admin/wg/operator-peer (global key). Read-back for the runbook
// (the operator /32 the endpoint's static forward chain must allow). 404 when none registered.
func (h *Handler) handleAdminGetOperatorPeer(w http.ResponseWriter, r *http.Request) {
_, _, isGlobal, ok := h.checkAuthHost(r)
if !ok || !isGlobal {
http.Error(w, "Forbidden: global key required", http.StatusForbidden)
return
}
op, err := h.store.GetOperatorOOBPeer()
if err != nil {
h.logger.Printf("[ERROR] get operator peer: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if op == nil {
http.Error(w, "No operator OOB peer registered", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"pubkey": op.Pubkey, "assigned_ip": op.AssignedIP + "/32",
})
}