package storage import ( "context" "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): // - NFS: vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev — 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. // - 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. // // 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" } // 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) b.WriteString("After=network-online.target\n") b.WriteString("Wants=network-online.target\n") 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) b.WriteString("After=network-online.target\n") b.WriteString("Wants=network-online.target\n") 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 } // 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 } if err := h.installUnit(ctx, automountUnit, renderNetworkAutomountUnit(spec)); err != nil { return err } 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.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". _ = h.run(ctx, h.bins.Systemctl, "stop", "--", automountUnit) _ = h.run(ctx, h.bins.Systemctl, "disable", "--", automountUnit) _ = h.run(ctx, h.bins.Systemctl, "stop", "--", 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) } h.logger.Info("netmount: removed network mount", "name", name, "where", where) return nil } // 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 := os.ReadDir(h.unitDir) if err != nil { return nil, fmt.Errorf("netmount: reading unit dir: %w", err) } mounts := h.mountedFSTypes() var out []NetworkMountStatus 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) } st := NetworkMountStatus{ Name: strings.TrimPrefix(where, NetworkMountRoot+"/"), Protocol: proto, Server: server, Export: export, Where: where, Configured: true, Mounted: isNetworkMounted(mounts[where]), Reachable: endpointReachable(netEndpoint(proto, server)), } st.Health = networkHealth(st.Reachable, st.Mounted) out = append(out, st) } 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 } } // 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 }