Files
felhom-agent/internal/storage/netmount.go
T
admin cb692f8788 v0.83.0: observability pass — always-DEBUG capture ring + GET /debug/logs + heartbeat log-pull + gap-fill sweep
Capture layer: applog.New returns (logger, Ring) — slog fan-out, stderr at the
configured level, ~1000-entry ring fixed at LevelDebug (remote diagnostics
without a config flip). GET /debug/logs (token-authed, ?raw=1) + request-level
DEBUG middleware. Heartbeat log-pull mirrors the report logtail pattern:
envelope log_tail_requested -> next heartbeat carries log_tail (128KB cap,
consume-once, failed-push retry proven). Gap-fill sweep over netverify/
netstorage/netmount/signedjobs/selfupdate/disks/controller-swap/desired/loop.
Red-proofs: ring-at-emit-level FAILs capture test; drain removed FAILs
consume-once; dropped phase line FAILs the S7 log-sequence smoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 16:24:07 +02:00

559 lines
23 KiB
Go

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/<name> 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/<Name>; 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=<file>,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)
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
}
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
}
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
}
}
// 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
}