agent v0.25.0: slice 10 P2 — bind enrolled user-data drives into the guest

POST /disks/guest-attach binds an enrolled drive's felhom-data namespace into
the guest (Model A: felhom-data is the bind source mounted at /mnt/<name>, so
only Felhom's namespace crosses in). GuestBinder does mkdir+chown(100000)+pct set
(RW bind) via the fenced runner. Idempotent, free-slot selection, path-validated.
Spike-proven on 9201. Pairs with controller P2C + golden /mnt:rslave (P2B).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:38:55 +02:00
parent d1bd44d2d5
commit c1d04c28c1
6 changed files with 311 additions and 8 deletions
+24
View File
@@ -3,6 +3,30 @@
All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed.
## v0.25.0 — slice 10 P2: bind enrolled user-data drives into the guest (passthrough) (2026-06-12)
External user-data drives are mounted on the HOST but were never passed INTO the guest (diagnosed
Branch A), so apps silently wrote to the rootfs and the controller couldn't see them. This adds the
guest passthrough. Spike-proven on 9201 first (see REPORT / the usb-passthrough-spike findings):
`pct set` **bind form** (host path, never `storage:size`), `chown` to the guest base (idmap not clean
for mixed-ownership data), `shared:49` propagation host↔guest automatic.
- **`POST /disks/guest-attach` (`internal/localapi`)** — self-scoped (vmid from token). Binds an
enrolled drive's **felhom-data namespace** into the guest at `/mnt/<name>` (**Model A**: the
felhom-data dir is the bind source mounted AT `/mnt/<name>`, so only Felhom's namespace crosses into
the guest — the customer's other data on the drive never does). Idempotent (returns the existing slot
if already bound); picks the lowest free `mpN`; validates `where` is `/mnt/<name>` (no traversal).
- **`GuestBinder` (`internal/localapi/guestbind.go`)** — the host-root steps over the fenced
`proxmox.Runner` (same pattern as the provision back-half's bind): `mkdir -p <drive>/felhom-data`
`chown 100000:100000` the namespace ROOT (not -R; per-app subdirs are chowned at deploy) → `pct set
<vmid> -mpN <drive>/felhom-data,mp=/mnt/<name>` (RW bind). The namespace is created fresh + uniformly
owned, which sidesteps the drive's pre-existing mixed-ownership data entirely.
- **Tests** — `TestGuestAttach_*`: free-slot selection (mp0 when mp9 taken), idempotency (no re-bind +
`already:true`), bad-path rejection (traversal/non-/mnt/multi-component), not-configured 503.
Pairs with felhom-controller P2C (enroll triggers attach) + the golden's `/mnt:rslave` controller bind
(P2B). Self-heal reconcile (P3) and dual-role (P4) follow.
## v0.24.0 — role-gate the eject path (system/backup mounts are unmount-protected at the agent) (2026-06-12)
Closes the eject gap in the storage-authorization redesign: `POST /disks/eject` now **refuses to
+13 -4
View File
@@ -43,7 +43,7 @@ import (
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.24.0"
var version = "0.25.0"
func main() {
var (
@@ -554,6 +554,13 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
}
logger.Info("local-api leaf ready", "fingerprint_sha256", fp, "cert", cfg.LocalAPI.CertPath())
runner := backup.NewBackupRunner(px, cfg.Backup.LocalBackupTarget, "", "felhom local-api", logger)
// Guest data-drive passthrough (slice 10 P2): a root-CLI runner for the `pct set` bind + chown
// (same fenced ExecRunner the host-storage + provision back-half use).
gaMode := proxmox.RunnerMode(cfg.Privileged.Mode)
if gaMode == "" {
gaMode = proxmox.RunnerSudo
}
guestBinder := localapi.NewGuestBinder(&proxmox.ExecRunner{Mode: gaMode, SudoPath: cfg.Privileged.SudoPath}, logger)
srv, err := localapi.NewServer(localapi.Options{
ListenAddr: cfg.LocalAPI.ListenAddr,
Cert: cert,
@@ -564,9 +571,11 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
Tokens: tokens,
BackupCadence: cfg.Backup.BackupCadence(),
// Disk management (slice 8C): the privileged host surface + the data-bearing wipe gate.
Disks: hostOps,
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
Guests2: px,
Disks: hostOps,
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
Guests2: px,
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
// Host metrics (slice 9): the shared collector serves GET /host/metrics — a fresh host +
// per-storage view to the customer's monitoring page (reuses the slice-4 collector).
HostMetrics: collector,
+94
View File
@@ -3,6 +3,7 @@ package localapi
import (
"context"
"net/http"
"strconv"
"strings"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
@@ -60,6 +61,12 @@ type GuestLister interface {
ListLXC(ctx context.Context) ([]proxmox.Guest, error)
}
// GuestAttacher binds an enrolled user-data drive's felhom-data namespace into a guest as an RW bind
// mount (slice 10 P2, Model A). Satisfied by *GuestBinder. The handler picks the slot + dedups.
type GuestAttacher interface {
AttachBind(ctx context.Context, vmid int, mountKey, where string) error
}
// ---- handlers ---------------------------------------------------------------------------
// DiskInfo is one host drive with its data-bearing flag (for the UI).
@@ -203,6 +210,93 @@ func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid in
writeOK(w, map[string]any{"vmid": vmid, "ejected": req.Where, "dependent_guests": dependents})
}
type guestAttachRequest struct {
VMID int `json:"vmid"`
Where string `json:"where"` // the host mount path of the enrolled drive (e.g. /mnt/felhom-usb)
}
// handleDiskGuestAttach binds an enrolled user-data drive's felhom-data namespace into THIS guest as
// an RW bind mount (slice 10 P2, Model A). Self-scoped (the vmid is the token's). Idempotent: if a
// mountpoint already binds `where`, it returns the existing slot without re-attaching. The drive must
// already be mounted on the host at `where` (the enroll flow's assign did that) — this only adds the
// guest passthrough. The customer's non-felhom data on the drive is NOT exposed (only felhom-data).
func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, vmid int) {
if s.guestAttach == nil {
writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
return
}
var req guestAttachRequest
if !decodeBody(w, r, &req) {
return
}
if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
return
}
where := strings.TrimSpace(req.Where)
if !validGuestMountPath(where) {
writeErr(w, http.StatusBadRequest, "where must be an absolute /mnt/<name> path (no traversal)")
return
}
// Read the guest config for idempotency + free-slot selection.
cfg, err := s.guests.GuestConfig(r.Context(), vmid)
if err != nil {
s.logger.Error("local-api: guest-attach guest config", "vmid", vmid, "err", err)
writeErr(w, http.StatusBadGateway, "could not read guest config")
return
}
mounts := cfg.MountPoints()
// Idempotency: already bound at `where`? (a bind's mp= equals the guest path).
for key, spec := range mounts {
if _, mp, _ := parseMount(spec); mp == where {
s.logger.Info("local-api: guest-attach idempotent (already bound)", "vmid", vmid, "where", where, "slot", key)
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": key, "already": true})
return
}
}
slot, ok := freeMountSlot(mounts)
if !ok {
writeErr(w, http.StatusConflict, "no free mountpoint slot on the guest")
return
}
if err := s.guestAttach.AttachBind(r.Context(), vmid, slot, where); err != nil {
s.logger.Error("local-api: guest-attach", "vmid", vmid, "where", where, "slot", slot, "err", err)
writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
return
}
writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
}
// validGuestMountPath accepts an absolute /mnt/<name> path with no traversal (the enroll convention
// root). Mirrors the controller's mount-name discipline so a hostile `where` can't escape /mnt.
func validGuestMountPath(p string) bool {
if !strings.HasPrefix(p, "/mnt/") || strings.Contains(p, "..") {
return false
}
rest := strings.TrimPrefix(p, "/mnt/")
if rest == "" || strings.ContainsAny(rest, "/ \t") {
return false // exactly one path component under /mnt
}
for _, c := range rest {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
continue
}
return false
}
return true
}
// freeMountSlot returns the lowest mpN (0..255) not present in the guest's current mountpoints. The
// bootstrap mount (mp9) and any existing data mounts are already in `mounts`, so they're skipped.
func freeMountSlot(mounts map[string]string) (string, bool) {
for i := 0; i <= 255; i++ {
key := "mp" + strconv.Itoa(i)
if _, used := mounts[key]; !used {
return key, true
}
}
return "", false
}
type formatRequest struct {
VMID int `json:"vmid"`
Device string `json:"device"`
+90
View File
@@ -372,6 +372,96 @@ func TestEject_RoleGated(t *testing.T) {
d4.mu.Unlock()
}
// ---- guest data-drive passthrough (slice 10 P2) -----------------------------------------
type fakeGuestAttacher struct {
mu sync.Mutex
calls []struct {
vmid int
slot, where string
}
}
func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, where string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, struct {
vmid int
slot, where string
}{vmid, mountKey, where})
return nil
}
func (f *fakeGuestAttacher) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.calls) }
func newAttachServer(t *testing.T, ga GuestAttacher, mounts map[int]map[string]string) http.Handler {
t.Helper()
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: &fakeGuestsCfg{mounts: mounts}, Backups: &fakeBackups{},
Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200, "B": 9300},
GuestAttach: ga, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
return srv.Handler()
}
// A first attach picks the lowest free slot (mp0; mp9 bootstrap is taken) and calls the binder.
func TestGuestAttach_PicksFreeSlotAndBinds(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{
8200: {"mp9": "/var/lib/.../bootstrap,mp=/etc/felhom-bootstrap,ro=1"},
})
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
if w.Code != http.StatusOK {
t.Fatalf("attach: got %d want 200 (%s)", w.Code, w.Body.String())
}
if ga.count() != 1 || ga.calls[0].slot != "mp0" || ga.calls[0].where != "/mnt/felhom-usb" || ga.calls[0].vmid != 8200 {
t.Fatalf("AttachBind not called with mp0/where/vmid: %+v", ga.calls)
}
}
// An already-bound drive is idempotent: returns the existing slot, binder NOT called again.
func TestGuestAttach_Idempotent(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{
8200: {"mp0": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb"},
})
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
if w.Code != http.StatusOK {
t.Fatalf("idempotent attach: got %d want 200 (%s)", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"already":true`) {
t.Fatalf("expected already:true: %s", w.Body.String())
}
if ga.count() != 0 {
t.Fatalf("AttachBind must NOT be called for an already-bound drive: %+v", ga.calls)
}
}
// A hostile/invalid where is refused with no binder call.
func TestGuestAttach_RejectsBadPath(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
for _, bad := range []string{`{"where":"/mnt/../etc"}`, `{"where":"/etc/passwd"}`, `{"where":"/mnt/a/b"}`, `{"where":""}`} {
if w := do(t, h, "POST", "/disks/guest-attach", "A", bad); w.Code != http.StatusBadRequest {
t.Fatalf("bad where %s: got %d want 400", bad, w.Code)
}
}
if ga.count() != 0 {
t.Fatal("binder called for an invalid path")
}
}
// Not configured (no GuestAttach dep) → 503.
func TestGuestAttach_NotConfigured(t *testing.T) {
h := newAttachServer(t, nil, map[int]map[string]string{8200: {}})
if w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`); w.Code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured: got %d want 503", w.Code)
}
}
// ---- auth / config ----------------------------------------------------------------------
func TestDisks_CrossGuest403(t *testing.T) {
+79
View File
@@ -0,0 +1,79 @@
package localapi
import (
"context"
"fmt"
"log/slog"
"strconv"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
// Guest data-drive passthrough (slice 10 P2, Model A). An enrolled external user-data drive is
// mounted on the HOST at /mnt/<name>; this binds its felhom-data NAMESPACE into the guest so the
// in-guest controller + apps can use it. Confinement is the inner (host→guest) bind: only
// <drive>/felhom-data crosses into the guest — the customer's other data on the drive never does.
//
// Model A: the felhom-data dir is bound AT the guest's /mnt/<name> (so the guest's /mnt/<name> IS the
// felhom-data namespace; `findmnt` shows /dev/sdXN[/felhom-data], which the controller's mount strip
// already handles). The bind is RW (NOT ro=1 like the bootstrap mount). The namespace is chowned to
// the unprivileged-LXC base so the guest reads it as root-owned (per-app subdirs are chowned to the
// app's mapped UID at deploy — NOT here). Spike-proven on 9201 (see usb-passthrough-spike memory).
// guestMappedRoot is the unprivileged-LXC idmap base — guest root (UID 0) == host UID 100000. chowning
// the namespace to this makes the guest see it as root:root, writable by the in-guest controller.
const guestMappedRoot = "100000:100000"
// felhomDataNS is the Felhom-managed namespace directory created on every external data drive. Only
// this subtree is exposed to the guest (matches the controller's appbackup.FelhomDataDir).
const felhomDataNS = "felhom-data"
// GuestBinder attaches a host data-drive's felhom-data namespace into a guest as an RW bind mount via
// `pct set` (a root@pam op — same fenced Runner the provision back-half uses for its bind). It does
// NOT make HTTP calls; the slot selection + idempotency live in the handler (which has the guest
// config). Satisfies localapi.GuestAttacher.
type GuestBinder struct {
runner proxmox.Runner
logger *slog.Logger
}
// NewGuestBinder builds a binder over the given root-CLI runner.
func NewGuestBinder(r proxmox.Runner, logger *slog.Logger) *GuestBinder {
if logger == nil {
logger = slog.Default()
}
return &GuestBinder{runner: r, logger: logger}
}
// AttachBind creates + chowns <where>/felhom-data on the host and binds it into the guest at <where>
// (Model A). mountKey is the chosen guest slot ("mp3"). Idempotency + slot choice are the caller's
// (it reads the guest config); this performs the host-root steps only.
func (b *GuestBinder) AttachBind(ctx context.Context, vmid int, mountKey, where string) error {
src := where + "/" + felhomDataNS // host source = the felhom-data namespace on the drive
// 1. Ensure the namespace dir exists (idempotent; created fresh + uniformly owned, so the drive's
// pre-existing mixed-ownership customer data is never touched).
if err := b.run(ctx, "mkdir", "-p", src); err != nil {
return fmt.Errorf("guest-attach: create namespace %s: %w", src, err)
}
// 2. chown the namespace ROOT to the guest base (NOT -R: per-app subdirs are chowned at deploy).
if err := b.run(ctx, "chown", guestMappedRoot, src); err != nil {
return fmt.Errorf("guest-attach: chown namespace %s: %w", src, err)
}
// 3. Bind it into the guest at `where`, RW. Bind form (host path), NEVER storage:size (that volume
// form would create a fresh empty disk and lose the existing data).
spec := fmt.Sprintf("%s,mp=%s", src, where)
if err := b.run(ctx, "pct", "set", strconv.Itoa(vmid), "-"+mountKey, spec); err != nil {
return fmt.Errorf("guest-attach: pct set %s: %w", spec, err)
}
b.logger.Info("guest-attach: data drive bound into guest",
"vmid", vmid, "slot", mountKey, "source", src, "guest_path", where)
return nil
}
func (b *GuestBinder) 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
}
+11 -4
View File
@@ -81,6 +81,9 @@ type Options struct {
Disks DiskOps
DiskGate StorageGate
Guests2 GuestLister
// GuestAttach binds an enrolled user-data drive's felhom-data namespace into the guest (slice 10
// P2, Model A). OPTIONAL — when nil, POST /disks/guest-attach reports "not configured".
GuestAttach GuestAttacher
// HostReader is the root-free host topology reader used to classify a device/mount's protection
// ROLE (it backs SystemDisks for the eject role-gate + the /disks role hints). OPTIONAL — when nil
// it defaults to the production *storage.ProcHostReader. Injectable so the role-gate is testable.
@@ -132,10 +135,11 @@ type Server struct {
logger *slog.Logger
now func() time.Time
disks DiskOps // slice 8C (optional)
diskGate StorageGate // slice 8C (optional)
guestList GuestLister // slice 8C (optional)
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
disks DiskOps // slice 8C (optional)
diskGate StorageGate // slice 8C (optional)
guestList GuestLister // slice 8C (optional)
guestAttach GuestAttacher // slice 10 P2 (optional)
host storage.HostReader // role classification source (optional; defaults to ProcHostReader)
hostMetrics HostMetricsProvider // slice 9 (optional)
hostID string // slice 10B: for the data-bearing-format pending-op hint
@@ -175,6 +179,7 @@ func NewServer(o Options) (*Server, error) {
disks: o.Disks,
diskGate: o.DiskGate,
guestList: o.Guests2,
guestAttach: o.GuestAttach,
host: o.HostReader,
hostMetrics: o.HostMetrics,
hostID: o.HostID,
@@ -200,6 +205,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /disks/assign", s.withGuest(s.handleDiskAssign))
mux.HandleFunc("POST /disks/eject", s.withGuest(s.handleDiskEject))
mux.HandleFunc("POST /disks/format", s.withGuest(s.handleDiskFormat))
// Guest data-drive passthrough (slice 10 P2): bind an enrolled drive's felhom-data namespace in.
mux.HandleFunc("POST /disks/guest-attach", s.withGuest(s.handleDiskGuestAttach))
return mux
}