Add a read-only mounts module: no tool could read a machine's fstab, mount units or exports
This commit is contained in:
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module mounts
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user