package localapi import ( "bufio" "context" "fmt" "os" "path/filepath" "strings" ) // Intermediary-mount model (replaces the per-drive `pct set -mpN` bind). A SINGLE permanent parent bind // `/mnt/felhom-drives` is set into the guest once (at provision/migration); the host keeps that dir a // SHARED mount, and the agent mounts/unmounts each drive's felhom-data namespace UNDERNEATH it host-side // (`mount --bind /mnt//felhom-data /mnt/felhom-drives/`). Mount propagation (host `shared` → // guest `slave`) carries the change into the RUNNING guest live — no `pct`, no reboot, and the parent // bind source never disappears (so the guest is inherently C1-immune). Confinement holds: only the // felhom-data subtree crosses in, never the customer's other top-level dirs. See // felhom.eu/documentation/audits/SPIKE-intermediary-mount-2026-06-15.md. // StableParentDir is the permanent host dir bound once into the guest; drives are swapped underneath it. const StableParentDir = "/mnt/felhom-drives" // sharedParentScript re-establishes the shared parent on every HOST boot. It MUST run before // pve-guests.service so the guest's parent bind inherits the shared peer group as `slave` (if the guest // starts first, its bind is `private` and drive swaps don't propagate until a guest restart). const sharedParentScriptPath = "/usr/local/sbin/felhom-shared-parent.sh" const sharedParentScript = `#!/bin/sh # felhom stable drive parent: a SHARED bind so the agent can swap backing drives underneath it and the # guest sees the change live (no restart). MUST run before pve-guests so the guest's parent bind inherits # the shared peer group (slave). Installed + enabled by felhom-agent. Idempotent. set -e mkdir -p ` + StableParentDir + ` mountpoint -q ` + StableParentDir + ` || mount --bind ` + StableParentDir + ` ` + StableParentDir + ` mount --make-shared ` + StableParentDir + ` ` const sharedParentUnitPath = "/etc/systemd/system/felhom-shared-parent.service" const sharedParentUnit = `[Unit] Description=Felhom stable drive parent (shared bind for live drive hot-swap) DefaultDependencies=no After=local-fs.target Before=pve-guests.service ConditionPathExists=` + sharedParentScriptPath + ` [Service] Type=oneshot RemainAfterExit=yes ExecStart=` + sharedParentScriptPath + ` [Install] WantedBy=multi-user.target ` // StablePathForRaw maps a drive's RAW host mount (/mnt/) to its stable in-guest path // (/mnt/felhom-drives/). The basename is the drive name — the single source of truth both repos // derive the guest path from. Returns "" if `where` is not a /mnt/ path. func StablePathForRaw(where string) string { name := DriveNameFromRaw(where) if name == "" { return "" } return StableParentDir + "/" + name } // DriveNameFromRaw returns the drive name from a raw /mnt/ host mount (the basename), or "" if the // path isn't a single-component /mnt/. func DriveNameFromRaw(where string) string { if !strings.HasPrefix(where, "/mnt/") { return "" } name := strings.TrimPrefix(where, "/mnt/") if name == "" || strings.ContainsAny(name, "/ \t") { return "" } return name } // EnsureSharedParent makes the host stable parent a SHARED mount and installs+enables the boot-time // systemd unit that re-establishes it before pve-guests. Idempotent: it only binds when the dir isn't // already a mountpoint (re-binding would stack), and always (re-)marks it shared (a no-op when already // shared). Best-effort install of the unit (a host-reboot-persistence concern) — a failed install does // not stop the live setup. Called at agent startup and at provision. func (b *GuestBinder) EnsureSharedParent(ctx context.Context) error { if err := b.run(ctx, "mkdir", "-p", StableParentDir); err != nil { return fmt.Errorf("shared-parent: mkdir %s: %w", StableParentDir, err) } if !isHostMountpoint(StableParentDir) { if err := b.run(ctx, "mount", "--bind", StableParentDir, StableParentDir); err != nil { return fmt.Errorf("shared-parent: self-bind: %w", err) } } if err := b.run(ctx, "mount", "--make-shared", StableParentDir); err != nil { return fmt.Errorf("shared-parent: make-shared: %w", err) } if err := b.installSharedParentUnit(ctx); err != nil { b.logger.Warn("shared-parent: boot-persistence unit install failed (live setup OK; survives until host reboot)", "err", err) } b.logger.Info("shared-parent: host stable parent is shared", "dir", StableParentDir) return nil } // installSharedParentUnit writes the script + unit (from agent-written temps) and enables the unit so the // shared parent is re-established on every host boot before pve-guests. Idempotent. func (b *GuestBinder) installSharedParentUnit(ctx context.Context) error { tmpScript := filepath.Join(os.TempDir(), "felhom-shared-parent.sh") if err := os.WriteFile(tmpScript, []byte(sharedParentScript), 0o755); err != nil { return fmt.Errorf("write temp script: %w", err) } defer os.Remove(tmpScript) if err := b.run(ctx, "install", "-m", "0755", "--", tmpScript, sharedParentScriptPath); err != nil { return fmt.Errorf("install script: %w", err) } tmpUnit := filepath.Join(os.TempDir(), "felhom-shared-parent.service") if err := os.WriteFile(tmpUnit, []byte(sharedParentUnit), 0o644); err != nil { return fmt.Errorf("write temp unit: %w", err) } defer os.Remove(tmpUnit) if err := b.run(ctx, "install", "-m", "0644", "--", tmpUnit, sharedParentUnitPath); err != nil { return fmt.Errorf("install unit: %w", err) } if err := b.run(ctx, "systemctl", "daemon-reload"); err != nil { return fmt.Errorf("daemon-reload: %w", err) } if err := b.run(ctx, "systemctl", "enable", "felhom-shared-parent.service"); err != nil { return fmt.Errorf("enable unit: %w", err) } return nil } // AttachDrive binds a drive's felhom-data namespace under the stable parent so it appears live in the // guest at the returned stable path (via propagation — no pct, no reboot). `where` is the drive's RAW // host PVE mount (/mnt/); only `/felhom-data` crosses into the guest (confinement). The // stable per-drive dir is created HOST-ROOT-owned (fail-closed when nothing is mounted under it); the // felhom-data namespace is created+chowned to the guest base so the in-guest controller owns it. // Idempotent: if the stable path is already a mountpoint, it's a no-op. func (b *GuestBinder) AttachDrive(ctx context.Context, where string) (string, error) { stable := StablePathForRaw(where) if stable == "" { return "", fmt.Errorf("guest-attach: %q is not a /mnt/ mount", where) } src := where + "/" + felhomDataNS // Ensure the namespace exists + is owned by the guest base (same as the legacy AttachBind). if err := b.run(ctx, "mkdir", "-p", src); err != nil { return "", fmt.Errorf("guest-attach: namespace %s: %w", src, err) } if err := b.run(ctx, "chown", guestMappedRoot, src); err != nil { return "", fmt.Errorf("guest-attach: chown namespace %s: %w", src, err) } // The stable mountpoint dir stays HOST-ROOT-owned (fail-closed) — create it, never chown it. if err := b.run(ctx, "mkdir", "-p", stable); err != nil { return "", fmt.Errorf("guest-attach: stable dir %s: %w", stable, err) } if isHostMountpoint(stable) { b.logger.Info("guest-attach: already bound under parent (idempotent)", "where", where, "stable", stable) return stable, nil } if err := b.run(ctx, "mount", "--bind", src, stable); err != nil { return "", fmt.Errorf("guest-attach: bind %s -> %s: %w", src, stable, err) } b.logger.Info("guest-attach: drive bound under shared parent (live, no reboot)", "where", where, "stable", stable) return stable, nil } // DetachDrive unmounts a drive's felhom-data from the stable parent (propagates OUT of the guest live), // leaving the bare HOST-ROOT-owned stable dir → fail-closed (the guest can't write to it even as root, // since host uid 0 is unmapped). No pct, no reboot. Idempotent: a non-mountpoint is a no-op. func (b *GuestBinder) DetachDrive(ctx context.Context, where string) error { stable := StablePathForRaw(where) if stable == "" { return fmt.Errorf("guest-detach: %q is not a /mnt/ mount", where) } if !isHostMountpoint(stable) { return nil // already detached } if err := b.run(ctx, "umount", stable); err != nil { return fmt.Errorf("guest-detach: umount %s: %w", stable, err) } b.logger.Info("guest-detach: drive unmounted from shared parent (live, fail-closed)", "where", where, "stable", stable) return nil } // isHostMountpoint reports whether path is currently a mount target in the host's mount table // (/proc/self/mountinfo). Pure read — used for idempotency (skip re-binding) and the BoundUnderParent // report. A read error → false (treat as not-mounted; AttachDrive then (re)binds, which is safe). func isHostMountpoint(path string) bool { f, err := os.Open("/proc/self/mountinfo") if err != nil { return false } defer f.Close() sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { // mountinfo field 5 (0-indexed 4) is the mount point. fields := strings.Fields(sc.Text()) if len(fields) >= 5 && fields[4] == path { return true } } return false }