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 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}) }