Files
mesh-catalog/modules/mounts/cmd/mounts/mounts.go
T

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
}