package storage import ( "context" "crypto/sha256" "encoding/hex" "fmt" "net" "os" "path/filepath" "regexp" "strconv" "strings" "time" ) // Network storage (NAS) — the bulk-media class (Part A1). A NAS share is mounted HOST-SIDE under // /mnt/felhom-drives/ via a systemd .automount (on-demand + idle-unmount) + .mount pair; it // propagates into the guest for free through the existing shared mp8 bind. This is a DISTINCT class // from a physical drive: it carries NO durable-id, never enters the IntentStore, is never SMART-probed, // and bypasses the enroll/eject/decommission/wipe/migrate machinery entirely (SPIKE-nas-storage Q7). // // The whole file is the validated, locked recipe from SPIKE-nas-storage-2026-06-29.md — match it, do // not re-derive: NFS preferred (vers=4.1,soft,timeo=50,retrans=2,noatime), SMB fallback, the +100000 // uid rule (container uid/gid N = host N+100000), automount idle-unmount for failure isolation. const ( // NetworkMountRoot is the ONLY place a network mount may live — under the existing shared mp8 // bind parent, so it propagates into the guest with no new mountpoint and no restart. The role // gate (NetworkMountRole) confines every network mount to this namespace. NetworkMountRoot = "/mnt/felhom-drives" // lxcUIDOffset is the unprivileged-LXC id map base: a container uid/gid N appears on the host as // N+100000 (verified against the live felhom-usb userdata). The NAS must present/own/squash files // as container_id+100000 so the guest sees its native id and reads+writes (SPIKE Q5). This is the // whole +100000 recipe; a naive +0 (anonuid=1000 / uid=1000) lands as nobody:nogroup → not writable. lxcUIDOffset = 100000 // netUnitMarker headers every network-storage unit. It is the explicit guard that keeps a network // mount OUT of the drive machinery: parseFelhomMountUnit refuses any unit carrying it, so the // host-reboot drive re-assert (ReassertEnrolledMounts) never touches a NAS mount (Scenario D). netUnitMarker = "Managed by felhom-agent (network storage)" // defaultIdleTimeoutSec is the automount TimeoutIdleSec: with no app reading, the share auto-unmounts, // so an idle NAS reboot is a non-event and the stale-mount window is minimised (SPIKE recommendation). defaultIdleTimeoutSec = 60 // netReachTimeout bounds the per-share liveness endpoint dial so a black-holed NAS cannot wedge a // list call (the mount itself may be EIO/D-state; we probe the endpoint, never stat the mount). netReachTimeout = 2 * time.Second ) // NetworkProtocol is the wire protocol of a network mount. type NetworkProtocol string const ( ProtocolNFS NetworkProtocol = "nfs" ProtocolSMB NetworkProtocol = "smb" ) // NetworkMountSpec describes one NAS share to mount host-side. It is NOT a drive: no durable-id, no // device, no role lifecycle. MappedUID/MappedGID are the CONTAINER ids (e.g. 1000); the agent applies // the +100000 offset where it matters (the SMB client mount). CredsRef points at the 0600 out-of-band // SMB credentials file (empty for NFS) — never the credentials themselves. type NetworkMountSpec struct { Name string // share name → mountpoint /mnt/felhom-drives/; a single safe path segment Protocol NetworkProtocol // nfs | smb Server string // NAS host or IP Export string // NFS export path (/srv/media) or SMB share name (media) MappedUID int // CONTAINER uid the media app runs as (host = +100000) MappedGID int // CONTAINER gid CredsRef string // SMB only: absolute path to the 0600 credentials file (out-of-band) IdleTimeoutSec int // automount idle-unmount window; 0 → defaultIdleTimeoutSec } // NetworkMountStatus is the per-share liveness surface (SPIKE Q7 health model): is it configured, // currently mounted, and is the NAS endpoint reachable. Health degrades to "unreachable" for the // affected share ONLY — it never folds into the box's overall health. type NetworkMountStatus struct { Name string `json:"name"` Protocol string `json:"protocol"` Server string `json:"server"` Export string `json:"export"` Where string `json:"where"` Configured bool `json:"configured"` // the automount unit is installed Mounted bool `json:"mounted"` // currently mounted (false when idle-unmounted — normal, not a fault) Reachable bool `json:"reachable"` // the NAS endpoint is TCP-reachable Health string `json:"health"` // ok | idle | unreachable } // Network mount health vocabulary. const ( NetHealthOK = "ok" // reachable + mounted: serving NetHealthIdle = "idle" // reachable + not mounted: automount idle-unmounted (benign) NetHealthUnreachable = "unreachable" // endpoint not reachable: the affected share is degraded ) var ( // reShareName: a single safe path segment for the share name (becomes the mountpoint dir + unit name). reShareName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) // reNetServer: a hostname or IPv4/IPv6 literal — no metacharacters, no '/', no ':' path tricks. reNetServer = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) ) const maxShareNameLen = 64 // Where returns the mountpoint for this spec under the network-mount root. func (s NetworkMountSpec) Where() string { return NetworkMountRoot + "/" + s.Name } // HostUID/HostGID apply the +100000 LXC offset — the host id the share must own/squash files as. func (s NetworkMountSpec) HostUID() int { return s.MappedUID + lxcUIDOffset } func (s NetworkMountSpec) HostGID() int { return s.MappedGID + lxcUIDOffset } // idleTimeout returns the configured idle-unmount window or the default. func (s NetworkMountSpec) idleTimeout() int { if s.IdleTimeoutSec > 0 { return s.IdleTimeoutSec } return defaultIdleTimeoutSec } // ValidateNetworkMountSpec is the security boundary for the network-mount surface — every value that // reaches a systemd unit (and thus a root-triggered mount) is checked here before any unit is rendered. // Mirrors validate.go's discipline: strict charset/length, no metacharacters, no traversal. func ValidateNetworkMountSpec(s NetworkMountSpec) error { if s.Name == "" || len(s.Name) > maxShareNameLen || !reShareName.MatchString(s.Name) || s.Name == "." || s.Name == ".." { return fmt.Errorf("netmount: invalid share name %q (want a single safe segment)", s.Name) } switch s.Protocol { case ProtocolNFS, ProtocolSMB: default: return fmt.Errorf("netmount: unsupported protocol %q (want nfs|smb)", s.Protocol) } if s.Server == "" || len(s.Server) > 255 || !reNetServer.MatchString(s.Server) { return fmt.Errorf("netmount: invalid server %q", s.Server) } if err := validateExport(s.Protocol, s.Export); err != nil { return err } if s.MappedUID < 0 || s.MappedUID > 60000 || s.MappedGID < 0 || s.MappedGID > 60000 { return fmt.Errorf("netmount: mapped uid/gid out of range (uid=%d gid=%d; want 0..60000)", s.MappedUID, s.MappedGID) } if s.Protocol == ProtocolSMB { if s.CredsRef == "" { return fmt.Errorf("netmount: smb requires a credentials file reference") } if err := ValidateMountPath(s.CredsRef); err != nil { return fmt.Errorf("netmount: invalid credentials path: %w", err) } } // The mountpoint must validate AND must land under the network-mount root (the role gate's namespace). if err := ValidateMountPath(s.Where()); err != nil { return err } return nil } // validateExport checks the NFS export path / SMB share name. NFS export must be an absolute, safe path; // the SMB share name a single safe segment. func validateExport(proto NetworkProtocol, export string) error { if export == "" { return fmt.Errorf("netmount: empty export/share") } switch proto { case ProtocolNFS: if export[0] != '/' { return fmt.Errorf("netmount: nfs export must be an absolute path, got %q", export) } if err := ValidateMountPath(export); err != nil { return fmt.Errorf("netmount: invalid nfs export: %w", err) } case ProtocolSMB: if len(export) > maxShareNameLen || !reShareName.MatchString(export) { return fmt.Errorf("netmount: invalid smb share name %q", export) } } return nil } // NetworkMountRole gates network storage to the bulk-userdata namespace (SPIKE Q7: a NAS is selectable // as a media app's data path but DISQUALIFIED as a system/backup/DB target). A network mount is // user-data ONLY when it lands under NetworkMountRoot (the controller's bind blast radius); any other // target is the most-protected role (system) → the caller refuses it. Pure → unit-tested. func NetworkMountRole(where string) DeviceRole { clean := cleanMountPath(where) if clean == NetworkMountRoot || strings.HasPrefix(clean, NetworkMountRoot+"/") { return RoleUserData } return RoleSystem } // mountSource builds the systemd What= for the spec: server:/export (NFS) or //server/share (SMB). func (s NetworkMountSpec) mountSource() string { if s.Protocol == ProtocolSMB { return "//" + s.Server + "/" + s.Export } return s.Server + ":" + s.Export } // fsType maps the protocol to the kernel filesystem type for the .mount unit. func (s NetworkMountSpec) fsType() string { if s.Protocol == ProtocolSMB { return "cifs" } return "nfs4" // vers=4.1 → nfs4 (avoids the rpcbind/lock-manager surface of v3) } // mountOptions returns the exact, validated option set for the protocol (SPIKE Q2/Q5 + // SPIKE-nas-verify Q4-vi): // - NFS: vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0 — soft is the failure-isolation // knob (clean EIO, never a wedge); the +100000 squash is the EXPORT's job (anonuid=101000), not // the client mount, so no uid appears here. retry=0 (SPIKE-nas-verify Q4-vi): without it a // dead-NAS on-demand access wedges the app until systemd's 90 s start cap (measured 91 s); with // it the access fails clean in ~3.8 s (ENODEV) AND the verify sees a classifiable // "No route to host" instead of a diagnostic-free systemd timeout. retry only governs retrying // a FAILED first attempt — the happy path is untouched, and each autofs re-access is a fresh // attempt anyway. // - SMB: vers=3.0,credentials=,uid=<+100000>,gid=<+100000>,forceuid,forcegid,file_mode=0664, // dir_mode=0775,_netdev — modes are PLAIN octal (not setgid 2775); the client forces the // guest-visible owner to the mapped id so the container reads+writes. NO retry= here — retry is // a mount.nfs option; mount.cifs would reject it. // // Every interpolated value is pre-validated by ValidateNetworkMountSpec, so the string carries no // newline / no extra directive. NEVER a default `hard` NFS mount (it wedges) — soft is mandatory. func (s NetworkMountSpec) mountOptions() string { if s.Protocol == ProtocolSMB { return strings.Join([]string{ "vers=3.0", "credentials=" + s.CredsRef, "uid=" + strconv.Itoa(s.HostUID()), "gid=" + strconv.Itoa(s.HostGID()), "forceuid", "forcegid", "file_mode=0664", "dir_mode=0775", "_netdev", }, ",") } return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0" } // renderNetworkMountUnit builds the .mount unit (triggered by the .automount; deliberately NO [Install] // — we enable the .automount, not this). Marked with netUnitMarker so the drive machinery skips it. func renderNetworkMountUnit(s NetworkMountSpec) string { var b strings.Builder b.WriteString("# " + netUnitMarker + " — do not edit by hand.\n") b.WriteString("[Unit]\n") fmt.Fprintf(&b, "Description=Felhom network storage %s (%s)\n", sanitizeDesc(s.Name), s.Protocol) // F12 (CAMPAIGN-3, CRITICAL): NO network-online.target ordering here. `_netdev` in Options is the // correct + sufficient network ordering for the REAL mount — systemd classes a _netdev mount under // remote-fs.target and orders it after the network without a hand-written After/Wants. A literal // `After=network-online.target` on this unit (which the .automount pulls in via local-fs) closed the // boot ordering cycle networking→local-fs→automount→network-online→networking; systemd broke it by // DELETING an arbitrary job (one boot lost networking entirely, the next lost the automount). b.WriteString("\n[Mount]\n") fmt.Fprintf(&b, "What=%s\n", s.mountSource()) fmt.Fprintf(&b, "Where=%s\n", s.Where()) fmt.Fprintf(&b, "Type=%s\n", s.fsType()) fmt.Fprintf(&b, "Options=%s\n", s.mountOptions()) return b.String() } // renderNetworkAutomountUnit builds the .automount unit (on-demand + idle-unmount). Enabling THIS is // what realises the on-demand mount; the idle timeout makes an idle NAS reboot a non-event. func renderNetworkAutomountUnit(s NetworkMountSpec) string { var b strings.Builder b.WriteString("# " + netUnitMarker + " — do not edit by hand.\n") b.WriteString("[Unit]\n") fmt.Fprintf(&b, "Description=Felhom network storage automount %s (%s)\n", sanitizeDesc(s.Name), s.Protocol) // F12 (CAMPAIGN-3, CRITICAL): the automount unit gets NO network relation of ANY kind. A trigger // needs no network — it just watches the mountpoint and fires the .mount on first access (the // .mount's `_netdev` then orders the real mount after the network). An automount is implicitly // ordered Before=local-fs.target; adding After/Wants=network-online.target here created the boot // ordering cycle that cost the host its network on one boot and its NAS on the next. Keep this unit // orderable before local-fs WITHOUT dragging the network into that transaction. b.WriteString("\n[Automount]\n") fmt.Fprintf(&b, "Where=%s\n", s.Where()) fmt.Fprintf(&b, "TimeoutIdleSec=%d\n", s.idleTimeout()) b.WriteString("\n[Install]\n") b.WriteString("WantedBy=multi-user.target\n") return b.String() } // parseNetworkMountUnit is the inverse of renderNetworkMountUnit for the fields the liveness list needs. // ok=false unless the content is a felhom network-storage .mount unit (the netUnitMarker + a parseable // network What=). Pure → unit-tested. func parseNetworkMountUnit(content string) (proto, server, export, where string, ok bool) { if !strings.Contains(content, netUnitMarker) { return "", "", "", "", false } var what, fstype string for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "What="): what = strings.TrimPrefix(line, "What=") case strings.HasPrefix(line, "Where="): where = strings.TrimPrefix(line, "Where=") case strings.HasPrefix(line, "Type="): fstype = strings.TrimPrefix(line, "Type=") } } if where == "" || what == "" { return "", "", "", "", false } switch fstype { case "cifs": proto = string(ProtocolSMB) // //server/share rest := strings.TrimPrefix(what, "//") if i := strings.IndexByte(rest, '/'); i > 0 { server, export = rest[:i], rest[i+1:] } case "nfs4", "nfs": proto = string(ProtocolNFS) if i := strings.IndexByte(what, ':'); i > 0 { server, export = what[:i], what[i+1:] } default: return "", "", "", "", false } if server == "" { return "", "", "", "", false } return proto, server, export, where, true } // networkHealth derives the per-share health string from reachability + mount state (SPIKE Q7). func networkHealth(reachable, mounted bool) string { switch { case !reachable: return NetHealthUnreachable case mounted: return NetHealthOK default: return NetHealthIdle // reachable but idle-unmounted — normal automount steady state } } // netEndpoint returns the host:port the liveness probe dials for a protocol (NFS 2049, SMB 445). func netEndpoint(proto, server string) string { port := "2049" if proto == string(ProtocolSMB) { port = "445" } return netJoin(server, port) } // ---- SudoHostOps: the privileged network-mount surface -------------------------------------------- // EnsureNetworkMount stages + installs the .mount and .automount units and enables the AUTOMOUNT (not // the mount): the share then mounts on first access and idle-unmounts when quiet. Idempotent. It does // NOT register a durable-id, NOT enter the IntentStore, NOT SMART-probe — a NAS is not a drive. func (h *SudoHostOps) EnsureNetworkMount(ctx context.Context, spec NetworkMountSpec) error { if err := ValidateNetworkMountSpec(spec); err != nil { return err } // Template-drift reconcile: bring any already-installed units up to the current template before we // touch the unit dir (F12 — a pre-0.85 unit still carrying the network-online ordering gets rewritten // here even if the daemon-startup migration hasn't run in this process). Best-effort; never blocks add. h.MigrateNetworkUnits(ctx) // Defense in depth: never realise a network mount outside the user-data namespace. if NetworkMountRole(spec.Where()) != RoleUserData { return fmt.Errorf("netmount: refusing to mount outside the user-data namespace: %s", spec.Where()) } mountUnit, err := UnitNameForMount(spec.Where()) if err != nil { return err } automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount" // Ensure the mountpoint exists (systemd automount also creates it, but be explicit — the parent is // the shared bind root). Reuse the intermediary mkdir grant. if err := h.run(ctx, "/usr/bin/mkdir", "-p", spec.Where()); err != nil { return fmt.Errorf("netmount: mkdir %s: %w", spec.Where(), err) } if err := h.installUnit(ctx, mountUnit, renderNetworkMountUnit(spec)); err != nil { return err } h.logger.Debug("netmount: mount unit installed", "unit", mountUnit) if err := h.installUnit(ctx, automountUnit, renderNetworkAutomountUnit(spec)); err != nil { return err } h.logger.Debug("netmount: automount unit installed", "unit", automountUnit, "idle_timeout_s", spec.idleTimeout()) if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil { return fmt.Errorf("netmount: daemon-reload: %w", err) } // Enable + start the AUTOMOUNT (creates the autofs trigger; idempotent). if err := h.run(ctx, h.bins.Systemctl, "enable", "--now", "--", automountUnit); err != nil { return fmt.Errorf("netmount: enabling automount %s: %w", automountUnit, err) } h.logger.Debug("netmount: automount enabled + started", "unit", automountUnit) h.logger.Info("netmount: ensured network mount", "name", spec.Name, "proto", spec.Protocol, "server", spec.Server, "export", spec.Export, "where", spec.Where(), "host_uid", spec.HostUID()) return nil } // installUnit stages an agent-owned unit file then root-installs it into the unit dir (atomic, fixed // mode/owner) — the same staging pattern as EnsureMount. func (h *SudoHostOps) installUnit(ctx context.Context, unitName, content string) error { if err := os.MkdirAll(h.stageDir, 0o700); err != nil { return fmt.Errorf("netmount: staging dir: %w", err) } stagePath := filepath.Join(h.stageDir, unitName) if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil { return fmt.Errorf("netmount: staging unit %s: %w", unitName, err) } dest := filepath.Join(h.unitDir, unitName) if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil { return fmt.Errorf("netmount: installing unit %s: %w", unitName, err) } return nil } // RemoveNetworkMount stops + disables the automount, stops the mount, and removes both unit files (and // the staged copies). It NEVER routes through the drive eject/decommission path — a NAS has no device // lifecycle. Best-effort on the per-step stop/disable (a not-loaded unit is fine); the file removal is // the authoritative "gone" signal. func (h *SudoHostOps) RemoveNetworkMount(ctx context.Context, name string) error { if name == "" || len(name) > maxShareNameLen || !reShareName.MatchString(name) || name == "." || name == ".." { return fmt.Errorf("netmount: invalid share name %q", name) } where := NetworkMountRoot + "/" + name mountUnit, err := UnitNameForMount(where) if err != nil { return err } automountUnit := strings.TrimSuffix(mountUnit, ".mount") + ".automount" // Stop the automount first (so it can't re-trigger the mount), then the mount. Tolerate "not loaded". for _, step := range [][]string{ {"stop", "--", automountUnit}, {"disable", "--", automountUnit}, {"stop", "--", mountUnit}, } { err := h.run(ctx, h.bins.Systemctl, step...) h.logger.Debug("netmount: remove step", "verb", step[0], "unit", step[len(step)-1], "ok", err == nil) // a "not loaded" failure here is expected + tolerated } // F2 (CAMPAIGN-3): clear any failed/start-limit runtime state on the pair BEFORE the files go, or // systemd keeps them as `not-found failed` residue after daemon-reload. reset-failed while the units // are still loaded; tolerate the not-failed case (nothing to reset). h.resetNetworkUnitsIfFailed(ctx, automountUnit, mountUnit) destAuto := filepath.Join(h.unitDir, automountUnit) destMount := filepath.Join(h.unitDir, mountUnit) if err := h.run(ctx, "/usr/bin/rm", "-f", destAuto); err != nil { return fmt.Errorf("netmount: removing %s: %w", automountUnit, err) } if err := h.run(ctx, "/usr/bin/rm", "-f", destMount); err != nil { return fmt.Errorf("netmount: removing %s: %w", mountUnit, err) } _ = os.Remove(filepath.Join(h.stageDir, automountUnit)) _ = os.Remove(filepath.Join(h.stageDir, mountUnit)) if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil { return fmt.Errorf("netmount: daemon-reload: %w", err) } // F1 (CAMPAIGN-3): remove the now-empty mountpoint directory (the campaign accumulated 10 stub-shaped // leftovers). rmdir ONLY — a non-empty dir (unexpected data present) is left in place with a WARN, the // fail-safe; `rm -rf` is forbidden here. The host removal propagates into running guests through the // shared bind; a fresh guest re-binds cleanly on next start. h.rmdirMountpoint(ctx, where) h.logger.Info("netmount: removed network mount", "name", name, "where", where) return nil } // resetNetworkUnitsIfFailed reset-failed's any of the given units that is in the failed state (F2 — // leave no `not-found failed`/start-limit residue behind a remove or a rolled-back add). Unprivileged // is-failed read + the FELHOM_NETMOUNT reset-failed grant; every step tolerated. func (h *SudoHostOps) resetNetworkUnitsIfFailed(ctx context.Context, units ...string) { for _, unit := range units { if h.unitFailed == nil || !h.unitFailed(ctx, unit) { continue } if err := h.run(ctx, h.bins.Systemctl, "reset-failed", "--", unit); err != nil { h.logger.Warn("netmount: reset-failed tolerated failure", "unit", unit, "err", err) } } } // rmdirMountpoint removes an empty network mountpoint dir under NetworkMountRoot. rmdir refuses a // non-empty dir (the fail-safe): unexpected data is preserved and flagged, never rm -rf'd. Best-effort. func (h *SudoHostOps) rmdirMountpoint(ctx context.Context, where string) { if !strings.HasPrefix(where, NetworkMountRoot+"/") { return // defense in depth: only ever under the bind root } if err := h.run(ctx, "/usr/bin/rmdir", where); err != nil { // rmdir fails on a non-empty dir — leave it (fail-safe) and flag it for the operator. h.logger.Warn("netmount: mountpoint dir not removed (non-empty or busy — left in place, fail-safe)", "where", where, "err", err) } } // MigrateNetworkUnits reconciles every marker-owned network-storage unit file on disk against a fresh // render of its own reconstructed spec — a general template-drift reconcile (the git-sync pattern: // content-hash compare, rewrite on drift, batched daemon-reload). It exists because a template change // must reach ALREADY-INSTALLED units, not only future adds: the F12 fix (CAMPAIGN-3) removed the // network-online ordering that turned every host boot with an enrolled share into a coin flip, and the // units installed before 0.85 still carry the ordering cycle until they are rewritten. Runs at agent // startup (before the reassert sweep) and at the head of EnsureNetworkMount. Idempotent: a unit already // byte-identical to its fresh render is left untouched (second pass rewrites nothing). Best-effort per // unit; one INFO line per migrated unit. Returns the count migrated. func (h *SudoHostOps) MigrateNetworkUnits(ctx context.Context) int { entries, err := os.ReadDir(h.unitDir) if err != nil { h.logger.Warn("netmigrate: reading unit dir failed", "err", err) return 0 } migrated := 0 changed := false for _, e := range entries { if !strings.HasSuffix(e.Name(), ".mount") { continue // the .mount carries What/Type; the paired .automount mirrors Where } mountPath := filepath.Join(h.unitDir, e.Name()) mountData, rerr := os.ReadFile(mountPath) if rerr != nil || !strings.Contains(string(mountData), netUnitMarker) { continue // unreadable or not one of ours } automountName := strings.TrimSuffix(e.Name(), ".mount") + ".automount" automountData, aerr := os.ReadFile(filepath.Join(h.unitDir, automountName)) if aerr != nil { continue // a .mount with no paired .automount is malformed — not ours to guess } spec, ok := specFromNetworkUnits(string(mountData), string(automountData)) if !ok { continue } freshMount := renderNetworkMountUnit(spec) freshAuto := renderNetworkAutomountUnit(spec) if contentHash(string(mountData)) == contentHash(freshMount) && contentHash(string(automountData)) == contentHash(freshAuto) { continue // already current — the idempotent no-op } if err := h.installUnit(ctx, e.Name(), freshMount); err != nil { h.logger.Warn("netmigrate: rewriting mount unit failed", "unit", e.Name(), "err", err) continue } if err := h.installUnit(ctx, automountName, freshAuto); err != nil { h.logger.Warn("netmigrate: rewriting automount unit failed", "unit", automountName, "err", err) continue } changed = true migrated++ h.logger.Info("netmigrate: migrated network-storage unit to the current template (F12: dropped the boot ordering cycle)", "name", spec.Name, "where", spec.Where()) } if changed { if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil { h.logger.Warn("netmigrate: daemon-reload after migration failed", "err", err) } } return migrated } // contentHash is the SHA-256 hex of a unit file's content — the drift comparator (git-sync pattern). func contentHash(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } // specFromNetworkUnits reconstructs the NetworkMountSpec that renders EXACTLY the given installed unit // pair — the fresh-render input for the drift reconcile. Round-trips by construction: every field the // render templates read is recovered (proto/server/export/where from the .mount; the SMB uid/gid+creds // from its Options; the idle window from the .automount). A NFS spec's mapped uid never appears in a // rendered unit, so it is irrelevant to the render and left at the container default. ok=false for a // non-marker or unparseable pair. func specFromNetworkUnits(mountContent, automountContent string) (NetworkMountSpec, bool) { proto, server, export, where, ok := parseNetworkMountUnit(mountContent) if !ok { return NetworkMountSpec{}, false } spec := NetworkMountSpec{ Name: strings.TrimPrefix(where, NetworkMountRoot+"/"), Protocol: NetworkProtocol(proto), Server: server, Export: export, MappedUID: 1000, // container default; unused by the NFS render, overwritten below for SMB MappedGID: 1000, } if spec.Protocol == ProtocolSMB { opts := unitLineValue(mountContent, "Options=") if uid, ok := csvIntField(opts, "uid="); ok { spec.MappedUID = uid - lxcUIDOffset } if gid, ok := csvIntField(opts, "gid="); ok { spec.MappedGID = gid - lxcUIDOffset } spec.CredsRef = csvField(opts, "credentials=") } if idle, ok := csvIntField(unitLineValue(automountContent, "TimeoutIdleSec="), ""); ok && idle > 0 { spec.IdleTimeoutSec = idle } return spec, true } // unitLineValue returns the value after the first line beginning with prefix (e.g. "Options="), trimmed. func unitLineValue(content, prefix string) string { for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, prefix) { return strings.TrimPrefix(line, prefix) } } return "" } // csvField finds the comma-separated token with the given key prefix (e.g. "credentials=") and returns // its value. "" if absent. func csvField(csv, key string) string { for _, tok := range strings.Split(csv, ",") { if strings.HasPrefix(tok, key) { return strings.TrimPrefix(tok, key) } } return "" } // csvIntField parses the int value of a comma-separated key (e.g. "uid=") — or, when key is "", parses // the whole string as an int (for a bare value like TimeoutIdleSec's already-extracted number). func csvIntField(csv, key string) (int, bool) { val := csv if key != "" { val = csvField(csv, key) } if val == "" { return 0, false } n, err := strconv.Atoi(strings.TrimSpace(val)) if err != nil { return 0, false } return n, true } // ListNetworkMounts enumerates the installed network-storage units and reports per-share liveness. It // reads the (world-readable) unit dir + /proc/mounts and TCP-probes each NAS endpoint with a short // timeout — it NEVER stat()s the (possibly EIO/D-state) mountpoint, so a black-holed NAS cannot wedge // this call. Best-effort: an unreadable unit dir yields an empty list. func (h *SudoHostOps) ListNetworkMounts(ctx context.Context) ([]NetworkMountStatus, error) { entries, err := h.networkUnitEntries() if err != nil { return nil, err } mounts := h.mountedFSTypes() var out []NetworkMountStatus for _, e := range entries { st := NetworkMountStatus{ Name: e.name, Protocol: e.proto, Server: e.server, Export: e.export, Where: e.where, Configured: true, Mounted: isNetworkMounted(mounts[e.where]), Reachable: endpointReachable(netEndpoint(e.proto, e.server)), } st.Health = networkHealth(st.Reachable, st.Mounted) out = append(out, st) } return out, nil } // networkUnitEntry is one installed network-storage unit pair, parsed from its .mount file. type networkUnitEntry struct { name, proto, server, export, where string } // networkUnitEntries enumerates the installed network-storage unit pairs from the (world-readable) // unit dir — the shared core of ListNetworkMounts and ReassertNetworkAutomounts. No probing, no // mountpoint access. Best-effort per file; an unreadable unit dir is the only error. func (h *SudoHostOps) networkUnitEntries() ([]networkUnitEntry, error) { entries, err := os.ReadDir(h.unitDir) if err != nil { return nil, fmt.Errorf("netmount: reading unit dir: %w", err) } var out []networkUnitEntry for _, e := range entries { if !strings.HasSuffix(e.Name(), ".mount") { continue // the .mount unit carries the What/Type; the .automount mirrors Where only } data, rerr := os.ReadFile(filepath.Join(h.unitDir, e.Name())) if rerr != nil { continue } proto, server, export, where, ok := parseNetworkMountUnit(string(data)) if !ok { continue // not one of ours (or a drive by-uuid mount) } out = append(out, networkUnitEntry{ name: strings.TrimPrefix(where, NetworkMountRoot+"/"), proto: proto, server: server, export: export, where: where, }) } return out, nil } // mountedFSTypes maps each active mountpoint to its filesystem type (from /proc/mounts: field 2 = where, // field 3 = fstype). Best-effort. func (h *SudoHostOps) mountedFSTypes() map[string]string { out := map[string]string{} data, err := os.ReadFile("/proc/mounts") if err != nil { return out } for _, line := range strings.Split(string(data), "\n") { f := strings.Fields(line) if len(f) >= 3 { out[f[1]] = f[2] } } return out } // isNetworkMounted reports whether the recorded fstype at a mountpoint is a real network mount (not the // autofs trigger). An idle automount shows fstype "autofs" (or nothing) → not mounted; an active mount // shows nfs4/nfs/cifs. func isNetworkMounted(fstype string) bool { switch fstype { case "nfs", "nfs4", "cifs": return true default: return false } } // NetworkEndpointReachable TCP-dials a share's NAS endpoint (NFS 2049 / SMB 445) with the short // liveness timeout. This is the add endpoint's SYNC pre-probe (SPIKE-nas-verify Scenario E): an // unreachable server is refused in ~2 s BEFORE any unit is installed. func NetworkEndpointReachable(proto NetworkProtocol, server string) bool { return endpointReachable(netEndpoint(string(proto), server)) } // NetworkMountedAt reports whether a REAL network filesystem (nfs/nfs4/cifs) is currently mounted at // where, per /proc/mounts. The autofs trigger does NOT count. This is the verify job's mount-success // truth source (SPIKE-nas-verify §8): success is judged from /proc/mounts, NEVER from readability — // a 0700 export owned by the squashed uid gives the agent user EACCES on a perfectly good mount. func NetworkMountedAt(where string) bool { data, err := os.ReadFile("/proc/mounts") if err != nil { return false } return networkMountedIn(string(data), where) } // networkMountedIn is the pure core of NetworkMountedAt (unit-tested against fixture tables). func networkMountedIn(procMounts, where string) bool { for _, line := range strings.Split(procMounts, "\n") { f := strings.Fields(line) if len(f) >= 3 && f[1] == where && isNetworkMounted(f[2]) { return true } } return false } // endpointReachable TCP-dials a NAS endpoint with a short timeout (the liveness probe that never touches // the mount). "" endpoint → not reachable. func endpointReachable(endpoint string) bool { if endpoint == "" { return false } conn, err := net.DialTimeout("tcp", endpoint, netReachTimeout) if err != nil { return false } _ = conn.Close() return true }