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, ",") var out []string for i := 0; i < len(parts); i++ { p := parts[i] k, v, has := strings.Cut(p, "=") key := strings.ToLower(strings.TrimSpace(k)) switch { case has && secretKeys[key]: // mount.cifs writes a comma inside a password as two, so the value runs on over every ",,": an // empty part, and the part after an empty one, are the password's. Read on the side of hiding: // a run of three commas could be either, and is taken as the password's. for i+1 < len(parts) && (parts[i+1] == "" || parts[i] == "") { i++ } out = append(out, k+"="+Redacted) case has && (key == "user" || key == "username") && strings.Contains(v, "%"): // user=name%password: mount.cifs takes the password after the percent sign. name, _, _ := strings.Cut(v, "%") out = append(out, k+"="+name+"%"+Redacted) default: out = append(out, p) } } return strings.Join(out, ",") } // 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"` } // IsAutomount says whether this line is only the autofs mount an armed automount leaves at its point. // Nothing is mounted there yet: the share is mounted over it at first use, and that line then wins in // ParseMountinfo. Every judgement of "mounted" asks this one question, so List, the look and the checks // before a mount cannot disagree (novox/hq issue 437). func (mt Mounted) IsAutomount() bool { return mt.Type == "autofs" } // 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 { // The names go after "--": the root mount's unit is -.mount, and systemctl reads an argument // that starts with a dash as an option and refuses the whole call, so every mount lost its // unit state (novox/hq issue 374). 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 && !mt.MountedNow.IsAutomount(): 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 }