package localapi import ( "context" "fmt" "io" "log/slog" "strconv" "strings" "sync" "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/; 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 // /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/ (so the guest's /mnt/ 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 // mountMu serializes AttachDrive/DetachDrive so a concurrent reconnect (controller-triggered) and the // agent's periodic reconcile can't both pass the isHostMountpoint check and double-bind the same // stable path (a TOCTOU race — observed live as 2 stacked binds). mountMu sync.Mutex } // 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 /felhom-data on the host and binds it into the guest at // (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 } // DetachBind removes a mountpoint bind from the guest config (`pct set --delete `). This is // the decommission/eject counterpart to AttachBind and the C1 FIX: a drive whose bind is removed here // leaves NO dead `mpN` whose now-missing source would brick the guest on its next reboot (the B3 // critical bug, where decommission unmounted the drive but never deleted the bind). It runs on a RUNNING // guest — a plain config edit, NOT a start — so it takes no start lock and cannot deadlock (unlike a // pre-start `--delete`, which is why the boot-time net uses placeholders instead). The live in-guest // mount lingers until the next reboot; the caller unmounts the host source separately. func (b *GuestBinder) DetachBind(ctx context.Context, vmid int, mountKey string) error { if err := b.run(ctx, "pct", "set", strconv.Itoa(vmid), "--delete", mountKey); err != nil { return fmt.Errorf("guest-detach: pct set %d --delete %s: %w", vmid, mountKey, err) } b.logger.Info("guest-detach: mountpoint bind removed from guest config", "vmid", vmid, "slot", mountKey) return nil } // RebootGuest reboots the guest (graceful shutdown + start) so persisted-but-inactive mountpoint // binds activate (slice 10 P2: the host-side live inject is blocked on an unprivileged guest, so a // drive enrolled into a RUNNING guest activates only at the next boot — this is the user-triggered // "Újraindítás most" path). `pct reboot` blocks until the guest is back, so callers run it detached. func (b *GuestBinder) RebootGuest(ctx context.Context, vmid int) error { b.logger.Warn("guest-reboot: rebooting guest to activate pending mountpoint binds", "vmid", vmid) if err := b.run(ctx, "pct", "reboot", strconv.Itoa(vmid)); err != nil { return fmt.Errorf("guest-reboot: pct reboot %d: %w", vmid, err) } b.logger.Info("guest-reboot: guest back up", "vmid", vmid) 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 } // GuestExec runs a command INSIDE the guest via `pct exec -- ` and returns its stdout. // It is the single guest-exec seam the controller-swap primitive composes over (cat the image file, // docker image-inspect, write the file, systemctl restart, docker inspect health) — reusing the same // fenced root runner as the bind ops rather than hand-rolling pct. The guest must be running. func (b *GuestBinder) GuestExec(ctx context.Context, vmid int, args ...string) (string, error) { pctArgs := append([]string{"exec", strconv.Itoa(vmid), "--"}, args...) out, stderr, err := b.runner.Run(ctx, "pct", pctArgs...) if err != nil { return string(out), fmt.Errorf("pct exec %d %v: %w: %s", vmid, args, err, strings.TrimSpace(string(stderr))) } return string(out), nil } // GuestExecStdin is GuestExec with the in-guest command's stdin fed from stdin. The controller-swap // write uses it to pipe the image ref into an in-guest `tee` (no shell vector, no interpolation), // through the same fenced runner so the `sudo -n` prefix stays in one place. func (b *GuestBinder) GuestExecStdin(ctx context.Context, vmid int, stdin io.Reader, args ...string) (string, error) { pctArgs := append([]string{"exec", strconv.Itoa(vmid), "--"}, args...) out, stderr, err := b.runner.RunStdin(ctx, stdin, "pct", pctArgs...) if err != nil { return string(out), fmt.Errorf("pct exec %d %v: %w: %s", vmid, args, err, strings.TrimSpace(string(stderr))) } return string(out), nil }