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:
2026-06-10 09:47:42 +02:00
parent fae11020a5
commit 3fecf4c713
18 changed files with 2203 additions and 11 deletions
+74
View File
@@ -29,9 +29,83 @@ type Config struct {
Storage StorageConfig `json:"storage"`
Backup BackupConfig `json:"backup"`
Escrow EscrowConfig `json:"escrow"`
LocalAPI LocalAPIConfig `json:"local_api"`
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
}
// LocalAPIConfig configures the per-guest local API server (doc 03 §6, slice 8A). The
// controller (inside its LXC) reaches the agent over the local bridge; the agent is the
// per-guest authorization gate — it maps a per-guest bearer token → VMID and authorizes
// every call against THAT guest only (self-scoped; never a caller-supplied id). Disabled
// unless Enable is set AND ListenAddr is non-empty (so a host that doesn't yet provision
// controllers runs the daemon without it).
//
// Defense-in-depth (spike gotcha 5): ListenAddr should be the host's BRIDGE IP (not
// 0.0.0.0), and a host firewall rule should limit the port to the guest bridge subnet
// (configs/felhom-localapi-firewall.example). The per-guest token remains the gate; the
// bind + firewall narrow exposure but are not the authorization.
type LocalAPIConfig struct {
Enable bool `json:"enable"`
ListenAddr string `json:"listen_addr"` // bridge IP:port, e.g. "192.168.0.162:8443"
// CertFile/KeyFile hold the agent's self-signed leaf served to controllers. Generated
// (persisted) on first start if absent, so the leaf SHA-256 fingerprint — baked into each
// guest's bootstrap for pinning — is STABLE across agent restarts.
CertFile string `json:"cert_file"` // default <token_store_dir>/local-api.crt
KeyFile string `json:"key_file"` // default <token_store_dir>/local-api.key
// TokenStore is the durable, hashed token→guest map (only a HASH of each token is
// persisted; the plaintext exists transiently at mint→write-to-mount, then is discarded).
TokenStore string `json:"token_store"` // default /var/lib/felhom-agent/local-tokens.log
}
// Default local-API file locations (under the agent's state dir).
const (
defaultLocalAPITokenStore = "/var/lib/felhom-agent/local-tokens.log"
defaultLocalAPICert = "/var/lib/felhom-agent/local-api.crt"
defaultLocalAPIKey = "/var/lib/felhom-agent/local-api.key"
)
// Enabled reports whether the local-API server should run.
func (l LocalAPIConfig) Enabled() bool {
return l.Enable && strings.TrimSpace(l.ListenAddr) != ""
}
// TokenStorePath returns the configured token-store path (default applied).
func (l LocalAPIConfig) TokenStorePath() string {
if l.TokenStore != "" {
return l.TokenStore
}
return defaultLocalAPITokenStore
}
// CertPath/KeyPath return the configured leaf cert/key paths (defaults applied).
func (l LocalAPIConfig) CertPath() string {
if l.CertFile != "" {
return l.CertFile
}
return defaultLocalAPICert
}
func (l LocalAPIConfig) KeyPath() string {
if l.KeyFile != "" {
return l.KeyFile
}
return defaultLocalAPIKey
}
// Validate checks the local-API config is usable when enabled.
func (l LocalAPIConfig) Validate() error {
if !l.Enable {
return nil
}
if strings.TrimSpace(l.ListenAddr) == "" {
return fmt.Errorf("config: local_api.listen_addr is required when local_api.enable is set (use the host bridge IP:port, e.g. 192.168.0.162:8443)")
}
if _, _, err := net.SplitHostPort(l.ListenAddr); err != nil {
return fmt.Errorf("config: local_api.listen_addr %q is not host:port: %w", l.ListenAddr, err)
}
return nil
}
// EscrowConfig configures PBS recovery-code escrow creation (slice 7, doc 03 §8a). Enrollment-time
// only (not the steady-state daemon). The default posture is zero-knowledge (Felhom holds the
// opaque blob, the customer holds the recovery code).
+126
View File
@@ -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
}
+57
View File
@@ -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")
}
}
+489
View File
@@ -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})
}
+340
View File
@@ -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())
}
}
+178
View File
@@ -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()
}
}
+131
View File
@@ -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
}
}
+158
View File
@@ -0,0 +1,158 @@
package provision
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// TokenMinter mints a per-guest local-API token, persisting only its hash. Satisfied by
// *localapi.TokenStore.
type TokenMinter interface {
Mint(vmid int) (string, error)
}
// defaults for the config-mount layout.
const (
// DefaultGuestPath is where the config mount appears INSIDE the guest (matches the
// controller's bootstrap.DefaultMountPath dir + the golden bootstrap unit).
DefaultGuestPath = "/etc/felhom-bootstrap"
// DefaultMountIndex is the mpN slot used for the config mount. It is intentionally high so
// it never collides with a bring-up data mount (mp0, mp1, …).
DefaultMountIndex = 9
// bootstrapFile is the file name inside the config mount.
bootstrapFile = "bootstrap.json"
// mappedRoot is the unprivileged-LXC host uid/gid that maps to the guest's root (spike
// gotcha 1): files chowned to this appear as root:root 0600 inside the guest.
mappedRoot = "100000:100000"
)
// BackHalf populates a guest's bootstrap config mount host-side (F3). It mints the per-guest
// token, renders bootstrap.json, writes it 0600, chowns it to the mapped guest-root, and attaches
// it as a read-only bind mount via `pct set`. The bind-mount attach + chown are host-root ops
// (NOT API ops and NOT one of proxmox.Privileged's 3 exceptions) — they run through the shared
// Runner (direct as root, or `sudo -n` with the configs/felhom-agent.sudoers PROVISION entries).
type BackHalf struct {
tokens TokenMinter
runner proxmox.Runner
stateDir string // agent state dir; the per-guest config dir lives under <stateDir>/guests/<vmid>/bootstrap
logger *slog.Logger
}
// NewBackHalf builds the back-half. stateDir defaults to /var/lib/felhom-agent when empty.
func NewBackHalf(tokens TokenMinter, runner proxmox.Runner, stateDir string, logger *slog.Logger) *BackHalf {
if stateDir == "" {
stateDir = "/var/lib/felhom-agent"
}
if logger == nil {
logger = slog.Default()
}
return &BackHalf{tokens: tokens, runner: runner, stateDir: stateDir, logger: logger}
}
// Input is everything the back-half needs that is NOT secret. The per-guest token is minted here,
// never supplied by the caller.
type Input struct {
VMID int
Customer DocCustomer
Hub DocHub
Endpoint string // local-api bridge IP:port
Fingerprint string // agent leaf-cert SHA-256 (hex)
GuestPath string // in-guest mount path; "" → DefaultGuestPath
MountIndex int // mpN slot; 0 → DefaultMountIndex (note: mp0 is a valid slot but reserved for data)
}
// Result reports the placement of the config mount. It deliberately contains NO token (the secret
// lives only in the 0600 file + the token store's hash).
type Result struct {
VMID int
HostDir string // agent-owned host dir backing the bind mount
GuestPath string // in-guest mount path
MountKey string // mpN key used
}
// Provision runs the back-half for one already-brought-up guest. Order: mint → render → write →
// chown → attach. On any failure the partial host dir is left for inspection (it holds the 0600
// token file; it is not world-readable) and the error is returned. The token plaintext is NEVER
// logged and NEVER returned.
func (b *BackHalf) Provision(ctx context.Context, in Input) (Result, error) {
if in.VMID <= 0 {
return Result{}, fmt.Errorf("provision: needs a positive vmid")
}
if in.Endpoint == "" || in.Fingerprint == "" {
return Result{}, fmt.Errorf("provision: needs the local-api endpoint and leaf fingerprint")
}
if in.Customer.ID == "" || in.Customer.Domain == "" {
return Result{}, fmt.Errorf("provision: needs customer id and domain (so the controller skips setup)")
}
guestPath := in.GuestPath
if guestPath == "" {
guestPath = DefaultGuestPath
}
idx := in.MountIndex
if idx == 0 {
idx = DefaultMountIndex
}
mountKey := "mp" + strconv.Itoa(idx)
// 1. Mint the per-guest token (only its hash is persisted). The plaintext exists in `tok`
// until it is written into the mount below; it is never logged or returned.
tok, err := b.tokens.Mint(in.VMID)
if err != nil {
return Result{}, fmt.Errorf("provision: mint token: %w", err)
}
// 2. Render the stable bootstrap.json contract (with the token injected).
doc := Doc{
Schema: SchemaV1,
Customer: in.Customer,
Hub: in.Hub,
LocalAPI: DocLocalAPI{Endpoint: in.Endpoint, Fingerprint: in.Fingerprint, Token: tok},
}
rendered, err := doc.render()
if err != nil {
return Result{}, fmt.Errorf("provision: render bootstrap: %w", err)
}
// 3. Write it 0600 into the agent-owned per-guest config dir.
hostDir := filepath.Join(b.stateDir, "guests", strconv.Itoa(in.VMID), "bootstrap")
if err := os.MkdirAll(hostDir, 0o700); err != nil {
return Result{}, fmt.Errorf("provision: config dir: %w", err)
}
bootPath := filepath.Join(hostDir, bootstrapFile)
if err := os.WriteFile(bootPath, rendered, 0o600); err != nil {
return Result{}, fmt.Errorf("provision: write bootstrap: %w", err)
}
// 4. chown to the unprivileged-LXC mapped root so the guest reads it as root:root 0600
// (spike gotcha 1). Host-root op via the Runner.
if err := b.run(ctx, "chown", "-R", mappedRoot, hostDir); err != nil {
return Result{}, fmt.Errorf("provision: chown config mount: %w", err)
}
// 5. Attach the read-only bind mount via `pct set` (host-root op; bind mounts are root@pam
// only, so this cannot be the API token). The golden's baked unit consumes it on boot.
mpSpec := fmt.Sprintf("%s,mp=%s,ro=1", hostDir, guestPath)
if err := b.run(ctx, "pct", "set", strconv.Itoa(in.VMID), "-"+mountKey, mpSpec); err != nil {
return Result{}, fmt.Errorf("provision: attach config mount: %w", err)
}
b.logger.Info("provision: back-half complete",
"vmid", in.VMID, "mount", mountKey, "guest_path", guestPath, "endpoint", in.Endpoint)
// tok intentionally goes out of scope here — never logged, never returned.
return Result{VMID: in.VMID, HostDir: hostDir, GuestPath: guestPath, MountKey: mountKey}, nil
}
// run executes a host-root command through the Runner, wrapping a nonzero exit with stderr.
func (b *BackHalf) run(ctx context.Context, name string, args ...string) error {
_, stderr, err := b.runner.Run(ctx, name, args...)
if err != nil {
return fmt.Errorf("%s: %w: %s", name, err, string(stderr))
}
return nil
}
+160
View File
@@ -0,0 +1,160 @@
package provision
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
// recRunner records every command issued (to assert chown + pct set ran with correct args).
type recRunner struct {
mu sync.Mutex
cmds [][]string
fail string // if a command's name == fail, return an error
}
func (r *recRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
r.mu.Lock()
r.cmds = append(r.cmds, append([]string{name}, args...))
r.mu.Unlock()
if name == r.fail {
return nil, []byte("boom"), io.ErrUnexpectedEOF
}
return nil, nil, nil
}
func (r *recRunner) find(name string) []string {
r.mu.Lock()
defer r.mu.Unlock()
for _, c := range r.cmds {
if c[0] == name {
return c
}
}
return nil
}
// mintMinter returns a fixed token and records the vmid it was minted for.
type mintMinter struct {
token string
vmids []int
}
func (m *mintMinter) Mint(vmid int) (string, error) { m.vmids = append(m.vmids, vmid); return m.token, nil }
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func newInput() Input {
return Input{
VMID: 8200,
Customer: DocCustomer{ID: "cust-8200", Domain: "cust8200.felhom.eu", Name: "Teszt"},
Hub: DocHub{URL: "https://hub.felhom.eu", APIKey: "HUBKEY", HostID: "demo-felhom-01"},
Endpoint: "192.168.0.162:8443",
Fingerprint: "ab12cd",
}
}
func TestProvision_WritesChownsAndAttaches(t *testing.T) {
dir := t.TempDir()
runner := &recRunner{}
minter := &mintMinter{token: "SECRET-TOKEN-XYZ"}
bh := NewBackHalf(minter, runner, dir, testLogger())
res, err := bh.Provision(context.Background(), newInput())
if err != nil {
t.Fatalf("provision: %v", err)
}
// token minted for the right guest
if len(minter.vmids) != 1 || minter.vmids[0] != 8200 {
t.Fatalf("mint vmids: %v", minter.vmids)
}
// bootstrap.json written 0600, contains the token + customer, valid contract
bootPath := filepath.Join(res.HostDir, "bootstrap.json")
info, err := os.Stat(bootPath)
if err != nil {
t.Fatalf("stat bootstrap: %v", err)
}
// Unix perms are not modeled on Windows; the 0600 is enforced on the Linux target (where the
// agent runs). Assert only where the OS honors it.
if runtime.GOOS != "windows" {
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("bootstrap perms: got %o want 600", perm)
}
}
raw, _ := os.ReadFile(bootPath)
var doc Doc
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("bootstrap not valid JSON: %v", err)
}
if doc.Schema != SchemaV1 || doc.Customer.ID != "cust-8200" || doc.LocalAPI.Token != "SECRET-TOKEN-XYZ" {
t.Fatalf("bootstrap content wrong: %+v", doc)
}
if doc.LocalAPI.Endpoint != "192.168.0.162:8443" || doc.LocalAPI.Fingerprint != "ab12cd" {
t.Fatalf("local_api wrong: %+v", doc.LocalAPI)
}
// chown to the mapped guest-root ran on the host dir
chown := runner.find("chown")
if chown == nil || chown[1] != "-R" || chown[2] != "100000:100000" || chown[3] != res.HostDir {
t.Fatalf("chown command wrong: %v", chown)
}
// pct set attached the read-only bind mount at the default high slot
pct := runner.find("pct")
if pct == nil {
t.Fatal("pct set not called")
}
joined := strings.Join(pct, " ")
if !strings.Contains(joined, "set 8200 -mp9") || !strings.Contains(joined, res.HostDir+",mp=/etc/felhom-bootstrap,ro=1") {
t.Fatalf("pct set command wrong: %v", pct)
}
if res.MountKey != "mp9" || res.GuestPath != "/etc/felhom-bootstrap" {
t.Fatalf("result placement wrong: %+v", res)
}
}
// The Result must never carry the token, and the token must not appear in any field returned to
// the caller (secret discipline — only the 0600 file + the store hash hold it).
func TestProvision_ResultHasNoToken(t *testing.T) {
dir := t.TempDir()
bh := NewBackHalf(&mintMinter{token: "SECRET-TOKEN-XYZ"}, &recRunner{}, dir, testLogger())
res, err := bh.Provision(context.Background(), newInput())
if err != nil {
t.Fatal(err)
}
blob, _ := json.Marshal(res)
if strings.Contains(string(blob), "SECRET-TOKEN-XYZ") {
t.Fatalf("token leaked into the Result: %s", blob)
}
}
func TestProvision_RejectsIncompleteInput(t *testing.T) {
dir := t.TempDir()
bh := NewBackHalf(&mintMinter{token: "t"}, &recRunner{}, dir, testLogger())
bad := Input{VMID: 8200} // no endpoint/fingerprint/customer
if _, err := bh.Provision(context.Background(), bad); err == nil {
t.Fatal("expected an error for incomplete input")
}
}
// A failed chown surfaces an error (and does not proceed to attach).
func TestProvision_ChownFailureStops(t *testing.T) {
dir := t.TempDir()
runner := &recRunner{fail: "chown"}
bh := NewBackHalf(&mintMinter{token: "t"}, runner, dir, testLogger())
if _, err := bh.Provision(context.Background(), newInput()); err == nil {
t.Fatal("expected chown failure to surface")
}
if runner.find("pct") != nil {
t.Fatal("pct set ran despite a chown failure")
}
}
+50
View File
@@ -0,0 +1,50 @@
// Package provision implements the slice-8A provisioning BACK HALF: after the slice-7 bring-up
// front half (restore → identity → size → start), the agent mints a per-guest local-API token,
// renders the stable bootstrap.json contract, and populates a read-only config mount the golden's
// baked controller-bootstrap unit consumes (F3: host-side only, no pct exec).
//
// The agent NEVER enters the guest and NEVER puts a registry credential in the guest (the
// controller image is baked into the golden — configs/build-golden.sh). The only secret written
// into the guest is the per-guest local-API token, in the 0600 bootstrap.json on the config mount.
package provision
import "encoding/json"
// SchemaV1 is the stable agent→controller contract version. It MUST stay byte-compatible with the
// controller's internal/bootstrap.SchemaV1 / Bootstrap shape (cross-repo contract; doc_test.go
// pins the key set, mirroring the controller's bootstrap_test.go).
const SchemaV1 = "felhom.bootstrap/v1"
// Doc is the bootstrap.json the agent emits. Field names + json tags MUST match the controller's
// internal/bootstrap.Bootstrap exactly. It carries ONLY what the controller needs to come up
// configured and reach the agent's local API — no registry credential (image is baked).
type Doc struct {
Schema string `json:"schema"`
Customer DocCustomer `json:"customer"`
Hub DocHub `json:"hub"`
LocalAPI DocLocalAPI `json:"local_api"`
}
type DocCustomer struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
Email string `json:"email"`
}
type DocHub struct {
URL string `json:"url"`
APIKey string `json:"api_key"`
HostID string `json:"host_id"`
}
type DocLocalAPI struct {
Endpoint string `json:"endpoint"` // host bridge IP:port
Fingerprint string `json:"fingerprint"` // agent leaf-cert SHA-256 (hex) to pin
Token string `json:"token"` // per-guest bearer; SECRET — written 0600 only
}
// render marshals the doc as indented JSON (the bytes written into the config mount).
func (d Doc) render() ([]byte, error) {
return json.MarshalIndent(d, "", " ")
}
+62
View File
@@ -0,0 +1,62 @@
package provision
import (
"encoding/json"
"sort"
"testing"
)
// The bootstrap.json key set is a CROSS-REPO contract: it must match felhom-controller's
// internal/bootstrap.Bootstrap exactly. This test pins the emitted key set; the controller's
// bootstrap_test.go ingests the same shape. A drift here (or there) breaks provisioning.
func TestDoc_ContractKeySet(t *testing.T) {
d := Doc{
Schema: SchemaV1,
Customer: DocCustomer{ID: "c", Name: "n", Domain: "d", Email: "e"},
Hub: DocHub{URL: "u", APIKey: "k", HostID: "h"},
LocalAPI: DocLocalAPI{Endpoint: "ep", Fingerprint: "fp", Token: "tok"},
}
b, err := d.render()
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
assertKeys(t, "top", m, []string{"schema", "customer", "hub", "local_api"})
var full struct {
Customer map[string]json.RawMessage `json:"customer"`
Hub map[string]json.RawMessage `json:"hub"`
LocalAPI map[string]json.RawMessage `json:"local_api"`
}
if err := json.Unmarshal(b, &full); err != nil {
t.Fatal(err)
}
assertKeys(t, "customer", full.Customer, []string{"id", "name", "domain", "email"})
assertKeys(t, "hub", full.Hub, []string{"url", "api_key", "host_id"})
assertKeys(t, "local_api", full.LocalAPI, []string{"endpoint", "fingerprint", "token"})
if SchemaV1 != "felhom.bootstrap/v1" {
t.Fatalf("schema drift: %q", SchemaV1)
}
}
func assertKeys(t *testing.T, label string, m map[string]json.RawMessage, want []string) {
t.Helper()
got := make([]string, 0, len(m))
for k := range m {
got = append(got, k)
}
sort.Strings(got)
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("%s: key set %v, want %v", label, got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("%s: key set %v, want %v", label, got, want)
}
}
}