mesh/merge-gate pass: builds mounts → shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A password holding '=' and a space or comma splits the entry, and the tail read as name=... was said as "the source <tail>", carrying part of the secret into the health answer and the operator's conditions (review of mesh-catalog#190, issue 386).
771 lines
35 KiB
Go
771 lines
35 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/health"
|
|
)
|
|
|
|
// 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 {
|
|
// mu guards calls, units and reachable, for a test that runs RunApply beside itself.
|
|
mu sync.Mutex
|
|
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", "")
|
|
// The mesh keeps no data on this machine, as the controller said through the bundle.
|
|
f.write(DataFile, `{"machine":"workstation","paths":[]}`)
|
|
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.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
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"}
|
|
case "start":
|
|
u := args[len(args)-1]
|
|
if f.units[u] == nil {
|
|
f.units[u] = map[string]string{}
|
|
}
|
|
f.units[u]["ActiveState"], f.units[u]["Result"] = "active", "success"
|
|
case "reset-failed":
|
|
u := args[len(args)-1]
|
|
if f.units[u]["ActiveState"] == "failed" {
|
|
f.units[u]["ActiveState"], f.units[u]["Result"] = "inactive", "success"
|
|
}
|
|
}
|
|
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 {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
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 {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
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://root@192.0.2.76/Share@/mnt/games usb=disk:1234-ABCD@/mnt/usb\n" +
|
|
"server=homeserver\nat=192.0.2.10\nnode=workstation\naccount=operator\n" +
|
|
"passwords=/var/lib/mesh/mounts\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, To: []ShareClient{{Node: "workstation", Address: "10.0.0.3", Access: "rw"}}}
|
|
downloads = ServerShare{Name: "downloads", Path: "/tank/downloads", Access: "rw", UID: 1001, GID: 2000, Live: true, To: []ShareClient{{Node: "workstation", Address: "10.0.0.3", Access: "rw"}}}
|
|
)
|
|
|
|
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-password-games.secret", 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-password-games.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 TestASourceWithoutItsLoginWaitsForTheOperatorAndIsNotMounted(t *testing.T) {
|
|
for name, c := range map[string]struct {
|
|
user, password string
|
|
writePassword bool
|
|
waitsFor []string
|
|
}{
|
|
"no username": {"", testPassword, true, []string{"setting sources"}},
|
|
"no password": {"root@", "", false, []string{"secret smb-password-games"}},
|
|
"an empty password file": {"root@", "", true, []string{"secret smb-password-games"}},
|
|
"a line break only": {"root@", "\n", true, []string{"secret smb-password-games"}},
|
|
"neither": {"", "", false, []string{"setting sources", "secret smb-password-games"}},
|
|
} {
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, "sources=games=smb://"+c.user+"192.0.2.76/Share@/mnt/games\npasswords=/var/lib/mesh/mounts\nstate=/var/lib/mesh/mounts\n")
|
|
if c.writePassword {
|
|
f.write("/var/lib/mesh/mounts/smb-password-games.secret", c.password)
|
|
}
|
|
now := time.Now()
|
|
st := f.applier(now).Look(context.Background())
|
|
if len(st.Shares) != 1 || st.Shares[0].State != StateWaiting || len(st.Shares[0].Waits) != len(c.waitsFor) {
|
|
t.Fatalf("%s: shares %+v", name, st.Shares)
|
|
}
|
|
for i, w := range st.Shares[0].Waits {
|
|
if got := strings.TrimPrefix(w.Names(), "the "); got != c.waitsFor[i] || w.Part != "the source games" || w.Check() != nil {
|
|
t.Errorf("%s: wait %d is %+v, want %s", name, i, w, c.waitsFor[i])
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "mnt-games.mount")); !os.IsNotExist(err) {
|
|
t.Errorf("%s: a source without its login gets no units", name)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(f.root, "/var/lib/mesh/mounts/smb-games.credentials")); !os.IsNotExist(err) {
|
|
t.Errorf("%s: a source without its login gets no credentials file", name)
|
|
}
|
|
h := f.machine().Health(now)
|
|
raw, _ := json.Marshal(h)
|
|
if h.State != health.StateWaits || h.Healthy || len(h.Waits) != len(c.waitsFor) || len(h.Found) != 0 {
|
|
t.Errorf("%s: health %s", name, raw)
|
|
}
|
|
// What the node-engine reads is a wait it can check: the SDK's reader keeps it a wait.
|
|
if read, err := health.Read(raw); err != nil || read.State != health.StateWaits {
|
|
t.Errorf("%s: the engine reads %+v %v", name, read, err)
|
|
}
|
|
if strings.Contains(string(raw), testPassword) || strings.Contains(f.everything("smb-password-games.secret"), testPassword) {
|
|
t.Errorf("%s: the password leaked", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPasswordIsOneLineKeptAsTyped(t *testing.T) {
|
|
pw := " a b=c " + testPassword + "=="
|
|
for text, want := range map[string]string{pw: pw, pw + "\n": pw, pw + "\r\n": pw} {
|
|
got, given, err := ReadPassword(text)
|
|
if err != nil || !given || got != want {
|
|
t.Errorf("%q read as %q %v %v", strings.ReplaceAll(text, testPassword, "<pw>"), strings.ReplaceAll(got, testPassword, "<pw>"), given, err)
|
|
}
|
|
}
|
|
if _, given, err := ReadPassword(""); given || err != nil {
|
|
t.Error("an empty file is not given")
|
|
}
|
|
if _, given, err := ReadPassword(testPassword + "\nsecond\n"); !given || err == nil || strings.Contains(err.Error(), testPassword) {
|
|
t.Errorf("two lines are refused, saying nothing typed: %v", err)
|
|
}
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, "sources=games=smb://root@192.0.2.76/Share@/mnt/games\npasswords=/var/lib/mesh/mounts\nstate=/var/lib/mesh/mounts\n")
|
|
f.write("/var/lib/mesh/mounts/smb-password-games.secret", pw+"\n")
|
|
f.applier(time.Now()).Look(context.Background())
|
|
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="+pw+"\n" {
|
|
t.Errorf("credentials file %q", strings.ReplaceAll(string(b), testPassword, "<pw>"))
|
|
}
|
|
// Two lines: refused as a fault the operator mends by giving it again, the password never said.
|
|
f.write("/var/lib/mesh/mounts/smb-password-games.secret", testPassword+"\nsecond\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, "more than one line") {
|
|
t.Fatalf("shares %+v", st.Shares)
|
|
}
|
|
raw, _ := json.Marshal(f.machine().Health(now))
|
|
if strings.Contains(string(raw), testPassword) || strings.Contains(f.everything("smb-password-games.secret", "smb-games.credentials"), testPassword) {
|
|
t.Error("the password leaked")
|
|
}
|
|
}
|
|
|
|
func TestAnSMBSourcesUserIsInItsEntryAndAPasswordThereIsRefusedUnsaid(t *testing.T) {
|
|
sources, problems := ParseSources("a=smb://root@192.0.2.76/Share@/mnt/a b=smb://192.0.2.76/Share@/mnt/b " +
|
|
"c=smb://WORKGROUP%5Cme@nas/Media@/mnt/c d=smb://me%40example@nas/Media@/mnt/d:ro")
|
|
users := map[string]string{}
|
|
for _, s := range sources {
|
|
users[s.Name] = s.User
|
|
if s.Host == "" || s.Share == "" {
|
|
t.Errorf("%s: %+v", s.Name, s)
|
|
}
|
|
}
|
|
if len(problems) != 0 || users["a"] != "root" || users["b"] != "" || users["c"] != `WORKGROUP\me` || users["d"] != "me@example" {
|
|
t.Errorf("users %v problems %q", users, problems)
|
|
}
|
|
for what, entry := range map[string]string{
|
|
"a password in the URL": "e=smb://root:" + testPassword + "@nas/Media@/mnt/e",
|
|
"a password with a slash": "e=smb://root:a/" + testPassword + "@nas/Media@/mnt/e",
|
|
"a user with a space, encoded": "e=smb://a%20" + testPassword + "@nas/Media@/mnt/e",
|
|
"a bad percent escape": "e=smb://%zz" + testPassword + "@nas/Media@/mnt/e",
|
|
"no mount point, with a password": "smb://root:" + testPassword + "@nas/Media",
|
|
"another scheme, upper case": "e=SMB://root:" + testPassword + "@nas/Media@/mnt/e",
|
|
"a cifs URL": "e=cifs://root:" + testPassword + "@nas/Media@/mnt/e",
|
|
"two @ before the host": "e=smb://me@example@nas/Media@/mnt/e" + testPassword,
|
|
"a control character in the user": "e=smb://me%07" + testPassword + "@nas/Media@/mnt/e",
|
|
} {
|
|
got, problems := ParseSources(entry)
|
|
said := strings.Join(problems, " ")
|
|
if len(got) != 0 || len(problems) != 1 || strings.Contains(said, testPassword) {
|
|
t.Errorf("%s: %+v %q", what, got, said)
|
|
}
|
|
}
|
|
if _, problems := ParseSources("e=smb://root:" + testPassword + "@nas/Media@/mnt/e"); !strings.Contains(problems[0], "smb-password-<source>") {
|
|
t.Errorf("a password in the URL is said with where it goes: %q", problems)
|
|
}
|
|
// A password with a space or a comma splits the entry: neither half, nor a name that is not one, is said.
|
|
const secret = "SECRETPW"
|
|
for _, v := range []string{
|
|
"e=smb://root:SEC RETPW@nas/Media@/mnt/e",
|
|
"e=smb://root:SEC,RETPW@nas/Media@/mnt/e",
|
|
"e=SMB://root:" + secret + "@nas/Media@/mnt/e",
|
|
"e=cifs://root:" + secret + "@nas/Media@/mnt/e",
|
|
"SECRET" + "PW=smb://nas/Media@/mnt/e",
|
|
// A password holding '=' and a space, or '=' and a comma: the tail reads like name=… and is still not said.
|
|
"e=smb://root:sec RETPW=x@nas/Media@/mnt/e",
|
|
"e=smb://root:sec,RETPW=x@nas/Media@/mnt/e",
|
|
"e=smb://root:SEC RETPW=smb://nas/Media@/mnt/e",
|
|
} {
|
|
_, problems := ParseSources(v)
|
|
said := strings.Join(problems, " ")
|
|
if len(problems) == 0 || strings.Contains(strings.ToUpper(said), "RETPW") || strings.Contains(said, "SEC") ||
|
|
strings.Contains(said, "sec") && !strings.Contains(said, "secret smb-password") {
|
|
t.Errorf("%q says %q", strings.ReplaceAll(v, "RETPW", "<pw>"), said)
|
|
}
|
|
// And nothing of it reaches the status or the health answer.
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, "sources="+v+"\npasswords=/var/lib/mesh/mounts\nstate=/var/lib/mesh/mounts\n")
|
|
now := time.Now()
|
|
f.applier(now).Look(context.Background())
|
|
raw, _ := json.Marshal(f.machine().Health(now))
|
|
if strings.Contains(strings.ToUpper(string(raw)), "RETPW") || strings.Contains(strings.ToUpper(f.everything("mounts.conf")), "RETPW") {
|
|
t.Errorf("%q leaked into the status or the health: %s", strings.ReplaceAll(v, "RETPW", "<pw>"), raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
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-password-other.secret", 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"] != StateWaiting || states["usb"] != StateAbsent {
|
|
t.Errorf("watch says how each is: %v", states)
|
|
}
|
|
if strings.Contains(f.everything("smb-password-other.secret"), testPassword) {
|
|
t.Error("the password leaked")
|
|
}
|
|
}
|
|
|
|
// setUnit sets one unit's state as the service manager answers it, under the lock.
|
|
func (f *fakeSystem) setUnit(unit string, state map[string]string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.units[unit] = state
|
|
}
|
|
|
|
// A wait beside a fault is said and excuses nothing: the answer is unhealthy, naming both (ADR 0283).
|
|
func TestAWaitBesideAFaultIsUnhealthy(t *testing.T) {
|
|
now := time.Date(2026, 10, 10, 19, 30, 0, 0, time.UTC)
|
|
at := now.Add(-time.Hour).Format(time.RFC3339)
|
|
wait := health.Wait{Part: "the source games", Secret: "smb-password-games", What: "the password of the source games"}
|
|
games := ShareStatus{Name: "games", Kind: "smb", Occasional: true, Mountpoint: "/mnt/games", State: StateWaiting, Since: at,
|
|
Why: "it waits for the operator to give the password of the source games (the secret smb-password-games)", Waits: []health.Wait{wait}}
|
|
library := ShareStatus{Name: "library", Kind: "nfs", Mountpoint: "/srv/library", State: StateUnreachable, Since: at, Why: "no answer"}
|
|
|
|
h := Judge(Status{Looked: now.Format(time.RFC3339), Server: "homeserver", Shares: []ShareStatus{games, library}}, nil, now)
|
|
if h.State != health.StateUnhealthy || h.Healthy || len(h.Waits) != 1 || !strings.Contains(h.Why, "library") || !strings.Contains(h.Why, "games") {
|
|
t.Errorf("a wait beside a fault: %+v", h)
|
|
}
|
|
h = Judge(Status{Looked: now.Format(time.RFC3339), Shares: []ShareStatus{games}}, nil, now)
|
|
if h.State != health.StateWaits || len(h.Waits) != 1 || len(h.Found) != 0 || !strings.Contains(h.Why, "waits for the operator") {
|
|
t.Errorf("a wait alone: %+v", h)
|
|
}
|
|
}
|
|
|
|
// A password that exists and cannot be read, and a configuration naming no directory for the passwords, are
|
|
// refused as faults: said without the value and without the path, and nothing is read from a relative path.
|
|
func TestAPasswordThatCannotBeReadIsAFaultSaidWithoutValueOrPath(t *testing.T) {
|
|
// A directory where the file should be: read fails, and not as "not there" (as root a mode would not stop it).
|
|
f := newSystem(t)
|
|
f.write(ConfigFile, "sources=games=smb://root@192.0.2.76/Share@/mnt/games\npasswords=/var/lib/mesh/mounts\nstate=/var/lib/mesh/mounts\n")
|
|
f.write("/var/lib/mesh/mounts/smb-password-games.secret/inside", testPassword)
|
|
st := f.applier(time.Now()).Look(context.Background())
|
|
if len(st.Shares) != 1 || st.Shares[0].State != StateFailed || !strings.Contains(st.Shares[0].Why, "could not be read") ||
|
|
strings.Contains(st.Shares[0].Why, "/var/lib") || strings.Contains(st.Shares[0].Why, testPassword) {
|
|
t.Fatalf("an unreadable password: %+v", st.Shares)
|
|
}
|
|
|
|
// No passwords= and no state=: the source is not set up, and no relative path is read.
|
|
for _, cfg := range []string{
|
|
"sources=games=smb://root@192.0.2.76/Share@/mnt/games\n",
|
|
"sources=games=smb://root@192.0.2.76/Share@/mnt/games\npasswords=relative/dir\n",
|
|
} {
|
|
g := newSystem(t)
|
|
g.write(ConfigFile, cfg)
|
|
g.write("smb-password-games.secret", testPassword)
|
|
g.write("relative/dir/smb-password-games.secret", testPassword)
|
|
st := g.applier(time.Now()).Look(context.Background())
|
|
if len(st.Shares) != 1 || st.Shares[0].State != StateFailed || !strings.Contains(st.Shares[0].Why, "names no directory for its passwords") ||
|
|
strings.Contains(st.Shares[0].Why, testPassword) || strings.Contains(st.Shares[0].Why, "relative") {
|
|
t.Errorf("%q: %+v", cfg, st.Shares)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(g.root, "/var/lib/mesh/mounts/smb-games.credentials")); !os.IsNotExist(err) {
|
|
t.Errorf("%q: a credentials file was written", cfg)
|
|
}
|
|
}
|
|
}
|