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"`