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