From 2b1b9b0235b55836ac29a6cb03efa06ca9bd96be Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 18:34:28 +0200 Subject: [PATCH] Let mounts write each share's mount and automount, so no node depends on a hand-written fstab line (hq ADR 0263) A machine mounts what its assignment names: a node's share from the provision nfs-share, whose path it reads from nfs-server's state on the bus, and occasional sources (an SMB device, a disk) whose absence is a state, not a fault. apply writes and arms the unit pairs as root; watch tries each source and judges the health; the operator's fstab lines are taken over only through adopt, on their word, once the replacement is armed. SMB logins are an outside party's secret, given with secret accept. --- modules/mounts/README.md | 119 ++++- modules/mounts/cmd/mounts/adopt.go | 196 ++++++++ modules/mounts/cmd/mounts/apply.go | 520 +++++++++++++++++++ modules/mounts/cmd/mounts/bus.go | 99 ++++ modules/mounts/cmd/mounts/config.go | 328 ++++++++++++ modules/mounts/cmd/mounts/health.go | 97 ++++ modules/mounts/cmd/mounts/machine.go | 37 +- modules/mounts/cmd/mounts/main.go | 64 ++- modules/mounts/cmd/mounts/manifest_test.go | 140 ++++++ modules/mounts/cmd/mounts/tools.go | 7 +- modules/mounts/cmd/mounts/units.go | 156 ++++++ modules/mounts/cmd/mounts/verbs.go | 148 ++++++ modules/mounts/cmd/mounts/write_test.go | 560 +++++++++++++++++++++ modules/mounts/go.mod | 2 +- modules/mounts/go.sum | 2 + modules/mounts/module.json | 126 ++++- 16 files changed, 2583 insertions(+), 18 deletions(-) create mode 100644 modules/mounts/cmd/mounts/adopt.go create mode 100644 modules/mounts/cmd/mounts/apply.go create mode 100644 modules/mounts/cmd/mounts/bus.go create mode 100644 modules/mounts/cmd/mounts/config.go create mode 100644 modules/mounts/cmd/mounts/health.go create mode 100644 modules/mounts/cmd/mounts/manifest_test.go create mode 100644 modules/mounts/cmd/mounts/units.go create mode 100644 modules/mounts/cmd/mounts/verbs.go create mode 100644 modules/mounts/cmd/mounts/write_test.go diff --git a/modules/mounts/README.md b/modules/mounts/README.md index ae23309d..a9d56837 100644 --- a/modules/mounts/README.md +++ b/modules/mounts/README.md @@ -1,15 +1,111 @@ # 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). +The shares and the occasional sources a machine mounts, and what it mounts and shares, read as tools +(novox/hq ADR 0263, to-be design 52 "Shares and mounts", from research 038). It holds the node seat +`node-mounts`, one holder per machine. + +A machine mounts only what its assignment names (ADR 0263 rule 5): nothing by default, and "every +machine" is the same setting given once at the mesh-wide layer. A share of another machine comes from the +provision `nfs-share`, which `nfs-server` provides on the machine that shares. ## 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. +- The packages `nfs-utils` and `cifs-utils`. +- `/etc/mesh-mounts/mounts.conf`, placed by the mesh from the assignment: the settings `shares` and + `sources`, the binding of `nfs-share` (`from`, the sharing machine, and `at`, where it is reached on + the private network), the operator account, and where the module's secret is. +- One `.mount` and one `.automount` unit per share or source, in `/etc/systemd/system`, named as + `systemd-escape --path` names the mount point and opened by the marker + `# Written by the mesh (module mounts, novox/hq ADR 0263).` A unit file without that marker is not the + module's: it is never written over, stopped or removed, and the share whose mount point it holds is + said as not set up. +- The SMB logins, one file per source in the module's state directory, readable by root only. +- `/var/lib/mesh-mounts/from-bus` (the bundle's: what the sharing machine exports) and `/run/mesh-mounts` + (the processes': what was applied and what was found). + +What it never does: chown or chmod a mount point (it makes one that is missing, and nothing else), touch a +mounted filesystem to look at it, or remove a pair because the sharing machine did not answer. A pair is +removed only when the settings stop naming its mount point. The files a share serves are the operator's +(ADR 0051): the module mounts them and owns nothing in them. + +## The unit pair + +Only the automount is enabled (under `remote-fs.target`; a disk's under `local-fs.target`). The mount +is never enabled: it is made at the first use of its mount point and released after ten idle minutes +(`TimeoutIdleSec=600`). So nothing mounts at boot and nothing can fail a boot, and a mount point is always +an autofs mount, so nothing writes into the empty directory under it. + +| kind | What | Options | TimeoutSec | +|---|---|---|---| +| a node's share, read-write | `:`, `Type=nfs4` | `vers=4.2,hard,timeo=150,retrans=3,_netdev` | 30 | +| a node's share, read-only | `:`, `Type=nfs4` | `ro,vers=4.2,soft,timeo=150,retrans=3,_netdev` | 30 | +| an SMB source | `///`, `Type=cifs` | `credentials=,iocharset=utf8,_netdev,nofail,uid=…,gid=…` | 10 | +| a disk | `/dev/disk/by-uuid/`; the mount `BindsTo=` the disk's device | `nofail` | 10 | + +A share is read-only when the setting says `:ro` or the sharing machine exports it read-only. A +read-write share is mounted hard, so a write is never dropped while the server is away. + +## Settings + +Both are preferences whose default, `none`, mounts nothing (ADR 0262 allows no empty default). + +- `shares`: space-separated `name=mountpoint`, each optionally `:ro`. The name is the share's on the + sharing machine; its path there comes from that machine over the bus, never from this setting. + For example `library=/srv/library downloads=/srv/downloads:ro`. +- `sources`: space-separated occasional sources, each optionally `:ro`: + `name=smb:///@` or `name=disk:@`. + For example `games=smb://192.0.2.76/Share@/mnt/games`. + +A mount point is an absolute, plainly written path, not a directory of the system's own (`/etc`, `/usr`, +`/run`, …). An entry that cannot be read is said, and only that entry is left out. + +## The secret + +`smb-credentials` is the module's own secret, issued by an outside party (the device's owner), so the mesh +never makes one in its place. The operator gives it with +`secret accept mounts smb-credentials --from `; the file holds one section per SMB source: + +``` +[games] +username=… +password=… +domain=… (optional) +``` + +A source whose section is missing is not mounted, and is said with that remedy. No tool answers a +password: the options, fstab lines and mountinfo the tools read are redacted. + +## The processes and the bus + +- `apply` (root, judged by its unit) writes and removes the unit pairs and the logins, makes a missing + mount point, reloads the service manager when anything changed and arms every automount, every 30 s. +- `watch` (root, judged by `mounts_health`) tries each source without touching its mount point — TCP 2049 + for a node's share, 445 for an SMB source, the device for a disk — and says each one's state. +- The bundle (the operator account) reads what the sharing machine exports from `nfs-server`'s state + `exports`, keyed by that machine, and leaves it for `apply`; a failed read keeps the last answer. It puts + what `watch` found in the module's state `shares`, keyed by this machine. No module reads another's + files (ADR 0260). + +The states: `armed` (the automount waits), `mounted`, `absent` (an occasional source that is not there — +a state, not a fault), `unreachable` (should be there and is not), `failed` (tried and refused, or not +set up; `why` says which). + +## Health + +Unhealthy when a share that should always be there has been unreachable or failed for five minutes ("The +share library of is not reachable from this machine since 19:21."), when an automount is not +armed, when something the settings name cannot be set up (a missing login, a unit file that is not the +module's), or when `watch` has not looked for two minutes. An occasional source that is absent is +healthy, and said as not present since when. + +## Taking over a hand-written line + +The operator's own fstab lines for a share are taken over only through `adopt`, on their word, and only +once the module's own pair for the same mount point is written and its automount armed: the replacement +works first, then the line goes. Without `confirm` it is a dry run. With it (and `why`), the line is +commented out as `# adopted by the mesh (module mounts) on : `, +the file as it was is kept as `/etc/fstab.mesh-adopted-` readable by root only, and the +service manager reloads. The module never takes a line over on its own. ## What it reads @@ -31,7 +127,16 @@ as empty. ## Tools +What only root may do (`mount`, `unmount`, `adopt`) goes through `sudo -n`, as the packet filter's and the +service manager's tools do; a refusal by sudo is said as one. + | 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 | +| `mounts_health` | r | the health check: healthy or not, with why, one sentence per finding | +| `node-mounts.list` | r | the machine's mount points, and every share and source the module is asked to mount with its state, since when and why | +| `node-mounts.test` | r | whether one share's or source's server answers now, tried without touching its mount point | +| `node-mounts.mount` | a | mount one share or source now | +| `node-mounts.unmount` | a | release one now; its automount stays armed | +| `node-mounts.adopt` | a | take over one hand-written fstab line, as above | diff --git a/modules/mounts/cmd/mounts/adopt.go b/modules/mounts/cmd/mounts/adopt.go new file mode 100644 index 00000000..302f63ea --- /dev/null +++ b/modules/mounts/cmd/mounts/adopt.go @@ -0,0 +1,196 @@ +package main + +// Taking over a hand-written fstab line (novox/hq ADR 0263 rule 10, to-be 52 §5). +// +// The line is the operator's, so it is taken over only on a person's word (`confirm`, with why) and only +// once the module's own unit pair for the same mount point exists and its automount is armed: the +// replacement works first, then the line goes. It is commented out, not deleted, with a dated marker; a +// password in it is redacted in the commented line, and the whole file as it was is kept beside it, +// readable by root only, because it holds that password. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// AdoptedPrefix opens a line the module took over. +const AdoptedPrefix = "# adopted by the mesh (module mounts) on " + +var passwordValue = regexp.MustCompile(`(?i)\b(password2?|pass|passwd)=[^,\s]*`) + +// RedactLine replaces every credential's value in a raw fstab line. +func RedactLine(line string) string { + return passwordValue.ReplaceAllString(line, "${1}="+Redacted) +} + +// Adoption is what taking a line over does, or would do. +type Adoption struct { + Mountpoint string `json:"mountpoint"` + Line int `json:"line"` + Was string `json:"was"` + Becomes string `json:"becomes"` + Copy string `json:"copy,omitempty"` + Done bool `json:"done"` + Said string `json:"said"` +} + +// PlanAdoption finds the one uncommented fstab line for a mount point and what it becomes. `was` is +// redacted; the new text keeps every other line byte for byte. +func PlanAdoption(fstab, mountpoint string, now time.Time) (string, Adoption, error) { + lines := strings.Split(fstab, "\n") + hit := -1 + for i, raw := range lines { + t := strings.TrimSpace(raw) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + f := strings.Fields(t) + if len(f) >= 2 && unescape(f[1]) == mountpoint { + if hit >= 0 { + return "", Adoption{}, fmt.Errorf("/etc/fstab has more than one line for %s (lines %d and %d); take it over by hand", mountpoint, hit+1, i+1) + } + hit = i + } + } + if hit < 0 { + return "", Adoption{}, fmt.Errorf("/etc/fstab has no line for %s", mountpoint) + } + becomes := AdoptedPrefix + now.Format("2006-01-02") + ": " + RedactLine(lines[hit]) + a := Adoption{Mountpoint: mountpoint, Line: hit + 1, Was: RedactLine(lines[hit]), Becomes: becomes} + lines[hit] = becomes + return strings.Join(lines, "\n"), a, nil +} + +// replacementArmed says whether the module's own pair for a mount point is written and its automount +// armed, with why not. +func (m *Machine) replacementArmed(ctx context.Context, mountpoint string) error { + base := EscapePath(mountpoint) + for _, unit := range []string{base + ".mount", base + ".automount"} { + if !IsOwnUnit(m, filepath.Join(UnitDir, unit)) { + return fmt.Errorf("the module has no unit pair for %s yet (%s is not its own): it takes a line over only once its replacement is in place", mountpoint, unit) + } + } + shown, err := m.Run(ctx, "systemctl", "show", "-p", "ActiveState", "--value", base+".automount") + if err != nil { + return fmt.Errorf("the service manager did not say whether %s.automount is armed: %v", base, err) + } + if strings.TrimSpace(shown) != "active" { + return fmt.Errorf("%s.automount is not armed (%s): the replacement does not work yet, so the line stays", base, strings.TrimSpace(shown)) + } + return nil +} + +// AdoptDryRun is what taking the line over would do, changing nothing. +func (m *Machine) AdoptDryRun(ctx context.Context, mountpoint string, now time.Time) (Adoption, error) { + if err := CheckMountpoint(mountpoint); err != nil { + return Adoption{}, err + } + fstab, err := m.read("/etc/fstab") + if err != nil { + return Adoption{}, errors.New(unreadable("/etc/fstab", err)) + } + _, a, err := PlanAdoption(fstab, mountpoint, now) + if err != nil { + return Adoption{}, err + } + if err := m.replacementArmed(ctx, mountpoint); err != nil { + a.Said = "Would be refused now: " + err.Error() + return a, nil + } + a.Said = fmt.Sprintf("A dry run: line %d of /etc/fstab would be commented out and a copy of the file kept beside it. Nothing changed; call again with confirm and why.", a.Line) + return a, nil +} + +// AdoptAsRoot takes the line over: the process's half, run as root through sudo by the tool. It checks +// everything again, since the tool's look was the operator account's. +func (m *Machine) AdoptAsRoot(ctx context.Context, mountpoint string, now time.Time) (Adoption, error) { + if err := CheckMountpoint(mountpoint); err != nil { + return Adoption{}, err + } + if err := m.replacementArmed(ctx, mountpoint); err != nil { + return Adoption{}, err + } + full := filepath.Join(m.Root, "etc/fstab") + raw, err := os.ReadFile(full) + if err != nil { + return Adoption{}, errors.New(unreadable("/etc/fstab", err)) + } + info, err := os.Stat(full) + if err != nil { + return Adoption{}, err + } + next, a, err := PlanAdoption(string(raw), mountpoint, now) + if err != nil { + return Adoption{}, err + } + a.Copy = "/etc/fstab.mesh-adopted-" + now.Format("20060102-150405") + if err := os.WriteFile(filepath.Join(m.Root, a.Copy), raw, 0o600); err != nil { + return Adoption{}, fmt.Errorf("the copy of /etc/fstab was not kept, so nothing changed: %w", err) + } + tmp := full + ".mesh-new" + if err := os.WriteFile(tmp, []byte(next), info.Mode().Perm()); err != nil { + return Adoption{}, fmt.Errorf("/etc/fstab was not written, so nothing changed: %w", err) + } + if err := os.Rename(tmp, full); err != nil { + os.Remove(tmp) + return Adoption{}, fmt.Errorf("/etc/fstab was not written, so nothing changed: %w", err) + } + a.Done = true + a.Said = fmt.Sprintf("Line %d of /etc/fstab, for %s, is commented out; the file as it was is kept at %s (root only, as it may hold a password).", a.Line, mountpoint, a.Copy) + if _, err := m.Run(ctx, "systemctl", "daemon-reload"); err != nil { + a.Said += " The service manager did not reload: " + err.Error() + } + return a, nil +} + +// Adopt is the verb: a dry run without confirm; with it, the root half through sudo. +func (m *Machine) Adopt(ctx context.Context, mountpoint string, confirm bool, why string, now time.Time) (Adoption, error) { + if !confirm { + return m.AdoptDryRun(ctx, mountpoint, now) + } + if strings.TrimSpace(why) == "" { + return Adoption{}, errors.New("say why the line is taken over (`why`): it is the operator's line, and the act is recorded") + } + self, err := os.Executable() + if err != nil { + return Adoption{}, err + } + out, err := m.Escalate(ctx, self, "adopt", mountpoint) + var a Adoption + if jerr := json.Unmarshal([]byte(strings.TrimSpace(out)), &a); jerr != nil { + if err != nil { + return Adoption{}, err + } + return Adoption{}, fmt.Errorf("the root half answered no adoption: %v", jerr) + } + if !a.Done && a.Said != "" { + return a, errors.New(a.Said) + } + a.Said += " Why: " + strings.TrimSpace(why) + "." + return a, nil +} + +// RunAdopt is `mounts adopt `, as root: the answer on stdout as JSON, also when refused. +func RunAdopt(args []string) int { + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: mounts adopt ") + return 2 + } + a, err := Here().AdoptAsRoot(context.Background(), args[0], time.Now()) + if err != nil { + a = Adoption{Mountpoint: args[0], Said: err.Error()} + } + raw, _ := json.Marshal(a) + fmt.Println(string(raw)) + if err != nil { + return 1 + } + return 0 +} diff --git a/modules/mounts/cmd/mounts/apply.go b/modules/mounts/cmd/mounts/apply.go new file mode 100644 index 00000000..5bfbfd1a --- /dev/null +++ b/modules/mounts/cmd/mounts/apply.go @@ -0,0 +1,520 @@ +package main + +// The module's two long-running processes, run by the node-engine as root (novox/hq ADR 0263, to-be 52 §2), +// over one pass of the same code every 30 seconds: +// +// - `mounts apply` acts: it writes or removes the module's own unit pairs and the SMB logins, makes a +// missing mount point and arms the automounts, and leaves what it could not do in AppliedFile. The +// service manager judges it by its unit; +// - `mounts watch` judges: it tries each source without touching a mount point and leaves what it found +// in StatusFile, which the health check `mounts_health`, the tools and the bundle read. +// +// Neither reaches the bus: the bundle reads what the sharing machine exports and leaves it in ExportsFile, +// and puts StatusFile in the module's state. +// +// What it never does: write, stop or remove a unit file without the module's marker; chown or chmod a +// mount point (it makes one that is missing, and nothing else); remove a pair because the sharing machine +// did not answer — a pair goes only when the settings stop naming it. + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// LookEvery is how often the process looks. +const LookEvery = 30 * time.Second + +// ProbeTimeout bounds one try of a source. +const ProbeTimeout = 2 * time.Second + +// The states of a share (to-be 52 §2). +const ( + StateArmed = "armed" // the automount waits for the first use + StateMounted = "mounted" // mounted now + StateAbsent = "absent" // an occasional source that is not there: a state, not a fault + StateUnreachable = "unreachable" // should be there and is not + StateFailed = "failed" // tried and refused, or not set up; why says which +) + +// ExportsAnswer is what the bundle left from the sharing machine's `node-nfs-server.exports`. +type ExportsAnswer struct { + Server string `json:"server"` + At string `json:"at,omitempty"` + Asked string `json:"asked"` + Shares []ServerShare `json:"shares"` + // Error is why the last ask got no answer; the shares are then the last answer's. + Error string `json:"error,omitempty"` + Answered string `json:"answered,omitempty"` +} + +// ShareStatus is one share or source as the process found it. +type ShareStatus struct { + Name string `json:"name"` + Kind string `json:"kind"` + Occasional bool `json:"occasional"` + Mountpoint string `json:"mountpoint"` + What string `json:"what,omitempty"` + Probe string `json:"probe,omitempty"` + State string `json:"state"` + Since string `json:"since"` + Why string `json:"why,omitempty"` + MountUnit string `json:"mount_unit,omitempty"` + AutoUnit string `json:"automount_unit,omitempty"` +} + +// Status is what one look found. +type Status struct { + Looked string `json:"looked"` + Server string `json:"server,omitempty"` + Shares []ShareStatus `json:"shares"` + Problems []string `json:"problems,omitempty"` + Changed []string `json:"changed,omitempty"` +} + +// Applier is the process, over one machine. Act writes and arms; Judge tries and says. A test does both. +type Applier struct { + M *Machine + Act bool + Judge bool + // Dial tries a host:port; Now is the clock; Lookup finds an account's uid and gid. + Dial func(addr string, timeout time.Duration) error + Now func() time.Time + Lookup func(account string) (int, int, error) +} + +// NewApplier is the process on this machine. +func NewApplier() *Applier { + return &Applier{ + M: Here(), + Dial: func(addr string, timeout time.Duration) error { + c, err := net.DialTimeout("tcp", addr, timeout) + if err == nil { + c.Close() + } + return err + }, + Now: time.Now, + Lookup: func(account string) (int, int, error) { + u, err := user.Lookup(account) + if err != nil { + return -1, -1, err + } + uid, _ := strconv.Atoi(u.Uid) + gid, _ := strconv.Atoi(u.Gid) + return uid, gid, nil + }, + } +} + +func (a *Applier) path(p string) string { return filepath.Join(a.M.Root, p) } + +// writeIfChanged writes a file through a temporary beside it, only when its content differs. +func (a *Applier) writeIfChanged(p, content string, mode os.FileMode) (bool, error) { + full := a.path(p) + if old, err := os.ReadFile(full); err == nil && string(old) == content { + if info, err := os.Stat(full); err == nil && info.Mode().Perm() == mode { + return false, nil + } + } + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return false, err + } + tmp := full + ".mesh-new" + if err := os.WriteFile(tmp, []byte(content), mode); err != nil { + return false, err + } + if err := os.Chmod(tmp, mode); err != nil { + os.Remove(tmp) + return false, err + } + return true, os.Rename(tmp, full) +} + +// ownUnits are the unit files in UnitDir that carry the module's marker, by name. +func (a *Applier) ownUnits() map[string]bool { + out := map[string]bool{} + for _, pattern := range []string{"*.mount", "*.automount"} { + for _, p := range a.M.glob(filepath.Join(UnitDir, pattern)) { + if IsOwnUnit(a.M, p) { + out[filepath.Base(p)] = true + } + } + } + return out +} + +// IsOwnUnit says whether a unit file opens with the module's marker. +func IsOwnUnit(m *Machine, p string) bool { + text, err := m.read(p) + return err == nil && strings.HasPrefix(text, Marker) +} + +// unitStates are the service manager's states for some units: ActiveState, SubState, Result and +// UnitFileState, by unit. +func (a *Applier) unitStates(ctx context.Context, units []string) map[string]map[string]string { + out := map[string]map[string]string{} + if len(units) == 0 { + return out + } + args := append([]string{"show", "-p", "Id,ActiveState,SubState,Result,UnitFileState", "--no-pager"}, units...) + shown, _ := a.M.Run(ctx, "systemctl", args...) + for _, block := range strings.Split(shown, "\n\n") { + kv := map[string]string{} + for _, line := range strings.Split(block, "\n") { + if k, v, ok := strings.Cut(line, "="); ok { + kv[k] = v + } + } + if kv["Id"] != "" { + out[kv["Id"]] = kv + } + } + return out +} + +func readJSON[T any](m *Machine, p string) (T, error) { + var v T + text, err := m.read(p) + if err != nil { + return v, err + } + return v, json.Unmarshal([]byte(text), &v) +} + +// Look is one pass: the units written as asked, the automounts armed, and each share found. +func (a *Applier) Look(ctx context.Context) Status { + now := a.Now() + st := Status{Looked: now.Format(time.RFC3339), Shares: []ShareStatus{}} + previous, _ := readJSON[Status](a.M, StatusFile) + since := map[string]ShareStatus{} + for _, s := range previous.Shares { + since[s.Name] = s + } + + text, err := a.M.read(ConfigFile) + if err != nil { + st.Problems = append(st.Problems, unreadable(ConfigFile, err)+"; nothing is written or removed until the mesh places it") + a.finish(&st, nil) + return st + } + cfg := ParseConfig(text) + st.Server = cfg.ServerNode() + st.Problems = append(st.Problems, cfg.Problems...) + + exports, eerr := readJSON[ExportsAnswer](a.M, ExportsFile) + byName := map[string]ServerShare{} + for _, e := range exports.Shares { + byName[e.Name] = e + } + + var pairs []Pair + notSet := map[string]ShareStatus{} // a share named but not set up, with why + for _, w := range cfg.Shares { + e, ok := byName[w.Name] + switch { + case cfg.At == "": + notSet[w.Name] = ShareStatus{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, State: StateFailed, + Why: "no machine shares nfs-share for this one: the binding gives no address"} + case !ok && eerr != nil: + notSet[w.Name] = ShareStatus{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, State: StateUnreachable, + Why: "what the sharing machine " + st.Server + " exports has not been read from the bus yet"} + case !ok: + why := "the sharing machine " + exports.Server + " does not export a share named " + w.Name + if exports.Error != "" { + why = "what the sharing machine " + st.Server + " exports could not be read from the bus: " + exports.Error + } + notSet[w.Name] = ShareStatus{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, State: StateFailed, Why: why} + case !strings.HasPrefix(e.Path, "/") || strings.ContainsAny(e.Path, " \t\n\\\"'"): + notSet[w.Name] = ShareStatus{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, State: StateFailed, + Why: fmt.Sprintf("the sharing machine gave the path %q, which the module does not mount", e.Path)} + default: + pairs = append(pairs, NFSPair(w, cfg.At, e)) + } + } + + var creds map[string]SMBCredential + needCreds := false + for _, s := range cfg.Sources { + needCreds = needCreds || s.Kind == "smb" + } + if needCreds { + secret, err := a.M.read(cfg.Secret) + if cfg.Secret == "" || err != nil { + st.Problems = append(st.Problems, "the module's secret smb-credentials is not on this machine") + } else { + var p []string + creds, p = ParseSMBCredentials(secret) + for _, x := range p { + st.Problems = append(st.Problems, "smb-credentials: "+x) + } + } + } + uid, gid := -1, -1 + if cfg.Account != "" && a.Lookup != nil { + if u, g, err := a.Lookup(cfg.Account); err == nil { + uid, gid = u, g + } + } + for _, s := range cfg.Sources { + switch s.Kind { + case "disk": + pairs = append(pairs, DiskPair(s)) + case "smb": + c, ok := creds[s.Name] + if !ok || cfg.State == "" { + notSet[s.Name] = ShareStatus{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, State: StateFailed, + Why: fmt.Sprintf("its login is not given: put a [%s] section in the module's secret with `secret accept mounts smb-credentials --from `", s.Name)} + continue + } + file := filepath.Join(cfg.State, "smb-"+s.Name+".credentials") + changed, err := false, error(nil) + if a.Act { + changed, err = a.writeIfChanged(file, c.CredentialsFile(), 0o600) + } + if err != nil { + notSet[s.Name] = ShareStatus{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, State: StateFailed, + Why: "its credentials file could not be written: " + err.Error()} + continue + } + if changed { + st.Changed = append(st.Changed, "the login of "+s.Name) + } + pairs = append(pairs, SMBPair(s, file, uid, gid)) + } + } + + // Every mount point the settings still name, set up or not: a pair is removed only when its mount + // point is no longer named, never because an answer is missing. + named := map[string]bool{} + for _, w := range cfg.Shares { + named[EscapePath(w.Mountpoint)] = true + } + for _, s := range cfg.Sources { + named[EscapePath(s.Mountpoint)] = true + } + + own := a.ownUnits() + mounted := ParseMountinfo(func() string { t, _ := a.M.read("/proc/self/mountinfo"); return t }()) + changed := false + var written []Pair + for _, p := range pairs { + if err := a.foreign(p, own); err != nil { + notSet[p.Name] = ShareStatus{Name: p.Name, Kind: p.Kind, Occasional: p.Occasional, Mountpoint: p.Mountpoint, What: p.What, + State: StateFailed, Why: err.Error()} + continue + } + if !a.Act { + written = append(written, p) + continue + } + c1, err1 := a.writeIfChanged(filepath.Join(UnitDir, p.MountUnit), p.Mount, 0o644) + c2, err2 := a.writeIfChanged(filepath.Join(UnitDir, p.AutoUnit), p.Automount, 0o644) + if err1 != nil || err2 != nil { + notSet[p.Name] = ShareStatus{Name: p.Name, Kind: p.Kind, Occasional: p.Occasional, Mountpoint: p.Mountpoint, What: p.What, + State: StateFailed, Why: fmt.Sprintf("its units could not be written: %v %v", err1, err2)} + continue + } + if c1 || c2 { + changed = true + st.Changed = append(st.Changed, "the units of "+p.Name) + } + // A missing mount point is made; one that is there is left exactly as it is. An automount point + // is in mountinfo and is never looked at, so a server that is away cannot hold this look. + if _, there := mounted[p.Mountpoint]; !there { + if _, err := os.Lstat(a.path(p.Mountpoint)); os.IsNotExist(err) { + if err := os.MkdirAll(a.path(p.Mountpoint), 0o755); err == nil { + st.Changed = append(st.Changed, "made the mount point "+p.Mountpoint) + } + } + } + written = append(written, p) + } + + // The module's own pairs whose mount point nothing names any more: stopped, disabled and removed. + var gone []string + for unit := range own { + base := strings.TrimSuffix(strings.TrimSuffix(unit, ".automount"), ".mount") + if !named[base] && a.Act { + gone = append(gone, unit) + } + } + sort.Strings(gone) + for _, unit := range gone { + if strings.HasSuffix(unit, ".automount") { + a.M.Run(ctx, "systemctl", "disable", "--now", unit) + } else { + a.M.Run(ctx, "systemctl", "stop", unit) + } + } + for _, unit := range gone { + if err := os.Remove(a.path(filepath.Join(UnitDir, unit))); err == nil { + changed = true + st.Changed = append(st.Changed, "removed "+unit+", which nothing names any more") + } + } + if changed { + if _, err := a.M.Run(ctx, "systemctl", "daemon-reload"); err != nil { + st.Problems = append(st.Problems, "the service manager did not reload: "+err.Error()) + } + } + + // Arm every automount that is not. + var units []string + for _, p := range written { + units = append(units, p.MountUnit, p.AutoUnit) + } + states := a.unitStates(ctx, units) + for _, p := range written { + auto := states[p.AutoUnit] + if a.Act && (auto["UnitFileState"] != "enabled" || auto["ActiveState"] != "active") { + if _, err := a.M.Run(ctx, "systemctl", "enable", "--now", p.AutoUnit); err != nil { + st.Problems = append(st.Problems, fmt.Sprintf("%s was not armed: %v", p.AutoUnit, err)) + } else { + st.Changed = append(st.Changed, "armed "+p.AutoUnit) + } + } + } + if !a.Judge { + a.finish(&st, notSet) + return st + } + if len(written) > 0 { + states = a.unitStates(ctx, units) + } + // What the acting process could not do, which only it knows: a unit or a login it could not write. + if applied, err := readJSON[Applied](a.M, AppliedFile); err == nil && !a.Act { + st.Problems = append(st.Problems, applied.Problems...) + for _, s := range applied.NotSet { + if _, said := notSet[s.Name]; !said { + notSet[s.Name] = s + } + } + } + + for _, p := range written { + if _, failed := notSet[p.Name]; failed { + continue + } + s := ShareStatus{Name: p.Name, Kind: p.Kind, Occasional: p.Occasional, Mountpoint: p.Mountpoint, What: p.What, + Probe: p.Probe, MountUnit: p.MountUnit, AutoUnit: p.AutoUnit} + s.State, s.Why = a.judge(p, states, mounted) + st.Shares = append(st.Shares, s) + } + for _, s := range notSet { + st.Shares = append(st.Shares, s) + } + sort.Slice(st.Shares, func(i, j int) bool { return st.Shares[i].Name < st.Shares[j].Name }) + for i := range st.Shares { + s := &st.Shares[i] + if prev, ok := since[s.Name]; ok && prev.State == s.State && prev.Since != "" { + s.Since = prev.Since + } else { + s.Since = st.Looked + } + } + a.finish(&st, notSet) + return st +} + +// AppliedFile is what the acting process could not do, for the judging one. +const AppliedFile = RunDir + "/applied.json" + +// Applied is the acting process's last pass. +type Applied struct { + Looked string `json:"looked"` + Problems []string `json:"problems,omitempty"` + NotSet []ShareStatus `json:"not_set,omitempty"` + Changed []string `json:"changed,omitempty"` +} + +// finish leaves what this pass found: the acting process's account, the judging one's status. +func (a *Applier) finish(st *Status, notSet map[string]ShareStatus) { + if a.Act && !a.Judge { + ap := Applied{Looked: st.Looked, Problems: st.Problems, Changed: st.Changed} + for _, s := range notSet { + ap.NotSet = append(ap.NotSet, s) + } + sort.Slice(ap.NotSet, func(i, j int) bool { return ap.NotSet[i].Name < ap.NotSet[j].Name }) + raw, _ := json.MarshalIndent(ap, "", " ") + if _, err := a.writeIfChanged(AppliedFile, string(raw)+"\n", 0o644); err != nil { + fmt.Fprintln(os.Stderr, "mounts: what was applied was not written:", err) + } + } + if a.Judge { + a.writeStatus(st) + } +} + +// foreign refuses a pair whose unit file exists and is not the module's. +func (a *Applier) foreign(p Pair, own map[string]bool) error { + for _, unit := range []string{p.MountUnit, p.AutoUnit} { + if own[unit] { + continue + } + if _, err := os.Lstat(a.path(filepath.Join(UnitDir, unit))); err == nil { + return fmt.Errorf("%s already has a unit file that is not the module's (%s); it is left as it is, and this share is not mounted here", p.Mountpoint, filepath.Join(UnitDir, unit)) + } + } + return nil +} + +// judge is one pair's state. The source is tried first, without touching the mount point. +func (a *Applier) judge(p Pair, states map[string]map[string]string, mounted map[string]Mounted) (string, string) { + there := true + var tried error + if p.Kind == "disk" { + if !a.M.present(p.Probe) { + there, tried = false, fmt.Errorf("the disk %s is not plugged in", p.Probe) + } + } else if err := a.Dial(p.Probe, ProbeTimeout); err != nil { + there, tried = false, err + } + mnt, auto := states[p.MountUnit], states[p.AutoUnit] + cur, isMounted := mounted[p.Mountpoint] + isMounted = isMounted && cur.Type != "autofs" + switch { + case !there && p.Occasional: + return StateAbsent, "not present: " + tried.Error() + case !there: + return StateUnreachable, fmt.Sprintf("%s does not answer: %v", p.Probe, tried) + case isMounted: + return StateMounted, "" + case mnt["ActiveState"] == "failed": + return StateFailed, fmt.Sprintf("its last mount failed (%s); see the journal of %s", orDefault(mnt["Result"], "failed"), p.MountUnit) + case auto["ActiveState"] == "active": + return StateArmed, "" + default: + return StateFailed, fmt.Sprintf("its automount %s is not armed (%s)", p.AutoUnit, orDefault(auto["ActiveState"], "unknown")) + } +} + +func (a *Applier) writeStatus(st *Status) { + raw, _ := json.MarshalIndent(st, "", " ") + if _, err := a.writeIfChanged(StatusFile, string(raw)+"\n", 0o644); err != nil { + fmt.Fprintln(os.Stderr, "mounts: the status was not written:", err) + } +} + +// RunApply looks every LookEvery until the context ends, saying on stderr what it changed. +func RunApply(ctx context.Context, a *Applier) { + for { + st := a.Look(ctx) + for _, c := range st.Changed { + fmt.Fprintln(os.Stderr, "mounts:", c) + } + select { + case <-ctx.Done(): + return + case <-time.After(LookEvery): + } + } +} diff --git a/modules/mounts/cmd/mounts/bus.go b/modules/mounts/cmd/mounts/bus.go new file mode 100644 index 00000000..7d61df91 --- /dev/null +++ b/modules/mounts/cmd/mounts/bus.go @@ -0,0 +1,99 @@ +package main + +// The bundle's half on the bus (novox/hq ADR 0263 rule 9, ADR 0201). The bundle runs as the operator +// account and reaches the bus through the runtime; the process runs as root and does not. So every 30 +// seconds the bundle: +// +// - reads what the sharing machine exports from nfs-server's state `exports`, keyed by that machine +// (ADR 0201: a module reads another's state, never its files), and leaves it in ExportsFile for the +// process. A failed read keeps the last answer's shares, so a bus that is away for a minute removes no +// mount; +// - puts what the process last found in the state `shares`, keyed by this machine, so a view or the +// controller watches it without reading this machine's files. + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// SharesState is the state the module keeps on the bus, one key per machine. +const SharesState = "shares" + +// ExportsState is nfs-server's state this module reads: what each sharing machine exports, by machine. +const ExportsState = "nfs-server.exports" + +// Getter reads one key of a state, nil when it has none; Putter puts one key of a state. +type ( + Getter func(key string) (json.RawMessage, error) + Putter func(key string, value any) error +) + +// AskExports reads what the sharing machine exports, once, and answers what to leave for the process. +func AskExports(cfg Config, previous ExportsAnswer, get Getter, now time.Time) ExportsAnswer { + out := ExportsAnswer{Server: cfg.ServerNode(), At: cfg.At, Asked: now.Format(time.RFC3339)} + if out.Server == "" { + out.Error = "no machine shares nfs-share for this one" + return out + } + raw, err := get(out.Server) + var answer struct { + Shares []ServerShare `json:"shares"` + } + if err == nil && len(raw) == 0 { + err = fmt.Errorf("%s keeps nothing in nfs-server's state exports yet", out.Server) + } + if err == nil { + err = json.Unmarshal(raw, &answer) + if err == nil && answer.Shares == nil { + err = fmt.Errorf("the answer names no shares") + } + } + if err != nil { + out.Error = err.Error() + if previous.Server == out.Server { + out.Shares, out.Answered = previous.Shares, previous.Answered + } + return out + } + out.Shares, out.Answered = answer.Shares, out.Asked + return out +} + +// Share is one pass of the bundle's work over a machine. +func (m *Machine) Share(node string, get Getter, put Putter, last *string, now time.Time) []string { + var said []string + if text, err := m.read(ConfigFile); err == nil { + cfg := ParseConfig(text) + if len(cfg.Shares) > 0 { + previous, _ := readJSON[ExportsAnswer](m, ExportsFile) + answer := AskExports(cfg, previous, get, now) + if answer.Error != "" { + said = append(said, "what the sharing machine exports was not read: "+answer.Error) + } + raw, _ := json.MarshalIndent(answer, "", " ") + full := filepath.Join(m.Root, ExportsFile) + tmp := full + ".new" + if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil { + said = append(said, "the answer was not left for the process: "+err.Error()) + } else if err := os.Rename(tmp, full); err != nil { + said = append(said, "the answer was not left for the process: "+err.Error()) + } + } + } + if node != "" && put != nil { + if text, err := m.read(StatusFile); err == nil && text != *last { + var st Status + if json.Unmarshal([]byte(text), &st) == nil { + if err := put(node, st); err != nil { + said = append(said, "the state was not put: "+err.Error()) + } else { + *last = text + } + } + } + } + return said +} diff --git a/modules/mounts/cmd/mounts/config.go b/modules/mounts/cmd/mounts/config.go new file mode 100644 index 00000000..e2ee219e --- /dev/null +++ b/modules/mounts/cmd/mounts/config.go @@ -0,0 +1,328 @@ +package main + +// What this machine is asked to mount (novox/hq ADR 0263, to-be 52 §2), and where the module keeps +// what it learns. +// +// The mesh renders ConfigFile from the assignment: the settings `shares` and `sources`, and the binding +// of the provision `nfs-share` (`from`, the machine that shares, and `at`, where it is reached). The +// long-running process reads it as root and writes the mount units; the bundle reads it as the operator +// account to read what the sharing machine exports from the bus. Nothing in it is secret: the SMB credentials are the +// module's own secret, in a file only root reads. + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +// Where the module keeps its things. ConfigFile is placed by the mesh; the rest by the module's code. +const ( + ConfigFile = "/etc/mesh-mounts/mounts.conf" + // BusDir is written by the bundle, which runs as the operator account and reaches the bus; the + // process, which runs as root and does not, reads what the sharing machine said from it. + BusDir = "/var/lib/mesh-mounts/from-bus" + ExportsFile = BusDir + "/exports.json" + // RunDir is the process's: what it found on its last look, which the tools and the bundle read. + RunDir = "/run/mesh-mounts" + StatusFile = RunDir + "/status.json" + // UnitDir is where the process writes each unit pair, beside the operator's own units. + UnitDir = "/etc/systemd/system" +) + +// None is the value of an empty setting: a definition's default may not be empty (ADR 0262). +const None = "none" + +// Config is the rendered file, read. +type Config struct { + Shares []Wanted `json:"shares"` + Sources []Source `json:"sources"` + // Server is the machine sharing the provision `nfs-share` (the binding's `from`), and At where it is + // reached on the private network (the binding's `at`). + Server string `json:"server,omitempty"` + At string `json:"at,omitempty"` + // Secret is the path of the module's own secret `smb-credentials`; State is the module's state + // directory, where the per-source credentials files are written. + Secret string `json:"secret,omitempty"` + State string `json:"state,omitempty"` + Account string `json:"account,omitempty"` + // Problems are what in the file could not be read; each is said, and its share is left out. + Problems []string `json:"problems,omitempty"` +} + +// Wanted is one NFS share this machine mounts: the share's name on the sharing machine, and where. +type Wanted struct { + Name string `json:"name"` + Mountpoint string `json:"mountpoint"` + ReadOnly bool `json:"read_only"` +} + +// Source is one occasional source: a device that is not a node and is not always there. +type Source struct { + Name string `json:"name"` + Kind string `json:"kind"` // smb or disk + Host string `json:"host,omitempty"` + Share string `json:"share,omitempty"` + UUID string `json:"uuid,omitempty"` + Mountpoint string `json:"mountpoint"` + ReadOnly bool `json:"read_only"` +} + +var ( + nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + hostRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$`) + shareRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._$-]{0,79}$`) + uuidRe = regexp.MustCompile(`^[A-Za-z0-9-]{4,64}$`) +) + +// CheckMountpoint refuses a mount point the module may not use: not absolute, not clean, the root, or +// under a directory the system owns. +func CheckMountpoint(p string) error { + switch { + case !strings.HasPrefix(p, "/"): + return fmt.Errorf("the mount point %q is not an absolute path", p) + case filepath.Clean(p) != p: + return fmt.Errorf("the mount point %q is not written plainly (it would be %q)", p, filepath.Clean(p)) + case strings.ContainsAny(p, " \t\n\\\"'"): + return fmt.Errorf("the mount point %q holds a space, a quote or a backslash", p) + } + for _, sys := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/proc", "/sys", "/dev", "/run", "/var", "/home", "/root", "/tmp"} { + if p == sys { + return fmt.Errorf("the mount point %q is the system's own directory", p) + } + } + for _, sys := range []string{"/etc/", "/usr/", "/bin/", "/sbin/", "/lib/", "/boot/", "/proc/", "/sys/", "/dev/", "/run/"} { + if strings.HasPrefix(p, sys) { + return fmt.Errorf("the mount point %q is under %s, which is the system's", p, strings.TrimSuffix(sys, "/")) + } + } + return nil +} + +func entries(v string) []string { + v = strings.TrimSpace(v) + if v == "" || v == None { + return nil + } + return strings.FieldsFunc(v, func(r rune) bool { return r == ' ' || r == ',' || r == '\n' || r == '\t' }) +} + +func readOnly(s string) (string, bool) { + if strings.HasSuffix(s, ":ro") { + return strings.TrimSuffix(s, ":ro"), true + } + return strings.TrimSuffix(s, ":rw"), false +} + +// ParseShares reads the setting `shares`: space-separated `name=mountpoint`, each optionally `:ro`. +// What cannot be read is said, and only that entry is left out. +func ParseShares(v string) ([]Wanted, []string) { + var out []Wanted + var problems []string + seen := map[string]bool{} + for _, e := range entries(v) { + name, rest, ok := strings.Cut(e, "=") + if !ok { + problems = append(problems, fmt.Sprintf("the share %q is not name=mountpoint", e)) + continue + } + point, ro := readOnly(rest) + if !nameRe.MatchString(name) { + problems = append(problems, fmt.Sprintf("the share name %q is not lower-case letters, digits and dashes", name)) + continue + } + if err := CheckMountpoint(point); err != nil { + problems = append(problems, fmt.Sprintf("the share %s: %v", name, err)) + continue + } + if seen[name] || seen["@"+point] { + problems = append(problems, fmt.Sprintf("the share %s or its mount point %s is named twice", name, point)) + continue + } + seen[name], seen["@"+point] = true, true + out = append(out, Wanted{Name: name, Mountpoint: point, ReadOnly: ro}) + } + return out, problems +} + +// ParseSources reads the setting `sources`: space-separated `name=smb:///@` or +// `name=disk:@`, each optionally `:ro`. +func ParseSources(v string) ([]Source, []string) { + var out []Source + var problems []string + seen := map[string]bool{} + for _, e := range entries(v) { + name, rest, ok := strings.Cut(e, "=") + if !ok { + problems = append(problems, fmt.Sprintf("the source %q is not name=@", e)) + continue + } + if !nameRe.MatchString(name) { + problems = append(problems, fmt.Sprintf("the source name %q is not lower-case letters, digits and dashes", name)) + continue + } + at := strings.LastIndex(rest, "@") + if at < 0 { + problems = append(problems, fmt.Sprintf("the source %s names no mount point (…@)", name)) + continue + } + where, pointRO := rest[:at], rest[at+1:] + point, ro := readOnly(pointRO) + if err := CheckMountpoint(point); err != nil { + problems = append(problems, fmt.Sprintf("the source %s: %v", name, err)) + continue + } + s := Source{Name: name, Mountpoint: point, ReadOnly: ro} + switch { + case strings.HasPrefix(where, "smb://"): + host, share, ok := strings.Cut(strings.TrimPrefix(where, "smb://"), "/") + if !ok || !hostRe.MatchString(host) || !shareRe.MatchString(share) { + problems = append(problems, fmt.Sprintf("the source %s: %q is not smb:///", name, where)) + continue + } + s.Kind, s.Host, s.Share = "smb", host, share + case strings.HasPrefix(where, "disk:"): + uuid := strings.TrimPrefix(where, "disk:") + if !uuidRe.MatchString(uuid) { + problems = append(problems, fmt.Sprintf("the source %s: %q is not disk:", name, where)) + continue + } + s.Kind, s.UUID = "disk", uuid + default: + problems = append(problems, fmt.Sprintf("the source %s: %q is neither smb:/// nor disk:", name, where)) + continue + } + if seen[name] || seen["@"+point] { + problems = append(problems, fmt.Sprintf("the source %s or its mount point %s is named twice", name, point)) + continue + } + seen[name], seen["@"+point] = true, true + out = append(out, s) + } + return out, problems +} + +// ParseConfig reads the rendered file: `key=value` lines. +func ParseConfig(text string) Config { + kv := map[string]string{} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + var c Config + var p []string + c.Shares, p = ParseShares(kv["shares"]) + c.Problems = append(c.Problems, p...) + c.Sources, p = ParseSources(kv["sources"]) + c.Problems = append(c.Problems, p...) + c.Server, c.At, c.Secret, c.State, c.Account = kv["server"], kv["at"], kv["secret"], kv["state"], kv["account"] + if c.At != "" && !hostRe.MatchString(c.At) { + c.Problems = append(c.Problems, fmt.Sprintf("the sharing machine's address %q is not a name or an address", c.At)) + c.At = "" + } + points := map[string]string{} + for _, w := range c.Shares { + points[w.Mountpoint] = w.Name + } + kept := c.Sources[:0] + for _, s := range c.Sources { + if other, dup := points[s.Mountpoint]; dup { + c.Problems = append(c.Problems, fmt.Sprintf("the source %s mounts at %s, where the share %s already does", s.Name, s.Mountpoint, other)) + continue + } + kept = append(kept, s) + } + c.Sources = kept + return c +} + +// ServerNode is the sharing machine, the key of its exports on the bus: the binding's `from`, else the first label of `at`. +func (c Config) ServerNode() string { + if c.Server != "" { + return c.Server + } + if c.At == "" { + return "" + } + first, _, _ := strings.Cut(c.At, ".") + return first +} + +// SMBCredential is one source's login, from the module's own secret. +type SMBCredential struct { + Username string + Password string + Domain string +} + +// ParseSMBCredentials reads the secret `smb-credentials`: one section per source, +// +// [name] +// username=… +// password=… +// domain=… (optional) +// +// Its values are never answered by any tool; a problem names the section and the key, never the value. +func ParseSMBCredentials(text string) (map[string]SMBCredential, []string) { + out := map[string]SMBCredential{} + var problems []string + section := "" + for i, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.TrimSpace(line[1 : len(line)-1]) + if _, dup := out[section]; dup { + problems = append(problems, fmt.Sprintf("the section [%s] is given twice; the first is kept", section)) + section = "" + continue + } + out[section] = SMBCredential{} + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok || section == "" { + problems = append(problems, fmt.Sprintf("line %d is not key=value inside a [section]", i+1)) + continue + } + c := out[section] + switch strings.ToLower(strings.TrimSpace(k)) { + case "username", "user": + c.Username = strings.TrimSpace(v) + case "password", "pass": + c.Password = strings.TrimRight(v, "\r") + case "domain": + c.Domain = strings.TrimSpace(v) + default: + problems = append(problems, fmt.Sprintf("[%s] has a key %q this module does not read", section, strings.TrimSpace(k))) + } + out[section] = c + } + for name, c := range out { + if c.Username == "" { + problems = append(problems, fmt.Sprintf("[%s] gives no username", name)) + } + if strings.ContainsAny(c.Username+c.Password+c.Domain, "\n") { + problems = append(problems, fmt.Sprintf("[%s] holds a line break", name)) + delete(out, name) + } + } + return out, problems +} + +// CredentialsFile is a source's credentials in mount.cifs's format. +func (c SMBCredential) CredentialsFile() string { + s := "username=" + c.Username + "\npassword=" + c.Password + "\n" + if c.Domain != "" { + s += "domain=" + c.Domain + "\n" + } + return s +} diff --git a/modules/mounts/cmd/mounts/health.go b/modules/mounts/cmd/mounts/health.go new file mode 100644 index 00000000..cdd5ce0b --- /dev/null +++ b/modules/mounts/cmd/mounts/health.go @@ -0,0 +1,97 @@ +package main + +// The module's health (novox/hq ADR 0240, ADR 0263 rule 8), judged from what the process last found. +// +// Unhealthy when a share that should always be there has been unreachable or failed for five minutes, +// when an automount is not armed, when something the settings name cannot be set up, or when the +// process has not looked for two minutes. An occasional source that is absent is healthy: it is said as +// not present since when, and raises nothing. + +import ( + "fmt" + "strings" + "time" +) + +// Health is the answer the node-engine reads from a tool check. +type Health struct { + Healthy bool `json:"healthy"` + Why string `json:"why"` + Found []string `json:"found,omitempty"` + Said []string `json:"said,omitempty"` +} + +// Grace is how long a share may be away before it is a fault; Stale how old the process's look may be. +const ( + Grace = 5 * time.Minute + Stale = 2 * time.Minute +) + +func clock(ts string) string { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return ts + } + return t.Local().Format("15:04") +} + +// Judge is the health of one status at a moment. +func Judge(st Status, readErr error, now time.Time) Health { + if readErr != nil { + return Health{Healthy: false, Why: "The module's process has left no account of this machine's mounts (" + readErr.Error() + ").", + Found: []string{"no status"}} + } + var found, said []string + if looked, err := time.Parse(time.RFC3339, st.Looked); err != nil || now.Sub(looked) > Stale { + found = append(found, fmt.Sprintf("The module's process last looked at %s, more than two minutes ago.", clock(st.Looked))) + } + for _, p := range st.Problems { + found = append(found, "The module's settings or secret: "+p+".") + } + from := "" + if st.Server != "" { + from = " of " + st.Server + } + for _, s := range st.Shares { + since, _ := time.Parse(time.RFC3339, s.Since) + long := now.Sub(since) >= Grace + switch s.State { + case StateMounted, StateArmed: + said = append(said, fmt.Sprintf("%s at %s: %s since %s.", s.Name, s.Mountpoint, s.State, clock(s.Since))) + case StateAbsent: + said = append(said, fmt.Sprintf("%s at %s: not present since %s.", s.Name, s.Mountpoint, clock(s.Since))) + case StateUnreachable: + line := fmt.Sprintf("The share %s%s is not reachable from this machine since %s (%s).", s.Name, from, clock(s.Since), s.Why) + if s.Kind != "nfs" { + line = fmt.Sprintf("The source %s is not reachable from this machine since %s (%s).", s.Name, clock(s.Since), s.Why) + } + if long { + found = append(found, line) + } else { + said = append(said, line+" Raised after five minutes.") + } + default: + line := fmt.Sprintf("The %s %s at %s cannot be mounted since %s: %s.", map[bool]string{true: "source", false: "share"}[s.Occasional], + s.Name, s.Mountpoint, clock(s.Since), strings.TrimSuffix(s.Why, ".")) + if long || strings.Contains(s.Why, "not armed") || strings.Contains(s.Why, "not the module's") || strings.Contains(s.Why, "not given") { + found = append(found, line) + } else { + said = append(said, line+" Raised after five minutes.") + } + } + } + if len(found) > 0 { + return Health{Healthy: false, Why: strings.Join(found, " "), Found: found, Said: said} + } + why := "Every share this machine is asked to mount is armed or mounted, and every occasional source is armed, mounted or not present." + if len(st.Shares) == 0 { + why = "This machine is asked to mount nothing." + } + return Health{Healthy: true, Why: why, Said: said} +} + +// Health reads the process's last look and judges it. +func (m *Machine) Health(now time.Time) Health { + st, err := readJSON[Status](m, StatusFile) + return Judge(st, err, now) +} diff --git a/modules/mounts/cmd/mounts/machine.go b/modules/mounts/cmd/mounts/machine.go index 88d42c65..b0c8722b 100644 --- a/modules/mounts/cmd/mounts/machine.go +++ b/modules/mounts/cmd/mounts/machine.go @@ -42,11 +42,15 @@ type Machine struct { Root string Run Runner LookPath func(name string) bool + // Escalate runs a command as root: as given where this process is root, else through `sudo -n`, as the + // bundle runs as the operator account (novox/hq ADR 0175 §4) and the sudo module lets it. + Escalate Runner } // 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 }} + return &Machine{Root: "/", Run: ExecRunner, Escalate: EscalatedRunner, + 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 @@ -81,3 +85,34 @@ func unreadable(path string, err error) string { return fmt.Sprintf("%s: not read (%v)", path, err) } } + +// EscalatedRunner runs a command as root, through sudo without a prompt where this process is not root. +// A refusal by sudo is said as one, never read as an empty answer. +func EscalatedRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + program, argv := name, args + if os.Getuid() != 0 { + program, argv = "sudo", append([]string{"-n", name}, args...) + } + cmd := exec.CommandContext(ctx, program, argv...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + said := strings.TrimSpace(stderr.String()) + if program == "sudo" && strings.HasPrefix(said, "sudo:") { + return stdout.String(), fmt.Errorf("%s needs root, and the tool runner's account may not run it without a prompt: %s", name, said) + } + 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 +} + +// present says whether a device or a file is there. Never used on a mount point. +func (m *Machine) present(p string) bool { + _, err := os.Stat(filepath.Join(m.Root, p)) + return err == nil +} diff --git a/modules/mounts/cmd/mounts/main.go b/modules/mounts/cmd/mounts/main.go index 5f95d600..33acfb1d 100644 --- a/modules/mounts/cmd/mounts/main.go +++ b/modules/mounts/cmd/mounts/main.go @@ -1,20 +1,74 @@ -// 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. +// The mounts module's Go bundle (novox/hq research 038, ADR 0263). One binary, three ways to run: +// +// - with no argument it is the bundle the node's tool runner launches and speaks MCP to over stdio, as the +// operator account: the tools that read what a machine mounts and shares, the seat node-mounts' verbs +// and the health check; and beside them the module's half on the bus (bus.go); +// - `apply` and `watch` are the module's two long-running processes, run by the node-engine as root: +// one writes the unit pair of every share and source the machine is asked to mount and arms the +// automounts, the other tries each source and says what it found (apply.go); +// - `adopt ` is the root half of the verb adopt, run by the bundle through sudo (adopt.go). package main import ( + "context" + "encoding/json" "fmt" "os" + "os/signal" + "syscall" + "time" stdio "git.novox.be/novox/mesh-sdk/go" ) func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "apply", "watch": + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + a := NewApplier() + a.Act, a.Judge = os.Args[1] == "apply", os.Args[1] == "watch" + RunApply(ctx, a) + return + case "adopt": + os.Exit(RunAdopt(os.Args[2:])) + default: + fmt.Fprintf(os.Stderr, "mounts: %q is not a way to run; with no argument it serves its tools, with apply it "+ + "writes and arms the machine's mounts, with watch it says how they are, with adopt it takes over an fstab line\n", os.Args[1]) + os.Exit(2) + } + } + m := Here() + go shareOnTheBus(m) // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): mounts. - if err := stdio.Serve("", Tools(Here())); err != nil { + if err := stdio.Serve("", Tools(m)); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } + +// shareOnTheBus is the bundle's half on the bus, every LookEvery for as long as the bundle runs. +func shareOnTheBus(m *Machine) { + node := os.Getenv("MESH_NODE") + get := func(key string) (json.RawMessage, error) { + e, err := stdio.State(ExportsState).Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil + } + put := func(key string, value any) error { + _, err := stdio.State(SharesState).Put(key, value) + return err + } + last, lastSaid := "", "" + for { + said := m.Share(node, get, put, &last, time.Now()) + if s := fmt.Sprint(said); len(said) > 0 && s != lastSaid { + fmt.Fprintln(os.Stderr, "mounts:", s) + lastSaid = s + } + time.Sleep(LookEvery) + } +} diff --git a/modules/mounts/cmd/mounts/manifest_test.go b/modules/mounts/cmd/mounts/manifest_test.go new file mode 100644 index 00000000..ab1e3491 --- /dev/null +++ b/modules/mounts/cmd/mounts/manifest_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "os" + "regexp" + "sort" + "strings" + "testing" +) + +type manifest struct { + Tools []string `json:"tools"` + Requires []string `json:"requires"` + Reads []string `json:"reads"` + Claims []map[string]any `json:"claims"` + State []map[string]any `json:"state"` + Settings map[string]map[string]any `json:"settings"` + OwnSecrets map[string]map[string]string `json:"own-secrets"` + Resources []map[string]any `json:"resources"` +} + +func readManifest(t *testing.T) (manifest, string) { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m, string(raw) +} + +// The tools the bundle serves are the manifest's tools and the seat's verbs, each saying whether it reads +// or acts; the module requires the share, asks the server's seat and holds node-mounts. +func TestTheManifestNamesWhatTheBundleServesAndWhatItNeeds(t *testing.T) { + m, raw := readManifest(t) + var tools, verbs []string + for _, tl := range Tools(newSystem(t).machine()) { + if !strings.HasSuffix(tl.Description, "(r)") && !strings.HasSuffix(tl.Description, "(a)") { + t.Errorf("%s does not say whether it reads (r) or acts (a)", tl.Name) + } + if v, ok := strings.CutPrefix(tl.Name, Seat+"."); ok { + verbs = append(verbs, v) + } else { + tools = append(tools, tl.Name) + } + } + sort.Strings(tools) + sort.Strings(m.Tools) + if strings.Join(tools, ",") != strings.Join(m.Tools, ",") { + t.Errorf("served %v, listed %v", tools, m.Tools) + } + if len(m.Claims) != 1 || m.Claims[0]["name"] != Seat || m.Claims[0]["scope"] != "node" { + t.Fatalf("claims %v", m.Claims) + } + var serves []string + for _, v := range m.Claims[0]["serves"].([]any) { + serves = append(serves, v.(string)) + } + sort.Strings(serves) + sort.Strings(verbs) + if strings.Join(serves, ",") != strings.Join(verbs, ",") || strings.Join(verbs, ",") != "adopt,list,mount,test,unmount" { + t.Errorf("the seat serves %v, the bundle %v", serves, verbs) + } + if strings.Join(m.Requires, ",") != "nfs-share" { + t.Errorf("requires %v", m.Requires) + } + if strings.Join(m.Reads, ",") != ExportsState || strings.Contains(raw, `"invokes"`) { + t.Errorf("the module reads nfs-server's exports from the bus, and calls nothing: reads %v", m.Reads) + } + if len(m.State) != 1 || m.State[0]["name"] != SharesState || m.State[0]["per-machine"] != true { + t.Errorf("state %v", m.State) + } + sec := m.OwnSecrets["smb-credentials"] + if sec["issued-by"] != "outside" || sec["taken"] != "at-start" || !strings.HasPrefix(sec["path"], "${dir:state}/") { + t.Errorf("the SMB logins are an outside party's, given with secret accept: %v", sec) + } + for _, key := range []string{"shares", "sources"} { + s := m.Settings[key] + if s["kind"] != "preference" || s["default"] != None || s["why"] == "" || !strings.Contains(raw, "${setting:"+key+"}") { + t.Errorf("setting %s: %v", key, s) + } + } + // A machine's or a person's name, as a word: "novox/hq" names the record, not the machine. + if hit := regexp.MustCompile(`\b(g14|novox|ace|shanks|jochen|recalbox)\b|/home/|/storage`).FindString(strings.ReplaceAll(raw, "novox/hq", "")); hit != "" { + t.Errorf("the manifest says %q", hit) + } + if strings.Contains(raw, "/etc/fstab") { + t.Error("the manifest places nothing in /etc/fstab: the operator's lines are taken over only by adopt") + } +} + +// Every long-running resource says how it is healthy (ADR 0240): apply by its unit, watch by mounts_health, +// both as root; the config file the process reads is the one the manifest places. +func TestTheProcessesAreRootAndJudgedByTheirUnitAndTheHealthTool(t *testing.T) { + m, raw := readManifest(t) + byID := map[string]map[string]any{} + for _, r := range m.Resources { + byID[r["id"].(string)] = r + if r["type"] == "process" || r["type"] == "service" || r["type"] == "container" { + if r["health"] == nil { + t.Errorf("%s runs and does not say how it is healthy", r["id"]) + } + } + } + apply, watch := byID["apply"], byID["watch"] + if h, _ := apply["health"].(map[string]any); apply["user"] != nil || h["kind"] != "unit" { + t.Errorf("apply runs as root and is judged by its unit: %v", apply) + } + if h, _ := watch["health"].(map[string]any); watch["user"] != nil || h["kind"] != "tool" || h["tool"] != "mounts_health" { + t.Errorf("watch runs as root and is judged by mounts_health: %v", watch) + } + for id, mode := range map[string]string{"apply": "apply", "watch": "watch"} { + if run, _ := byID[id]["run"].([]any); len(run) != 2 || run[1] != mode { + t.Errorf("%s runs the bundle's %s: %v", id, mode, byID[id]["run"]) + } + } + if byID["config"]["path"] != ConfigFile { + t.Errorf("the config file is %v, the code reads %s", byID["config"]["path"], ConfigFile) + } + content, _ := byID["config"]["content"].(string) + cfg := ParseConfig(content) + if len(cfg.Problems) != 0 && !strings.Contains(strings.Join(cfg.Problems, " "), "${") { + t.Errorf("the config's template reads: %v", cfg.Problems) + } + for _, want := range []string{"server=${bound:nfs-share:from}", "at=${bound:nfs-share:at}", "secret=${dir:state}/smb-credentials.secret"} { + if !strings.Contains(content, want) { + t.Errorf("the config lacks %s", want) + } + } + if byID["bus-dir"]["path"] != BusDir || byID["bus-dir"]["owner"] != "${machine:account}" { + t.Errorf("the bundle, as the operator account, writes the bus's answer: %v", byID["bus-dir"]) + } + if strings.Contains(raw, "password") { + t.Error("the manifest holds no password") + } +} diff --git a/modules/mounts/cmd/mounts/tools.go b/modules/mounts/cmd/mounts/tools.go index 7e6df4d7..3e29881f 100644 --- a/modules/mounts/cmd/mounts/tools.go +++ b/modules/mounts/cmd/mounts/tools.go @@ -6,9 +6,10 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" ) -// Tools is the module's tools over one machine. Every one only reads. +// Tools is the module's tools over one machine: the two that read what the machine has, the seat's verbs +// and the health check (verbs.go). func Tools(m *Machine) []stdio.Tool { - return []stdio.Tool{ + return append([]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 " + @@ -36,7 +37,7 @@ func Tools(m *Machine) []stdio.Tool { return m.Exports(), nil }, }, - } + }, Verbs(m)...) } func text(args map[string]any, key string) string { diff --git a/modules/mounts/cmd/mounts/units.go b/modules/mounts/cmd/mounts/units.go new file mode 100644 index 00000000..9c76bc2c --- /dev/null +++ b/modules/mounts/cmd/mounts/units.go @@ -0,0 +1,156 @@ +package main + +// The unit pair for one share (novox/hq ADR 0263 rule 4, to-be 52 §2): a `.mount` that is never enabled +// and an `.automount` that is. Nothing is mounted at boot and nothing can fail a boot: the mount is made +// at first use and released after ten idle minutes, and a mount point is always an autofs mount, so +// nothing writes into the empty directory under it. + +import ( + "fmt" + "strconv" + "strings" +) + +// Marker opens every unit file this module writes. A unit file without it is not the module's, and the +// module never writes over, stops or removes one. +const Marker = "# Written by the mesh (module mounts, novox/hq ADR 0263)." + +// Timing, in seconds. +const ( + IdleSeconds = 600 // released after ten idle minutes + MountSeconds = 30 // a share of a node + OccasionalSeconds = 10 // a source that comes and goes +) + +// EscapePath is `systemd-escape --path`: the name systemd gives the units for a mount point. +func EscapePath(p string) string { + p = strings.Trim(p, "/") + if p == "" { + return "-" + } + var b strings.Builder + for i := 0; i < len(p); i++ { + c := p[i] + switch { + case c == '/': + b.WriteByte('-') + case c == '.' && i == 0: + fmt.Fprintf(&b, `\x%02x`, c) + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == ':', c == '_', c == '.': + b.WriteByte(c) + default: + fmt.Fprintf(&b, `\x%02x`, c) + } + } + return b.String() +} + +// Pair is one share's two units, as written. +type Pair struct { + Name string `json:"name"` + Kind string `json:"kind"` // nfs, smb or disk + Occasional bool `json:"occasional"` + Mountpoint string `json:"mountpoint"` + What string `json:"what"` + // Probe is what is tried to tell whether the source is there: host:port, or a device path. + Probe string `json:"probe"` + MountUnit string `json:"mount_unit"` + AutoUnit string `json:"automount_unit"` + Mount string `json:"-"` + Automount string `json:"-"` + // Credentials is the path of the source's credentials file (SMB), written beside the units. + Credentials string `json:"credentials,omitempty"` +} + +// ServerShare is one share as the sharing machine says it exports it (its `node-nfs-server.exports`). +type ServerShare struct { + Name string `json:"name"` + Path string `json:"path"` + Access string `json:"access"` + UID int `json:"uid"` + GID int `json:"gid"` + Clients string `json:"clients,omitempty"` + Live bool `json:"live"` +} + +func header(name, kind string) string { + return Marker + "\n# The " + kind + " " + name + ". Replaced whenever the module's settings or the sharing machine's\n" + + "# answer change, and removed when nothing asks for it; edit nothing here: change the module's settings.\n" +} + +// NFSPair is the pair for a share of a node: NFS version 4.2 from the sharing machine's address on the +// private network. Read-write is mounted hard, so a write is never dropped while the server is away; +// read-only, soft, so a reader gets an error rather than waiting. +func NFSPair(w Wanted, at string, e ServerShare) Pair { + ro := w.ReadOnly || e.Access == "ro" + opts := "vers=4.2,hard,timeo=150,retrans=3,_netdev" + if ro { + opts = "ro,vers=4.2,soft,timeo=150,retrans=3,_netdev" + } + p := Pair{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, What: at + ":" + e.Path, Probe: hostPort(at, 2049)} + p.render("share", "nfs4", opts, MountSeconds, "remote-fs.target", "") + return p +} + +// SMBPair is the pair for an occasional SMB source. Its login is in a file only root reads. +func SMBPair(s Source, credentials string, uid, gid int) Pair { + opts := "credentials=" + credentials + ",iocharset=utf8,_netdev,nofail" + if uid >= 0 { + opts += ",uid=" + strconv.Itoa(uid) + ",gid=" + strconv.Itoa(gid) + } + if s.ReadOnly { + opts = "ro," + opts + } + p := Pair{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, + What: "//" + s.Host + "/" + s.Share, Probe: hostPort(s.Host, 445), Credentials: credentials} + p.render("source", "cifs", opts, OccasionalSeconds, "remote-fs.target", "") + return p +} + +// DiskPair is the pair for a disk that is plugged in now and then: bound to its device, so the mount +// goes when the disk does. +func DiskPair(s Source) Pair { + dev := "/dev/disk/by-uuid/" + s.UUID + opts := "nofail" + if s.ReadOnly { + opts = "ro,nofail" + } + p := Pair{Name: s.Name, Kind: "disk", Occasional: true, Mountpoint: s.Mountpoint, What: dev, Probe: dev} + p.render("source", "", opts, OccasionalSeconds, "local-fs.target", EscapePath(dev)+".device") + return p +} + +func hostPort(host string, port int) string { + if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") { + return "[" + host + "]:" + strconv.Itoa(port) + } + return host + ":" + strconv.Itoa(port) +} + +func (p *Pair) render(kind, fstype, opts string, timeout int, target, device string) { + base := EscapePath(p.Mountpoint) + p.MountUnit, p.AutoUnit = base+".mount", base+".automount" + var m strings.Builder + m.WriteString(header(p.Name, kind)) + m.WriteString("[Unit]\nDescription=" + p.Name + " (" + p.What + "), mounted by the mesh\n") + if device != "" { + m.WriteString("BindsTo=" + device + "\nAfter=" + device + "\n") + } else { + m.WriteString("Wants=network-online.target\nAfter=network-online.target\n") + } + m.WriteString("\n[Mount]\nWhat=" + p.What + "\nWhere=" + p.Mountpoint + "\n") + if fstype != "" { + m.WriteString("Type=" + fstype + "\n") + } + m.WriteString("Options=" + opts + "\nTimeoutSec=" + strconv.Itoa(timeout) + "\n") + p.Mount = m.String() + + var a strings.Builder + a.WriteString(header(p.Name, kind)) + // The automount is armed whether or not the source is there; only the mount waits on it, so an + // absent disk leaves an armed automount, never a failed unit. + a.WriteString("[Unit]\nDescription=" + p.Name + ", mounted by the mesh at first use\n") + a.WriteString("\n[Automount]\nWhere=" + p.Mountpoint + "\nTimeoutIdleSec=" + strconv.Itoa(IdleSeconds) + "\n") + a.WriteString("\n[Install]\nWantedBy=" + target + "\n") + p.Automount = a.String() +} diff --git a/modules/mounts/cmd/mounts/verbs.go b/modules/mounts/cmd/mounts/verbs.go new file mode 100644 index 00000000..dc4eae4c --- /dev/null +++ b/modules/mounts/cmd/mounts/verbs.go @@ -0,0 +1,148 @@ +package main + +// The seat node-mounts' verbs (novox/hq ADR 0263), served as `node-mounts.` so the runtime answers +// them on the seat's subject, and the module's health check. + +import ( + "context" + "fmt" + "path/filepath" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Seat is the node seat this module holds. +const Seat = "node-mounts" + +func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run} +} + +var nameArg = map[string]any{"type": "string", "description": "the share's or source's name, as the module's settings give it"} + +// share is one share or source the process last found, by name. +func (m *Machine) share(name string) (ShareStatus, error) { + st, err := readJSON[Status](m, StatusFile) + if err != nil { + return ShareStatus{}, fmt.Errorf("the module's process has left no account yet: %v", err) + } + for _, s := range st.Shares { + if s.Name == name { + return s, nil + } + } + return ShareStatus{}, fmt.Errorf("this machine is asked to mount nothing named %q", name) +} + +// ownPair refuses a share whose units are not both the module's. +func (m *Machine) ownPair(s ShareStatus) error { + if s.MountUnit == "" || !IsOwnUnit(m, filepath.Join(UnitDir, s.MountUnit)) || !IsOwnUnit(m, filepath.Join(UnitDir, s.AutoUnit)) { + return fmt.Errorf("%s has no unit pair of the module's (%s): %s", s.Name, s.State, s.Why) + } + return nil +} + +// Verbs are the seat's verbs and the health check, over one machine. +func Verbs(m *Machine) []stdio.Tool { + ctx := context.Background() + return []stdio.Tool{ + verb("list", "Every mount point on this machine — its fstab line, its mount and automount units, whether it is "+ + "mounted now — and every share and occasional source the module is asked to mount, with its state (armed, "+ + "mounted, absent, unreachable, failed), since when and why. Credentials are never answered. Replaces cat "+ + "/etc/fstab, findmnt and systemctl list-units --type=mount. (r)", + map[string]any{"network": map[string]any{"type": "boolean", "description": "only network filesystems (default false)"}}, + func(a map[string]any) (any, error) { + st, err := readJSON[Status](m, StatusFile) + out := map[string]any{"machine": m.List(flag(a, "network"), "")} + if err != nil { + out["shares"] = "the module's process has left no account yet: " + err.Error() + } else { + out["shares"], out["looked"], out["problems"] = st.Shares, st.Looked, st.Problems + } + return out, nil + }), + verb("test", "Whether one share's or source's server answers now (TCP 2049 for NFS, 445 for SMB; for a disk, "+ + "whether it is plugged in), tried without touching its mount point. Replaces showmount -e and ping. (r)", + map[string]any{"name": nameArg}, + func(a map[string]any) (any, error) { + s, err := m.share(text(a, "name")) + if err != nil { + return nil, err + } + if s.Probe == "" { + return map[string]any{"name": s.Name, "answers": false, "said": "nothing to try: " + s.Why}, nil + } + ap := NewApplier() + ap.M = m + p := Pair{Kind: s.Kind, Probe: s.Probe} + start := time.Now() + var tried error + if p.Kind == "disk" { + if !m.present(p.Probe) { + tried = fmt.Errorf("the disk %s is not plugged in", p.Probe) + } + } else { + tried = ap.Dial(p.Probe, ProbeTimeout) + } + out := map[string]any{"name": s.Name, "tried": s.Probe, "answers": tried == nil, "took_ms": time.Since(start).Milliseconds()} + if tried != nil { + out["said"] = tried.Error() + } + return out, nil + }), + verb("mount", "Mount one share or source now, instead of waiting for its first use: its mount unit is "+ + "started. Replaces mount. (a)", + map[string]any{"name": nameArg}, + func(a map[string]any) (any, error) { + s, err := m.share(text(a, "name")) + if err == nil { + err = m.ownPair(s) + } + if err != nil { + return nil, err + } + if _, err := m.Escalate(ctx, "systemctl", "start", s.MountUnit); err != nil { + return nil, err + } + return map[string]any{"name": s.Name, "said": fmt.Sprintf("%s is mounted at %s.", s.Name, s.Mountpoint)}, nil + }), + verb("unmount", "Release one share or source now: its mount unit is stopped, and its automount stays armed, "+ + "so the next use mounts it again. Replaces umount. (a)", + map[string]any{"name": nameArg}, + func(a map[string]any) (any, error) { + s, err := m.share(text(a, "name")) + if err == nil { + err = m.ownPair(s) + } + if err != nil { + return nil, err + } + if _, err := m.Escalate(ctx, "systemctl", "stop", s.MountUnit); err != nil { + return nil, err + } + return map[string]any{"name": s.Name, "said": fmt.Sprintf("%s is released; its automount waits for the next use.", s.Name)}, nil + }), + verb("adopt", "Take over one hand-written /etc/fstab line, on a person's word: refused unless this module's own "+ + "unit pair for the same mount point is written and its automount armed. Then the line is commented out with "+ + "a dated marker (any password in it redacted), the file as it was is kept beside it readable by root only, "+ + "and the service manager reloads. Without confirm, a dry run that changes nothing. Replaces sed -i /etc/fstab. (a)", + map[string]any{ + "mountpoint": map[string]any{"type": "string", "description": "the mount point of the line, as fstab names it"}, + "confirm": map[string]any{"type": "boolean", "description": "true to change the file; otherwise a dry run"}, + "why": map[string]any{"type": "string", "description": "why the line is taken over (required with confirm)"}, + }, + func(a map[string]any) (any, error) { + return m.Adopt(ctx, text(a, "mountpoint"), flag(a, "confirm"), text(a, "why"), time.Now()) + }), + { + Name: "mounts_health", + Description: "Whether this machine's shares are healthy, as the module's health check (novox/hq ADR 0263): unhealthy " + + "when a share that should always be there has been unreachable or failed for five minutes, an automount is not " + + "armed, something the settings name cannot be set up (a missing login, a unit file that is not the module's), or " + + "the module's process has not looked for two minutes; with why, one sentence per finding. An occasional source " + + "that is absent is healthy, and said as not present since when. (r)", + Run: func(map[string]any) (any, error) { return m.Health(time.Now()), nil }, + }, + } +} diff --git a/modules/mounts/cmd/mounts/write_test.go b/modules/mounts/cmd/mounts/write_test.go new file mode 100644 index 00000000..2e9b9973 --- /dev/null +++ b/modules/mounts/cmd/mounts/write_test.go @@ -0,0 +1,560 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The secret every test that has one uses; it must never appear in anything the module says. +const testPassword = "s3cr3t-Pa55" + +// fakeSystem is a machine under a temporary root with a recorded service manager. +type fakeSystem struct { + t *testing.T + root string + calls []string + // units are the service manager's answers: ActiveState and UnitFileState by unit. + units map[string]map[string]string + // reachable are the host:port pairs that answer. + reachable map[string]bool +} + +func newSystem(t *testing.T) *fakeSystem { + f := &fakeSystem{t: t, root: t.TempDir(), units: map[string]map[string]string{}, reachable: map[string]bool{}} + for _, d := range []string{UnitDir, RunDir, BusDir, "/etc/mesh-mounts", "/proc/self", "/var/lib/mesh/mounts"} { + if err := os.MkdirAll(filepath.Join(f.root, d), 0o755); err != nil { + t.Fatal(err) + } + } + f.write("/proc/self/mountinfo", "") + return f +} + +func (f *fakeSystem) write(p, content string) { + f.t.Helper() + full := filepath.Join(f.root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } +} + +func (f *fakeSystem) read(p string) string { + b, _ := os.ReadFile(filepath.Join(f.root, p)) + return string(b) +} + +func (f *fakeSystem) run(_ context.Context, name string, args ...string) (string, error) { + f.calls = append(f.calls, name+" "+strings.Join(args, " ")) + if name != "systemctl" || len(args) == 0 { + return "", nil + } + switch args[0] { + case "show": + if len(args) >= 5 && args[3] == "--value" { + return f.units[args[4]]["ActiveState"] + "\n", nil + } + var blocks []string + for _, u := range args { + if !strings.HasSuffix(u, ".mount") && !strings.HasSuffix(u, ".automount") { + continue + } + s := f.units[u] + blocks = append(blocks, "Id="+u+"\nActiveState="+orDefault(s["ActiveState"], "inactive")+"\nSubState=dead\nResult="+ + orDefault(s["Result"], "success")+"\nUnitFileState="+orDefault(s["UnitFileState"], "disabled")) + } + return strings.Join(blocks, "\n\n") + "\n", nil + case "enable": + u := args[len(args)-1] + f.units[u] = map[string]string{"ActiveState": "active", "UnitFileState": "enabled"} + } + return "", nil +} + +func (f *fakeSystem) machine() *Machine { + return &Machine{Root: f.root, Run: f.run, Escalate: f.run, LookPath: func(string) bool { return true }} +} + +func (f *fakeSystem) applier(now time.Time) *Applier { + return &Applier{M: f.machine(), Act: true, Judge: true, + Dial: func(addr string, _ time.Duration) error { + if f.reachable[addr] { + return nil + } + return errors.New("dial tcp " + addr + ": connect: no route to host") + }, + Now: func() time.Time { return now }, + Lookup: func(string) (int, int, error) { return 1000, 1000, nil }, + } +} + +func (f *fakeSystem) called(prefix string) bool { + for _, c := range f.calls { + if strings.HasPrefix(c, prefix) { + return true + } + } + return false +} + +// everything is every file under the root, for a search that a secret appears nowhere it should not. +func (f *fakeSystem) everything(except ...string) string { + var b strings.Builder + filepath.Walk(f.root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + for _, e := range except { + if strings.HasSuffix(p, e) { + return nil + } + } + raw, _ := os.ReadFile(p) + b.Write(raw) + return nil + }) + return b.String() +} + +const config = "shares=library=/srv/library downloads=/srv/downloads:ro\n" + + "sources=games=smb://192.0.2.76/Share@/mnt/games usb=disk:1234-ABCD@/mnt/usb\n" + + "server=homeserver\nat=192.0.2.10\naccount=operator\n" + + "secret=/var/lib/mesh/mounts/smb-credentials.secret\nstate=/var/lib/mesh/mounts\n" + +func exportsAnswer(t *testing.T, shares ...ServerShare) string { + raw, err := json.Marshal(ExportsAnswer{Server: "homeserver", At: "192.0.2.10", Shares: shares}) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +var ( + library = ServerShare{Name: "library", Path: "/tank/media", Access: "rw", UID: 1001, GID: 2000, Live: true} + downloads = ServerShare{Name: "downloads", Path: "/tank/downloads", Access: "rw", UID: 1001, GID: 2000, Live: true} +) + +func TestTheSettingsAreReadAndWhatCannotBeIsSaidPlainly(t *testing.T) { + shares, problems := ParseShares("library=/srv/library downloads=/srv/downloads:ro,bad Upper=/x other=relative library=/srv/again root=/") + if len(shares) != 2 || shares[0] != (Wanted{"library", "/srv/library", false}) || shares[1] != (Wanted{"downloads", "/srv/downloads", true}) { + t.Fatalf("shares %+v", shares) + } + want := []string{`"bad" is not name=mountpoint`, `"Upper" is not lower-case`, `"relative" is not an absolute path`, "named twice", `"/" is the system's own directory`} + for i, w := range want { + if i >= len(problems) || !strings.Contains(problems[i], w) { + t.Errorf("problem %d: want %q in %q", i, w, problems) + } + } + if s, p := ParseShares("none"); len(s) != 0 || len(p) != 0 { + t.Errorf("none is nothing: %v %v", s, p) + } + sources, problems := ParseSources("games=smb://192.0.2.76/Share@/mnt/games:ro usb=disk:1234-ABCD@/mnt/usb ftp=ftp://x/y@/mnt/x nomount=smb://h/s") + if len(sources) != 2 || sources[0].Kind != "smb" || sources[0].Host != "192.0.2.76" || sources[0].Share != "Share" || !sources[0].ReadOnly || + sources[1].Kind != "disk" || sources[1].UUID != "1234-ABCD" { + t.Fatalf("sources %+v", sources) + } + if len(problems) != 2 || !strings.Contains(problems[0], "neither smb://") || !strings.Contains(problems[1], "names no mount point") { + t.Errorf("problems %q", problems) + } + if err := CheckMountpoint("/etc/x"); err == nil { + t.Error("a mount point under /etc is the system's") + } +} + +func TestAMountPointIsNamedAsSystemdEscapeNamesIt(t *testing.T) { + for in, want := range map[string]string{ + "/srv/library": "srv-library", + "/storage/media": "storage-media", + "/mnt/my-disk": `mnt-my\x2ddisk`, + "/mnt/.hidden": `mnt-.hidden`, + "/.x": `\x2ex`, + "/srv/a b": `srv-a\x20b`, + "/dev/disk/by-uuid/1234-ABCD": `dev-disk-by\x2duuid-1234\x2dABCD`, + } { + if got := EscapePath(in); got != want { + t.Errorf("EscapePath(%q) = %q, want %q", in, got, want) + } + } +} + +func TestANodesShareIsMountedHardWhenWritableAndSoftWhenNot(t *testing.T) { + rw := NFSPair(Wanted{Name: "library", Mountpoint: "/srv/library"}, "192.0.2.10", library) + for _, want := range []string{"What=192.0.2.10:/tank/media", "Where=/srv/library", "Type=nfs4", + "Options=vers=4.2,hard,timeo=150,retrans=3,_netdev", "TimeoutSec=30", "After=network-online.target"} { + if !strings.Contains(rw.Mount, want) { + t.Errorf("the read-write mount lacks %q:\n%s", want, rw.Mount) + } + } + if !strings.HasPrefix(rw.Mount, Marker) || !strings.HasPrefix(rw.Automount, Marker) { + t.Error("both units open with the module's marker") + } + if strings.Contains(rw.Mount, "[Install]") { + t.Error("the mount is never enabled: only the automount is") + } + for _, want := range []string{"[Automount]\nWhere=/srv/library\nTimeoutIdleSec=600", "WantedBy=remote-fs.target"} { + if !strings.Contains(rw.Automount, want) { + t.Errorf("the automount lacks %q:\n%s", want, rw.Automount) + } + } + if rw.MountUnit != "srv-library.mount" || rw.AutoUnit != "srv-library.automount" || rw.Probe != "192.0.2.10:2049" { + t.Errorf("names %+v", rw) + } + ro := NFSPair(Wanted{Name: "downloads", Mountpoint: "/srv/downloads", ReadOnly: true}, "192.0.2.10", downloads) + if !strings.Contains(ro.Mount, "Options=ro,vers=4.2,soft,") { + t.Errorf("a read-only share is mounted ro and soft:\n%s", ro.Mount) + } + served := library + served.Access = "ro" + if p := NFSPair(Wanted{Name: "library", Mountpoint: "/srv/library"}, "192.0.2.10", served); !strings.Contains(p.Mount, "Options=ro,") { + t.Error("a share the server exports read-only is mounted read-only") + } +} + +func TestAnOccasionalSourceWaitsTenSecondsAndNeverFailsABoot(t *testing.T) { + smb := SMBPair(Source{Name: "games", Kind: "smb", Host: "192.0.2.76", Share: "Share", Mountpoint: "/mnt/games"}, "/var/lib/mesh/mounts/smb-games.credentials", 1000, 1000) + for _, want := range []string{"What=//192.0.2.76/Share", "Type=cifs", "credentials=/var/lib/mesh/mounts/smb-games.credentials", "nofail", "uid=1000,gid=1000", "TimeoutSec=10"} { + if !strings.Contains(smb.Mount, want) { + t.Errorf("the SMB mount lacks %q:\n%s", want, smb.Mount) + } + } + if !smb.Occasional || smb.Probe != "192.0.2.76:445" || strings.Contains(smb.Mount, "password") { + t.Errorf("smb %+v", smb) + } + disk := DiskPair(Source{Name: "usb", Kind: "disk", UUID: "1234-ABCD", Mountpoint: "/mnt/usb"}) + dev := `dev-disk-by\x2duuid-1234\x2dABCD.device` + if !strings.Contains(disk.Mount, "BindsTo="+dev) || !strings.Contains(disk.Mount, "What=/dev/disk/by-uuid/1234-ABCD") { + t.Errorf("the disk's mount goes with the disk:\n%s", disk.Mount) + } + if strings.Contains(disk.Automount, "BindsTo") || !strings.Contains(disk.Automount, "WantedBy=local-fs.target") { + t.Errorf("the disk's automount stays armed when the disk is away:\n%s", disk.Automount) + } +} + +func TestALookWritesArmsAndRemovesOnlyTheModulesOwnUnits(t *testing.T) { + f := newSystem(t) + f.write(ConfigFile, config) + f.write(ExportsFile, exportsAnswer(t, library, downloads)) + f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[games]\nusername=root\npassword="+testPassword+"\n") + // The operator's own unit for the downloads' mount point: never written over. + f.write(filepath.Join(UnitDir, "srv-downloads.mount"), "[Mount]\nWhat=elsewhere\n") + // A pair of the module's whose mount point nothing names any more. + f.write(filepath.Join(UnitDir, "srv-old.mount"), Marker+"\n[Mount]\n") + f.write(filepath.Join(UnitDir, "srv-old.automount"), Marker+"\n[Automount]\n") + f.reachable["192.0.2.10:2049"] = true + now := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC) + + st := f.applier(now).Look(context.Background()) + + if got := f.read(filepath.Join(UnitDir, "srv-library.mount")); !strings.Contains(got, "What=192.0.2.10:/tank/media") { + t.Errorf("the library's mount: %q", got) + } + if got := f.read(filepath.Join(UnitDir, "srv-downloads.mount")); got != "[Mount]\nWhat=elsewhere\n" { + t.Errorf("a unit file that is not the module's is left byte for byte: %q", got) + } + if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-old.mount")); !os.IsNotExist(err) { + t.Error("the module's pair nothing names any more is removed") + } + for _, c := range []string{"systemctl disable --now srv-old.automount", "systemctl stop srv-old.mount", "systemctl daemon-reload", + "systemctl enable --now srv-library.automount", "systemctl enable --now mnt-games.automount", "systemctl enable --now mnt-usb.automount"} { + if !f.called(c) { + t.Errorf("not called: %s (calls %q)", c, f.calls) + } + } + if f.called("systemctl enable --now srv-downloads.automount") { + t.Error("the refused share is not armed") + } + if info, err := os.Stat(filepath.Join(f.root, "/srv/library")); err != nil || !info.IsDir() { + t.Error("a missing mount point is made") + } + creds := filepath.Join(f.root, "/var/lib/mesh/mounts/smb-games.credentials") + if info, err := os.Stat(creds); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("the source's login is a file only root reads: %v", err) + } + if b, _ := os.ReadFile(creds); string(b) != "username=root\npassword="+testPassword+"\n" { + t.Errorf("credentials file %q", b) + } + + states := map[string]ShareStatus{} + for _, s := range st.Shares { + states[s.Name] = s + } + if states["library"].State != StateArmed || states["games"].State != StateAbsent || states["usb"].State != StateAbsent { + t.Errorf("states %+v", states) + } + if d := states["downloads"]; d.State != StateFailed || !strings.Contains(d.Why, "not the module's") { + t.Errorf("downloads %+v", d) + } + if !strings.Contains(states["games"].Why, "not present") || states["games"].Since != now.Format(time.RFC3339) { + t.Errorf("games %+v", states["games"]) + } + // The password is in the secret and in the credentials file, and nowhere else. + if strings.Contains(f.everything("smb-credentials.secret", "smb-games.credentials"), testPassword) { + t.Error("the password leaked into a file the module wrote") + } + raw, _ := json.Marshal(f.machine().Health(now)) + if strings.Contains(string(raw), testPassword) { + t.Error("the password leaked into the health") + } + + // A second look a minute later changes nothing and keeps since. + f.calls = nil + later := now.Add(time.Minute) + st = f.applier(later).Look(context.Background()) + if f.called("systemctl daemon-reload") || f.called("systemctl enable") { + t.Errorf("a look with nothing to change changes nothing: %q", f.calls) + } + for _, s := range st.Shares { + if s.Since != now.Format(time.RFC3339) { + t.Errorf("%s since %s, want the first look's", s.Name, s.Since) + } + } +} + +func TestASilentServerRemovesNoMount(t *testing.T) { + f := newSystem(t) + f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n") + f.write(filepath.Join(UnitDir, "srv-library.mount"), Marker+"\nkept\n") + f.write(filepath.Join(UnitDir, "srv-library.automount"), Marker+"\nkept\n") + st := f.applier(time.Now()).Look(context.Background()) + if f.read(filepath.Join(UnitDir, "srv-library.mount")) != Marker+"\nkept\n" { + t.Error("with no answer from the server the pair stays as it is") + } + if len(st.Shares) != 1 || st.Shares[0].State != StateUnreachable || !strings.Contains(st.Shares[0].Why, "has not been read from the bus yet") { + t.Errorf("shares %+v", st.Shares) + } + // And a missing config file writes and removes nothing. + g := newSystem(t) + g.write(filepath.Join(UnitDir, "srv-x.mount"), Marker+"\n") + st = g.applier(time.Now()).Look(context.Background()) + if _, err := os.Stat(filepath.Join(g.root, UnitDir, "srv-x.mount")); err != nil || len(st.Problems) == 0 { + t.Errorf("without its config the process removes nothing and says why: %v %v", err, st.Problems) + } +} + +func TestAMissingLoginIsSaidWithItsRemedyAndTheSourceIsNotMounted(t *testing.T) { + f := newSystem(t) + f.write(ConfigFile, "sources=games=smb://192.0.2.76/Share@/mnt/games\nsecret=/var/lib/mesh/mounts/smb-credentials.secret\nstate=/var/lib/mesh/mounts\n") + f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[other]\nusername=x\npassword="+testPassword+"\n") + now := time.Now() + st := f.applier(now).Look(context.Background()) + if len(st.Shares) != 1 || st.Shares[0].State != StateFailed || + !strings.Contains(st.Shares[0].Why, "secret accept mounts smb-credentials --from ") { + t.Fatalf("shares %+v", st.Shares) + } + if _, err := os.Stat(filepath.Join(f.root, UnitDir, "mnt-games.mount")); !os.IsNotExist(err) { + t.Error("a source without its login gets no units") + } + h := f.machine().Health(now) + if h.Healthy || !strings.Contains(h.Why, "login is not given") || strings.Contains(h.Why, testPassword) { + t.Errorf("health %+v", h) + } + creds, problems := ParseSMBCredentials("[a]\nusername=u\npassword=" + testPassword + "\nbogus=1\n[b]\npassword=p\n") + if creds["a"].Password != testPassword || len(problems) != 2 || strings.Contains(strings.Join(problems, " "), testPassword) { + t.Errorf("creds %v problems %q", len(creds), problems) + } +} + +func TestTheHealthGivesAShareFiveMinutesAndAnAbsentSourceNone(t *testing.T) { + now := time.Date(2026, 10, 8, 19, 30, 0, 0, time.Local) + at := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) } + st := Status{Looked: at(10 * time.Second), Server: "homeserver", Shares: []ShareStatus{ + {Name: "library", Kind: "nfs", Mountpoint: "/srv/library", State: StateUnreachable, Since: at(4 * time.Minute), Why: "192.0.2.10:2049 does not answer"}, + {Name: "games", Kind: "smb", Occasional: true, Mountpoint: "/mnt/games", State: StateAbsent, Since: at(3 * time.Hour)}, + }} + if h := Judge(st, nil, now); !h.Healthy || !strings.Contains(strings.Join(h.Said, " "), "games at /mnt/games: not present since 16:30") { + t.Errorf("under five minutes, and an absent source, are healthy: %+v", h) + } + st.Shares[0].Since = at(9 * time.Minute) + h := Judge(st, nil, now) + if h.Healthy || !strings.Contains(h.Why, "The share library of homeserver is not reachable from this machine since 19:21") { + t.Errorf("five minutes away raises: %+v", h) + } + st.Shares = []ShareStatus{{Name: "library", Kind: "nfs", Mountpoint: "/srv/library", State: StateFailed, Since: at(time.Minute), + Why: "its automount srv-library.automount is not armed (inactive)"}} + if h := Judge(st, nil, now); h.Healthy { + t.Error("an automount not armed is raised at once") + } + st.Looked = at(3 * time.Minute) + st.Shares = nil + if h := Judge(st, nil, now); h.Healthy || !strings.Contains(h.Why, "last looked") { + t.Errorf("a stale look is raised: %+v", h) + } + if h := Judge(Status{}, errors.New("not present"), now); h.Healthy { + t.Error("no status is unhealthy") + } + if h := Judge(Status{Looked: at(time.Second)}, nil, now); !h.Healthy || h.Why != "This machine is asked to mount nothing." { + t.Errorf("nothing asked: %+v", h) + } +} + +// The workstation's two lines as they were found (research 038), in documentation addresses. +const operatorsFstab = "UUID=ad8b / ext4 rw,relatime 0 1\n" + + "192.0.2.10:/tank/media /srv/library nfs defaults,_netdev 0 0\n" + + "//192.0.2.76/Share /mnt/games cifs username=root,password=" + testPassword + ",iocharset=utf8,sec=ntlmssp 0 0\n" + +func TestAnFstabLineIsTakenOverOnlyOnceItsReplacementIsArmed(t *testing.T) { + f := newSystem(t) + f.write("/etc/fstab", operatorsFstab) + m := f.machine() + now := time.Date(2026, 10, 8, 20, 0, 0, 0, time.UTC) + ctx := context.Background() + + a, err := m.Adopt(ctx, "/mnt/games", false, "", now) + if err != nil || a.Done || !strings.HasPrefix(a.Said, "Would be refused now: the module has no unit pair") { + t.Fatalf("no pair yet: %+v %v", a, err) + } + if strings.Contains(a.Was, testPassword) || !strings.Contains(a.Was, "password=(redacted)") { + t.Errorf("the dry run says the line without its password: %q", a.Was) + } + if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil { + t.Fatal("the root half refuses too") + } + if f.read("/etc/fstab") != operatorsFstab { + t.Fatal("a refusal changes nothing") + } + + f.write(filepath.Join(UnitDir, "mnt-games.mount"), Marker+"\n") + f.write(filepath.Join(UnitDir, "mnt-games.automount"), Marker+"\n") + f.units["mnt-games.automount"] = map[string]string{"ActiveState": "inactive"} + if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil || !strings.Contains(err.Error(), "not armed") { + t.Fatalf("an automount not armed refuses: %v", err) + } + f.units["mnt-games.automount"] = map[string]string{"ActiveState": "active"} + if a, err := m.Adopt(ctx, "/mnt/games", false, "", now); err != nil || !strings.HasPrefix(a.Said, "A dry run") || a.Line != 3 { + t.Fatalf("dry run: %+v %v", a, err) + } + if _, err := m.Adopt(ctx, "/mnt/games", true, "", now); err == nil { + t.Error("confirm without why is refused") + } + + a, err = m.AdoptAsRoot(ctx, "/mnt/games", now) + if err != nil || !a.Done || a.Line != 3 || a.Copy != "/etc/fstab.mesh-adopted-20261008-200000" { + t.Fatalf("adopt: %+v %v", a, err) + } + got := f.read("/etc/fstab") + lines := strings.Split(got, "\n") + if lines[0] != "UUID=ad8b / ext4 rw,relatime 0 1" || lines[1] != "192.0.2.10:/tank/media /srv/library nfs defaults,_netdev 0 0" { + t.Errorf("every other line is kept byte for byte:\n%s", got) + } + if !strings.HasPrefix(lines[2], AdoptedPrefix+"2026-10-08: //192.0.2.76/Share") || strings.Contains(got, testPassword) { + t.Errorf("the line is commented out with its password redacted:\n%s", got) + } + info, err := os.Stat(filepath.Join(f.root, a.Copy)) + if err != nil || info.Mode().Perm() != 0o600 || f.read(a.Copy) != operatorsFstab { + t.Errorf("the file as it was is kept, root only: %v", err) + } + if !f.called("systemctl daemon-reload") { + t.Error("the service manager reloads") + } + raw, _ := json.Marshal(a) + if strings.Contains(string(raw), testPassword) { + t.Error("the answer holds the password") + } + if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil || !strings.Contains(err.Error(), "no line for /mnt/games") { + t.Errorf("a line taken over is not taken twice: %v", err) + } + if _, _, err := PlanAdoption("a /x nfs d 0 0\nb /x nfs d 0 0\n", "/x", now); err == nil || !strings.Contains(err.Error(), "more than one line") { + t.Errorf("two lines for one mount point are left to a person: %v", err) + } +} + +func TestAFailedAskKeepsTheLastAnswer(t *testing.T) { + cfg := ParseConfig("shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n") + now := time.Now() + var asked string + ok := func(key string) (json.RawMessage, error) { + asked = key + return json.RawMessage(`{"shares":[{"name":"library","path":"/tank/media","access":"rw","uid":1001,"gid":2000,"live":true}]}`), nil + } + first := AskExports(cfg, ExportsAnswer{}, ok, now) + if asked != "homeserver" || len(first.Shares) != 1 || first.Error != "" { + t.Fatalf("first %+v asked %q", first, asked) + } + fail := func(string) (json.RawMessage, error) { return nil, errors.New("no responders") } + second := AskExports(cfg, first, fail, now.Add(time.Minute)) + empty := AskExports(cfg, first, func(string) (json.RawMessage, error) { return nil, nil }, now) + if !strings.Contains(empty.Error, "keeps nothing") || len(empty.Shares) != 1 { + t.Errorf("a machine with nothing kept yet: %+v", empty) + } + if second.Error != "no responders" || len(second.Shares) != 1 || second.Answered != first.Answered { + t.Errorf("second %+v", second) + } + if got := ParseConfig("at=homeserver.mesh.example\n").ServerNode(); got != "homeserver" { + t.Errorf("without from, the first label of at: %q", got) + } +} + +func TestTheBundlePutsWhatTheProcessFoundOnTheBus(t *testing.T) { + f := newSystem(t) + f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n") + f.write(StatusFile, `{"looked":"2026-10-08T19:21:00Z","shares":[{"name":"library","state":"armed"}]}`) + var put []string + last := "" + ask := func(string) (json.RawMessage, error) { return json.RawMessage(`{"shares":[]}`), nil } + said := f.machine().Share("workstation", ask, func(key string, _ any) error { put = append(put, key); return nil }, &last, time.Now()) + if len(said) != 0 || len(put) != 1 || put[0] != "workstation" || !strings.Contains(f.read(ExportsFile), `"server": "homeserver"`) { + t.Errorf("said %q put %q exports %q", said, put, f.read(ExportsFile)) + } + f.machine().Share("workstation", ask, func(key string, _ any) error { put = append(put, key); return nil }, &last, time.Now()) + if len(put) != 1 { + t.Error("an unchanged status is not put again") + } +} + +// In production the acting process and the judging one are two: apply writes and arms and says nothing +// of how the shares are; watch writes and arms nothing and says how they are. +func TestApplyActsAndWatchJudgesAndNeitherDoesTheOthersWork(t *testing.T) { + f := newSystem(t) + f.write(ConfigFile, config) + f.write(ExportsFile, exportsAnswer(t, library, downloads)) + f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[other]\nusername=x\npassword="+testPassword+"\n") + f.write(filepath.Join(UnitDir, "srv-downloads.mount"), "[Mount]\nWhat=elsewhere\n") + f.reachable["192.0.2.10:2049"] = true + now := time.Now() + + apply := f.applier(now) + apply.Judge = false + apply.Look(context.Background()) + if _, err := os.Stat(filepath.Join(f.root, StatusFile)); !os.IsNotExist(err) { + t.Error("apply leaves no status: that is watch's") + } + if !f.called("systemctl enable --now srv-library.automount") || f.read(filepath.Join(UnitDir, "srv-library.mount")) == "" { + t.Fatal("apply writes and arms") + } + applied, err := readJSON[Applied](f.machine(), AppliedFile) + if err != nil || len(applied.NotSet) != 2 { + t.Fatalf("apply says what it could not set up: %+v %v", applied, err) + } + + f.calls = nil + watch := f.applier(now) + watch.Act = false + f.write(filepath.Join(UnitDir, "srv-gone.mount"), Marker+"\n") + st := watch.Look(context.Background()) + for _, c := range f.calls { + if !strings.HasPrefix(c, "systemctl show") { + t.Errorf("watch only reads the service manager: %s", c) + } + } + if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-gone.mount")); err != nil { + t.Error("watch removes nothing") + } + states := map[string]string{} + for _, s := range st.Shares { + states[s.Name] = s.State + } + if states["library"] != StateArmed || states["downloads"] != StateFailed || states["games"] != StateFailed || states["usb"] != StateAbsent { + t.Errorf("watch says how each is: %v", states) + } + if strings.Contains(f.everything("smb-credentials.secret"), testPassword) { + t.Error("the password leaked") + } +} diff --git a/modules/mounts/go.mod b/modules/mounts/go.mod index 4970b6c3..9549ae83 100644 --- a/modules/mounts/go.mod +++ b/modules/mounts/go.mod @@ -2,4 +2,4 @@ module mounts go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.6 +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/mounts/go.sum b/modules/mounts/go.sum index 0dd60617..017ed9ad 100644 --- a/modules/mounts/go.sum +++ b/modules/mounts/go.sum @@ -1,2 +1,4 @@ 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= +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mounts/module.json b/modules/mounts/module.json index e3db63d4..a17e8e02 100644 --- a/modules/mounts/module.json +++ b/modules/mounts/module.json @@ -1,9 +1,133 @@ { "module": "mounts", "version": "1", + "requires": [ + "nfs-share" + ], + "reads": [ + "nfs-server.exports" + ], + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-mounts", + "scope": "node", + "serves": [ + "list", + "test", + "mount", + "unmount", + "adopt" + ] + } + ], "tools": [ "mounts_list", - "mounts_exports" + "mounts_exports", + "mounts_health" + ], + "state": [ + { + "name": "shares", + "per-machine": true + } + ], + "settings": { + "shares": { + "kind": "preference", + "default": "none", + "why": "a machine mounts no share of another until its assignment names one: space-separated name=mountpoint, each optionally :ro (hq ADR 0263 rule 5)" + }, + "sources": { + "kind": "preference", + "default": "none", + "why": "a machine has no occasional source until its assignment names one: space-separated name=smb:///@ or name=disk:@, each optionally :ro (hq ADR 0263 rule 6)" + } + }, + "own-secrets": { + "smb-credentials": { + "path": "${dir:state}/smb-credentials.secret", + "taken": "at-start", + "issued-by": "outside" + } + }, + "resources": [ + { + "id": "nfs-utils", + "type": "package", + "package": "nfs-utils" + }, + { + "id": "cifs-utils", + "type": "package", + "package": "cifs-utils" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "config-dir", + "type": "directory", + "path": "/etc/mesh-mounts", + "mode": "0755" + }, + { + "id": "config", + "type": "file", + "path": "/etc/mesh-mounts/mounts.conf", + "mode": "0644", + "content": "# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings\n# shares and sources, and the binding of nfs-share. Replaced on every push; change the settings, not this.\n# The module's process reads it as root every 30 seconds and writes one .mount and .automount per entry.\nshares=${setting:shares}\nsources=${setting:sources}\nserver=${bound:nfs-share:from}\nat=${bound:nfs-share:at}\naccount=${machine:account}\nsecret=${dir:state}/smb-credentials.secret\nstate=${dir:state}\n" + }, + { + "id": "bus-dir-parent", + "type": "directory", + "path": "/var/lib/mesh-mounts", + "mode": "0755" + }, + { + "id": "bus-dir", + "type": "directory", + "path": "/var/lib/mesh-mounts/from-bus", + "mode": "0755", + "owner": "${machine:account}" + }, + { + "id": "apply", + "type": "process", + "name": "mesh-mounts-apply", + "artifact": "tools-go", + "run": [ + "./mounts", + "apply" + ], + "health": { + "kind": "unit" + } + }, + { + "id": "watch", + "type": "process", + "name": "mesh-mounts-watch", + "artifact": "tools-go", + "run": [ + "./mounts", + "watch" + ], + "health": { + "kind": "tool", + "tool": "mounts_health", + "interval": "60s", + "timeout": "10s", + "looks": 2, + "grace": "90s" + } + } ], "build": { "artifacts": [