Files
mesh-catalog/modules/mounts/cmd/mounts/mounts.go
T
jschoubben 520763e98d
mesh/merge-gate pass: builds mounts → shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mounts: list says an armed automount waits, not mounted, as the look already judges it (issue 437)
The autofs line an armed automount leaves in mountinfo made List say
"mounted" while nothing was mounted. One judgement, Mounted.IsAutomount,
now serves List, the look and the checks before a mount.
2026-10-11 02:17:47 +02:00

442 lines
14 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"`
}
// 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
}