431 lines
13 KiB
Go
431 lines
13 KiB
Go
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"`
|
|
}
|
|
|
|
// 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
|
|
}
|