Files
felhom-agent/internal/localapi/backup_target.go
T
admin 58b598b697 v0.113.0 — E-2a: guarded backup-target wrapper + POST /backup/target
The agent cannot create a PVE storage (Datastore.Allocate at /storage) or grant
an ACL (Permissions.Modify) -- it holds neither by design, and widening the role
would trade the whole blast-radius containment model for one feature. The
privileged half therefore lives in a new fenced shim behind a literal
FELHOM_BACKUPTARGET sudoers alias, following the mkfs/pbs-apply pattern.

The wrapper enforces the two laws E-1 paid for on live hardware so no caller can
forget them: F-1 the path must BE the drive's own mountpoint, F-2 is_mountpoint 1
is hardcoded rather than a caller flag. It refuses a root-device target, has NO
storage-removal path of any kind (the pbs-apply no-delete law, grep-assertable),
is idempotent for the same path, and REFUSES to repoint an existing id.

POST /backup/target drives it in a fixed order: create -> grant -> config.
Reversed, a config pointing at an ungranted storage 403s every backup on first
run -- exactly E-1 finding F-3. A failed grant leaves the config untouched.

It deliberately does NOT restart the agent: restarting with a backup in flight
cancels the wait and records a spurious tier failure for a backup that actually
succeeded (E-1 did this to a real felhom-pbs run). It returns restart_required
and the caller restarts behind its own immediate in-flight check.

Config rewrite preserves unknown keys verbatim and writes in place, since
/etc/felhom-agent is root-owned while agent.json is agent-owned 0600.

Green gate: build + vet + test rc=0 (29 packages), run separately from this commit.
2026-07-29 09:05:59 +02:00

213 lines
8.5 KiB
Go

package localapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
// BackupTargetWrapperPath is the pinned sudoers vector (configs/felhom-backup-target-apply). The
// agent cannot create a PVE storage or grant an ACL itself — Datastore.Allocate at /storage and
// Permissions.Modify are deliberately outside its role — so the privileged half runs here.
const BackupTargetWrapperPath = "/usr/local/sbin/felhom-backup-target-apply"
// backupTargetRequest is POST /backup/target: move the PRIMARY whole-guest backup tier onto the
// drive mounted at Where, creating the storage if needed.
type backupTargetRequest struct {
VMID int `json:"vmid"`
Where string `json:"where"` // the drive's OWN host mountpoint (F-1)
ID string `json:"id,omitempty"` // storage id; default backupTargetStorageID
}
// backupTargetStorageID is the conventional id, matching what E-1 created by hand on both demo boxes.
// Keeping the name identical is what makes this endpoint IDEMPOTENT on an already-migrated box: the
// wrapper accepts an existing entry with the same path and changes nothing.
const backupTargetStorageID = "felhom-backup"
// handleSetBackupTarget performs the whole move as one ordered operation: create the storage, grant
// the agent access, repoint the primary tier in agent.json, and hand back what the caller must do to
// make it take effect.
//
// THE ORDER IS THE DESIGN, and each step is a precondition for the next:
//
// create → grant → config
//
// Reversed, a config pointing at a storage that does not exist would make the tier DEFER (harmless
// but silent), and a config pointing at an ungranted storage would make every backup 403 on its
// first run — which is exactly what E-1 hit when the grant was forgotten (finding F-3). Creating and
// granting BEFORE the config means the worst interruption leaves an unused storage, never a broken
// tier.
//
// IT DOES NOT RESTART THE AGENT. That is deliberate and it is the E-1 lesson encoded: the backup
// tiers are built once at daemon start, so the move needs a restart to take effect — but restarting
// while a backup or restore-test is in flight cancels the wait and records a SPURIOUS tier failure
// for a backup that actually succeeded (E-1 did exactly this to a felhom-pbs run). A restart that
// this handler fires itself could never be re-checked against in-flight work by the caller, so the
// response reports `restart_required` and the caller performs it behind its own immediate
// in-flight check.
func (s *Server) handleSetBackupTarget(w http.ResponseWriter, r *http.Request, vmid int) {
if s.privileged == nil {
writeErr(w, http.StatusServiceUnavailable, "privileged runner not configured on this host")
return
}
var req backupTargetRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
where := strings.TrimSpace(req.Where)
if where == "" {
writeErr(w, http.StatusBadRequest, "where (the drive's own mountpoint) is required")
return
}
id := strings.TrimSpace(req.ID)
if id == "" {
id = backupTargetStorageID
}
// AGENT-SIDE VALIDATION FIRST, from the agent's own storage view — never the caller's claim.
// The wrapper re-checks everything as root (it is the security boundary), but refusing here gives
// the customer a reason instead of a shell error, and keeps a bad request from reaching sudo at all.
if err := s.validateBackupTargetMount(r.Context(), where); err != nil {
s.logger.Warn("local-api: backup-target move refused", "vmid", vmid, "where", where, "err", err)
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if _, errOut, err := s.privileged.Run(r.Context(), BackupTargetWrapperPath, "create", id, where); err != nil {
s.logger.Error("local-api: backup-target create failed", "id", id, "where", where, "err", err, "stderr", string(errOut))
writeErr(w, http.StatusBadGateway, "could not create the backup storage: "+wrapperReason(errOut, err))
return
}
if _, errOut, err := s.privileged.Run(r.Context(), BackupTargetWrapperPath, "grant", id); err != nil {
// The storage exists but the agent cannot write to it. Say so precisely: this is the exact
// state that produced E-1's "403 permission denied at /storage/felhom-backup" on first backup.
s.logger.Error("local-api: backup-target grant failed", "id", id, "err", err, "stderr", string(errOut))
writeErr(w, http.StatusBadGateway, "storage created but the access grant failed — backups would 403: "+wrapperReason(errOut, err))
return
}
if err := s.setConfiguredBackupTarget(id); err != nil {
s.logger.Error("local-api: backup-target config write failed", "id", id, "err", err)
writeErr(w, http.StatusInternalServerError, "storage is ready but the config could not be updated: "+err.Error())
return
}
s.logger.Info("local-api: backup target moved — RESTART REQUIRED for it to take effect",
"vmid", vmid, "target", id, "where", where)
writeOK(w, map[string]any{
"vmid": vmid, "target": id, "where": where,
// The caller must restart the agent BEHIND ITS OWN in-flight check — see the doc comment.
"restart_required": true,
})
}
// validateBackupTargetMount refuses a mount that cannot be a real backup target, from the agent's own
// storage view + mount table. Mirrors the wrapper's laws so the customer gets a reason, not a shell error.
func (s *Server) validateBackupTargetMount(ctx context.Context, where string) error {
if s.storage == nil {
return fmt.Errorf("storage view unavailable")
}
// It must currently BE a mountpoint (F-1/F-2). Resolved from the mount table, which is the same
// source the wrapper's `mountpoint -q` consults.
mounts, err := s.hostReader().Mounts()
if err != nil {
return fmt.Errorf("could not read the mount table")
}
var dev string
for _, m := range mounts {
if m.MountPoint == where {
dev = m.Device
break
}
}
if dev == "" {
return fmt.Errorf("%s is not a mountpoint — the backup target must be the drive's own mountpoint", where)
}
// Never the system disk: a target there protects against corruption only, never drive loss.
for _, m := range mounts {
if m.MountPoint == "/" && m.Device == dev {
return fmt.Errorf("%s is on the system disk — a backup target there cannot survive a drive failure", where)
}
}
return nil
}
// setConfiguredBackupTarget rewrites backup.local_backup_target in agent.json.
//
// Read-modify-write over map[string]json.RawMessage so UNKNOWN KEYS ARE PRESERVED VERBATIM — the
// same discipline as pbsdr.seedEscrowStorageID, and the property that made E-1's hand edit safe to
// begin with. A typed round-trip would silently drop any key this build does not know about.
//
// Written IN PLACE (O_TRUNC), not tmp+rename: /etc/felhom-agent is root-owned while agent.json is
// agent-owned 0600, so the non-root agent cannot rename into that directory. A recovery copy is
// parked first, so a torn write is recoverable.
func (s *Server) setConfiguredBackupTarget(id string) error {
path := s.configPath
if path == "" {
return fmt.Errorf("no config path known to this agent (env-only config)")
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var doc map[string]json.RawMessage
if err := json.Unmarshal(raw, &doc); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
var bk map[string]json.RawMessage
if cur, ok := doc["backup"]; ok {
if err := json.Unmarshal(cur, &bk); err != nil {
return fmt.Errorf("parse backup section: %w", err)
}
} else {
bk = map[string]json.RawMessage{}
}
idJSON, _ := json.Marshal(id)
bk["local_backup_target"] = idJSON
bkJSON, err := json.Marshal(bk)
if err != nil {
return err
}
doc["backup"] = bkJSON
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
st, err := os.Stat(path)
if err != nil {
return err
}
if s.stateDir != "" {
if err := os.MkdirAll(s.stateDir, 0o700); err == nil {
_ = os.WriteFile(filepath.Join(s.stateDir, "agent.json.pre-backup-target"), raw, 0o600)
}
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, st.Mode().Perm())
if err != nil {
return err
}
if _, err := f.Write(out); err != nil {
f.Close()
return err
}
return f.Close()
}
// wrapperReason surfaces the wrapper's own REFUSED line when it produced one — it explains WHY in
// terms the customer can act on ("not a mountpoint", "already exists at …") — falling back to the
// exec error only when stderr said nothing useful.
func wrapperReason(errOut []byte, err error) string {
for _, line := range strings.Split(string(errOut), "\n") {
if strings.Contains(line, "REFUSED:") {
return strings.TrimSpace(line)
}
}
return err.Error()
}