Files
felhom-agent/internal/pbs/pin.go
T
admin 766500dfc3 v0.6.0: slice 6 Phase B — PBS offsite tier (verify + PBS-API client + reporting)
Spike-proven that backup/restore-to-PBS reuse Phase A unchanged; the only new code is
the verify capability, a small PBS-API client, and PBSSnapshot reporting.

- internal/pbs: fingerprint-pinned, token-authed PBS-API client (Verify/Snapshots/
  TaskStatus, node-from-UPID; secret read from /etc/pve/priv/storage/<id>.pw at runtime,
  never logged) + the verify maintenance loop (own cadence, default 6h, NOT gated/journaled,
  like the watchdog) + SnapshotStore.
- hub: PBSSnapshot filled (namespace/type/id/time/size/owner/protected/encrypted/
  verify_state/verify_upid); PBSReporter collector seam; cross-repo golden + bidirectional
  key-set tests; hub handler parses pbs_snapshots + logs a failed-verify WARN.
- backup: report the ACTUAL vzdump mode (parsed from the task log; PVE may downgrade
  snapshot->stop). proxmox.Storage.Username. config PBSVerifyCadence/secret-dir.
  --selftest=pbs-verify. Backup/restore-to-PBS unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:53:04 +02:00

49 lines
1.6 KiB
Go

package pbs
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"strings"
)
// pinnedTLS builds a tls.Config that pins the PBS server's leaf cert by SHA-256 — the same
// model as the PVE client (proxmox/tls.go). PBS serves a self-signed cert, so we disable the
// default chain check but enforce an exact-cert match: a spoofed PBS presents a different
// fingerprint and is rejected. fingerprint is hex with optional colons (the form in
// /etc/pve/storage.cfg and the slice-5 durable_id).
func pinnedTLS(fingerprint string) (*tls.Config, error) {
want, err := normalizeFingerprint(fingerprint)
if err != nil {
return nil, err
}
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // replaced by the exact-cert pin below
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return fmt.Errorf("pbs: TLS pin: peer presented no certificate")
}
got := sha256.Sum256(rawCerts[0])
if hex.EncodeToString(got[:]) != want {
return fmt.Errorf("pbs: TLS pin mismatch: server cert sha256 does not match configured fingerprint")
}
return nil
},
}, nil
}
// normalizeFingerprint lowercases and strips colons/whitespace, validating a 64-char
// (32-byte) hex SHA-256.
func normalizeFingerprint(fp string) (string, error) {
s := strings.ToLower(strings.NewReplacer(":", "", " ", "", "\t", "").Replace(fp))
if len(s) != 64 {
return "", fmt.Errorf("pbs: fingerprint must be a SHA-256 (64 hex chars), got %d", len(s))
}
if _, err := hex.DecodeString(s); err != nil {
return "", fmt.Errorf("pbs: fingerprint is not valid hex: %w", err)
}
return s, nil
}