hub v0.44.0: PBS DR tier SLICE 1 — felhom-tenantsync surface (script+client) + hub provisioning flow (consume-once host secret, pbs_dr desired-state descriptor, fail-closed + idempotent, re-issue)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-10 20:49:48 +02:00
parent 00afadc1fe
commit ce6a56691e
19 changed files with 1743 additions and 0 deletions
+4
View File
@@ -181,6 +181,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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)
// PBS DR tier (SLICE 1): the agent's consume-once fetch of its PBS token secret (api/pbsdr.go).
case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/pbs/consume-token"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/pbs/consume-token")
h.handleConsumePBSToken(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")
+47
View File
@@ -0,0 +1,47 @@
package api
// PBS DR tier (SLICE 1): the agent-facing consume-once endpoint for the host's PBS token secret.
// The hub stored the secret at provision time (tenantsync → Store.SaveHostPBSSecret); the agent
// fetches it EXACTLY ONCE with its per-host key while applying the desired-state pbs_dr block
// (SLICE 2). The controller-side offsite consume endpoint is the precedent; this is its host-side
// twin. The secret value is never logged.
import (
"database/sql"
"encoding/json"
"net/http"
)
// handleConsumePBSToken serves POST /api/v1/hosts/{host_id}/pbs/consume-token. Per-host key,
// self-scoped (the global key may consume on a host's behalf — the operator recovery path).
// 200 exactly once per stored secret; 404 when absent or already consumed; 403 on a foreign key.
func (h *Handler) handleConsumePBSToken(w http.ResponseWriter, r *http.Request, pathHostID string) {
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if pathHostID == "" {
http.Error(w, "Missing host_id", http.StatusBadRequest)
return
}
if !isGlobal && authHostID != pathHostID {
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
return
}
secret, err := h.store.ConsumeHostPBSSecret(pathHostID)
if err == sql.ErrNoRows {
http.Error(w, "No unconsumed PBS token secret for this host", http.StatusNotFound)
return
}
if err != nil {
h.logger.Printf("[ERROR] pbs consume-token %s: %v", pathHostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.logger.Printf("[INFO] pbs token secret consumed by host %s (single-use; value withheld from logs)", pathHostID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"token_secret": secret})
}
+80
View File
@@ -0,0 +1,80 @@
package api
// PBS DR SLICE 1 — the agent-facing consume-once endpoint. The contract: 200 with the secret
// EXACTLY once per stored value, 404 after (and when nothing is stored), 403 on a foreign
// host's key WITHOUT burning the secret, 401 unauthenticated.
import (
"encoding/json"
"net/http"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func TestConsumePBSToken_OnceThen404(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
if err := st.SaveHostPBSSecret("h1", "tok-secret-1"); err != nil {
t.Fatalf("seed secret: %v", err)
}
rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY", "")
if rr.Code != http.StatusOK {
t.Fatalf("first consume = %d, want 200 (%s)", rr.Code, rr.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("response parse: %v", err)
}
if resp["token_secret"] != "tok-secret-1" {
t.Errorf("token_secret = %q, want tok-secret-1", resp["token_secret"])
}
// Consume-once: the second fetch MUST 404. (Red-proof: drop the consumed_at UPDATE in
// store.ConsumeHostPBSSecret → this returns 200 with the secret again → FAIL.)
rr = do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY", "")
if rr.Code != http.StatusNotFound {
t.Fatalf("second consume = %d (%s), want 404 — single-use broken", rr.Code, rr.Body.String())
}
}
func TestConsumePBSToken_ForeignKeyForbiddenAndSecretSurvives(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"})
if err := st.SaveHostPBSSecret("h1", "tok-secret-1"); err != nil {
t.Fatalf("seed secret: %v", err)
}
// h2's key against h1's path → 403, and the attempt must NOT consume h1's secret.
rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY2", "")
if rr.Code != http.StatusForbidden {
t.Fatalf("foreign-key consume = %d, want 403", rr.Code)
}
rr = do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY", "")
if rr.Code != http.StatusOK {
t.Fatalf("own consume after foreign 403 = %d, want 200 — the 403 burned the secret", rr.Code)
}
}
func TestConsumePBSToken_AuthMatrix(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
st.SaveHostPBSSecret("h1", "tok-secret-1")
if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "", ""); rr.Code != http.StatusUnauthorized {
t.Errorf("unauthenticated = %d, want 401", rr.Code)
}
if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "bogus", ""); rr.Code != http.StatusUnauthorized {
t.Errorf("bogus key = %d, want 401", rr.Code)
}
// The global key may consume on a host's behalf (operator recovery path).
if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", globalKey, ""); rr.Code != http.StatusOK {
t.Errorf("global key = %d, want 200", rr.Code)
}
// Nothing stored (just consumed above) → 404, not an error leak.
if rr := do(h, http.MethodPost, "/hosts/h1/pbs/consume-token", "HKEY", ""); rr.Code != http.StatusNotFound {
t.Errorf("post-consume = %d, want 404", rr.Code)
}
}