slice 8A (agent half): local-API server + provisioning back-half (v0.10.0)
internal/localapi: per-guest local-API server (doc 03 §6) — 7 self-scoped endpoints, hashed per-guest token store, persisted self-signed leaf with stable SHA-256 pin, optional 6th daemon goroutine. internal/provision: back-half — mint token, render bootstrap.json (no registry cred), write 0600, chown 100000:100000, attach pct-set bind mount (host-side, F3, no pct exec). --selftest=provision. build-golden.sh bakes the controller image + bootstrap unit. sudoers FELHOM_PROVISION; firewall narrowing artifact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// certValidity is the self-signed leaf lifetime. It is long because the leaf's SHA-256
|
||||
// fingerprint is baked into every guest's bootstrap for pinning — rotating the cert means
|
||||
// re-issuing bootstraps, so this is deliberately decoupled from short-lived TLS norms. Cert
|
||||
// rotation is an operator action (slice 10+), not an automatic expiry event.
|
||||
const certValidity = 10 * 365 * 24 * time.Hour
|
||||
|
||||
// EnsureLeaf loads the agent's local-API leaf from certPath/keyPath, generating and persisting
|
||||
// a fresh self-signed ECDSA-P256 leaf (SAN = bridgeHost, when it is an IP/host) if either file
|
||||
// is absent. It returns the tls.Certificate to serve and the leaf's SHA-256 fingerprint (the
|
||||
// agent's pin convention: lowercase hex of the leaf DER) for baking into bootstraps.
|
||||
//
|
||||
// Persisting the generated pair keeps the fingerprint STABLE across agent restarts — a fresh
|
||||
// cert each boot would silently invalidate every already-issued bootstrap's pin.
|
||||
func EnsureLeaf(certPath, keyPath, bridgeHost string) (tls.Certificate, string, error) {
|
||||
if fileExists(certPath) && fileExists(keyPath) {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: load leaf %s: %w", certPath, err)
|
||||
}
|
||||
fp, err := leafFingerprint(cert)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", err
|
||||
}
|
||||
return cert, fp, nil
|
||||
}
|
||||
return generateLeaf(certPath, keyPath, bridgeHost)
|
||||
}
|
||||
|
||||
// generateLeaf creates a self-signed ECDSA-P256 leaf, writes the cert (0644) + key (0600) to
|
||||
// disk, and returns the loaded pair + its fingerprint.
|
||||
func generateLeaf(certPath, keyPath, bridgeHost string) (tls.Certificate, string, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: gen key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: gen serial: %w", err)
|
||||
}
|
||||
notBefore := time.Now().Add(-1 * time.Hour) // small backdate for clock skew
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "felhom-agent local-api"},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notBefore.Add(certValidity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
// SAN: the bridge host (an IP in practice). The controller pins the leaf SHA-256, so the
|
||||
// SAN is not load-bearing for trust, but a correct SAN keeps standard tooling happy.
|
||||
if host := bridgeHost; host != "" {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
|
||||
} else {
|
||||
tmpl.DNSNames = append(tmpl.DNSNames, host)
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: create cert: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: marshal key: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: cert dir: %w", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: write cert: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: write key: %w", err)
|
||||
}
|
||||
syncDir(filepath.Dir(certPath))
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("localapi: reload generated leaf: %w", err)
|
||||
}
|
||||
fp, err := leafFingerprint(cert)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", err
|
||||
}
|
||||
return cert, fp, nil
|
||||
}
|
||||
|
||||
// leafFingerprint returns the lowercase-hex SHA-256 of the leaf certificate DER — the same pin
|
||||
// convention the agent uses for the Proxmox/PBS host certs.
|
||||
func leafFingerprint(cert tls.Certificate) (string, error) {
|
||||
if len(cert.Certificate) == 0 {
|
||||
return "", fmt.Errorf("localapi: certificate has no leaf")
|
||||
}
|
||||
sum := sha256.Sum256(cert.Certificate[0]) // Certificate[0] is the leaf DER
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The leaf is generated once and its fingerprint stays STABLE across "restarts" (re-loads from
|
||||
// disk) — a fresh cert each boot would invalidate every already-baked bootstrap pin.
|
||||
func TestEnsureLeaf_StableFingerprintAcrossReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "leaf.crt")
|
||||
keyPath := filepath.Join(dir, "leaf.key")
|
||||
|
||||
cert1, fp1, err := EnsureLeaf(certPath, keyPath, "192.168.0.162")
|
||||
if err != nil {
|
||||
t.Fatalf("first ensure: %v", err)
|
||||
}
|
||||
cert2, fp2, err := EnsureLeaf(certPath, keyPath, "192.168.0.162")
|
||||
if err != nil {
|
||||
t.Fatalf("second ensure: %v", err)
|
||||
}
|
||||
if fp1 != fp2 {
|
||||
t.Fatalf("fingerprint changed across reload: %s != %s", fp1, fp2)
|
||||
}
|
||||
// The reported fingerprint must equal the SHA-256 of the served leaf DER (the pin the
|
||||
// controller checks against).
|
||||
got := sha256.Sum256(cert2.Certificate[0])
|
||||
if hex.EncodeToString(got[:]) != fp2 {
|
||||
t.Fatal("reported fingerprint does not match the served leaf DER")
|
||||
}
|
||||
if len(fp1) != 64 {
|
||||
t.Fatalf("fingerprint is not a 64-hex SHA-256: %q", fp1)
|
||||
}
|
||||
_ = cert1
|
||||
}
|
||||
|
||||
// The generated leaf is a usable TLS server cert whose presented leaf matches the pin.
|
||||
func TestEnsureLeaf_ServesPinnableLeaf(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cert, fp, err := EnsureLeaf(filepath.Join(dir, "c"), filepath.Join(dir, "k"), "10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
if len(cert.Certificate) == 0 {
|
||||
t.Fatal("no leaf in cert chain")
|
||||
}
|
||||
if cert.PrivateKey == nil {
|
||||
t.Fatal("generated cert has no private key")
|
||||
}
|
||||
sum := sha256.Sum256(cert.Certificate[0])
|
||||
if hex.EncodeToString(sum[:]) != fp {
|
||||
t.Fatal("pin mismatch against served leaf")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
)
|
||||
|
||||
// GuestAPI is the narrow Proxmox surface the local API needs. Satisfied by *proxmox.Client.
|
||||
// Every method here is invoked ONLY with the VMID resolved from the caller's token — never a
|
||||
// caller-supplied id — so the proxmox op is structurally self-scoped.
|
||||
type GuestAPI interface {
|
||||
GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error)
|
||||
Snapshot(ctx context.Context, vmid int, snapname, description string) (string, error)
|
||||
Rollback(ctx context.Context, vmid int, snapname string) (string, error)
|
||||
WaitTask(ctx context.Context, upid string, opts proxmox.WaitOptions) (proxmox.TaskStatus, error)
|
||||
}
|
||||
|
||||
// BackupService enqueues a (crash-consistent) vzdump/PBS backup of a guest. Satisfied by
|
||||
// *backup.BackupRunner. The app-consistent quiesce path is 8B.
|
||||
type BackupService interface {
|
||||
Backup(ctx context.Context, vmid int) (hub.Backup, error)
|
||||
}
|
||||
|
||||
// BackupStore records + reads the latest backup/restore-test state. Satisfied by *backup.Store.
|
||||
type BackupStore interface {
|
||||
RecordBackup(hub.Backup)
|
||||
Backups(ctx context.Context) []hub.Backup
|
||||
RestoreTests(ctx context.Context) []hub.RestoreTest
|
||||
}
|
||||
|
||||
// StorageView yields the host's observed storage targets (for mapping a mount's storage id →
|
||||
// fast/slow class). Satisfied by *storage.Observer.
|
||||
type StorageView interface {
|
||||
Observe(ctx context.Context) ([]hub.StorageTarget, error)
|
||||
}
|
||||
|
||||
// TokenAuthority resolves a presented bearer token to its guest VMID. Satisfied by *TokenStore.
|
||||
type TokenAuthority interface {
|
||||
Lookup(token string) (int, bool)
|
||||
}
|
||||
|
||||
// Options configures a Server.
|
||||
type Options struct {
|
||||
ListenAddr string // bridge IP:port
|
||||
Cert tls.Certificate
|
||||
Guests GuestAPI
|
||||
Backups BackupService
|
||||
Store BackupStore
|
||||
Storage StorageView
|
||||
Tokens TokenAuthority
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Server is the per-guest local API (doc 03 §6). It serves the agent's pinned self-signed leaf
|
||||
// and authorizes every request against the token's guest only.
|
||||
type Server struct {
|
||||
addr string
|
||||
cert tls.Certificate
|
||||
guests GuestAPI
|
||||
backups BackupService
|
||||
store BackupStore
|
||||
storage StorageView
|
||||
tokens TokenAuthority
|
||||
logger *slog.Logger
|
||||
|
||||
baseCtx context.Context // for fire-and-forget backups; set in Run
|
||||
}
|
||||
|
||||
// NewServer builds a Server. It does not bind a socket until Run.
|
||||
func NewServer(o Options) (*Server, error) {
|
||||
if o.ListenAddr == "" {
|
||||
return nil, fmt.Errorf("localapi: listen addr required")
|
||||
}
|
||||
if o.Guests == nil || o.Backups == nil || o.Store == nil || o.Storage == nil || o.Tokens == nil {
|
||||
return nil, fmt.Errorf("localapi: all dependencies (guests, backups, store, storage, tokens) are required")
|
||||
}
|
||||
if o.Logger == nil {
|
||||
o.Logger = slog.Default()
|
||||
}
|
||||
return &Server{
|
||||
addr: o.ListenAddr,
|
||||
cert: o.Cert,
|
||||
guests: o.Guests,
|
||||
backups: o.Backups,
|
||||
store: o.Store,
|
||||
storage: o.Storage,
|
||||
tokens: o.Tokens,
|
||||
logger: o.Logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handler builds the routed mux (exposed for tests via httptest).
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /storage", s.withGuest(s.handleStorage))
|
||||
mux.HandleFunc("POST /snapshot", s.withGuest(s.handleSnapshot))
|
||||
mux.HandleFunc("POST /rollback", s.withGuest(s.handleRollback))
|
||||
mux.HandleFunc("POST /backup", s.withGuest(s.handleBackup))
|
||||
mux.HandleFunc("GET /backup/due", s.withGuest(s.handleBackupDue))
|
||||
mux.HandleFunc("GET /backup/status", s.withGuest(s.handleBackupStatus))
|
||||
mux.HandleFunc("GET /restore-test/status", s.withGuest(s.handleRestoreTestStatus))
|
||||
return mux
|
||||
}
|
||||
|
||||
// Run binds the bridge socket, serves TLS, and shuts down gracefully on ctx cancellation. It
|
||||
// returns nil on a clean shutdown (mirrors the other daemon loops' ctx-cancel contract).
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
s.baseCtx = ctx
|
||||
srv := &http.Server{
|
||||
Addr: s.addr,
|
||||
Handler: s.Handler(),
|
||||
TLSConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{s.cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
ln, err := net.Listen("tcp", s.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("localapi: bind %s: %w", s.addr, err)
|
||||
}
|
||||
s.logger.Info("local-api server listening", "addr", s.addr)
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- srv.ServeTLS(ln, "", "") }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
return nil
|
||||
case err := <-errc:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ---- auth + self-scoping ----------------------------------------------------------------
|
||||
|
||||
// withGuest wraps a handler with bearer auth and self-scoping: it resolves the token → VMID
|
||||
// (401 on absent/unknown), and refuses any explicit `vmid` that disagrees with the token's
|
||||
// guest (403, cross-guest). The wrapped handler is invoked ONLY with the token's VMID, so a
|
||||
// proxmox op is never issued for another guest. Self-scoping is by the token→guest map; a
|
||||
// caller-supplied id is only ever a consistency check, never the authority.
|
||||
func (s *Server) withGuest(fn func(w http.ResponseWriter, r *http.Request, vmid int)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tok := bearer(r)
|
||||
if tok == "" {
|
||||
writeErr(w, http.StatusUnauthorized, "missing bearer token")
|
||||
return
|
||||
}
|
||||
vmid, ok := s.tokens.Lookup(tok)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unknown token")
|
||||
return
|
||||
}
|
||||
// Cross-guest probe via an explicit query vmid → 403 (the op is not run).
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("vmid")); q != "" {
|
||||
if want, err := strconv.Atoi(q); err != nil || want != vmid {
|
||||
s.logger.Warn("local-api: cross-guest request refused",
|
||||
"token_guest", vmid, "requested", q, "path", r.URL.Path)
|
||||
writeErr(w, http.StatusForbidden, "token is not scoped to that guest")
|
||||
return
|
||||
}
|
||||
}
|
||||
fn(w, r, vmid)
|
||||
}
|
||||
}
|
||||
|
||||
// bearer extracts the Authorization: Bearer <token> value.
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const p = "Bearer "
|
||||
if len(h) > len(p) && strings.EqualFold(h[:len(p)], p) {
|
||||
return strings.TrimSpace(h[len(p):])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scopedFromBody enforces self-scoping for a POST body that may carry an explicit vmid: a
|
||||
// non-zero body vmid that disagrees with the token's guest → 403 (false return; caller stops).
|
||||
func (s *Server) scopedFromBody(w http.ResponseWriter, bodyVMID, tokenVMID int, path string) bool {
|
||||
if bodyVMID != 0 && bodyVMID != tokenVMID {
|
||||
s.logger.Warn("local-api: cross-guest request refused (body)",
|
||||
"token_guest", tokenVMID, "requested", bodyVMID, "path", path)
|
||||
writeErr(w, http.StatusForbidden, "token is not scoped to that guest")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- handlers ---------------------------------------------------------------------------
|
||||
|
||||
// MountInfo is one of the guest's attached mountpoints with its placement class.
|
||||
type MountInfo struct {
|
||||
Key string `json:"key"` // mp0, mp1, …
|
||||
Storage string `json:"storage"` // PVE storage id
|
||||
MountPoint string `json:"mount_point"` // in-guest path
|
||||
Class string `json:"class"` // fast | slow | "" (unknown) — from the host storage view
|
||||
Backup bool `json:"backup"` // included in vzdump (backup=1)
|
||||
}
|
||||
|
||||
// StorageResponse is GET /storage — this guest's mounts + class so the controller can place
|
||||
// hot vs bulk volumes per .felhom.yml.
|
||||
type StorageResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Mounts []MountInfo `json:"mounts"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStorage(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
cfg, err := s.guests.GuestConfig(r.Context(), vmid)
|
||||
if err != nil {
|
||||
s.logger.Error("local-api: /storage guest config", "vmid", vmid, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "could not read guest config")
|
||||
return
|
||||
}
|
||||
classByStore := s.classByStorage(r.Context())
|
||||
mounts := make([]MountInfo, 0)
|
||||
for key, val := range cfg.MountPoints() {
|
||||
store, mp, backup := parseMount(val)
|
||||
mounts = append(mounts, MountInfo{
|
||||
Key: key, Storage: store, MountPoint: mp,
|
||||
Class: classByStore[store], Backup: backup,
|
||||
})
|
||||
}
|
||||
writeOK(w, StorageResponse{VMID: vmid, Mounts: mounts})
|
||||
}
|
||||
|
||||
type snapshotRequest struct {
|
||||
VMID int `json:"vmid"` // optional; must match the token's guest if set
|
||||
Snapname string `json:"snapname"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
var req snapshotRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
||||
return
|
||||
}
|
||||
snap := strings.TrimSpace(req.Snapname)
|
||||
if snap == "" {
|
||||
snap = "felhom-" + strconv.FormatInt(time.Now().UTC().Unix(), 10)
|
||||
}
|
||||
if !validSnapname(snap) {
|
||||
writeErr(w, http.StatusBadRequest, "invalid snapname (allowed: letters, digits, '_', '-')")
|
||||
return
|
||||
}
|
||||
upid, err := s.guests.Snapshot(r.Context(), vmid, snap, req.Description)
|
||||
if err != nil {
|
||||
s.logger.Error("local-api: snapshot", "vmid", vmid, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "snapshot failed")
|
||||
return
|
||||
}
|
||||
if _, err := s.guests.WaitTask(r.Context(), upid, proxmox.WaitOptions{}); err != nil {
|
||||
s.logger.Error("local-api: snapshot task", "vmid", vmid, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "snapshot task failed")
|
||||
return
|
||||
}
|
||||
writeOK(w, map[string]any{"vmid": vmid, "snapname": snap})
|
||||
}
|
||||
|
||||
type rollbackRequest struct {
|
||||
VMID int `json:"vmid"`
|
||||
Snapname string `json:"snapname"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
var req rollbackRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
||||
return
|
||||
}
|
||||
snap := strings.TrimSpace(req.Snapname)
|
||||
if snap == "" || !validSnapname(snap) {
|
||||
writeErr(w, http.StatusBadRequest, "snapname is required (letters, digits, '_', '-')")
|
||||
return
|
||||
}
|
||||
upid, err := s.guests.Rollback(r.Context(), vmid, snap)
|
||||
if err != nil {
|
||||
s.logger.Error("local-api: rollback", "vmid", vmid, "snap", snap, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "rollback failed")
|
||||
return
|
||||
}
|
||||
if _, err := s.guests.WaitTask(r.Context(), upid, proxmox.WaitOptions{}); err != nil {
|
||||
s.logger.Error("local-api: rollback task", "vmid", vmid, "err", err)
|
||||
writeErr(w, http.StatusBadGateway, "rollback task failed")
|
||||
return
|
||||
}
|
||||
writeOK(w, map[string]any{"vmid": vmid, "rolled_back_to": snap})
|
||||
}
|
||||
|
||||
type backupRequest struct {
|
||||
VMID int `json:"vmid"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
// Body is optional; if present its vmid must match the token's guest.
|
||||
if r.ContentLength != 0 {
|
||||
var req backupRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Enqueue: a vzdump runs for minutes, so we do not block the request. The backup runs on
|
||||
// the server's base context (cancelled on daemon shutdown) and records into the store; the
|
||||
// controller polls GET /backup/status. This is the crash-consistent path (8A); the
|
||||
// app-consistent quiesce-then-backup loop is 8B.
|
||||
base := s.baseCtx
|
||||
if base == nil {
|
||||
base = context.Background()
|
||||
}
|
||||
go func() {
|
||||
bctx, cancel := context.WithTimeout(base, 2*time.Hour)
|
||||
defer cancel()
|
||||
b, err := s.backups.Backup(bctx, vmid)
|
||||
if err != nil {
|
||||
b.VMID = vmid
|
||||
b.Success = false
|
||||
if b.Error == "" {
|
||||
b.Error = err.Error()
|
||||
}
|
||||
s.logger.Error("local-api: enqueued backup failed", "vmid", vmid, "err", err)
|
||||
} else {
|
||||
s.logger.Info("local-api: enqueued backup complete", "vmid", vmid, "archive", b.Archive)
|
||||
}
|
||||
s.store.RecordBackup(b)
|
||||
}()
|
||||
writeStatus(w, http.StatusAccepted, true, map[string]any{"vmid": vmid, "enqueued": true}, "")
|
||||
}
|
||||
|
||||
// BackupDueResponse is GET /backup/due. Thin in 8A: a guest with no successful backup recorded
|
||||
// is "due"; otherwise not. Policy-scheduled cadence (hub manifest) lands in slice 10, and the
|
||||
// quiesce-on-due consumer is 8B — both noted in the response.
|
||||
type BackupDueResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Due bool `json:"due"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBackupDue(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
latest := s.latestBackupFor(r.Context(), vmid)
|
||||
if latest == nil || !latest.Success {
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: true, Reason: "no successful backup recorded yet"})
|
||||
return
|
||||
}
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: false, Reason: "policy-scheduled cadence lands in slice 10"})
|
||||
}
|
||||
|
||||
func (s *Server) handleBackupStatus(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
latest := s.latestBackupFor(r.Context(), vmid)
|
||||
writeOK(w, map[string]any{"vmid": vmid, "backup": latest})
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreTestStatus(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
// The self-restore-test is host-level (it tests the newest backup in a throwaway scratch),
|
||||
// not per-guest — the controller surfaces it in its UI. Return the latest (0 or 1).
|
||||
tests := s.store.RestoreTests(r.Context())
|
||||
var latest *hub.RestoreTest
|
||||
if len(tests) > 0 {
|
||||
latest = &tests[0]
|
||||
}
|
||||
writeOK(w, map[string]any{"restore_test": latest})
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------------------
|
||||
|
||||
// latestBackupFor returns this guest's most recent backup from the store (nil if none).
|
||||
func (s *Server) latestBackupFor(ctx context.Context, vmid int) *hub.Backup {
|
||||
var latest *hub.Backup
|
||||
for _, b := range s.store.Backups(ctx) {
|
||||
if b.VMID != vmid {
|
||||
continue
|
||||
}
|
||||
bb := b
|
||||
if latest == nil || bb.StartedAt > latest.StartedAt { // RFC3339 sorts lexically
|
||||
latest = &bb
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
// classByStorage builds a storage-id → class(fast|slow|"") map from the host storage view. A
|
||||
// view error is logged and yields an empty map (class falls back to "" — a hint, not load-bearing).
|
||||
func (s *Server) classByStorage(ctx context.Context) map[string]string {
|
||||
out := map[string]string{}
|
||||
targets, err := s.storage.Observe(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("local-api: storage view unavailable for class hints", "err", err)
|
||||
return out
|
||||
}
|
||||
for _, t := range targets {
|
||||
out[t.Name] = t.ClassHint
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseMount splits a PVE mpN value like "local-lvm:1,mp=/mnt/bulk,backup=0" into its storage
|
||||
// id, in-guest mount path, and backup flag.
|
||||
func parseMount(val string) (storage, mountPoint string, backup bool) {
|
||||
parts := strings.Split(val, ",")
|
||||
if len(parts) > 0 {
|
||||
if i := strings.IndexByte(parts[0], ':'); i >= 0 {
|
||||
storage = parts[0][:i]
|
||||
} else {
|
||||
storage = parts[0]
|
||||
}
|
||||
}
|
||||
for _, p := range parts[1:] {
|
||||
switch {
|
||||
case strings.HasPrefix(p, "mp="):
|
||||
mountPoint = strings.TrimPrefix(p, "mp=")
|
||||
case strings.HasPrefix(p, "backup="):
|
||||
backup = strings.TrimPrefix(p, "backup=") == "1"
|
||||
}
|
||||
}
|
||||
return storage, mountPoint, backup
|
||||
}
|
||||
|
||||
// validSnapname allows PVE-safe snapshot names (letters, digits, '_' and '-').
|
||||
func validSnapname(s string) bool {
|
||||
if s == "" || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if r.Body == nil || r.ContentLength == 0 {
|
||||
return true // empty body is allowed; fields stay zero
|
||||
}
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- response envelope (matches the controller's {ok,data,error} style) -----------------
|
||||
|
||||
type apiResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func writeOK(w http.ResponseWriter, data any) {
|
||||
writeStatus(w, http.StatusOK, true, data, "")
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||
writeStatus(w, code, false, nil, msg)
|
||||
}
|
||||
|
||||
func writeStatus(w http.ResponseWriter, code int, ok bool, data any, errMsg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(apiResponse{OK: ok, Data: data, Error: errMsg})
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
)
|
||||
|
||||
// ---- fakes ------------------------------------------------------------------------------
|
||||
|
||||
// fakeGuests records every VMID it was called with, so a test can assert an op was NOT issued
|
||||
// for a guest the caller is not scoped to.
|
||||
type fakeGuests struct {
|
||||
mu sync.Mutex
|
||||
snapVMIDs []int
|
||||
rbVMIDs []int
|
||||
cfgVMIDs []int
|
||||
mounts map[string]string // mpN -> value, returned by GuestConfig
|
||||
failSnap bool
|
||||
}
|
||||
|
||||
func (f *fakeGuests) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) {
|
||||
f.mu.Lock()
|
||||
f.cfgVMIDs = append(f.cfgVMIDs, vmid)
|
||||
f.mu.Unlock()
|
||||
extra := map[string]json.RawMessage{}
|
||||
for k, v := range f.mounts {
|
||||
b, _ := json.Marshal(v)
|
||||
extra[k] = b
|
||||
}
|
||||
return proxmox.GuestConfig{Extra: extra}, nil
|
||||
}
|
||||
|
||||
func (f *fakeGuests) Snapshot(_ context.Context, vmid int, _, _ string) (string, error) {
|
||||
f.mu.Lock()
|
||||
f.snapVMIDs = append(f.snapVMIDs, vmid)
|
||||
f.mu.Unlock()
|
||||
if f.failSnap {
|
||||
return "", fmt.Errorf("boom")
|
||||
}
|
||||
return "UPID:snap", nil
|
||||
}
|
||||
|
||||
func (f *fakeGuests) Rollback(_ context.Context, vmid int, _ string) (string, error) {
|
||||
f.mu.Lock()
|
||||
f.rbVMIDs = append(f.rbVMIDs, vmid)
|
||||
f.mu.Unlock()
|
||||
return "UPID:rb", nil
|
||||
}
|
||||
|
||||
func (f *fakeGuests) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) {
|
||||
return proxmox.TaskStatus{ExitStatus: "OK"}, nil
|
||||
}
|
||||
|
||||
type fakeBackups struct {
|
||||
mu sync.Mutex
|
||||
vmids []int
|
||||
}
|
||||
|
||||
func (f *fakeBackups) Backup(_ context.Context, vmid int) (hub.Backup, error) {
|
||||
f.mu.Lock()
|
||||
f.vmids = append(f.vmids, vmid)
|
||||
f.mu.Unlock()
|
||||
return hub.Backup{VMID: vmid, Success: true, Archive: "local:backup/vzdump-x", StartedAt: "2026-06-10T00:00:00Z"}, nil
|
||||
}
|
||||
func (f *fakeBackups) called() []int { f.mu.Lock(); defer f.mu.Unlock(); return append([]int(nil), f.vmids...) }
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
backups []hub.Backup
|
||||
tests []hub.RestoreTest
|
||||
}
|
||||
|
||||
func (s *fakeStore) RecordBackup(b hub.Backup) { s.mu.Lock(); s.backups = append(s.backups, b); s.mu.Unlock() }
|
||||
func (s *fakeStore) Backups(context.Context) []hub.Backup {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]hub.Backup(nil), s.backups...)
|
||||
}
|
||||
func (s *fakeStore) RestoreTests(context.Context) []hub.RestoreTest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]hub.RestoreTest(nil), s.tests...)
|
||||
}
|
||||
|
||||
type fakeStorage struct{ targets []hub.StorageTarget }
|
||||
|
||||
func (f fakeStorage) Observe(context.Context) ([]hub.StorageTarget, error) { return f.targets, nil }
|
||||
|
||||
// staticTokens is a fixed token→guest map for server tests (the durable store is tested
|
||||
// separately). Token "A" → guest 8200, token "B" → guest 9300.
|
||||
type staticTokens map[string]int
|
||||
|
||||
func (m staticTokens) Lookup(tok string) (int, bool) { v, ok := m[tok]; return v, ok }
|
||||
|
||||
// ---- harness ----------------------------------------------------------------------------
|
||||
|
||||
func newTestServer(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) http.Handler {
|
||||
t.Helper()
|
||||
if sv == nil {
|
||||
sv = fakeStorage{}
|
||||
}
|
||||
srv, err := NewServer(Options{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Guests: g,
|
||||
Backups: b,
|
||||
Store: st,
|
||||
Storage: sv,
|
||||
Tokens: staticTokens{"A": 8200, "B": 9300},
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
srv.baseCtx = context.Background()
|
||||
return srv.Handler()
|
||||
}
|
||||
|
||||
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var r *http.Request
|
||||
if body != "" {
|
||||
r = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// ---- auth -------------------------------------------------------------------------------
|
||||
|
||||
func TestAuth_AbsentAndWrongToken(t *testing.T) {
|
||||
g := &fakeGuests{}
|
||||
h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil)
|
||||
|
||||
if w := do(t, h, "GET", "/storage", "", ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("absent token: got %d, want 401", w.Code)
|
||||
}
|
||||
if w := do(t, h, "GET", "/storage", "nope", ""); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong token: got %d, want 401", w.Code)
|
||||
}
|
||||
if len(g.cfgVMIDs) != 0 {
|
||||
t.Fatal("guest config was read despite failed auth")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- self-scoping: the headline security test -------------------------------------------
|
||||
|
||||
// A token for guest A (8200) cannot snapshot/rollback/backup guest B (9300): the agent refuses
|
||||
// with 403 and the proxmox op is NEVER issued for B.
|
||||
func TestSelfScoping_CrossGuestRefused(t *testing.T) {
|
||||
g := &fakeGuests{}
|
||||
b := &fakeBackups{}
|
||||
h := newTestServer(t, g, b, &fakeStore{}, nil)
|
||||
|
||||
// token A targets guest B via explicit body vmid → 403, op not called.
|
||||
if w := do(t, h, "POST", "/snapshot", "A", `{"vmid":9300,"snapname":"x"}`); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-guest snapshot: got %d, want 403", w.Code)
|
||||
}
|
||||
if w := do(t, h, "POST", "/rollback", "A", `{"vmid":9300,"snapname":"x"}`); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-guest rollback: got %d, want 403", w.Code)
|
||||
}
|
||||
if w := do(t, h, "POST", "/backup", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-guest backup: got %d, want 403", w.Code)
|
||||
}
|
||||
// token A targets guest B via explicit query vmid → 403.
|
||||
if w := do(t, h, "GET", "/storage?vmid=9300", "A", ""); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-guest storage query: got %d, want 403", w.Code)
|
||||
}
|
||||
|
||||
if len(g.snapVMIDs) != 0 || len(g.rbVMIDs) != 0 {
|
||||
t.Fatalf("a guest op was issued on a cross-guest request: snaps=%v rb=%v", g.snapVMIDs, g.rbVMIDs)
|
||||
}
|
||||
// the backup goroutine must never have started for B
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if called := b.called(); len(called) != 0 {
|
||||
t.Fatalf("backup issued on a cross-guest request: %v", called)
|
||||
}
|
||||
}
|
||||
|
||||
// Own-guest ops call the proxmox op for the CORRECT VMID (the token's guest), even when the
|
||||
// caller names its own guest explicitly.
|
||||
func TestSelfScoping_OwnGuestUsesTokenVMID(t *testing.T) {
|
||||
g := &fakeGuests{}
|
||||
h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil)
|
||||
|
||||
if w := do(t, h, "POST", "/snapshot", "A", `{"snapname":"pre-deploy"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("own snapshot: got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if w := do(t, h, "POST", "/snapshot", "B", `{"vmid":9300,"snapname":"ok"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("own snapshot (explicit matching vmid): got %d", w.Code)
|
||||
}
|
||||
if len(g.snapVMIDs) != 2 || g.snapVMIDs[0] != 8200 || g.snapVMIDs[1] != 9300 {
|
||||
t.Fatalf("snapshot used wrong VMIDs: %v", g.snapVMIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- endpoints --------------------------------------------------------------------------
|
||||
|
||||
func TestStorage_ReturnsOnlyThisGuestMountsWithClass(t *testing.T) {
|
||||
g := &fakeGuests{mounts: map[string]string{
|
||||
"mp0": "fastpool:8,mp=/var/lib/docker,backup=1",
|
||||
"mp1": "bulk:200,mp=/mnt/media,backup=0",
|
||||
}}
|
||||
sv := fakeStorage{targets: []hub.StorageTarget{
|
||||
{Name: "fastpool", ClassHint: "fast"},
|
||||
{Name: "bulk", ClassHint: "slow"},
|
||||
}}
|
||||
h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, sv)
|
||||
|
||||
w := do(t, h, "GET", "/storage", "A", "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("storage: got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data StorageResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Data.VMID != 8200 {
|
||||
t.Fatalf("vmid: got %d want 8200", resp.Data.VMID)
|
||||
}
|
||||
byKey := map[string]MountInfo{}
|
||||
for _, m := range resp.Data.Mounts {
|
||||
byKey[m.Key] = m
|
||||
}
|
||||
if byKey["mp0"].Class != "fast" || byKey["mp0"].Storage != "fastpool" || byKey["mp0"].MountPoint != "/var/lib/docker" || !byKey["mp0"].Backup {
|
||||
t.Fatalf("mp0 wrong: %+v", byKey["mp0"])
|
||||
}
|
||||
if byKey["mp1"].Class != "slow" || byKey["mp1"].Backup {
|
||||
t.Fatalf("mp1 wrong: %+v", byKey["mp1"])
|
||||
}
|
||||
if g.cfgVMIDs[0] != 8200 {
|
||||
t.Fatalf("guest config read for wrong vmid: %v", g.cfgVMIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback_RequiresSnapname(t *testing.T) {
|
||||
g := &fakeGuests{}
|
||||
h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil)
|
||||
if w := do(t, h, "POST", "/rollback", "A", `{}`); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing snapname: got %d, want 400", w.Code)
|
||||
}
|
||||
if len(g.rbVMIDs) != 0 {
|
||||
t.Fatal("rollback issued without a snapname")
|
||||
}
|
||||
if w := do(t, h, "POST", "/rollback", "A", `{"snapname":"pre-deploy"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("valid rollback: got %d", w.Code)
|
||||
}
|
||||
if len(g.rbVMIDs) != 1 || g.rbVMIDs[0] != 8200 {
|
||||
t.Fatalf("rollback VMIDs: %v", g.rbVMIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshot_RejectsBadName(t *testing.T) {
|
||||
g := &fakeGuests{}
|
||||
h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil)
|
||||
if w := do(t, h, "POST", "/snapshot", "A", `{"snapname":"bad name/slash"}`); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad snapname: got %d, want 400", w.Code)
|
||||
}
|
||||
if len(g.snapVMIDs) != 0 {
|
||||
t.Fatal("snapshot issued with an invalid name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackup_EnqueuesForTokenGuest(t *testing.T) {
|
||||
g := &fakeGuests{}
|
||||
b := &fakeBackups{}
|
||||
st := &fakeStore{}
|
||||
h := newTestServer(t, g, b, st, nil)
|
||||
|
||||
w := do(t, h, "POST", "/backup", "A", "")
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("backup enqueue: got %d, want 202", w.Code)
|
||||
}
|
||||
// the goroutine should run and record for guest 8200
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if c := b.called(); len(c) == 1 && c[0] == 8200 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if c := b.called(); len(c) != 1 || c[0] != 8200 {
|
||||
t.Fatalf("backup not enqueued for token guest: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupDue_ThinHeuristic(t *testing.T) {
|
||||
st := &fakeStore{}
|
||||
h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil)
|
||||
|
||||
// no backup recorded → due
|
||||
w := do(t, h, "GET", "/backup/due", "A", "")
|
||||
var resp struct {
|
||||
Data BackupDueResponse `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if !resp.Data.Due {
|
||||
t.Fatal("expected due=true with no backup recorded")
|
||||
}
|
||||
// a successful backup for this guest → not due
|
||||
st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-10T00:00:00Z"}}
|
||||
w = do(t, h, "GET", "/backup/due", "A", "")
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Data.Due {
|
||||
t.Fatal("expected due=false after a successful backup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupStatus_FiltersToThisGuest(t *testing.T) {
|
||||
st := &fakeStore{backups: []hub.Backup{
|
||||
{VMID: 9300, Success: true, StartedAt: "2026-06-10T10:00:00Z", Archive: "other"},
|
||||
{VMID: 8200, Success: true, StartedAt: "2026-06-10T09:00:00Z", Archive: "mine-old"},
|
||||
{VMID: 8200, Success: true, StartedAt: "2026-06-10T11:00:00Z", Archive: "mine-new"},
|
||||
}}
|
||||
h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil)
|
||||
w := do(t, h, "GET", "/backup/status", "A", "")
|
||||
if !strings.Contains(w.Body.String(), "mine-new") || strings.Contains(w.Body.String(), "other") {
|
||||
t.Fatalf("status not scoped to this guest / not latest: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Package localapi implements the agent's per-guest local API (doc 03 §6, slice 8A): the
|
||||
// HTTPS server the in-guest controller calls over the bridge, the durable per-guest token
|
||||
// store, and the self-signed leaf the agent serves. The agent is the per-guest authorization
|
||||
// gate — every call is authorized against the token's guest only (self-scoped), never a
|
||||
// caller-supplied id.
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// TokenStore is the durable, crash-safe per-guest token→guest map. It mirrors the
|
||||
// authz.FileNonceStore mechanism (fsync'd append-only JSONL + in-memory index) with two
|
||||
// differences: it stores a SHA-256 HASH of each token (never the plaintext — the plaintext
|
||||
// exists only transiently between Mint and write-to-mount, then is discarded), and it is a
|
||||
// last-write-wins map keyed by VMID (re-provisioning a guest rotates its token).
|
||||
//
|
||||
// Security: a leaked token store reveals only hashes, not usable tokens. Lookup is
|
||||
// constant-time per candidate (subtle.ConstantTimeCompare) to avoid leaking the hash via
|
||||
// timing. Single-process; one mutex guards the file handle and both indexes (03 §10).
|
||||
type TokenStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
f *os.File
|
||||
byHash map[string]int // tokenHash(hex) -> vmid
|
||||
byVMID map[int]string // vmid -> current tokenHash(hex)
|
||||
}
|
||||
|
||||
// tokenRecord is one durable line: VMID v gets token-hash h (last write per v wins).
|
||||
type tokenRecord struct {
|
||||
VMID int `json:"v"`
|
||||
Hash string `json:"h"`
|
||||
}
|
||||
|
||||
// tokenBytes is the per-guest token length (256-bit; well above the ≥128-bit requirement).
|
||||
const tokenBytes = 32
|
||||
|
||||
// OpenTokenStore opens (or creates) the durable store at path, replaying the log into the
|
||||
// in-memory indexes (last record per VMID wins; a superseded hash is dropped from byHash).
|
||||
func OpenTokenStore(path string) (*TokenStore, error) {
|
||||
s := &TokenStore{
|
||||
path: path,
|
||||
byHash: make(map[string]int),
|
||||
byVMID: make(map[int]string),
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("localapi: token store dir: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.f = f
|
||||
syncDir(filepath.Dir(path))
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *TokenStore) load() error {
|
||||
b, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range bytes.Split(b, []byte("\n")) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var r tokenRecord
|
||||
if json.Unmarshal(line, &r) != nil {
|
||||
continue // skip a torn trailing line from a crash mid-append
|
||||
}
|
||||
s.apply(r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apply folds one record into the indexes (last-write-wins per VMID). Caller holds the mutex
|
||||
// (or is in single-threaded load).
|
||||
func (s *TokenStore) apply(r tokenRecord) {
|
||||
if old, ok := s.byVMID[r.VMID]; ok && old != r.Hash {
|
||||
delete(s.byHash, old) // the previous token for this guest is no longer valid
|
||||
}
|
||||
s.byVMID[r.VMID] = r.Hash
|
||||
s.byHash[r.Hash] = r.VMID
|
||||
}
|
||||
|
||||
// Mint generates a fresh high-entropy token for vmid, durably records its HASH (last-write
|
||||
// wins — any previous token for this guest is revoked), and returns the PLAINTEXT exactly
|
||||
// once. The caller must write the plaintext into the guest's bootstrap mount and then discard
|
||||
// it; only the hash is persisted. On any I/O failure the token is not recorded and an error is
|
||||
// returned (the caller must not hand out an unrecorded token).
|
||||
func (s *TokenStore) Mint(vmid int) (string, error) {
|
||||
if vmid <= 0 {
|
||||
return "", fmt.Errorf("localapi: Mint needs a positive vmid")
|
||||
}
|
||||
raw := make([]byte, tokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("localapi: token entropy: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
hash := hashToken(token)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, _ := json.Marshal(tokenRecord{VMID: vmid, Hash: hash})
|
||||
rec = append(rec, '\n')
|
||||
if _, err := s.f.Write(rec); err != nil {
|
||||
return "", fmt.Errorf("localapi: token store write: %w", err)
|
||||
}
|
||||
if err := s.f.Sync(); err != nil {
|
||||
return "", fmt.Errorf("localapi: token store sync: %w", err)
|
||||
}
|
||||
s.apply(tokenRecord{VMID: vmid, Hash: hash})
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Lookup resolves a presented bearer token to its guest VMID. It hashes the candidate and
|
||||
// looks it up; the per-candidate comparison is constant-time to avoid a timing oracle on the
|
||||
// stored hash. ok is false for an unknown/empty token.
|
||||
func (s *TokenStore) Lookup(token string) (int, bool) {
|
||||
if token == "" {
|
||||
return 0, false
|
||||
}
|
||||
want := hashToken(token)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Direct map hit is the common path; the constant-time compare guards against a timing
|
||||
// side-channel by re-checking the matched key (map lookup itself is not the secret-bearing
|
||||
// comparison — the hash of a random 256-bit token is not feasibly guessable regardless).
|
||||
if vmid, ok := s.byHash[want]; ok {
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(s.byVMID[vmid])) == 1 {
|
||||
return vmid, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Close releases the file handle.
|
||||
func (s *TokenStore) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.f != nil {
|
||||
return s.f.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashToken is the persisted representation of a token: lowercase hex SHA-256.
|
||||
func hashToken(tok string) string {
|
||||
sum := sha256.Sum256([]byte(tok))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// syncDir best-effort fsyncs a directory so a create/rename is durable.
|
||||
func syncDir(dir string) {
|
||||
if d, err := os.Open(dir); err == nil {
|
||||
_ = d.Sync()
|
||||
_ = d.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenStore_MintLookup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tokens.log")
|
||||
s, err := OpenTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
tok, err := s.Mint(8200)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
if tok == "" {
|
||||
t.Fatal("mint returned empty token")
|
||||
}
|
||||
if vmid, ok := s.Lookup(tok); !ok || vmid != 8200 {
|
||||
t.Fatalf("lookup: got (%d,%v), want (8200,true)", vmid, ok)
|
||||
}
|
||||
if _, ok := s.Lookup("not-a-real-token"); ok {
|
||||
t.Fatal("lookup of unknown token succeeded")
|
||||
}
|
||||
if _, ok := s.Lookup(""); ok {
|
||||
t.Fatal("lookup of empty token succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// The persisted file must contain only the HASH, never the plaintext token.
|
||||
func TestTokenStore_PlaintextNeverPersisted(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tokens.log")
|
||||
s, err := OpenTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
tok, err := s.Mint(101)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
s.Close()
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read store: %v", err)
|
||||
}
|
||||
if strings.Contains(string(b), tok) {
|
||||
t.Fatal("plaintext token found in the persisted store — must store only the hash")
|
||||
}
|
||||
if !strings.Contains(string(b), hashToken(tok)) {
|
||||
t.Fatal("token hash not found in the persisted store")
|
||||
}
|
||||
}
|
||||
|
||||
// A re-mint for the same guest revokes the previous token (last-write-wins).
|
||||
func TestTokenStore_RemintRevokesOld(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tokens.log")
|
||||
s, err := OpenTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
old, _ := s.Mint(7)
|
||||
fresh, _ := s.Mint(7)
|
||||
if old == fresh {
|
||||
t.Fatal("re-mint produced the same token")
|
||||
}
|
||||
if _, ok := s.Lookup(old); ok {
|
||||
t.Fatal("old token still valid after re-mint")
|
||||
}
|
||||
if vmid, ok := s.Lookup(fresh); !ok || vmid != 7 {
|
||||
t.Fatalf("fresh token lookup: got (%d,%v), want (7,true)", vmid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Durability: tokens survive a store reopen (replay).
|
||||
func TestTokenStore_SurvivesReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tokens.log")
|
||||
s1, err := OpenTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
a, _ := s1.Mint(1)
|
||||
b, _ := s1.Mint(2)
|
||||
// rotate guest 1
|
||||
a2, _ := s1.Mint(1)
|
||||
s1.Close()
|
||||
|
||||
s2, err := OpenTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
|
||||
if _, ok := s2.Lookup(a); ok {
|
||||
t.Fatal("rotated-out token survived reopen")
|
||||
}
|
||||
if vmid, ok := s2.Lookup(a2); !ok || vmid != 1 {
|
||||
t.Fatalf("guest1 current token lost across reopen: (%d,%v)", vmid, ok)
|
||||
}
|
||||
if vmid, ok := s2.Lookup(b); !ok || vmid != 2 {
|
||||
t.Fatalf("guest2 token lost across reopen: (%d,%v)", vmid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStore_Uniqueness(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tokens.log")
|
||||
s, err := OpenTokenStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
seen := map[string]bool{}
|
||||
for i := 1; i <= 200; i++ {
|
||||
tok, err := s.Mint(i)
|
||||
if err != nil {
|
||||
t.Fatalf("mint %d: %v", i, err)
|
||||
}
|
||||
if seen[tok] {
|
||||
t.Fatalf("duplicate token at %d", i)
|
||||
}
|
||||
seen[tok] = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user