From 32517583408a3e9c6665cd9cb6d9a013b688ba39 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 17:20:50 +0200 Subject: [PATCH] Add a read-only mounts module: no tool could read a machine's fstab, mount units or exports --- modules/mounts/README.md | 37 ++ modules/mounts/cmd/mounts/exports.go | 281 +++++++++++++++ modules/mounts/cmd/mounts/exports_test.go | 108 ++++++ modules/mounts/cmd/mounts/machine.go | 83 +++++ modules/mounts/cmd/mounts/main.go | 20 ++ modules/mounts/cmd/mounts/mounts.go | 414 ++++++++++++++++++++++ modules/mounts/cmd/mounts/mounts_test.go | 211 +++++++++++ modules/mounts/cmd/mounts/tools.go | 55 +++ modules/mounts/go.mod | 5 + modules/mounts/go.sum | 2 + modules/mounts/module.json | 23 ++ 11 files changed, 1239 insertions(+) create mode 100644 modules/mounts/README.md create mode 100644 modules/mounts/cmd/mounts/exports.go create mode 100644 modules/mounts/cmd/mounts/exports_test.go create mode 100644 modules/mounts/cmd/mounts/machine.go create mode 100644 modules/mounts/cmd/mounts/main.go create mode 100644 modules/mounts/cmd/mounts/mounts.go create mode 100644 modules/mounts/cmd/mounts/mounts_test.go create mode 100644 modules/mounts/cmd/mounts/tools.go create mode 100644 modules/mounts/go.mod create mode 100644 modules/mounts/go.sum create mode 100644 modules/mounts/module.json diff --git a/modules/mounts/README.md b/modules/mounts/README.md new file mode 100644 index 00000000..ae23309d --- /dev/null +++ b/modules/mounts/README.md @@ -0,0 +1,37 @@ +# mounts + +What a machine mounts and what it shares over the network, read as tools (novox/hq research 038, shares +and mounts managed by the mesh). + +## What it owns + +Nothing yet. This is the read half of the shares-and-mounts design: it was built because no tool could +read a machine's fstab, its mount units or its NFS exports, which two failing fstab mounts on the +workstation needed. The to-be design in novox/hq makes this module write the mount and automount units +for the shares a node requires; until that design is decided, it declares no resource and changes +nothing. + +## What it reads + +- `/etc/fstab`, every line but swap. A credential in the options (`password=`, `pass=`, `passwd=`) is + answered as `(redacted)`; a `credentials=` file is named and never opened. +- The service manager's mount and automount units, with `systemctl list-units` and `systemctl show`: + state, result, timeout, and whether each was generated from fstab or comes from a unit file. The + kernel's own filesystems (proc, tmpfs, mqueue and the like) are left out. +- `/proc/self/mountinfo`, for what is mounted now. A mount point is never opened or statted, so a + network filesystem whose server is away cannot hang a call. +- `/etc/exports`, `/etc/exports.d/*.exports` and the kernel's live export table `/var/lib/nfs/etab`. +- The ZFS `sharenfs` and `sharesmb` properties, where `zfs` is installed. +- The share sections of `/etc/samba/smb.conf`: path, valid users, read only or writable, force user and + the like. The `[global]` section and every other setting are left out, so no password or password + backend is answered. + +A file that cannot be read, and a command that does not answer, is said under `notes`, never answered +as empty. + +## Tools + +| tool | | answers | +|---|---|---| +| `mounts_list` | r | every mount point, its fstab line, its mount and automount units and whether it is mounted now; network filesystems marked; a network mount without automount or nofail said. Narrowed with `network` and `match` | +| `mounts_exports` | r | the NFS exports declared and live, the ZFS share properties, the Samba shares, and whether nfs-server and smb are active; an export declared and not live, or live and declared nowhere, said | diff --git a/modules/mounts/cmd/mounts/exports.go b/modules/mounts/cmd/mounts/exports.go new file mode 100644 index 00000000..921bdef3 --- /dev/null +++ b/modules/mounts/cmd/mounts/exports.go @@ -0,0 +1,281 @@ +package main + +import ( + "context" + "fmt" + "sort" + "strings" +) + +// Export is one path offered to one client over NFS. +type Export struct { + Path string `json:"path"` + Client string `json:"client"` + Options string `json:"options,omitempty"` + File string `json:"file"` + Line int `json:"line,omitempty"` +} + +// ParseExports reads an exports(5) file: a path, then clients each with its options in brackets; a +// `-options` word sets the default for the clients after it; a backslash continues a line. +func ParseExports(text, file string) []Export { + out := []Export{} + lines := strings.Split(text, "\n") + for i := 0; i < len(lines); i++ { + start := i + 1 + line := lines[i] + for strings.HasSuffix(strings.TrimRight(line, " \t"), `\`) && i+1 < len(lines) { + line = strings.TrimSuffix(strings.TrimRight(line, " \t"), `\`) + " " + lines[i+1] + i++ + } + if c := strings.Index(line, "#"); c >= 0 { + line = line[:c] + } + f := splitExportLine(line) + if len(f) == 0 { + continue + } + path := unescape(strings.Trim(f[0], `"`)) + defaults := "" + clients := 0 + for _, w := range f[1:] { + if strings.HasPrefix(w, "-") { + defaults = strings.TrimPrefix(w, "-") + continue + } + client, opts := w, defaults + if o := strings.Index(w, "("); o >= 0 { + client = w[:o] + opts = strings.TrimSuffix(w[o+1:], ")") + } + if client == "" { + client = "*" + } + out = append(out, Export{Path: path, Client: client, Options: opts, File: file, Line: start}) + clients++ + } + if clients == 0 { + out = append(out, Export{Path: path, Client: "*", Options: defaults, File: file, Line: start}) + } + } + return out +} + +// splitExportLine splits on blanks outside double quotes. +func splitExportLine(line string) []string { + var out []string + var cur strings.Builder + quoted := false + for _, r := range line { + switch { + case r == '"': + quoted = !quoted + cur.WriteRune(r) + case (r == ' ' || r == '\t') && !quoted: + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + default: + cur.WriteRune(r) + } + } + if cur.Len() > 0 { + out = append(out, cur.String()) + } + return out +} + +// ZFSShare is one dataset's sharenfs or sharesmb property. +type ZFSShare struct { + Dataset string `json:"dataset"` + Property string `json:"property"` + Value string `json:"value"` + Source string `json:"source"` +} + +// ParseZFSGet reads `zfs get -H -o name,property,value,source`. +func ParseZFSGet(text string) []ZFSShare { + out := []ZFSShare{} + for _, line := range strings.Split(text, "\n") { + f := strings.Split(line, "\t") + if len(f) < 4 { + continue + } + out = append(out, ZFSShare{Dataset: f[0], Property: f[1], Value: f[2], Source: f[3]}) + } + return out +} + +// SMBShare is one Samba share: what it serves and to whom, never a password. +type SMBShare struct { + Name string `json:"name"` + Path string `json:"path,omitempty"` + Settings map[string]string `json:"settings"` +} + +// smbKept are the share settings answered. Everything else in a section — and the [global] section +// whole — is left out, so no password, password backend or secret a file may hold is ever answered. +var smbKept = map[string]bool{ + "comment": true, "valid users": true, "invalid users": true, "read only": true, "writable": true, + "writeable": true, "write ok": true, "write list": true, "read list": true, "force user": true, "force group": true, + "guest ok": true, "public": true, "browseable": true, "browsable": true, "hosts allow": true, "hosts deny": true, + "available": true, "create mask": true, "directory mask": true, +} + +// ParseSMBConf reads smb.conf's share sections. +func ParseSMBConf(text string) []SMBShare { + out := []SMBShare{} + var cur *SMBShare + for _, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(raw) + if line == "" || line[0] == '#' || line[0] == ';' { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + name := strings.TrimSpace(line[1 : len(line)-1]) + if strings.EqualFold(name, "global") { + cur = nil + continue + } + out = append(out, SMBShare{Name: name, Settings: map[string]string{}}) + cur = &out[len(out)-1] + continue + } + if cur == nil { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + key := strings.Join(strings.Fields(strings.ToLower(k)), " ") + v = strings.TrimSpace(v) + if key == "path" { + cur.Path = v + continue + } + if smbKept[key] { + cur.Settings[key] = v + } + } + return out +} + +// ExportsReport is what mounts_exports answers. +type ExportsReport struct { + Said []string `json:"said"` + Services map[string]string `json:"services"` + Exports []Export `json:"exports"` + Live []Export `json:"live_exports"` + ZFS []ZFSShare `json:"zfs_shares,omitempty"` + SMB []SMBShare `json:"smb_shares"` + Notes []string `json:"notes,omitempty"` +} + +// Exports reads what this machine shares over the network. +func (m *Machine) Exports() ExportsReport { + r := ExportsReport{Said: []string{}, Services: map[string]string{}, Exports: []Export{}, Live: []Export{}, SMB: []SMBShare{}} + ctx := context.Background() + + units := []string{"nfs-server.service", "smb.service"} + active, err := m.Run(ctx, "systemctl", append([]string{"is-active"}, units...)...) + states := strings.Fields(active) + for i, u := range units { + if i < len(states) { + r.Services[u] = states[i] + } else if err != nil { + r.Services[u] = "not read" + } + } + + files := []string{"/etc/exports"} + files = append(files, m.glob("/etc/exports.d/*.exports")...) + for _, f := range files { + text, err := m.read(f) + if err != nil { + r.Notes = append(r.Notes, unreadable(f, err)) + continue + } + r.Exports = append(r.Exports, ParseExports(text, f)...) + } + if etab, err := m.read("/var/lib/nfs/etab"); err != nil { + r.Notes = append(r.Notes, unreadable("/var/lib/nfs/etab", err)+" — the kernel's live exports are not known") + } else { + for _, line := range strings.Split(etab, "\n") { + path, rest, ok := strings.Cut(line, "\t") + if !ok { + continue + } + for _, e := range ParseExports("x "+rest, "/var/lib/nfs/etab") { + r.Live = append(r.Live, Export{Path: unescape(path), Client: e.Client, Options: e.Options, File: e.File}) + } + } + } + + if m.LookPath != nil && m.LookPath("zfs") { + out, err := m.Run(ctx, "zfs", "get", "-H", "-o", "name,property,value,source", "-t", "filesystem", "sharenfs,sharesmb") + if err != nil { + r.Notes = append(r.Notes, fmt.Sprintf("the ZFS share properties were not read: %v", err)) + } + for _, z := range ParseZFSGet(out) { + if z.Value != "off" && z.Value != "-" { + r.ZFS = append(r.ZFS, z) + } + } + } + + if conf, err := m.read("/etc/samba/smb.conf"); err != nil { + r.Notes = append(r.Notes, unreadable("/etc/samba/smb.conf", err)) + } else { + r.SMB = ParseSMBConf(conf) + } + + r.Said = r.say() + return r +} + +func (r ExportsReport) say() []string { + said := []string{} + for _, u := range []string{"nfs-server.service", "smb.service"} { + said = append(said, fmt.Sprintf("%s: %s", u, r.Services[u])) + } + live := map[string]bool{} + for _, e := range r.Live { + live[e.Path+" "+e.Client] = true + } + for _, e := range r.Exports { + line := fmt.Sprintf("NFS %s to %s (%s), in %s", e.Path, e.Client, e.Options, e.File) + if len(r.Live) > 0 && !live[e.Path+" "+e.Client] { + line += " — NOT in the kernel's live exports" + } + said = append(said, line) + } + declared := map[string]bool{} + for _, e := range r.Exports { + declared[e.Path+" "+e.Client] = true + } + for _, e := range r.Live { + if !declared[e.Path+" "+e.Client] { + said = append(said, fmt.Sprintf("NFS %s to %s is live but in no exports file (set by hand with exportfs, or by ZFS before its file was written)", e.Path, e.Client)) + } + } + for _, z := range r.ZFS { + said = append(said, fmt.Sprintf("ZFS %s %s=%s (%s)", z.Dataset, z.Property, z.Value, z.Source)) + } + sort.SliceStable(r.SMB, func(i, j int) bool { return r.SMB[i].Name < r.SMB[j].Name }) + for _, s := range r.SMB { + line := fmt.Sprintf("SMB [%s] %s", s.Name, s.Path) + if v := s.Settings["valid users"]; v != "" { + line += ", for " + v + } + if v := s.Settings["force user"]; v != "" { + line += ", writes as " + v + } + said = append(said, line) + } + if len(r.Exports) == 0 && len(r.Live) == 0 && len(r.ZFS) == 0 && len(r.SMB) == 0 { + said = append(said, "this machine shares nothing over NFS or SMB") + } + return said +} diff --git a/modules/mounts/cmd/mounts/exports_test.go b/modules/mounts/cmd/mounts/exports_test.go new file mode 100644 index 00000000..afcee3b7 --- /dev/null +++ b/modules/mounts/cmd/mounts/exports_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestParseExports(t *testing.T) { + text := `# comment +/srv/media 192.0.2.0/24(rw,sync,root_squash) 198.51.100.7(ro) +"/srv/with space" -ro,sync host.example \ + other.example(rw) +/srv/bare +` + got := ParseExports(text, "/etc/exports") + want := []Export{ + {"/srv/media", "192.0.2.0/24", "rw,sync,root_squash", "/etc/exports", 2}, + {"/srv/media", "198.51.100.7", "ro", "/etc/exports", 2}, + {"/srv/with space", "host.example", "ro,sync", "/etc/exports", 3}, + {"/srv/with space", "other.example", "rw", "/etc/exports", 3}, + {"/srv/bare", "*", "", "/etc/exports", 5}, + } + if len(got) != len(want) { + t.Fatalf("got %d, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("export %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestParseSMBConfLeavesSecretsOut(t *testing.T) { + text := `[global] +workgroup = WORKGROUP +passdb backend = tdbsam +; comment +[media] +comment = Media +path = /srv/media/ +valid users = media +Read Only = no +force user = media +smb encrypt = required +username map script = /bin/secret +[homes] +browseable = no +` + got := ParseSMBConf(text) + if len(got) != 2 || got[0].Name != "media" || got[0].Path != "/srv/media/" { + t.Fatalf("got %+v", got) + } + if got[0].Settings["read only"] != "no" || got[0].Settings["force user"] != "media" { + t.Errorf("settings = %+v", got[0].Settings) + } + b, _ := json.Marshal(got) + for _, leak := range []string{"tdbsam", "WORKGROUP", "/bin/secret"} { + if strings.Contains(string(b), leak) { + t.Errorf("%q was answered: %s", leak, b) + } + } +} + +func TestExports(t *testing.T) { + f := newFake(t) + f.zfs = true + f.file("/etc/exports", "# only comments\n") + f.file("/etc/exports.d/zfs.exports", "/storage/media 192.0.2.0/24(sec=sys,rw,no_subtree_check,mountpoint)\n") + f.file("/var/lib/nfs/etab", "/storage/media\t192.0.2.0/24(rw,sync,root_squash,sec=sys)\n/old\t198.51.100.0/24(ro)\n") + f.file("/etc/samba/smb.conf", "[global]\nworkgroup = W\n[media]\npath = /storage/media/\nvalid users = media\nforce user = media\n") + f.answers["systemctl is-active nfs-server.service smb.service"] = "active\ninactive\n" + f.answers["zfs get -H -o name,property,value,source -t filesystem sharenfs,sharesmb"] = + "storage\tsharenfs\toff\tdefault\nstorage/media\tsharenfs\trw=@192.0.2.0/24\tlocal\nstorage/media\tsharesmb\ton\tlocal\n" + r := f.machine().Exports() + said := strings.Join(r.Said, "\n") + for _, want := range []string{ + "nfs-server.service: active", + "smb.service: inactive", + "NFS /storage/media to 192.0.2.0/24 (sec=sys,rw,no_subtree_check,mountpoint), in /etc/exports.d/zfs.exports", + "NFS /old to 198.51.100.0/24 is live but in no exports file", + "ZFS storage/media sharenfs=rw=@192.0.2.0/24 (local)", + "SMB [media] /storage/media/, for media, writes as media", + } { + if !strings.Contains(said, want) { + t.Errorf("said lacks %q:\n%s", want, said) + } + } + if len(r.ZFS) != 2 { + t.Errorf("a share property that is off must be left out: %+v", r.ZFS) + } +} + +func TestExportsUnreadableIsSaid(t *testing.T) { + r := newFake(t).machine().Exports() + notes := strings.Join(r.Notes, "\n") + for _, want := range []string{"/etc/exports: not present", "/var/lib/nfs/etab: not present", "/etc/samba/smb.conf: not present"} { + if !strings.Contains(notes, want) { + t.Errorf("notes lack %q: %s", want, notes) + } + } + if r.Services["nfs-server.service"] != "not read" { + t.Errorf("services = %+v", r.Services) + } + if !strings.Contains(strings.Join(r.Said, "\n"), "shares nothing") { + t.Errorf("said = %v", r.Said) + } +} diff --git a/modules/mounts/cmd/mounts/machine.go b/modules/mounts/cmd/mounts/machine.go new file mode 100644 index 00000000..88d42c65 --- /dev/null +++ b/modules/mounts/cmd/mounts/machine.go @@ -0,0 +1,83 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// CommandTimeout bounds every command this module runs. No command it runs touches a mounted +// filesystem, so none should block on a server that is gone; the bound is there in case one does. +const CommandTimeout = 5 * time.Second + +// Runner runs one command and answers its standard output. It is injected so that the tools are tested +// against recorded answers rather than this machine's service manager. +type Runner func(ctx context.Context, name string, args ...string) (string, error) + +// ExecRunner runs a command on the machine, bounded by CommandTimeout. +func ExecRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, CommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + said := strings.TrimSpace(stderr.String()) + if said != "" { + return stdout.String(), fmt.Errorf("%s: %w: %s", name, err, said) + } + return stdout.String(), fmt.Errorf("%s: %w", name, err) + } + return stdout.String(), nil +} + +// Machine is what the module reads: a filesystem root (the real one, or a test's tree standing in for +// /etc, /proc and /var/lib), a way to run a command, and a way to look a command up. +type Machine struct { + Root string + Run Runner + LookPath func(name string) bool +} + +// Here is the machine this process runs on. +func Here() *Machine { + return &Machine{Root: "/", Run: ExecRunner, LookPath: func(n string) bool { _, err := exec.LookPath(n); return err == nil }} +} + +// read answers one file's text under the root. Only regular files the module names are read; a +// mountpoint is never opened, so a network filesystem whose server is gone cannot block a call. +func (m *Machine) read(path string) (string, error) { + b, err := os.ReadFile(filepath.Join(m.Root, path)) + return string(b), err +} + +// glob answers the files under the root matching a pattern, as paths seen from the machine's root. +func (m *Machine) glob(pattern string) []string { + found, _ := filepath.Glob(filepath.Join(m.Root, pattern)) + out := make([]string, 0, len(found)) + for _, f := range found { + rel, err := filepath.Rel(m.Root, f) + if err != nil { + continue + } + out = append(out, "/"+filepath.ToSlash(rel)) + } + return out +} + +// unreadable says why a file could not be read, in the module's words. +func unreadable(path string, err error) string { + switch { + case os.IsNotExist(err): + return path + ": not present" + case os.IsPermission(err): + return path + ": not readable by the tool runner's account" + default: + return fmt.Sprintf("%s: not read (%v)", path, err) + } +} diff --git a/modules/mounts/cmd/mounts/main.go b/modules/mounts/cmd/mounts/main.go new file mode 100644 index 00000000..5f95d600 --- /dev/null +++ b/modules/mounts/cmd/mounts/main.go @@ -0,0 +1,20 @@ +// The mounts module's Go bundle (novox/hq research 038): a process the node's runtime launches and +// speaks MCP over stdio to. It reads what a machine mounts — its fstab, its mount and automount units, +// what is mounted now — and what it shares over the network — its NFS exports, its ZFS share +// properties, its Samba shares — and changes nothing. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): mounts. + if err := stdio.Serve("", Tools(Here())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/modules/mounts/cmd/mounts/mounts.go b/modules/mounts/cmd/mounts/mounts.go new file mode 100644 index 00000000..c8606ebb --- /dev/null +++ b/modules/mounts/cmd/mounts/mounts.go @@ -0,0 +1,414 @@ +package main + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" +) + +// networkTypes are the filesystem types whose files live on another machine: a mount of one waits on a +// server that may be away, which is what makes it worth telling apart. +var networkTypes = map[string]bool{ + "nfs": true, "nfs4": true, "cifs": true, "smb3": true, "smbfs": true, + "sshfs": true, "fuse.sshfs": true, "9p": true, "ceph": true, "glusterfs": true, "fuse.glusterfs": true, + "davfs": true, "fuse.rclone": true, +} + +// secretKeys are the mount options whose value is a credential. Their value is never answered. +var secretKeys = map[string]bool{"password": true, "pass": true, "passwd": true, "password2": true} + +// Redacted stands in for a credential's value. +const Redacted = "(redacted)" + +// RedactOptions answers a comma-separated option list with every credential's value replaced. The path +// of a credentials= file is kept: it names where the secret is, and the file is never read. +func RedactOptions(opts string) string { + if opts == "" { + return opts + } + parts := strings.Split(opts, ",") + for i, p := range parts { + k, _, has := strings.Cut(p, "=") + if has && secretKeys[strings.ToLower(strings.TrimSpace(k))] { + parts[i] = k + "=" + Redacted + } + } + return strings.Join(parts, ",") +} + +// IsNetwork says whether a mount's files live on another machine, by its type or, for a type that +// does not say (fuse), by a source naming a host. +func IsNetwork(fstype, source string) bool { + if networkTypes[fstype] { + return true + } + return strings.HasPrefix(fstype, "fuse.") && strings.Contains(source, ":") && !strings.HasPrefix(source, "/") +} + +// unescape decodes the octal escapes fstab and mountinfo use for a space, a tab, a newline and a +// backslash in a path (\040, \011, \012, \134). +func unescape(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+4 <= len(s) { + if n, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil { + b.WriteByte(byte(n)) + i += 3 + continue + } + } + b.WriteByte(s[i]) + } + return b.String() +} + +// FstabEntry is one line of /etc/fstab. +type FstabEntry struct { + Line int `json:"line"` + Source string `json:"source"` + Mountpoint string `json:"mountpoint"` + Type string `json:"type"` + Options string `json:"options"` + Dump string `json:"dump,omitempty"` + Pass string `json:"pass,omitempty"` + Network bool `json:"network"` +} + +// ParseFstab reads fstab text: comments and blank lines are skipped, and every credential in the +// options is redacted before it leaves this function. +func ParseFstab(text string) []FstabEntry { + out := []FstabEntry{} + for i, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + f := strings.Fields(line) + if len(f) < 2 { + continue + } + e := FstabEntry{Line: i + 1, Source: unescape(f[0]), Mountpoint: unescape(f[1])} + if len(f) > 2 { + e.Type = f[2] + } + if len(f) > 3 { + e.Options = RedactOptions(f[3]) + } + if len(f) > 4 { + e.Dump = f[4] + } + if len(f) > 5 { + e.Pass = f[5] + } + e.Network = IsNetwork(e.Type, e.Source) + out = append(out, e) + } + return out +} + +// Mounted is one filesystem mounted now, as /proc/self/mountinfo says. +type Mounted struct { + Mountpoint string `json:"mountpoint"` + Source string `json:"source"` + Type string `json:"type"` + Options string `json:"options,omitempty"` +} + +// ParseMountinfo reads /proc/self/mountinfo: per line the mount point (field 5), and after the " - " +// separator the type, the source and the superblock options. The last mount at a point wins, as it +// is the one a path resolves to. +func ParseMountinfo(text string) map[string]Mounted { + out := map[string]Mounted{} + for _, line := range strings.Split(text, "\n") { + before, after, ok := strings.Cut(line, " - ") + if !ok { + continue + } + f := strings.Fields(before) + a := strings.Fields(after) + if len(f) < 5 || len(a) < 2 { + continue + } + mt := Mounted{Mountpoint: unescape(f[4]), Type: a[0], Source: unescape(a[1])} + if len(a) > 2 { + mt.Options = RedactOptions(a[2]) + } + out[mt.Mountpoint] = mt + } + return out +} + +// Unit is one mount or automount unit, as the service manager knows it. +type Unit struct { + Unit string `json:"unit"` + Kind string `json:"kind"` + Where string `json:"where"` + What string `json:"what,omitempty"` + Type string `json:"type,omitempty"` + Options string `json:"options,omitempty"` + Active string `json:"active"` + Sub string `json:"sub"` + Result string `json:"result,omitempty"` + From string `json:"from"` + FragmentPath string `json:"fragment_path,omitempty"` + SourcePath string `json:"source_path,omitempty"` + Timeout string `json:"timeout,omitempty"` + UnitFile string `json:"unit_file_state,omitempty"` +} + +// ShowProperties are what is asked of each unit. +var ShowProperties = "Id,What,Where,Type,Options,Result,ActiveState,SubState,FragmentPath,SourcePath,TimeoutUSec,UnitFileState" + +// ParseListUnits reads `systemctl list-units --plain --no-legend`: the unit name is the first field. +func ParseListUnits(text string) []string { + out := []string{} + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if len(f) == 0 { + continue + } + name := strings.TrimLeft(f[0], "●* ") + if strings.HasSuffix(name, ".mount") || strings.HasSuffix(name, ".automount") { + out = append(out, name) + } + } + return out +} + +// ParseShow reads `systemctl show -p ... a b c`: one block of Key=Value lines per unit, blocks +// separated by a blank line. +func ParseShow(text string) []Unit { + out := []Unit{} + for _, block := range strings.Split(strings.ReplaceAll(text, "\r", ""), "\n\n") { + kv := map[string]string{} + for _, line := range strings.Split(block, "\n") { + k, v, ok := strings.Cut(line, "=") + if ok { + kv[k] = v + } + } + if kv["Id"] == "" { + continue + } + u := Unit{ + Unit: kv["Id"], Where: kv["Where"], What: kv["What"], Type: kv["Type"], + Options: RedactOptions(kv["Options"]), Active: kv["ActiveState"], Sub: kv["SubState"], + Result: kv["Result"], FragmentPath: kv["FragmentPath"], SourcePath: kv["SourcePath"], + Timeout: kv["TimeoutUSec"], UnitFile: kv["UnitFileState"], + } + u.Kind = "mount" + if strings.HasSuffix(u.Unit, ".automount") { + u.Kind = "automount" + } + switch { + case strings.HasPrefix(u.FragmentPath, "/run/systemd/generator"): + u.From = "generated from " + orDefault(u.SourcePath, "/etc/fstab") + case u.FragmentPath != "": + u.From = "unit file " + u.FragmentPath + default: + u.From = "made by the service manager at runtime (no unit file)" + } + out = append(out, u) + } + return out +} + +func orDefault(s, d string) string { + if s == "" { + return d + } + return s +} + +// Mount is one mount point, everything the machine knows of it joined: its fstab line, its mount and +// automount units, and what is mounted there now. +type Mount struct { + Mountpoint string `json:"mountpoint"` + Network bool `json:"network"` + Fstab *FstabEntry `json:"fstab,omitempty"` + MountUnit *Unit `json:"mount_unit,omitempty"` + Automount *Unit `json:"automount_unit,omitempty"` + MountedNow *Mounted `json:"mounted_now,omitempty"` +} + +// MountsReport is what mounts_list answers. +type MountsReport struct { + Said []string `json:"said"` + Mounts []Mount `json:"mounts"` + Notes []string `json:"notes,omitempty"` +} + +// virtualTypes are the kernel's own filesystems; a unit for one is never a storage question. +var virtualTypes = map[string]bool{ + "proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "cgroup": true, "cgroup2": true, + "securityfs": true, "pstore": true, "efivarfs": true, "bpf": true, "debugfs": true, "tracefs": true, + "configfs": true, "fusectl": true, "mqueue": true, "hugetlbfs": true, "binfmt_misc": true, "autofs": true, + "nsfs": true, "overlay": true, "ramfs": true, "rpc_pipefs": true, "nfsd": true, +} + +// List joins fstab, the service manager's units and mountinfo, narrowed as asked. +func (m *Machine) List(onlyNetwork bool, match string) MountsReport { + r := MountsReport{Said: []string{}, Mounts: []Mount{}} + byPoint := map[string]*Mount{} + get := func(p string) *Mount { + if mt, ok := byPoint[p]; ok { + return mt + } + mt := &Mount{Mountpoint: p} + byPoint[p] = mt + return mt + } + + fstab, err := m.read("/etc/fstab") + if err != nil { + r.Notes = append(r.Notes, unreadable("/etc/fstab", err)) + } + for _, e := range ParseFstab(fstab) { + if e.Type == "swap" || e.Mountpoint == "none" { + continue + } + e := e + get(e.Mountpoint).Fstab = &e + } + + info, err := m.read("/proc/self/mountinfo") + if err != nil { + r.Notes = append(r.Notes, unreadable("/proc/self/mountinfo", err)) + } + now := ParseMountinfo(info) + + ctx := context.Background() + listed, err := m.Run(ctx, "systemctl", "list-units", "--type=mount,automount", "--all", "--no-legend", "--plain", "--no-pager") + if err != nil { + r.Notes = append(r.Notes, fmt.Sprintf("the service manager's units were not read: %v", err)) + } + if names := ParseListUnits(listed); len(names) > 0 { + args := append([]string{"show", "-p", ShowProperties, "--no-pager"}, names...) + shown, err := m.Run(ctx, "systemctl", args...) + if err != nil && shown == "" { + r.Notes = append(r.Notes, fmt.Sprintf("the units' properties were not read: %v", err)) + } + for _, u := range ParseShow(shown) { + if u.Where == "" || virtualTypes[u.Type] { + continue + } + u := u + mt := get(u.Where) + if u.Kind == "automount" { + mt.Automount = &u + } else { + mt.MountUnit = &u + } + } + } + + for p, mt := range byPoint { + if cur, ok := now[p]; ok { + cur := cur + mt.MountedNow = &cur + } + mt.Network = mt.Fstab != nil && mt.Fstab.Network || + mt.MountUnit != nil && IsNetwork(mt.MountUnit.Type, mt.MountUnit.What) || + mt.MountedNow != nil && IsNetwork(mt.MountedNow.Type, mt.MountedNow.Source) + } + + points := make([]string, 0, len(byPoint)) + for p := range byPoint { + points = append(points, p) + } + sort.Strings(points) + for _, p := range points { + mt := byPoint[p] + if onlyNetwork && !mt.Network { + continue + } + if match != "" && !strings.Contains(p, match) && !strings.Contains(mt.source(), match) { + continue + } + r.Mounts = append(r.Mounts, *mt) + r.Said = append(r.Said, mt.say()) + } + return r +} + +func (mt *Mount) source() string { + switch { + case mt.Fstab != nil: + return mt.Fstab.Source + case mt.MountUnit != nil: + return mt.MountUnit.What + case mt.MountedNow != nil: + return mt.MountedNow.Source + } + return "" +} + +func (mt *Mount) fstype() string { + switch { + case mt.Fstab != nil && mt.Fstab.Type != "": + return mt.Fstab.Type + case mt.MountUnit != nil && mt.MountUnit.Type != "": + return mt.MountUnit.Type + case mt.MountedNow != nil: + return mt.MountedNow.Type + } + return "?" +} + +func (mt *Mount) options() string { + if mt.Fstab != nil { + return mt.Fstab.Options + } + if mt.MountUnit != nil { + return mt.MountUnit.Options + } + return "" +} + +// say is the mount in one line: what it is, its state, where it is declared, and for a network +// mount whether it can hold up a boot. +func (mt *Mount) say() string { + head := fmt.Sprintf("%s (%s from %s)", mt.Mountpoint, mt.fstype(), mt.source()) + var state string + switch { + case mt.MountUnit != nil && mt.MountUnit.Active == "failed": + state = "failed" + if mt.MountUnit.Result != "" && mt.MountUnit.Result != "success" { + state += ", " + mt.MountUnit.Result + } + case mt.MountedNow != nil: + state = "mounted" + case mt.Automount != nil && mt.Automount.Active == "active": + state = "not mounted, waits for its first use (automount " + mt.Automount.Sub + ")" + case mt.MountUnit != nil: + state = "not mounted (" + mt.MountUnit.Active + ", " + mt.MountUnit.Sub + ")" + default: + state = "not mounted" + } + var from []string + switch { + case mt.Fstab != nil: + from = append(from, fmt.Sprintf("from /etc/fstab line %d", mt.Fstab.Line)) + case mt.MountUnit != nil: + from = append(from, mt.MountUnit.From) + } + if mt.Network { + opts := "," + mt.options() + "," + if mt.Automount == nil && !strings.Contains(opts, ",x-systemd.automount,") { + from = append(from, "no automount") + } + if !strings.Contains(opts, ",nofail,") { + from = append(from, "no nofail") + } + } + line := head + ": " + state + if len(from) > 0 { + line += "; " + strings.Join(from, ", ") + } + return line +} diff --git a/modules/mounts/cmd/mounts/mounts_test.go b/modules/mounts/cmd/mounts/mounts_test.go new file mode 100644 index 00000000..d342029d --- /dev/null +++ b/modules/mounts/cmd/mounts/mounts_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// fake is a machine for a test: a tree standing in for /etc, /proc and /var/lib, and a runner +// answering from a table. +type fake struct { + t *testing.T + root string + answers map[string]string + zfs bool +} + +func newFake(t *testing.T) *fake { + t.Helper() + return &fake{t: t, root: t.TempDir(), answers: map[string]string{}} +} + +func (f *fake) file(path, text string) { + f.t.Helper() + p := filepath.Join(f.root, path) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(p, []byte(text), 0o644); err != nil { + f.t.Fatal(err) + } +} + +func (f *fake) machine() *Machine { + return &Machine{ + Root: f.root, + Run: func(_ context.Context, name string, args ...string) (string, error) { + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + if out, ok := f.answers[line]; ok { + return out, nil + } + return "", fmt.Errorf("%s: not found", line) + }, + LookPath: func(n string) bool { return n == "zfs" && f.zfs }, + } +} + +func TestRedactOptions(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"defaults,_netdev", "defaults,_netdev"}, + {"username=root,password=hunter2,iocharset=utf8", "username=root,password=(redacted),iocharset=utf8"}, + {"user=a,pass=x,PASSWD=y", "user=a,pass=(redacted),PASSWD=(redacted)"}, + {"credentials=/etc/samba/creds,sec=ntlmssp", "credentials=/etc/samba/creds,sec=ntlmssp"}, + {"password=", "password=(redacted)"}, + {"password2=z,passwordless", "password2=(redacted),passwordless"}, + } + for _, c := range cases { + if got := RedactOptions(c.in); got != c.want { + t.Errorf("RedactOptions(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +const sampleFstab = `# /etc/fstab +UUID=aaaa / ext4 rw,relatime 0 1 +UUID=bbbb none swap defaults 0 0 +192.0.2.10:/storage/media /storage/media nfs defaults,_netdev 0 0 +//192.0.2.20/Share /mnt/my\040share cifs username=root,password=s3cret,iocharset=utf8 0 0 +192.0.2.10:/backup /mnt/backup nfs4 nofail,x-systemd.automount 0 0 +` + +func TestParseFstab(t *testing.T) { + got := ParseFstab(sampleFstab) + if len(got) != 5 { + t.Fatalf("got %d entries, want 5: %+v", len(got), got) + } + cases := []struct { + i int + point string + network bool + line int + }{ + {0, "/", false, 2}, {2, "/storage/media", true, 4}, {3, "/mnt/my share", true, 5}, {4, "/mnt/backup", true, 6}, + } + for _, c := range cases { + e := got[c.i] + if e.Mountpoint != c.point || e.Network != c.network || e.Line != c.line { + t.Errorf("entry %d = %+v, want point %q network %v line %d", c.i, e, c.point, c.network, c.line) + } + } + if strings.Contains(got[3].Options, "s3cret") { + t.Errorf("a password was answered: %q", got[3].Options) + } +} + +func TestIsNetwork(t *testing.T) { + cases := []struct { + typ, src string + want bool + }{ + {"nfs", "h:/x", true}, {"cifs", "//h/s", true}, {"fuse.sshfs", "u@h:/x", true}, {"ext4", "/dev/sda1", false}, + {"fuseblk", "/dev/sda2", false}, {"fuse.rclone", "remote:", true}, {"fuse.portal", "portal", false}, + } + for _, c := range cases { + if got := IsNetwork(c.typ, c.src); got != c.want { + t.Errorf("IsNetwork(%q, %q) = %v, want %v", c.typ, c.src, got, c.want) + } + } +} + +const sampleMountinfo = `22 1 259:3 / / rw,relatime shared:1 - ext4 /dev/nvme0n1p3 rw +40 22 0:50 / /mnt/backup rw,relatime shared:30 - autofs systemd-1 rw,fd=50 +61 40 0:60 / /mnt/backup rw,relatime shared:31 - nfs4 192.0.2.10:/backup rw,vers=4.2 +70 22 0:70 / /mnt/my\040share rw - cifs //192.0.2.20/Share rw,username=root,password=leaked +` + +func TestParseMountinfo(t *testing.T) { + got := ParseMountinfo(sampleMountinfo) + if got["/mnt/backup"].Type != "nfs4" { + t.Errorf("the last mount at a point must win: %+v", got["/mnt/backup"]) + } + share, ok := got["/mnt/my share"] + if !ok { + t.Fatalf("an escaped mount point was not decoded: %+v", got) + } + if strings.Contains(share.Options, "leaked") { + t.Errorf("a password in mountinfo was answered: %q", share.Options) + } +} + +const sampleListUnits = `-.mount loaded active mounted Root Mount +mnt-backup.automount loaded active waiting mnt-backup.automount +mnt-backup.mount loaded active mounted /mnt/backup +mnt-my\x20share.mount loaded failed failed /mnt/my share +storage-media.mount loaded failed failed /storage/media +dev-mqueue.mount loaded active mounted POSIX Message Queue File System +` + +var sampleShow = strings.Join([]string{ + "Id=-.mount\nWhere=/\nWhat=/dev/nvme0n1p3\nType=ext4\nOptions=rw\nResult=success\nActiveState=active\nSubState=mounted\nFragmentPath=/run/systemd/generator/-.mount\nSourcePath=/etc/fstab\nTimeoutUSec=1min 30s\nUnitFileState=generated", + "Id=mnt-backup.automount\nWhere=/mnt/backup\nResult=success\nActiveState=active\nSubState=waiting\nFragmentPath=/run/systemd/generator/mnt-backup.automount\nSourcePath=/etc/fstab\nUnitFileState=generated", + "Id=mnt-backup.mount\nWhere=/mnt/backup\nWhat=192.0.2.10:/backup\nType=nfs4\nOptions=rw,vers=4.2\nResult=success\nActiveState=active\nSubState=mounted\nFragmentPath=/run/systemd/generator/mnt-backup.mount\nSourcePath=/etc/fstab\nTimeoutUSec=1min 30s\nUnitFileState=generated", + "Id=mnt-my\\x20share.mount\nWhere=/mnt/my share\nWhat=//192.0.2.20/Share\nType=cifs\nOptions=username=root,password=s3cret\nResult=exit-code\nActiveState=failed\nSubState=failed\nFragmentPath=/run/systemd/generator/mnt-my\\x20share.mount\nSourcePath=/etc/fstab\nUnitFileState=generated", + "Id=storage-media.mount\nWhere=/storage/media\nWhat=192.0.2.10:/storage/media\nType=nfs\nOptions=defaults,_netdev\nResult=timeout\nActiveState=failed\nSubState=failed\nFragmentPath=/run/systemd/generator/storage-media.mount\nSourcePath=/etc/fstab\nTimeoutUSec=1min 30s\nUnitFileState=generated", + "Id=dev-mqueue.mount\nWhere=/dev/mqueue\nWhat=mqueue\nType=mqueue\nActiveState=active\nSubState=mounted\nFragmentPath=/usr/lib/systemd/system/dev-mqueue.mount\nUnitFileState=static", +}, "\n\n") + "\n" + +func listFake(t *testing.T) *fake { + f := newFake(t) + f.file("/etc/fstab", sampleFstab) + f.file("/proc/self/mountinfo", sampleMountinfo) + f.answers["systemctl list-units --type=mount,automount --all --no-legend --plain --no-pager"] = sampleListUnits + names := strings.Join(ParseListUnits(sampleListUnits), " ") + f.answers["systemctl show -p "+ShowProperties+" --no-pager "+names] = sampleShow + return f +} + +func TestList(t *testing.T) { + r := listFake(t).machine().List(false, "") + said := strings.Join(r.Said, "\n") + for _, want := range []string{ + "/storage/media (nfs from 192.0.2.10:/storage/media): failed, timeout; from /etc/fstab line 4, no automount, no nofail", + "/mnt/my share (cifs from //192.0.2.20/Share): failed, exit-code; from /etc/fstab line 5, no automount, no nofail", + "/mnt/backup (nfs4 from 192.0.2.10:/backup): mounted; from /etc/fstab line 6", + "/ (ext4 from UUID=aaaa): mounted; from /etc/fstab line 2", + } { + if !strings.Contains(said, want) { + t.Errorf("said lacks %q:\n%s", want, said) + } + } + if strings.Contains(said, "mqueue") { + t.Errorf("a kernel filesystem was said:\n%s", said) + } + for _, mt := range r.Mounts { + if mt.Mountpoint == "/mnt/backup" && (mt.Automount == nil || mt.MountUnit == nil) { + t.Errorf("/mnt/backup must carry both units: %+v", mt) + } + if mt.Mountpoint == "/storage/media" && mt.MountUnit.From != "generated from /etc/fstab" { + t.Errorf("from = %q", mt.MountUnit.From) + } + } + all, _ := json.Marshal(r) + if strings.Contains(string(all), "s3cret") || strings.Contains(string(all), "leaked") { + t.Errorf("a credential was answered: %s", all) + } +} + +func TestListNarrowed(t *testing.T) { + m := listFake(t).machine() + if r := m.List(true, ""); len(r.Mounts) != 3 { + t.Errorf("network only: got %d mounts, want 3: %v", len(r.Mounts), r.Said) + } + if r := m.List(false, "192.0.2.20"); len(r.Mounts) != 1 || r.Mounts[0].Mountpoint != "/mnt/my share" { + t.Errorf("match on source: %v", r.Said) + } +} + +func TestListWithoutFstabSaysSo(t *testing.T) { + f := newFake(t) + f.file("/proc/self/mountinfo", sampleMountinfo) + r := f.machine().List(false, "") + notes := strings.Join(r.Notes, "\n") + if !strings.Contains(notes, "/etc/fstab: not present") || !strings.Contains(notes, "units were not read") { + t.Errorf("a file or command that could not be read must be said: %q", notes) + } +} diff --git a/modules/mounts/cmd/mounts/tools.go b/modules/mounts/cmd/mounts/tools.go new file mode 100644 index 00000000..7e6df4d7 --- /dev/null +++ b/modules/mounts/cmd/mounts/tools.go @@ -0,0 +1,55 @@ +package main + +import ( + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Tools is the module's tools over one machine. Every one only reads. +func Tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "mounts_list", + Description: "This machine's mounts as fstab and the service manager know them, joined by mount point: each /etc/fstab line " + + "(source, type, options, dump and pass), its mount and automount units (state, result, timeout, and whether generated " + + "from fstab or a unit file), and whether it is mounted now (from /proc/self/mountinfo, never by touching the mount point, " + + "so a server that is away cannot hang the call). Network filesystems (NFS, SMB, sshfs) are marked, and a network mount " + + "without automount or nofail is said. Credentials in options are never answered. Each mount is said in a line under " + + "`said`. Replaces cat /etc/fstab, findmnt and systemctl list-units --type=mount. (r)", + Input: map[string]any{ + "network": map[string]any{"type": "boolean", "description": "only network filesystems (default false)"}, + "match": map[string]any{"type": "string", "description": "only mounts whose mount point or source contains this"}, + }, + Run: func(args map[string]any) (any, error) { + return m.List(flag(args, "network"), text(args, "match")), nil + }, + }, + { + Name: "mounts_exports", + Description: "What this machine shares over the network: every NFS export in /etc/exports and /etc/exports.d (path, client, " + + "options, file), the kernel's live export table, the ZFS sharenfs and sharesmb properties where ZFS is present, the " + + "Samba shares in smb.conf (path, valid users, read only or writable, force user — never a password), and whether " + + "nfs-server and smb are active. An export that is declared and not live, or live and declared nowhere, is said. " + + "Replaces cat /etc/exports, exportfs -v, zfs get sharenfs and testparm -s. (r)", + Run: func(map[string]any) (any, error) { + return m.Exports(), nil + }, + }, + } +} + +func text(args map[string]any, key string) string { + s, _ := args[key].(string) + return strings.TrimSpace(s) +} + +func flag(args map[string]any, key string) bool { + switch v := args[key].(type) { + case bool: + return v + case string: + return v == "true" + } + return false +} diff --git a/modules/mounts/go.mod b/modules/mounts/go.mod new file mode 100644 index 00000000..4970b6c3 --- /dev/null +++ b/modules/mounts/go.mod @@ -0,0 +1,5 @@ +module mounts + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/mounts/go.sum b/modules/mounts/go.sum new file mode 100644 index 00000000..0dd60617 --- /dev/null +++ b/modules/mounts/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mounts/module.json b/modules/mounts/module.json new file mode 100644 index 00000000..e3db63d4 --- /dev/null +++ b/modules/mounts/module.json @@ -0,0 +1,23 @@ +{ + "module": "mounts", + "version": "1", + "tools": [ + "mounts_list", + "mounts_exports" + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mounts", + "binary": "mounts", + "loads": [ + "mounts" + ] + } + ] + } +}