// Package guesthook is the LXC guest pre-start self-heal (C1 net, transitional). // // THE BUG IT FIXES (C1, B3 audit): in the per-drive bind model an external data drive is bound into the // guest as `pct set -mpN /felhom-data,mp=/mnt/`. When that drive is ABSENT at guest // boot, the bind SOURCE `/felhom-data` does not exist, `pct start` fails the mount, and the guest // BRICKS (pre-start exit 255 — ALL apps down). Today nothing recovers it. // // THE FIX: a PVE `pre-start` hookscript runs this code; for every BIND mountpoint whose source path is // missing it CREATES an empty, host-root-owned placeholder directory so the mount succeeds and the guest // boots. It is fail-closed: the placeholder is owned by host root (uid 0), which is UNMAPPED in the // unprivileged-LXC user namespace, so the in-guest controller/apps (even as guest-root) cannot write to // it — and a returning drive simply shadows it (the agent mounts over it). // // WHY CREATE, NOT DELETE: removing the dead mp would need `pct set --delete mpN`, which takes the // per-guest config lock the start task ALREADY holds → it dead-times-out (~10s) and the guest still // bricks. So in pre-start we NEUTRALISE (placeholder) rather than mutate config. Proper mp removal runs // OUTSIDE the start lock — at decommission (handleDiskDecommission → DetachBind) and the startup // reconcile. The intermediary-mount re-architecture later makes C1 STRUCTURAL (the only bind source is // the permanent, always-present /mnt/felhom-drives parent), after which this hook is pure defense-in-depth. package guesthook import ( "fmt" "os" "path/filepath" "sort" "strings" ) // PhasePreStart is the PVE hook phase at which we self-heal (before the container mounts are set up). const PhasePreStart = "pre-start" // PhasePostStart is the PVE hook phase after the container started — the NAS automount reassert // point (the fresh guest namespace has no idle autofs triggers; see netreassert.go). const PhasePostStart = "post-start" // placeholderMode is the mode for a created bind-source placeholder. Host-root-owned + this mode = // fail-closed against the unprivileged guest (host uid 0 is unmapped in the guest userns). const placeholderMode = 0o755 // ParseConfMounts parses an LXC config file body (/etc/pve/lxc/.conf) and returns each mount key // (`mp0`..`mp255`, plus `rootfs`) mapped to its SOURCE — the first comma-field of the value, before any // `mp=`/`size=`/`backup=` options. A BIND mount has an absolute-path source (`/mnt/...`); a storage // volume has a `:` source (no leading slash). Lines that aren't a mountpoint/rootfs key // are ignored. func ParseConfMounts(conf string) map[string]string { out := map[string]string{} for _, line := range strings.Split(conf, "\n") { line = strings.TrimSpace(line) colon := strings.IndexByte(line, ':') if colon <= 0 { continue } key := line[:colon] if key != "rootfs" && !(strings.HasPrefix(key, "mp") && isAllDigits(strings.TrimPrefix(key, "mp"))) { continue } val := strings.TrimSpace(line[colon+1:]) if val == "" { continue } src := val if c := strings.IndexByte(val, ','); c >= 0 { src = val[:c] } out[key] = strings.TrimSpace(src) } return out } // isBindSource reports whether an mp source is a host-path BIND (an absolute path) rather than a PVE // storage volume (`:`, never absolute). On the Linux host a bind source is `/mnt/...` // (leading slash); the filepath.IsAbs arm additionally recognises an OS-absolute path so the real-IO // tests pass under a Windows temp dir too — on Linux both arms agree and a storage volid matches neither. func isBindSource(src string) bool { return strings.HasPrefix(src, "/") || filepath.IsAbs(src) } func isAllDigits(s string) bool { if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true } // MissingBindSources returns the BIND-mount source paths (absolute host paths) that do NOT exist, sorted // and de-duplicated. Storage-volume sources (`:`, no leading '/') are NEVER returned — // only a real host-path bind can have a vanished source we must heal; a storage volume that's missing is // PVE's own concern, not ours to mkdir. `exists` reports whether a path is present (injected for tests). func MissingBindSources(mounts map[string]string, exists func(string) bool) []string { seen := map[string]bool{} var miss []string for _, src := range mounts { if !isBindSource(src) { // storage volume (:), not a host-path bind — never touch continue } if seen[src] || exists(src) { continue } seen[src] = true miss = append(miss, src) } sort.Strings(miss) return miss } // Heal reads the LXC config at confPath and creates a placeholder directory for every bind-mount source // that is missing, returning the list of paths it created. It never returns a fatal error for an // unreadable/empty config (a guest with no config simply has nothing to heal) — the hook must NEVER block // a start. A mkdir failure on one path is collected into err but the others still proceed. func Heal(confPath string) (created []string, err error) { data, readErr := os.ReadFile(confPath) if readErr != nil { // No config = nothing to heal. Never block the start over a read error. return nil, nil } mounts := ParseConfMounts(string(data)) miss := MissingBindSources(mounts, func(p string) bool { _, statErr := os.Stat(p) return statErr == nil }) var errs []string for _, p := range miss { if mkErr := os.MkdirAll(p, placeholderMode); mkErr != nil { errs = append(errs, fmt.Sprintf("%s: %v", p, mkErr)) continue } created = append(created, p) } if len(errs) > 0 { return created, fmt.Errorf("guesthook: placeholder creation failed for: %s", strings.Join(errs, "; ")) } return created, nil }