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:
@@ -215,6 +215,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleAdminDeleteWGPeer(w, r)
|
||||
case r.Method == http.MethodGet && path == "/admin/wg/peers":
|
||||
h.handleAdminListWGPeers(w, r)
|
||||
// H1: the fleet operator OOB peer (register/rotate at an explicit /32) + read-back.
|
||||
case r.Method == http.MethodPut && path == "/admin/wg/operator-peer":
|
||||
h.handleAdminSetOperatorPeer(w, r)
|
||||
case r.Method == http.MethodGet && path == "/admin/wg/operator-peer":
|
||||
h.handleAdminGetOperatorPeer(w, r)
|
||||
case r.Method == http.MethodPost && path == "/event":
|
||||
h.handleEvent(w, r)
|
||||
case r.Method == http.MethodPost && path == "/mail":
|
||||
|
||||
+84
-1
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -385,6 +385,70 @@ func TestDesiredState_WireguardMergeMatchesGolden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// H1: with a fleet operator OOB peer registered, a host's served wireguard block carries oob_peer_ip
|
||||
// (the operator's /32) so the agent renders it into AllowedIPs. Without it, no key (byte-identical —
|
||||
// covered by TestDesiredState_WireguardMergeMatchesGolden).
|
||||
func TestDesiredState_IncludesOOBPeerIPWhenOperatorRegistered(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
seedHost(t, st, "h1", "c1", "HKEY1")
|
||||
putGoldenEndpoint(t, h)
|
||||
h.SetWGSyncer(&fakeWGSyncer{})
|
||||
if rr := do(h, http.MethodPost, "/hosts/h1/wg", "HKEY1", `{"pubkey":"`+testPK(1)+`"}`); rr.Code != 200 {
|
||||
t.Fatalf("register: %d", rr.Code)
|
||||
}
|
||||
|
||||
// no operator peer yet → served block has NO oob_peer_ip
|
||||
got := servedWG(t, h)
|
||||
if _, has := got["oob_peer_ip"]; has {
|
||||
t.Fatalf("oob_peer_ip present with no operator peer: %v", got)
|
||||
}
|
||||
|
||||
// register the operator peer (global key)
|
||||
if rr := do(h, http.MethodPut, "/admin/wg/operator-peer", globalKey,
|
||||
`{"pubkey":"`+opTestPubkey+`","assigned_ip":"10.77.0.250"}`); rr.Code != 200 {
|
||||
t.Fatalf("set operator peer: %d %s", rr.Code, servedBody(t, h))
|
||||
}
|
||||
got = servedWG(t, h)
|
||||
if got["oob_peer_ip"] != "10.77.0.250" {
|
||||
t.Fatalf("served oob_peer_ip = %v, want 10.77.0.250", got["oob_peer_ip"])
|
||||
}
|
||||
|
||||
// read-back route
|
||||
if rr := do(h, http.MethodGet, "/admin/wg/operator-peer", globalKey, ""); rr.Code != 200 {
|
||||
t.Fatalf("operator-peer read-back: %d", rr.Code)
|
||||
}
|
||||
// a per-host key cannot set the operator peer
|
||||
if rr := do(h, http.MethodPut, "/admin/wg/operator-peer", "HKEY1",
|
||||
`{"pubkey":"`+opTestPubkey+`","assigned_ip":"10.77.0.248"}`); rr.Code != 403 {
|
||||
t.Fatalf("host key setting operator peer must be 403, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
const opTestPubkey = "cdmN4U+fjR18zBk+SKoceJQyz9HgA9+hN8/FiKF1u0o="
|
||||
|
||||
func servedWG(t *testing.T, h *Handler) map[string]any {
|
||||
t.Helper()
|
||||
rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("GET desired-state: %d", rr.Code)
|
||||
}
|
||||
var got struct {
|
||||
DesiredState json.RawMessage `json:"desired_state"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &got)
|
||||
var doc map[string]any
|
||||
json.Unmarshal(got.DesiredState, &doc)
|
||||
wg, _ := doc["wireguard"].(map[string]any)
|
||||
if wg == nil {
|
||||
t.Fatalf("no wireguard block in served state: %s", got.DesiredState)
|
||||
}
|
||||
return wg
|
||||
}
|
||||
|
||||
func servedBody(t *testing.T, h *Handler) string {
|
||||
return do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "").Body.String()
|
||||
}
|
||||
|
||||
func TestAdminSetDesiredState_RejectsWireguardKey(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
seedHost(t, st, "h1", "c1", "HKEY1")
|
||||
|
||||
Reference in New Issue
Block a user