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