3fecf4c713
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>
490 lines
16 KiB
Go
490 lines
16 KiB
Go
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})
|
|
}
|