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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, "", " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user