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:
@@ -413,12 +413,14 @@ func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
ActiveNav string
|
||||
Error string
|
||||
CSRFField template.HTML
|
||||
PBSDR pbsDRView
|
||||
}{
|
||||
IsNew: true,
|
||||
Config: &store.CustomerConfig{},
|
||||
Overrides: make(map[string]interface{}),
|
||||
ActiveNav: "configs",
|
||||
CSRFField: s.csrfField(r),
|
||||
PBSDR: s.pbsDRViewFor(""),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
@@ -484,6 +486,13 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// PBS DR tier (fail-closed, same discipline; the descriptor lives in the HOST desired-state).
|
||||
if err := s.applyPBSDR(r.Context(), r, cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] pbsdr provision for %s: %v", customerID, err)
|
||||
http.Error(w, "PBS DR provisioning failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SaveCustomerConfig(cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
@@ -512,12 +521,14 @@ func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, cu
|
||||
ActiveNav string
|
||||
Error string
|
||||
CSRFField template.HTML
|
||||
PBSDR pbsDRView
|
||||
}{
|
||||
IsNew: false,
|
||||
Config: cfg,
|
||||
Overrides: overrides,
|
||||
ActiveNav: "configs",
|
||||
CSRFField: s.csrfField(r),
|
||||
PBSDR: s.pbsDRViewFor(customerID),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
@@ -546,6 +557,14 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
|
||||
return
|
||||
}
|
||||
|
||||
// PBS DR tier (fail-closed; idempotent on an already-provisioned descriptor — no re-key,
|
||||
// no second secret, no spurious generation bump).
|
||||
if err := s.applyPBSDR(r.Context(), r, cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] pbsdr provision for %s: %v", customerID, err)
|
||||
http.Error(w, "PBS DR provisioning failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SaveCustomerConfig(cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
@@ -897,6 +916,7 @@ func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew
|
||||
ActiveNav string
|
||||
Error string
|
||||
CSRFField template.HTML
|
||||
PBSDR pbsDRView
|
||||
}{
|
||||
IsNew: isNew,
|
||||
Config: cfg,
|
||||
@@ -904,6 +924,7 @@ func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew
|
||||
ActiveNav: "configs",
|
||||
Error: errMsg,
|
||||
CSRFField: s.csrfField(r),
|
||||
PBSDR: s.pbsDRViewFor(cfg.CustomerID),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ func TestConfigForm_DebugRenderState(t *testing.T) {
|
||||
ActiveNav string
|
||||
Error string
|
||||
CSRFField string
|
||||
PBSDR pbsDRView
|
||||
}{
|
||||
Config: &store.CustomerConfig{CustomerID: "c1"},
|
||||
Overrides: overrides,
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package web
|
||||
|
||||
// PBS DR tier (SLICE 1): the hub-side provisioning flow behind the customer config form's
|
||||
// "PBS DR tier (ep0)" section. On enable+save: verify the host's WG peer exists (the agent
|
||||
// self-registers its pubkey — absence is fail-closed, nothing to allocate here) → provision the
|
||||
// per-customer ep0 tenancy over the tenantsync channel → store the token secret CONSUME-ONCE
|
||||
// (host-scoped; the agent fetches it via POST /api/v1/hosts/{id}/pbs/consume-token) → merge the
|
||||
// NON-SECRET descriptor into the host's desired_json under "pbs_dr" (the admin-set path;
|
||||
// SetHostDesired bumps the generation, which is the agent's change signal).
|
||||
//
|
||||
// Fail-closed like applyOffsite: any error means NO descriptor write, NO bump, NO half-enabled
|
||||
// state. Idempotent: an already-provisioned descriptor short-circuits — no tenantsync call, no
|
||||
// second secret, no spurious bump. Disable rewrites the descriptor with enabled=false (bump) —
|
||||
// the ep0 namespace/token are NOT deprovisioned (the offsite-disable precedent: data deletion is
|
||||
// a deliberate, separate decision).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync"
|
||||
)
|
||||
|
||||
// tenancyProvisioner is the tenantsync seam — satisfied by *tenantsync.Client; tests inject a fake.
|
||||
type tenancyProvisioner interface {
|
||||
Provision(ctx context.Context, customerID string) (*tenantsync.Result, error)
|
||||
Reissue(ctx context.Context, customerID string) (*tenantsync.Result, error)
|
||||
}
|
||||
|
||||
// SetTenantSync enables PBS DR tier provisioning (optional). Without it, saving a config with the
|
||||
// tier enabled returns an error (not configured on this hub); the form section still renders.
|
||||
func (s *Server) SetTenantSync(p tenancyProvisioner) { s.tenantsync = p }
|
||||
|
||||
// pbsDRDescriptor is the NON-SECRET pbs_dr block in a host's desired_json. It NEVER carries the
|
||||
// token secret (that is host_pbs_secrets custody, consume-once).
|
||||
type pbsDRDescriptor struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
StorageID string `json:"storage_id,omitempty"`
|
||||
PBSTunnelIP string `json:"pbs_tunnel_ip,omitempty"`
|
||||
Datastore string `json:"datastore,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
TokenID string `json:"token_id,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// defaultPBSStorageID is the storage-entry id the agent bridge creates on a customer box. The DEMO
|
||||
// host's adopted manual entry is `felhom-offsite` — the descriptor carries the id so the bridge is
|
||||
// name-agnostic and the felhom-pbs collision on the demo dissolves (spike naming note).
|
||||
const defaultPBSStorageID = "felhom-pbs"
|
||||
|
||||
// pveStorageIDRe validates the operator-typed storage id (PVE storage-id grammar, conservative).
|
||||
var pveStorageIDRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,27}$`)
|
||||
|
||||
// readPBSDR extracts the pbs_dr descriptor from a host's desired_json ("" / absent → nil).
|
||||
func readPBSDR(desiredJSON string) *pbsDRDescriptor {
|
||||
var doc struct {
|
||||
PBSDR *pbsDRDescriptor `json:"pbs_dr"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(desiredJSON), &doc); err != nil {
|
||||
return nil
|
||||
}
|
||||
return doc.PBSDR
|
||||
}
|
||||
|
||||
// mergePBSDR merges the descriptor under the "pbs_dr" key of a desired_json object, preserving
|
||||
// every other key (the operator blob, dr blocks…). Same shape as offsite.MergeDescriptor.
|
||||
func mergePBSDR(desiredJSON string, d *pbsDRDescriptor) (string, error) {
|
||||
obj := map[string]json.RawMessage{}
|
||||
if strings.TrimSpace(desiredJSON) != "" && desiredJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(desiredJSON), &obj); err != nil {
|
||||
return "", fmt.Errorf("pbsdr: parse desired_json: %w", err)
|
||||
}
|
||||
}
|
||||
db, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
obj["pbs_dr"] = db
|
||||
out, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// applyPBSDR handles the config form's PBS DR section on create/update. Called BEFORE
|
||||
// SaveCustomerConfig (fail-closed: an error must abort the whole save). The descriptor lives in
|
||||
// the HOST's desired_json — ConfigJSON never carries pbs_dr (single source of truth).
|
||||
func (s *Server) applyPBSDR(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error {
|
||||
enabled := r.FormValue("pbsdr_enabled") == "on" || r.FormValue("pbsdr_enabled") == "true"
|
||||
storageID := strings.TrimSpace(r.FormValue("pbsdr_storage_id"))
|
||||
if storageID == "" {
|
||||
storageID = defaultPBSStorageID
|
||||
}
|
||||
|
||||
host, err := s.store.GetHostByCustomer(cfg.CustomerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pbsdr: host lookup: %w", err)
|
||||
}
|
||||
if host == nil {
|
||||
if !enabled {
|
||||
return nil // nothing enrolled, nothing enabled — nothing to do
|
||||
}
|
||||
return fmt.Errorf("no host enrolled for customer %s yet — the PBS DR tier needs the enrolled host", cfg.CustomerID)
|
||||
}
|
||||
cur := readPBSDR(host.DesiredJSON)
|
||||
|
||||
if !enabled {
|
||||
// Disable = descriptor enabled:false (coords kept; NO ep0 deprovision). Only when there is
|
||||
// something to disable — otherwise a pure no-op (no spurious bump).
|
||||
if cur == nil || !cur.Enabled {
|
||||
return nil
|
||||
}
|
||||
cur.Enabled = false
|
||||
merged, err := mergePBSDR(host.DesiredJSON, cur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gen, err := s.store.SetHostDesired(host.HostID, []byte(merged))
|
||||
if err != nil {
|
||||
return fmt.Errorf("pbsdr: desired-state write: %w", err)
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr disabled for %s (host %s, gen %d; ep0 tenancy kept)", cfg.CustomerID, host.HostID, gen)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !pveStorageIDRe.MatchString(storageID) {
|
||||
return fmt.Errorf("invalid PBS storage id %q", storageID)
|
||||
}
|
||||
|
||||
// Already provisioned → success-no-op (descriptor re-served unchanged; NO new secret, no
|
||||
// tenantsync call). Only an actual change (re-enable, storage id edit) rewrites + bumps.
|
||||
if cur != nil && cur.Namespace != "" {
|
||||
if cur.Enabled && cur.StorageID == storageID {
|
||||
return nil
|
||||
}
|
||||
cur.Enabled = true
|
||||
cur.StorageID = storageID
|
||||
merged, err := mergePBSDR(host.DesiredJSON, cur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gen, err := s.store.SetHostDesired(host.HostID, []byte(merged))
|
||||
if err != nil {
|
||||
return fmt.Errorf("pbsdr: desired-state write: %w", err)
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr descriptor updated for %s (host %s, gen %d; tenancy unchanged)", cfg.CustomerID, host.HostID, gen)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fresh provision. Fail-closed preconditions first.
|
||||
if s.tenantsync == nil {
|
||||
return fmt.Errorf("PBS DR provisioning is not configured on this hub (no tenantsync key)")
|
||||
}
|
||||
if _, err := s.store.GetWGPeerForHost(host.HostID); err == sql.ErrNoRows {
|
||||
return fmt.Errorf("host %s has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier", host.HostID)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("pbsdr: wg peer lookup: %w", err)
|
||||
}
|
||||
ep, err := s.store.GetWGEndpoint()
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("wg endpoint not configured — register ep0 before the PBS DR tier")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("pbsdr: wg endpoint read: %w", err)
|
||||
}
|
||||
|
||||
// Detach from the request context (the applyOffsite F1 precedent): once provisioning starts,
|
||||
// provision→store-secret→descriptor must complete; an impatient re-click must not cancel
|
||||
// between the ep0 mutation and the consume-once store write (a stranded token would need a
|
||||
// manual re-issue). The absolute timeout still bounds a hung SSH exec.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||
defer cancel()
|
||||
res, err := s.tenantsync.Provision(ctx, cfg.CustomerID)
|
||||
if errors.Is(err, tenantsync.ErrTokenExists) {
|
||||
// ep0 has a token but the hub has no descriptor — state mismatch (lost hub state or a
|
||||
// half-torn earlier attempt). Never silently re-key: the operator decides via Re-issue.
|
||||
return fmt.Errorf("the endpoint already holds a PBS token for %s but the hub has no descriptor — use the explicit \"Re-issue PBS credentials\" action", cfg.CustomerID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The atom: secret first (consume-once custody), then descriptor+bump (the agent's signal).
|
||||
if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil {
|
||||
return fmt.Errorf("pbsdr: store one-time token secret: %w", err)
|
||||
}
|
||||
desc := &pbsDRDescriptor{
|
||||
Enabled: true,
|
||||
StorageID: storageID,
|
||||
PBSTunnelIP: ep.PBSTunnelIP,
|
||||
Datastore: res.Datastore,
|
||||
Namespace: res.Namespace,
|
||||
TokenID: res.TokenID,
|
||||
Fingerprint: res.Fingerprint,
|
||||
}
|
||||
merged, err := mergePBSDR(host.DesiredJSON, desc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gen, err := s.store.SetHostDesired(host.HostID, []byte(merged))
|
||||
if err != nil {
|
||||
return fmt.Errorf("pbsdr: desired-state write: %w", err)
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr provisioned for %s (host %s, ns %s, token_id %s, gen %d; secret stored consume-once, withheld from logs)",
|
||||
cfg.CustomerID, host.HostID, res.Namespace, res.TokenID, gen)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePBSDRReissue explicitly re-keys the customer's ep0 PBS token (the offsite F4 precedent):
|
||||
// tenantsync reissue → fresh consume-once secret → descriptor refresh + generation bump so the
|
||||
// agent re-runs its bridge and consumes the fresh secret. The secret value is never logged.
|
||||
func (s *Server) handlePBSDRReissue(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if s.tenantsync == nil {
|
||||
http.Error(w, "PBS DR provisioning is not configured on this hub", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil || cfg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
host, err := s.store.GetHostByCustomer(customerID)
|
||||
if err != nil || host == nil {
|
||||
http.Error(w, "No host enrolled for this customer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cur := readPBSDR(host.DesiredJSON)
|
||||
if cur == nil || cur.Namespace == "" {
|
||||
http.Error(w, "No provisioned PBS DR tier for this customer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Same detached-ctx discipline as applyPBSDR: reissue→store→bump is the atom.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Minute)
|
||||
defer cancel()
|
||||
res, err := s.tenantsync.Reissue(ctx, customerID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] pbsdr reissue for %s: %v", customerID, err)
|
||||
http.Error(w, "PBS credential re-issue failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if err := s.store.SaveHostPBSSecret(host.HostID, res.TokenSecret); err != nil {
|
||||
s.logger.Printf("[ERROR] pbsdr reissue for %s: secret store: %v", customerID, err)
|
||||
http.Error(w, "Re-issued on the endpoint but storing the secret failed — re-issue again", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cur.TokenID = res.TokenID
|
||||
cur.Fingerprint = res.Fingerprint
|
||||
cur.Datastore = res.Datastore
|
||||
cur.Namespace = res.Namespace
|
||||
merged, err := mergePBSDR(host.DesiredJSON, cur)
|
||||
if err == nil {
|
||||
_, err = s.store.SetHostDesired(host.HostID, []byte(merged))
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] pbsdr reissue for %s: descriptor bump: %v", customerID, err)
|
||||
http.Error(w, "Credential re-issued but the descriptor bump failed — save the config once to trigger the pickup", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] pbsdr credentials re-issued for %s (host %s; fresh consume-once secret stored)", customerID, host.HostID)
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=pbsdr_reissued", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// pbsDRView is the config form's render model for the PBS DR section.
|
||||
type pbsDRView struct {
|
||||
Supported bool // tenantsync configured on this hub
|
||||
NoHost bool // no enrolled host for the customer (enable would fail-closed)
|
||||
HostID string
|
||||
Enabled bool
|
||||
StorageID string // current or the default
|
||||
Provisioned bool
|
||||
Namespace string
|
||||
TokenID string
|
||||
}
|
||||
|
||||
// pbsDRViewFor loads the section state for the form. Read-only; every error degrades to a
|
||||
// zero-ish view (the section still renders).
|
||||
func (s *Server) pbsDRViewFor(customerID string) pbsDRView {
|
||||
v := pbsDRView{Supported: s.tenantsync != nil, StorageID: defaultPBSStorageID}
|
||||
if customerID == "" {
|
||||
v.NoHost = true
|
||||
return v
|
||||
}
|
||||
host, err := s.store.GetHostByCustomer(customerID)
|
||||
if err != nil || host == nil {
|
||||
v.NoHost = true
|
||||
return v
|
||||
}
|
||||
v.HostID = host.HostID
|
||||
if d := readPBSDR(host.DesiredJSON); d != nil {
|
||||
v.Enabled = d.Enabled
|
||||
if d.StorageID != "" {
|
||||
v.StorageID = d.StorageID
|
||||
}
|
||||
v.Provisioned = d.Namespace != ""
|
||||
v.Namespace = d.Namespace
|
||||
v.TokenID = d.TokenID
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package web
|
||||
|
||||
// PBS DR SLICE 1 — the provisioning flow behind the config form, against a FAKE tenancy
|
||||
// provisioner (no SSH in CI). The load-bearing contracts: descriptor lands in the HOST
|
||||
// desired_json + generation bump; the secret is stored consume-once and appears NOWHERE else
|
||||
// (ConfigJSON, desired-state, logs); every failure is fail-closed (no descriptor, no bump, no
|
||||
// secret); an already-provisioned re-save is a pure no-op (no re-key, no second secret, no
|
||||
// spurious bump); re-issue rotates the secret + bumps.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync"
|
||||
)
|
||||
|
||||
type fakeTenancy struct {
|
||||
provisionCalls int
|
||||
reissueCalls int
|
||||
err error
|
||||
secret string
|
||||
}
|
||||
|
||||
func (f *fakeTenancy) result(customerID string) *tenantsync.Result {
|
||||
return &tenantsync.Result{
|
||||
TokenID: "felhom@pbs!" + customerID,
|
||||
TokenSecret: f.secret,
|
||||
Fingerprint: "aa:bb:cc",
|
||||
Datastore: "felhom-offsite",
|
||||
Namespace: customerID,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeTenancy) Provision(ctx context.Context, customerID string) (*tenantsync.Result, error) {
|
||||
f.provisionCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.result(customerID), nil
|
||||
}
|
||||
|
||||
func (f *fakeTenancy) Reissue(ctx context.Context, customerID string) (*tenantsync.Result, error) {
|
||||
f.reissueCalls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.result(customerID), nil
|
||||
}
|
||||
|
||||
// newPBSDRServer builds a server + store with the full provisioning preconditions satisfied:
|
||||
// customer config, enrolled host, WG endpoint record, bound WG peer. The logger is captured so
|
||||
// tests can grep-assert the secret never reaches it.
|
||||
func newPBSDRServer(t *testing.T, fake *fakeTenancy) (*Server, *store.Store, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
s, st := newTestServer(t)
|
||||
logBuf := &bytes.Buffer{}
|
||||
s.logger = log.New(logBuf, "", 0)
|
||||
if fake != nil {
|
||||
s.SetTenantSync(fake)
|
||||
}
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "peti", APIKey: "capi", RetrievalPassword: "pw",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
if err := st.UpsertHost(&store.Host{HostID: "peti-01", CustomerID: "peti", APIKey: "hapi"}); err != nil {
|
||||
t.Fatalf("seed host: %v", err)
|
||||
}
|
||||
if err := st.SetWGEndpoint(&store.WGEndpoint{
|
||||
EndpointID: "ep0", DNSName: "ep0.felhom.eu", WGPort: 443,
|
||||
ServerPubkey: "SPK", TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed endpoint: %v", err)
|
||||
}
|
||||
if _, _, err := st.RegisterWGPeerForHost("peti-01", "PETIPUBKEY"); err != nil {
|
||||
t.Fatalf("seed wg peer: %v", err)
|
||||
}
|
||||
return s, st, logBuf
|
||||
}
|
||||
|
||||
// postUpdate drives the REAL handler pipeline (handleConfigUpdate → applyPBSDR → save).
|
||||
func postUpdate(t *testing.T, s *Server, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("POST", "/configs/peti", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfigUpdate(rr, req, "peti")
|
||||
return rr
|
||||
}
|
||||
|
||||
func hostState(t *testing.T, st *store.Store) (desc *pbsDRDescriptor, desiredJSON string, gen int64) {
|
||||
t.Helper()
|
||||
h, err := st.GetHost("peti-01")
|
||||
if err != nil || h == nil {
|
||||
t.Fatalf("host read: %v", err)
|
||||
}
|
||||
return readPBSDR(h.DesiredJSON), h.DesiredJSON, h.DesiredGeneration
|
||||
}
|
||||
|
||||
func TestPBSDR_ProvisionHappyPath(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
|
||||
s, st, logBuf := newPBSDRServer(t, fake)
|
||||
|
||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}, "pbsdr_storage_id": {"felhom-pbs"}})
|
||||
if rr.Code != 303 {
|
||||
t.Fatalf("save = %d (%s), want 303", rr.Code, rr.Body.String())
|
||||
}
|
||||
if fake.provisionCalls != 1 {
|
||||
t.Errorf("provision calls = %d, want 1", fake.provisionCalls)
|
||||
}
|
||||
|
||||
desc, desiredJSON, gen := hostState(t, st)
|
||||
if desc == nil {
|
||||
t.Fatalf("no pbs_dr descriptor in desired_json: %s", desiredJSON)
|
||||
}
|
||||
if !desc.Enabled || desc.StorageID != "felhom-pbs" || desc.PBSTunnelIP != "10.77.0.1" ||
|
||||
desc.Datastore != "felhom-offsite" || desc.Namespace != "peti" ||
|
||||
desc.TokenID != "felhom@pbs!peti" || desc.Fingerprint != "aa:bb:cc" {
|
||||
t.Errorf("descriptor wrong: %+v", desc)
|
||||
}
|
||||
if gen != 1 {
|
||||
t.Errorf("desired_generation = %d, want 1 (exactly one bump)", gen)
|
||||
}
|
||||
|
||||
// The secret: stored consume-once for the HOST…
|
||||
got, err := st.ConsumeHostPBSSecret("peti-01")
|
||||
if err != nil || got != "SUPER-SECRET-TOKEN" {
|
||||
t.Fatalf("consume = (%q, %v), want the stored secret", got, err)
|
||||
}
|
||||
// …and NOWHERE else: not in the desired-state, not in ConfigJSON, not in any log line.
|
||||
if strings.Contains(desiredJSON, "SUPER-SECRET-TOKEN") {
|
||||
t.Error("secret leaked into desired_json")
|
||||
}
|
||||
cfg, _ := st.GetCustomerConfig("peti")
|
||||
if strings.Contains(cfg.ConfigJSON, "SUPER-SECRET-TOKEN") || strings.Contains(cfg.ConfigJSON, "pbs_dr") {
|
||||
t.Errorf("ConfigJSON must carry neither the secret nor the descriptor: %s", cfg.ConfigJSON)
|
||||
}
|
||||
if strings.Contains(logBuf.String(), "SUPER-SECRET-TOKEN") {
|
||||
t.Error("secret leaked into the hub log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPBSDR_ResaveIsNoOp(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "SUPER-SECRET-TOKEN"}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
|
||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != nil {
|
||||
t.Fatalf("first secret consume: %v", err)
|
||||
}
|
||||
|
||||
// Idempotent re-save: NO re-key, NO second secret row, NO spurious generation bump.
|
||||
// (Red-proof: dropping the already-provisioned short-circuit in applyPBSDR re-runs
|
||||
// Provision → calls=2 + a fresh consumable secret → FAIL.)
|
||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
if rr.Code != 303 {
|
||||
t.Fatalf("re-save = %d, want 303 (already-provisioned is success-no-op)", rr.Code)
|
||||
}
|
||||
if fake.provisionCalls != 1 {
|
||||
t.Errorf("provision calls after re-save = %d, want 1 (token must not rotate)", fake.provisionCalls)
|
||||
}
|
||||
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
|
||||
t.Errorf("re-save created a fresh secret (consume err %v, want ErrNoRows)", err)
|
||||
}
|
||||
if _, _, gen := hostState(t, st); gen != 1 {
|
||||
t.Errorf("generation after re-save = %d, want 1 (no spurious bump)", gen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPBSDR_FailClosed(t *testing.T) {
|
||||
t.Run("no WG peer", func(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "S"}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
// Kill the precondition: the host has never registered a WG key.
|
||||
if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
|
||||
t.Fatalf("remove seed peer: %v", err)
|
||||
}
|
||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
if rr.Code != 502 {
|
||||
t.Fatalf("save without WG peer = %d, want 502", rr.Code)
|
||||
}
|
||||
if fake.provisionCalls != 0 {
|
||||
t.Errorf("provision reached despite missing peer (%d calls)", fake.provisionCalls)
|
||||
}
|
||||
h, _ := st.GetHost("peti-01")
|
||||
if readPBSDR(h.DesiredJSON) != nil || h.DesiredGeneration != 0 {
|
||||
t.Errorf("fail-closed violated: desc=%v gen=%d", readPBSDR(h.DesiredJSON), h.DesiredGeneration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tenantsync error", func(t *testing.T) {
|
||||
fake := &fakeTenancy{err: errors.New("ssh boom")}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
if rr.Code != 502 {
|
||||
t.Fatalf("save with tenantsync error = %d, want 502", rr.Code)
|
||||
}
|
||||
desc, _, gen := hostState(t, st)
|
||||
if desc != nil || gen != 0 {
|
||||
t.Errorf("fail-closed violated: desc=%+v gen=%d", desc, gen)
|
||||
}
|
||||
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
|
||||
t.Errorf("a secret exists after a failed provision (err %v)", err)
|
||||
}
|
||||
// Fail-closed also means the config save itself was aborted.
|
||||
cfg, _ := st.GetCustomerConfig("peti")
|
||||
if cfg.CustomerName == "half-saved" {
|
||||
t.Error("config row saved despite provisioning failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_exists points at re-issue", func(t *testing.T) {
|
||||
fake := &fakeTenancy{err: tenantsync.ErrTokenExists}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
if rr.Code != 502 || !strings.Contains(rr.Body.String(), "Re-issue") {
|
||||
t.Fatalf("token_exists = %d (%s), want 502 mentioning Re-issue", rr.Code, rr.Body.String())
|
||||
}
|
||||
if desc, _, gen := hostState(t, st); desc != nil || gen != 0 {
|
||||
t.Errorf("state written on token_exists: desc=%+v gen=%d", desc, gen)
|
||||
}
|
||||
_ = st
|
||||
})
|
||||
|
||||
t.Run("not configured", func(t *testing.T) {
|
||||
s, st, _ := newPBSDRServer(t, nil) // no tenantsync
|
||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
if rr.Code != 502 {
|
||||
t.Fatalf("save without tenantsync = %d, want 502", rr.Code)
|
||||
}
|
||||
if desc, _, gen := hostState(t, st); desc != nil || gen != 0 {
|
||||
t.Errorf("state written without a provisioner: desc=%+v gen=%d", desc, gen)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPBSDR_DisableKeepsTenancy(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "S"}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
|
||||
// Unchecked box → descriptor enabled:false, coords kept, ONE bump; no endpoint mutation.
|
||||
rr := postUpdate(t, s, url.Values{})
|
||||
if rr.Code != 303 {
|
||||
t.Fatalf("disable save = %d, want 303", rr.Code)
|
||||
}
|
||||
desc, _, gen := hostState(t, st)
|
||||
if desc == nil || desc.Enabled || desc.Namespace != "peti" || desc.TokenID == "" {
|
||||
t.Fatalf("disable must keep coords with enabled=false: %+v", desc)
|
||||
}
|
||||
if gen != 2 {
|
||||
t.Errorf("generation = %d, want 2 (enable + disable)", gen)
|
||||
}
|
||||
// A second disabled save is a pure no-op.
|
||||
postUpdate(t, s, url.Values{})
|
||||
if _, _, gen := hostState(t, st); gen != 2 {
|
||||
t.Errorf("second disabled save bumped generation to %d", gen)
|
||||
}
|
||||
if fake.provisionCalls != 1 || fake.reissueCalls != 0 {
|
||||
t.Errorf("endpoint touched on disable: provision=%d reissue=%d", fake.provisionCalls, fake.reissueCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPBSDR_StorageIDChangeUpdatesDescriptorOnly(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "S"}
|
||||
s, st, _ := newPBSDRServer(t, fake)
|
||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
st.ConsumeHostPBSSecret("peti-01") // spend the provision secret
|
||||
|
||||
rr := postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}, "pbsdr_storage_id": {"felhom-offsite"}})
|
||||
if rr.Code != 303 {
|
||||
t.Fatalf("storage-id change = %d, want 303", rr.Code)
|
||||
}
|
||||
desc, _, gen := hostState(t, st)
|
||||
if desc.StorageID != "felhom-offsite" || !desc.Enabled {
|
||||
t.Errorf("descriptor not updated: %+v", desc)
|
||||
}
|
||||
if gen != 2 {
|
||||
t.Errorf("generation = %d, want 2", gen)
|
||||
}
|
||||
if fake.provisionCalls != 1 {
|
||||
t.Errorf("a descriptor edit re-provisioned the tenancy (%d calls)", fake.provisionCalls)
|
||||
}
|
||||
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
|
||||
t.Errorf("a descriptor edit staged a new secret (err %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPBSDR_Reissue(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "OLD-SECRET"}
|
||||
s, st, logBuf := newPBSDRServer(t, fake)
|
||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
st.ConsumeHostPBSSecret("peti-01") // agent already consumed; the dead-end scenario
|
||||
|
||||
fake.secret = "FRESH-SECRET"
|
||||
req := httptest.NewRequest("POST", "/configs/peti/pbsdr-reissue", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handlePBSDRReissue(rr, req, "peti")
|
||||
if rr.Code != 303 {
|
||||
t.Fatalf("reissue = %d (%s), want 303", rr.Code, rr.Body.String())
|
||||
}
|
||||
if fake.reissueCalls != 1 {
|
||||
t.Errorf("reissue calls = %d, want 1", fake.reissueCalls)
|
||||
}
|
||||
got, err := st.ConsumeHostPBSSecret("peti-01")
|
||||
if err != nil || got != "FRESH-SECRET" {
|
||||
t.Fatalf("fresh secret consume = (%q, %v)", got, err)
|
||||
}
|
||||
if _, err := st.ConsumeHostPBSSecret("peti-01"); err != sql.ErrNoRows {
|
||||
t.Error("the old secret path survived the re-issue")
|
||||
}
|
||||
if _, _, gen := hostState(t, st); gen != 2 {
|
||||
t.Errorf("generation = %d, want 2 (provision + reissue)", gen)
|
||||
}
|
||||
if strings.Contains(logBuf.String(), "FRESH-SECRET") || strings.Contains(logBuf.String(), "OLD-SECRET") {
|
||||
t.Error("secret leaked into the hub log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPBSDR_ReissueRequiresProvisionedState(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "S"}
|
||||
s, _, _ := newPBSDRServer(t, fake)
|
||||
req := httptest.NewRequest("POST", "/configs/peti/pbsdr-reissue", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handlePBSDRReissue(rr, req, "peti")
|
||||
if rr.Code != 400 {
|
||||
t.Fatalf("reissue without a provisioned tier = %d, want 400", rr.Code)
|
||||
}
|
||||
if fake.reissueCalls != 0 {
|
||||
t.Errorf("endpoint touched without a descriptor (%d calls)", fake.reissueCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The form render leg: the section reflects the provisioned descriptor (checkbox + namespace line)
|
||||
// and the fresh-form default storage id.
|
||||
func TestPBSDR_FormRendersState(t *testing.T) {
|
||||
fake := &fakeTenancy{secret: "S"}
|
||||
s, _, _ := newPBSDRServer(t, fake)
|
||||
postUpdate(t, s, url.Values{"pbsdr_enabled": {"on"}})
|
||||
|
||||
req := httptest.NewRequest("GET", "/configs/peti/edit", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfigEditForm(rr, req, "peti")
|
||||
out := rr.Body.String()
|
||||
if !strings.Contains(out, `name="pbsdr_enabled" checked`) {
|
||||
t.Error("enabled checkbox not checked after provisioning")
|
||||
}
|
||||
if !strings.Contains(out, "Provisioned: namespace peti, token felhom@pbs!peti") {
|
||||
t.Error("provisioned-state line missing")
|
||||
}
|
||||
if !strings.Contains(out, "pbsdr-reissue") {
|
||||
t.Error("re-issue button missing")
|
||||
}
|
||||
if strings.Contains(out, "S\"") && strings.Contains(out, "token_secret") {
|
||||
t.Error("secret-ish content rendered")
|
||||
}
|
||||
|
||||
// Deadline check on the detached-ctx behavior is out of scope here; the view path must
|
||||
// stay read-only — a render must not have touched provisioning.
|
||||
if fake.provisionCalls != 1 {
|
||||
t.Errorf("rendering the form called provision (%d calls)", fake.provisionCalls)
|
||||
}
|
||||
_ = time.Second
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type Server struct {
|
||||
assetsMgr *assets.Manager
|
||||
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
|
||||
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
|
||||
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
|
||||
|
||||
sessions map[string]*hubSession
|
||||
sessionsMu sync.RWMutex
|
||||
@@ -367,6 +368,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/pbsdr-reissue"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/pbsdr-reissue")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handlePBSDRReissue(w, r, customerID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/offsite-freeze"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/offsite-freeze")
|
||||
|
||||
@@ -169,6 +169,36 @@
|
||||
{{end}}{{end}}{{end}}
|
||||
</details>
|
||||
|
||||
<details class="card" {{if .PBSDR.Enabled}}open{{end}}>
|
||||
<summary><h2 style="display:inline">PBS DR tier (ep0)</h2></summary>
|
||||
<div class="form-grid" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="pbsdr_enabled" {{if .PBSDR.Enabled}}checked{{end}}>
|
||||
Enable PBS DR (provisions the ep0 namespace + token on save; the host agent applies the storage entry)</label>
|
||||
{{if not .PBSDR.Supported}}
|
||||
<small class="form-hint">Not configured on this hub (no tenantsync key) — enabling will fail until it is.</small>
|
||||
{{else if .PBSDR.NoHost}}
|
||||
<small class="form-hint">No host enrolled for this customer yet — enabling fails until the agent enrolls and reports its WG key.</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pbsdr_storage_id">PVE storage id</label>
|
||||
<input type="text" id="pbsdr_storage_id" name="pbsdr_storage_id"
|
||||
value="{{.PBSDR.StorageID}}" placeholder="felhom-pbs">
|
||||
<small class="form-hint">The storage-entry id the agent creates on the box. Default felhom-pbs; the demo host's adopted manual entry is felhom-offsite.</small>
|
||||
</div>
|
||||
</div>
|
||||
{{if .PBSDR.Provisioned}}
|
||||
<p class="form-hint" style="margin-top:.5rem">Provisioned: namespace {{.PBSDR.Namespace}}, token {{.PBSDR.TokenID}} (host {{.PBSDR.HostID}}) — the token secret is delivered to the host agent once (never shown here).</p>
|
||||
<!-- The offsite F4 precedent: explicit operator re-key for a consumed-secret dead-end.
|
||||
Rides the parent form via formaction; _csrf submits with it. -->
|
||||
<button type="submit" class="btn btn-outline" style="margin-top:.5rem"
|
||||
formaction="/configs/{{.Config.CustomerID}}/pbsdr-reissue" formmethod="POST"
|
||||
onclick="return confirm('Re-issue the PBS credentials?\n\nThe endpoint token is re-keyed and a fresh one-time secret is staged for the host agent. A box with a working storage entry re-applies on its next desired-state fetch.')">
|
||||
Re-issue PBS credentials</button>
|
||||
{{end}}
|
||||
</details>
|
||||
|
||||
<div style="margin-top: 1.5rem; display: flex; gap: 1rem; align-items: center;">
|
||||
<button type="submit" class="btn">{{if .IsNew}}Create Configuration{{else}}Save Changes{{end}}</button>
|
||||
<a href="{{if .IsNew}}/configs{{else}}/customers/{{.Config.CustomerID}}{{end}}" class="btn btn-outline">Cancel</a>
|
||||
|
||||
Reference in New Issue
Block a user