mounts: take an SMB source's username as a setting and its password as its own secret, and wait for them
mesh/merge-gate fail: builds mounts → shanks; no bus step; a manifest the change touches fails the module check: modules/mounts/module.json: this manifest …
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 24
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate fail: builds mounts → shanks; no bus step; a manifest the change touches fails the module check: modules/mounts/module.json: this manifest …
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 24
mesh/delivery superseded: a newer head of the same pull request
The one INI secret smb-credentials could not be given through the desk prompt, which takes one hidden line, and a missing login was said as a fault: the first-node gate put the build back and the operator was told nothing was theirs to do. The username is now the setting smb-users and each password the one-line secret smb-password-<source>; a source missing either answers waits through mesh-sdk's health answer (hq ADR 0283, issue 386, issue 379).
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
||||
"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.
|
||||
@@ -149,7 +151,7 @@ func (f *fakeSystem) everything(except ...string) 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\nnode=workstation\naccount=operator\n" +
|
||||
"secret=/var/lib/mesh/mounts/smb-credentials.secret\nstate=/var/lib/mesh/mounts\n"
|
||||
"smb-users=games=root\npasswords=/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})
|
||||
@@ -264,7 +266,7 @@ 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")
|
||||
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.
|
||||
@@ -318,7 +320,7 @@ func TestALookWritesArmsAndRemovesOnlyTheModulesOwnUnits(t *testing.T) {
|
||||
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) {
|
||||
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))
|
||||
@@ -361,26 +363,99 @@ func TestASilentServerRemovesNoMount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingLoginIsSaidWithItsRemedyAndTheSourceIsNotMounted(t *testing.T) {
|
||||
func TestASourceWithoutItsLoginWaitsForTheOperatorAndIsNotMounted(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
users, password string
|
||||
writePassword bool
|
||||
waitsFor []string
|
||||
}{
|
||||
"no username": {"", testPassword, true, []string{"setting smb-users"}},
|
||||
"no password": {"games=root", "", false, []string{"secret smb-password-games"}},
|
||||
"an empty password file": {"games=root", "", true, []string{"secret smb-password-games"}},
|
||||
"a line break only": {"games=root", "\n", true, []string{"secret smb-password-games"}},
|
||||
"neither": {"other=x", "", false, []string{"setting smb-users", "secret smb-password-games"}},
|
||||
} {
|
||||
f := newSystem(t)
|
||||
f.write(ConfigFile, "sources=games=smb://192.0.2.76/Share@/mnt/games\nsmb-users="+c.users+"\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://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")
|
||||
f.write(ConfigFile, "sources=games=smb://192.0.2.76/Share@/mnt/games\nsmb-users=games=root\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, "secret accept <node> mounts smb-credentials --from <file>") {
|
||||
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)
|
||||
}
|
||||
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")
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func TestTheUsersSettingIsReadAndWhatCannotBeIsSaid(t *testing.T) {
|
||||
users, problems := ParseSMBUsers("games=root nas=media-user bad Nas=x nas=again weird=a=b")
|
||||
if users["games"] != "root" || users["nas"] != "media-user" || len(users) != 2 || len(problems) != 4 {
|
||||
t.Errorf("users %v problems %q", users, problems)
|
||||
}
|
||||
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)
|
||||
if users, problems := ParseSMBUsers(None); len(users) != 0 || len(problems) != 0 {
|
||||
t.Errorf("none: %v %q", users, problems)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +612,7 @@ 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("/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()
|
||||
@@ -573,10 +648,10 @@ func TestApplyActsAndWatchJudgesAndNeitherDoesTheOthersWork(t *testing.T) {
|
||||
for _, s := range st.Shares {
|
||||
states[s.Name] = s.State
|
||||
}
|
||||
if states["library"] != StateArmed || states["downloads"] != StateFailed || states["games"] != StateFailed || states["usb"] != StateAbsent {
|
||||
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-credentials.secret"), testPassword) {
|
||||
if strings.Contains(f.everything("smb-password-other.secret"), testPassword) {
|
||||
t.Error("the password leaked")
|
||||
}
|
||||
}
|
||||
@@ -588,87 +663,21 @@ func (f *fakeSystem) setUnit(unit string, state map[string]string) {
|
||||
f.units[unit] = state
|
||||
}
|
||||
|
||||
// The login as typed at the desk's one-line prompt (novox/hq ADR 0277) is the login the file form gives
|
||||
// (issue 379): the password is last and runs to the end of the line, with its spaces and its `=`.
|
||||
func TestAOneLineLoginIsTheLoginTheFileFormGives(t *testing.T) {
|
||||
pw := "a b=c " + testPassword + "=="
|
||||
file := "[recalbox]\nusername=root\npassword=" + pw + "\ndomain=WORKGROUP\n"
|
||||
line := "[recalbox] username=root domain=WORKGROUP password=" + pw + "\n"
|
||||
fromFile, fileProblems := ParseSMBCredentials(file)
|
||||
fromLine, lineProblems := ParseSMBCredentials(line)
|
||||
want := SMBCredential{Username: "root", Password: pw, Domain: "WORKGROUP"}
|
||||
if len(fileProblems) != 0 || len(lineProblems) != 0 || fromFile["recalbox"] != want || fromLine["recalbox"] != want {
|
||||
t.Fatalf("file %+v %q, line %+v %q", fromFile, fileProblems, fromLine, lineProblems)
|
||||
}
|
||||
// After `password=` it is kept as typed: a leading space is the password's, in both forms.
|
||||
fromFile, _ = ParseSMBCredentials("[recalbox]\nusername=root\npassword= " + pw + "\n")
|
||||
fromLine, _ = ParseSMBCredentials("[recalbox] username=root password= " + pw + "\n")
|
||||
if fromFile["recalbox"].Password != " "+pw || fromLine["recalbox"] != fromFile["recalbox"] {
|
||||
t.Errorf("a leading space: file %+v, line %+v", fromFile, fromLine)
|
||||
}
|
||||
fromLine, _ = ParseSMBCredentials(line)
|
||||
if fromLine["recalbox"].CredentialsFile() != "username=root\npassword="+pw+"\ndomain=WORKGROUP\n" {
|
||||
t.Errorf("credentials file %q", strings.ReplaceAll(fromLine["recalbox"].CredentialsFile(), pw, "<pw>"))
|
||||
}
|
||||
// Several sources are several lines, in either form, mixed; a `\r` from a terminal is not the password's.
|
||||
mixed := "[recalbox] username=root password=" + pw + "\r\n[nas]\nusername=u\npassword=p\n[usb] user=x pass=y\n"
|
||||
got, problems := ParseSMBCredentials(mixed)
|
||||
if len(problems) != 0 || len(got) != 3 || got["recalbox"].Password != pw || got["nas"].Password != "p" || got["usb"] != (SMBCredential{Username: "x", Password: "y"}) {
|
||||
t.Errorf("mixed %v %q", len(got), problems)
|
||||
}
|
||||
// A one-line login closes the file-form section open before it: a key on the next line lands in no
|
||||
// login, neither the one given whole nor the one that was open.
|
||||
got, problems = ParseSMBCredentials("[nas]\nusername=u\npassword=p\n[recalbox] username=root password=x\nusername=other\n")
|
||||
if got["recalbox"].Username != "root" || got["nas"].Username != "u" || len(problems) != 1 || !strings.Contains(problems[0], "outside a [section]") {
|
||||
t.Errorf("a stray line after a one-line login: %+v %q", got, problems)
|
||||
}
|
||||
}
|
||||
// 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"}
|
||||
|
||||
// A line the module cannot read is refused with the form it expected, and nothing typed is said back:
|
||||
// not the password, not a word that may be the password typed without its key.
|
||||
func TestAMalformedLoginIsRefusedWithTheExpectedFormAndSaysNothingTyped(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"the password alone, as the prompt invites": testPassword + "\n",
|
||||
"a line without a section": "username=root password=" + testPassword + "\n",
|
||||
"a word that is not key=value": "[recalbox] root " + testPassword + "\n",
|
||||
"a value without a key": "[recalbox] =" + testPassword + "\n",
|
||||
"no space after the name": "[recalbox]username=root password=" + testPassword + "\n",
|
||||
"a name that is not a source's": "[Recal box] username=root password=" + testPassword + "\n",
|
||||
"a bracket never closed": "[recalbox username=root password=" + testPassword + "\n",
|
||||
"a password alone that looks like a name": "[abc-1]" + testPassword + "\n",
|
||||
"no username, line form": "[recalbox] password=" + testPassword + "\n",
|
||||
"no username, file form": "[recalbox]\npassword=" + testPassword + "\n",
|
||||
"a key this module does not read, file form": "[recalbox]\nusername=root\n" + testPassword + "=1\npassword=x\n",
|
||||
"a key this module does not read, line form": "[recalbox] username=root " + testPassword + "=1 password=x\n",
|
||||
"a section given twice, the second one-line": "[recalbox]\nusername=root\npassword=x\n[recalbox] username=u password=" + testPassword + "\n",
|
||||
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)
|
||||
}
|
||||
for what, text := range cases {
|
||||
got, problems := ParseSMBCredentials(text)
|
||||
said := strings.Join(problems, " ")
|
||||
if len(problems) == 0 {
|
||||
t.Errorf("%s: not refused: %+v", what, got)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(said, testPassword) || strings.Contains(said, "root") || strings.Contains(said, "abc-1") {
|
||||
t.Errorf("%s: a refusal says what was typed: %q", what, said)
|
||||
}
|
||||
if strings.Contains(what, "does not read") {
|
||||
if !strings.Contains(said, "it reads username, password and domain") || got["recalbox"].Password != "x" {
|
||||
t.Errorf("%s: %q %+v", what, said, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(what, "twice") {
|
||||
if got["recalbox"].Username != "root" || got["recalbox"].Password != "x" {
|
||||
t.Errorf("%s: the first is not kept: %+v", what, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(said, SMBOneLineForm) {
|
||||
t.Errorf("%s: the refusal does not say the expected form: %q", what, said)
|
||||
}
|
||||
if _, kept := got["recalbox"]; kept {
|
||||
t.Errorf("%s: a refused login is answered: %+v", what, got)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user