hub: S2 API — box-facing WG registration + merge-at-read + hub-owned-key guard
POST /hosts/{id}/wg (per-host self-scoped; global = operator/DR path): bind /
re-key-in-place / adopt; generation bump + endpoint push ONLY on real change.
mergeWireguard injects the hub-owned block into served desired-state at READ
time (stored operator blob never touched; fail-safe unmerged on any error;
no-peer = byte-identical pass-through — existing golden test untouched+green).
handleAdminSetDesiredState rejects top-level wireguard (400). Admin DELETE of a
BOUND peer bumps the owning host. NEW golden desired-state-wireguard.golden.json
= the S3 cross-repo contract. Red-proofs a/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:
@@ -169,6 +169,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/restore-directive"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/restore-directive")
|
||||
h.handleGetRestoreDirective(w, r, hostID)
|
||||
// S2 offsite connectivity: box-facing WG pubkey registration (per-host key, self-scoped).
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/wg"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/wg")
|
||||
h.handleRegisterHostWG(w, r, hostID)
|
||||
// Desired-state serving (slice 10A) — per-host-key, self-scoped (a host reads only its own).
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/desired-state"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/desired-state")
|
||||
@@ -911,6 +915,9 @@ func (h *Handler) handleGetDesiredState(w http.ResponseWriter, r *http.Request,
|
||||
if strings.TrimSpace(desired) == "" {
|
||||
desired = "{}"
|
||||
}
|
||||
// S2: merge the hub-OWNED wireguard block at read time (no peer → pass-through unchanged;
|
||||
// the stored operator blob is never modified). See api/wg.go mergeWireguard.
|
||||
desired = h.mergeWireguard(pathHostID, desired)
|
||||
resp := map[string]interface{}{
|
||||
"generation": host.DesiredGeneration,
|
||||
"desired_state": json.RawMessage(desired), // opaque to the hub — agent owns the schema
|
||||
@@ -1008,6 +1015,16 @@ func (h *Handler) handleAdminSetDesiredState(w http.ResponseWriter, r *http.Requ
|
||||
http.Error(w, "Invalid payload: body must be JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// S2: the wireguard block is HUB-owned, merged at read time — an operator copy-paste of a
|
||||
// served desired-state must never write it back into the stored blob (it would go stale and
|
||||
// shadow the live assignment). Reject at the door.
|
||||
var topKeys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &topKeys); err == nil {
|
||||
if _, has := topKeys["wireguard"]; has {
|
||||
http.Error(w, "wireguard is hub-owned; register via POST /hosts/{id}/wg", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
gen, err := h.store.SetHostDesired(pathHostID, body)
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "Unknown host_id", http.StatusNotFound)
|
||||
|
||||
Reference in New Issue
Block a user