027948bf3f
Phase-1 live probe (felhom-hetzner) proved backup/restore/list/isolation over the tunnel with a per-customer DatastoreBackup token, but the agent's PBS client was namespace-unaware: Snapshots hit the datastore root (403 for a scoped token) and Verify was whole-datastore (needs Datastore.Verify ~ admin). Operator- approved fix. - pbs.Config.Namespace + Client.namespace; Snapshots appends ?ns=; Verify sends ns= (ns-scoped verify works with DatastoreBackup on the own ns — no admin widening, Phase-1 confirmed). Root-ns clients unchanged (whole-datastore). - proxmox.Storage.Namespace (parsed from /storage `namespace`). - pbsTargetsFromPVE threads s.Namespace into the client. Confirmed tenant ACL: DatastoreBackup on /datastore/felhom-offsite/<ns> (NOT /ns/<ns>) to BOTH felhom@pbs (user) AND felhom@pbs!<ns> (token) — PBS privsep = intersection; isolation holds (cross-ns 403 proven). TestClient_NamespaceScoping red-proofed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
223 lines
8.5 KiB
Go
223 lines
8.5 KiB
Go
package pbs
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fingerprintOf returns the SHA-256 hex of a TLS test server's leaf cert (what the client pins).
|
|
func fingerprintOf(ts *httptest.Server) string {
|
|
sum := sha256.Sum256(ts.Certificate().Raw)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// newPBSTestServer spins up a TLS server routing requests to fn, plus its fingerprint.
|
|
func newPBSTestServer(t *testing.T, fn http.HandlerFunc) (*httptest.Server, string) {
|
|
t.Helper()
|
|
ts := httptest.NewTLSServer(fn)
|
|
t.Cleanup(ts.Close)
|
|
return ts, fingerprintOf(ts)
|
|
}
|
|
|
|
// hostPort splits an httptest URL "https://127.0.0.1:PORT" into host + port for NewClient.
|
|
func hostPort(t *testing.T, url string) (string, int) {
|
|
t.Helper()
|
|
hp := strings.TrimPrefix(url, "https://")
|
|
host, port, ok := strings.Cut(hp, ":")
|
|
if !ok {
|
|
t.Fatalf("bad url %q", url)
|
|
}
|
|
p := 0
|
|
for _, c := range port {
|
|
p = p*10 + int(c-'0')
|
|
}
|
|
return host, p
|
|
}
|
|
|
|
// TestClient_FingerprintPinEnforced is the headline: a WRONG fingerprint is rejected; the
|
|
// RIGHT one succeeds (mirrors the slice-1 PVE pin test).
|
|
func TestClient_FingerprintPinEnforced(t *testing.T) {
|
|
ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Write([]byte(`{"data":[]}`))
|
|
})
|
|
host, port := hostPort(t, ts.URL)
|
|
|
|
// Wrong fingerprint → connection rejected at the TLS pin.
|
|
wrong := strings.Repeat("ab", 32)
|
|
bad, _ := NewClient(Config{Server: host, Port: port, Fingerprint: wrong, TokenID: "u@pbs!t", Secret: "s"})
|
|
if _, err := bad.Snapshots(context.Background(), "ds"); err == nil || !strings.Contains(err.Error(), "pin mismatch") {
|
|
t.Fatalf("wrong fingerprint must be rejected with a pin mismatch, got %v", err)
|
|
}
|
|
|
|
// Correct fingerprint → succeeds.
|
|
good, err := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := good.Snapshots(context.Background(), "ds"); err != nil {
|
|
t.Fatalf("correct fingerprint should connect: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestClient_TokenHeaderAndNeverLogged asserts the auth header is the PBS token form and that
|
|
// the secret is not exposed by the client's printed form.
|
|
func TestClient_TokenHeaderAndNeverLogged(t *testing.T) {
|
|
const secret = "super-secret-token-value-xyz"
|
|
var gotAuth string
|
|
ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
w.Write([]byte(`{"data":[]}`))
|
|
})
|
|
host, port := hostPort(t, ts.URL)
|
|
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "felhom@pbs!n100", Secret: secret})
|
|
if _, err := c.Snapshots(context.Background(), "ds"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotAuth != "PBSAPIToken=felhom@pbs!n100:"+secret {
|
|
t.Errorf("auth header = %q, want PBSAPIToken=<id>:<secret>", gotAuth)
|
|
}
|
|
// The secret lives only in the unexported authHeader and is never handed to a logger (the
|
|
// client takes no logger). The "never logged" property of the verify LOOP — which does log
|
|
// — is asserted in TestVerifyLoop_NeverLogsSecret (verify_test.go).
|
|
}
|
|
|
|
func TestClient_VerifyParsesUPID(t *testing.T) {
|
|
ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/admin/datastore/ds/verify") {
|
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
}
|
|
w.Write([]byte(`{"data":"UPID:dooplex:00034582:5269BDD7:00000005:6A282176:verify:ds:felhom@pbs!n100:"}`))
|
|
})
|
|
host, port := hostPort(t, ts.URL)
|
|
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"})
|
|
upid, err := c.Verify(context.Background(), "ds")
|
|
if err != nil || !strings.HasPrefix(upid, "UPID:dooplex:") {
|
|
t.Fatalf("Verify upid=%q err=%v", upid, err)
|
|
}
|
|
}
|
|
|
|
func TestClient_SnapshotsMapToHub(t *testing.T) {
|
|
body := `{"data":[
|
|
{"backup-type":"ct","backup-id":"9001","backup-time":1781014713,"size":2518889256,
|
|
"owner":"felhom@pbs!n100","protected":false,
|
|
"verification":{"state":"ok","upid":"UPID:dooplex:..:verify:ds:u:"},
|
|
"files":[{"filename":"pct.conf.blob","crypt-mode":"encrypt","size":240},
|
|
{"filename":"index.json.blob","crypt-mode":"sign-only","size":646}]},
|
|
{"backup-type":"ct","backup-id":"9002","backup-time":1781000000,"size":10,"owner":"x",
|
|
"files":[{"filename":"root.pxar.didx","crypt-mode":"encrypt","size":10}]}
|
|
]}`
|
|
ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(body)) })
|
|
host, port := hostPort(t, ts.URL)
|
|
c, _ := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "u@pbs!t", Secret: "s"})
|
|
snaps, err := c.Snapshots(context.Background(), "ds")
|
|
if err != nil || len(snaps) != 2 {
|
|
t.Fatalf("snaps=%d err=%v", len(snaps), err)
|
|
}
|
|
|
|
// First: verified ok, encrypted, RFC3339 time, default namespace.
|
|
h0 := snaps[0].ToHub()
|
|
if h0.VerifyState != VerifyOK || !h0.Encrypted || h0.Namespace != "root" {
|
|
t.Errorf("h0 = %+v, want verify=ok encrypted=true ns=root", h0)
|
|
}
|
|
if h0.BackupTime != "2026-06-09T14:18:33Z" {
|
|
t.Errorf("backup_time = %q, want RFC3339 UTC", h0.BackupTime)
|
|
}
|
|
// Second: NO verification field → verify_state "none".
|
|
if h1 := snaps[1].ToHub(); h1.VerifyState != VerifyNone {
|
|
t.Errorf("absent verification must map to %q, got %q", VerifyNone, h1.VerifyState)
|
|
}
|
|
}
|
|
|
|
func TestNodeFromUPID(t *testing.T) {
|
|
cases := map[string]string{
|
|
"UPID:dooplex:00034582:5269BDD7:00000005:6A282176:verify:ds:u:": "dooplex",
|
|
"UPID:demo-felhom:00:00:00:00:vzdump:9001:root@pam:": "demo-felhom",
|
|
"not-a-upid": "",
|
|
"": "",
|
|
}
|
|
for in, want := range cases {
|
|
if got := NodeFromUPID(in); got != want {
|
|
t.Errorf("NodeFromUPID(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeFingerprint(t *testing.T) {
|
|
if _, err := normalizeFingerprint("3b:95:5a"); err == nil {
|
|
t.Error("short fingerprint must error")
|
|
}
|
|
if _, err := normalizeFingerprint(strings.Repeat("g", 64)); err == nil {
|
|
t.Error("non-hex must error")
|
|
}
|
|
got, err := normalizeFingerprint("3B:95:" + strings.Repeat("a", 60))
|
|
if err != nil || strings.Contains(got, ":") || got != strings.ToLower(got) {
|
|
t.Errorf("normalize = %q err=%v (want lowercased, colons stripped)", got, err)
|
|
}
|
|
}
|
|
|
|
// TestClient_NamespaceScoping pins the S4 per-customer tenancy behavior: a namespace-scoped client
|
|
// lists ONLY its namespace (snapshots ?ns=) and verifies ONLY its namespace (verify ns=), so a
|
|
// DatastoreBackup token never touches the datastore root; a root client (no namespace) sends
|
|
// neither — whole-datastore behavior preserved (the DooPlex felhom-pbs path). Red-proof: drop the
|
|
// `if c.namespace != ""` guard in Snapshots/Verify and the scoped assertions fail.
|
|
func TestClient_NamespaceScoping(t *testing.T) {
|
|
var gotSnapQuery, gotVerifyNS string
|
|
ts, fp := newPBSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.Contains(r.URL.Path, "/snapshots"):
|
|
gotSnapQuery = r.URL.RawQuery
|
|
w.Write([]byte(`{"data":[]}`))
|
|
case strings.Contains(r.URL.Path, "/verify"):
|
|
r.ParseForm()
|
|
gotVerifyNS = r.PostFormValue("ns")
|
|
w.Write([]byte(`{"data":"UPID:node:1:2:3:4:verify:ds:felhom@pbs!demo:"}`))
|
|
default:
|
|
w.Write([]byte(`{"data":[]}`))
|
|
}
|
|
})
|
|
host, port := hostPort(t, ts.URL)
|
|
ctx := context.Background()
|
|
|
|
// Namespace-scoped tenant client.
|
|
scoped, err := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "felhom@pbs!demo-felhom-01", Secret: "s", Namespace: "demo-felhom-01"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := scoped.Snapshots(ctx, "felhom-offsite"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotSnapQuery != "ns=demo-felhom-01" {
|
|
t.Errorf("scoped snapshots query = %q, want ns=demo-felhom-01", gotSnapQuery)
|
|
}
|
|
if _, err := scoped.Verify(ctx, "felhom-offsite"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotVerifyNS != "demo-felhom-01" {
|
|
t.Errorf("scoped verify ns = %q, want demo-felhom-01", gotVerifyNS)
|
|
}
|
|
|
|
// Root client: no namespace → whole-datastore, no ns on either call.
|
|
gotSnapQuery, gotVerifyNS = "SENTINEL", "SENTINEL"
|
|
root, err := NewClient(Config{Server: host, Port: port, Fingerprint: fp, TokenID: "felhom@pbs!n100", Secret: "s"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := root.Snapshots(ctx, "felhom-spike"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotSnapQuery != "" {
|
|
t.Errorf("root snapshots query = %q, want empty (no ns → whole datastore)", gotSnapQuery)
|
|
}
|
|
if _, err := root.Verify(ctx, "felhom-spike"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotVerifyNS != "" {
|
|
t.Errorf("root verify ns = %q, want empty (no ns → whole datastore)", gotVerifyNS)
|
|
}
|
|
}
|