diff --git a/modules/mounts/README.md b/modules/mounts/README.md index 20aff3ee..06a37d22 100644 --- a/modules/mounts/README.md +++ b/modules/mounts/README.md @@ -11,15 +11,16 @@ provision `nfs-share`, which `nfs-server` provides on the machine that shares. ## What it owns - 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. +- `/etc/mesh-mounts/mounts.conf`, placed by the mesh from the assignment: the settings `shares`, + `sources` and `smb-users`, 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 secrets are placed. - 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. +- The SMB logins, one credentials file per source in the module's state directory, readable by root only, + written from the source's username and password. - `/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). @@ -58,7 +59,7 @@ read-write share is mounted hard, so a write is never dropped while the server i ## Settings -Both are preferences whose default, `none`, mounts nothing (ADR 0262 allows no empty default). +All three are preferences whose default, `none`, names 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. @@ -66,45 +67,39 @@ Both are preferences whose default, `none`, mounts nothing (ADR 0262 allows no e - `sources`: space-separated occasional sources, each optionally `:ro`: `name=smb:///@` or `name=disk:@`. For example `games=smb://192.0.2.76/Share@/mnt/games`. +- `smb-users`: the username each SMB source logs in with, space-separated `name=username`, for example + `games=root`. A username is not a secret, so it is a setting (novox/hq ADR 0283). It holds no space, + comma or `=`. 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 +All three are trusted settings: an agent proposes them (`settings propose mounts … --node `) and the +operator approves the change on their phone; it is set on that approval and pushed after (ADR 0277). -`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. It holds one login per SMB source, named by the source's name in brackets. +## The secrets -The operator gives it at the desk with `secret ask mounts smb-credentials` (ADR 0277): a one-line -prompt opens that shows nothing of what is typed, and the login is typed as one line, the keys -space-separated after the name: +Each SMB source's password is its own secret, `smb-password-`: a member of the module's secret +family `smb-password-*`, issued by an outside party (the device's owner). The mesh never makes a value for +a member (ADR 0283), so one not given is no file, and the module reads that as not given. -``` -[games] username=… password=… -``` +The operator gives it at the desk: an agent asks for it with the controller's `secret-ask` verb (or the +operator runs `secret ask mounts smb-password-`), a prompt opens on that machine's desk that +shows nothing of what is typed and names the module, the secret and who asked, and the password is typed +as one line (ADR 0277). It may also be given from a file at the controller's terminal with +`secret accept mounts smb-password- --from `. -`password=` is last and takes everything after it to the end of the line, so a password may hold spaces -and `=`; `domain=`, when the device wants one, comes before it. The line is read trimmed, so a password -does not end with a space; after `password=` it is kept as typed, so it may begin with one. Several -sources are several such lines, which the prompt cannot take at once: a file can, given with -`secret accept mounts smb-credentials --from `, holding -one line per source, or one section per source with one key a line: +The file is read as one line: one trailing line break is dropped and everything else is kept as typed, so +a password may hold spaces and `=`. An empty file is not given. A file of more than one line is refused, +saying so and never what it holds; give it again. No tool answers a password: the options, fstab lines and +mountinfo the tools read are redacted. -``` -[games] -username=… -password=… -domain=… (optional) -``` - -Both forms give the same login, and a file may mix them. A line the module cannot read is said with the -form it expected, never with what was typed. A source whose login 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. +A source whose username or password is not given is not mounted. It is said `waiting`, naming the setting +or the secret it waits for, and the module's health answers *waits* (below). ## The processes and the bus -- `apply` (root, judged by its unit) writes and removes the unit pairs and the logins, makes a missing +- `apply` (root, judged by its unit) writes and removes the unit pairs and the credentials files, 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. @@ -144,15 +139,25 @@ And what it never does: 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). +set up; `why` says which), `waiting` (an SMB source whose username or password the operator has not +given; `waits` names the setting or the secret). ## Health +The answer is mesh-sdk's (`go/health`): healthy, unhealthy, or waits (novox/hq ADR 0283). + 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. +armed, when something the settings name cannot be set up (a unit file that is not the module's, a +password of more than one line), or when `watch` has not looked for two minutes. An occasional source that +is absent is healthy, and said as not present since when. + +A source waiting for its username or password is not a fault. With nothing else found, the answer is +*waits*, with one wait per thing not given: the setting `smb-users` ("the username of the source games") +or the secret `smb-password-games` ("the password of the source games"). The node-engine states the +resource `waiting`, the controller says it to the operator as needing them, naming the act, and the +first-node gate does not fail a build for it. Beside a fault, the waits are said and the answer is +unhealthy. ## Taking over a hand-written line diff --git a/modules/mounts/cmd/mounts/apply.go b/modules/mounts/cmd/mounts/apply.go index 54e8016d..02f25dad 100644 --- a/modules/mounts/cmd/mounts/apply.go +++ b/modules/mounts/cmd/mounts/apply.go @@ -19,7 +19,9 @@ package main import ( "context" "encoding/json" + "errors" "fmt" + "io/fs" "net" "os" "os/user" @@ -28,6 +30,8 @@ import ( "strconv" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/health" ) // LookEvery is how often the process looks. @@ -48,6 +52,9 @@ const ( 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 + // StateWaiting is a source that cannot be set up until the operator gives its username or password + // (novox/hq ADR 0283): not a fault, said with what it waits for. + StateWaiting = "waiting" ) // ExportsAnswer is what the bundle left from the sharing machine's `node-nfs-server.exports`. @@ -74,6 +81,8 @@ type ShareStatus struct { Why string `json:"why,omitempty"` MountUnit string `json:"mount_unit,omitempty"` AutoUnit string `json:"automount_unit,omitempty"` + // Waits is what a waiting source waits for: the setting smb-users, or its secret smb-password-. + Waits []health.Wait `json:"waits,omitempty"` } // Status is what one look found. @@ -272,23 +281,6 @@ func (a *Applier) Look(ctx context.Context) Status { } } - 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 { @@ -300,11 +292,22 @@ func (a *Applier) Look(ctx context.Context) Status { case "disk": pairs = append(pairs, DiskPair(s)) case "smb": - c, ok := creds[s.Name] - if !ok || cfg.State == "" { + c, waits, why := a.login(cfg, s.Name) + switch { + case why != "": + notSet[s.Name] = ShareStatus{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, State: StateFailed, Why: why} + continue + case len(waits) > 0: + var gives []string + for _, w := range waits { + gives = append(gives, w.What+" ("+w.Names()+")") + } + notSet[s.Name] = ShareStatus{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, State: StateWaiting, + Why: "it waits for the operator to give " + strings.Join(gives, " and "), Waits: waits} + continue + case 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: at the desk, `secret ask mounts smb-credentials` and type one line `[%s] username=… password=…`; "+ - "or put a [%s] section in a file and `secret accept mounts smb-credentials --from `", s.Name, s.Name)} + Why: "the module's state directory is not named in its configuration, so no credentials file can be written"} continue } file := filepath.Join(cfg.State, "smb-"+s.Name+".credentials") @@ -701,3 +704,44 @@ func orInterval(d, otherwise time.Duration) time.Duration { } return otherwise } + +// login is an SMB source's login, or what it waits for, or why it cannot be read. The password is read as +// one line from the source's own secret; it is never said, and neither is anything the file holds. +func (a *Applier) login(cfg Config, source string) (SMBCredential, []health.Wait, string) { + var waits []health.Wait + part := "the source " + source + user, hasUser := cfg.SMBUsers[source] + if !hasUser { + waits = append(waits, health.Wait{Part: part, Setting: "smb-users", What: "the username of the source " + source}) + } + var password string + raw, err := a.M.read(PasswordFile(cfg.Passwords, source)) + switch { + case cfg.Passwords == "" || errors.Is(err, fs.ErrNotExist): + waits = append(waits, health.Wait{Part: part, Secret: PasswordSecret(source), What: "the password of the source " + source}) + case err != nil: + return SMBCredential{}, nil, "its password " + PasswordSecret(source) + " could not be read: " + bare(err) + default: + pw, given, perr := ReadPassword(raw) + if perr != nil { + return SMBCredential{}, nil, "its password " + PasswordSecret(source) + " " + perr.Error() + ": give it again" + } + if !given { + waits = append(waits, health.Wait{Part: part, Secret: PasswordSecret(source), What: "the password of the source " + source}) + } + password = pw + } + if len(waits) > 0 { + return SMBCredential{}, waits, "" + } + return SMBCredential{Username: user, Password: password}, nil, "" +} + +// bare is an error without the path it names: the reason alone. +func bare(err error) string { + var pe *fs.PathError + if errors.As(err, &pe) { + return pe.Err.Error() + } + return err.Error() +} diff --git a/modules/mounts/cmd/mounts/config.go b/modules/mounts/cmd/mounts/config.go index bb41c280..1c085b05 100644 --- a/modules/mounts/cmd/mounts/config.go +++ b/modules/mounts/cmd/mounts/config.go @@ -6,8 +6,8 @@ package main // 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. +// account to read what the sharing machine exports from the bus. Nothing in it is secret: an SMB source's username is the +// setting smb-users, and its password the module's own secret smb-password-, in a file only root reads. import ( "fmt" @@ -41,11 +41,13 @@ type Config struct { // 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"` + // SMBUsers is the setting `smb-users`: each SMB source's username. Passwords is the directory where + // the mesh places each source's password, `smb-password-.secret` (ADR 0283); State is the + // module's state directory, where the per-source credentials files are written. + SMBUsers map[string]string `json:"smb_users,omitempty"` + Passwords string `json:"passwords,omitempty"` + State string `json:"state,omitempty"` + Account string `json:"account,omitempty"` // Node is this machine's name: a share this machine serves itself is never mounted onto it. Node string `json:"node,omitempty"` // Problems are what in the file could not be read; each is said, and its share is left out. @@ -229,7 +231,12 @@ func ParseConfig(text string) Config { c.Sources, p = ParseSources(kv["sources"]) c.Problems = append(c.Problems, p...) c.Unreadable = append(c.Unreadable, p...) - c.Server, c.At, c.Secret, c.State, c.Account = kv["server"], kv["at"], kv["secret"], kv["state"], kv["account"] + c.SMBUsers, p = ParseSMBUsers(kv["smb-users"]) + c.Problems = append(c.Problems, p...) + c.Server, c.At, c.Passwords, c.State, c.Account = kv["server"], kv["at"], kv["passwords"], kv["state"], kv["account"] + if c.Passwords == "" { + c.Passwords = c.State + } c.Node = kv["node"] 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)) @@ -263,174 +270,65 @@ func (c Config) ServerNode() string { return first } -// SMBCredential is one source's login, from the module's own secret. +// SMBCredential is one source's login: the username from the setting `smb-users`, the password from the +// source's own secret `smb-password-` (novox/hq ADR 0283). type SMBCredential struct { Username string Password string - Domain string } -// SMBOneLineForm is the form of a login the operator types at the desk's one-line prompt (`secret ask`, -// novox/hq ADR 0277), said in every refusal so the operator knows what to type next. -const SMBOneLineForm = "[] username=… password=…" +// PasswordSecret is the name of a source's password: a member of the module's secret family +// `smb-password-*`, given by that full name (`secret ask mounts smb-password-`). +func PasswordSecret(source string) string { return "smb-password-" + source } -// ParseSMBCredentials reads the secret `smb-credentials`: one login per SMB source, named by its section. -// A login is one line, as typed at the desk's prompt (ADR 0277), with the source's name in brackets and -// the keys after it, space-separated: -// -// [name] username=… password=… -// -// `password=` is last and takes everything after it to the end of the line, so a password may hold spaces -// and `=`; `domain=`, when given, comes before it. Or a login is a section of a file, as `secret accept -// --from ` takes it, one key a line: -// -// [name] -// username=… -// password=… -// domain=… (optional) -// -// The two forms give the same login, and a file may mix them. A line is read trimmed, so a password does -// not end with a space; after `password=` it is kept as typed, so it may begin with one. The values are -// never answered by any tool; a problem names the line or the section, never a value nor a key the -// operator typed. -func ParseSMBCredentials(text string) (map[string]SMBCredential, []string) { - out := map[string]SMBCredential{} +// PasswordFile is where the mesh places a source's password, in the directory the config names. +func PasswordFile(dir, source string) string { + return filepath.Join(dir, PasswordSecret(source)+".secret") +} + +// userRe is a username mount.cifs takes on its own line: no space, no `=`, no line break, nothing that +// would end the line early. +var userRe = regexp.MustCompile(`^[^\s=,]{1,64}$`) + +// ParseSMBUsers reads the setting `smb-users`: space-separated `name=username`, one per SMB source. Not a +// secret, so it is a setting (ADR 0283). What cannot be read is said, and only that entry is left out. +func ParseSMBUsers(v string) (map[string]string, []string) { + out := map[string]string{} var problems []string - // section is the open section of the file form, whose key=value lines follow; "" is none. A one-line - // login closes it, so a key on a later line cannot land in a login given whole. - 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, "[") { - if section == "" { - problems = append(problems, fmt.Sprintf("line %d is outside a [section]: a login is one line as %s, or a [section] with username= and password= lines", i+1, SMBOneLineForm)) - continue - } - k, v, ok := strings.Cut(line, "=") - if !ok { - problems = append(problems, fmt.Sprintf("line %d in [%s] is not key=value", i+1, section)) - continue - } - c := out[section] - if !c.read(k, v) { - problems = append(problems, fmt.Sprintf("[%s] has a key this module does not read; it reads username, password and domain", section)) - } - out[section] = c - continue - } - // `[` opens a section: the file form when nothing follows the `]` on the line, the one-line form when - // the keys do. The name is the first `]`'s, and a source's name holds no `]`, so nothing typed after it - // can open or close another section. - section = "" - name, rest, closed := strings.Cut(line[1:], "]") - name = strings.TrimSpace(name) - if !closed || !nameRe.MatchString(name) { - problems = append(problems, fmt.Sprintf("line %d opens no source: the name in brackets is lower-case letters, digits and dashes, as %s", i+1, SMBOneLineForm)) - continue - } - if _, dup := out[name]; dup { - problems = append(problems, fmt.Sprintf("the section [%s] is given twice; the first is kept", name)) - continue - } - if strings.TrimSpace(rest) == "" { - out[name] = SMBCredential{} - section = name - continue - } - // From here the line may be a password typed alone that happens to start like a name, so until it - // reads as a login nothing of it is said: not the name, not what follows it. - if rest[0] != ' ' && rest[0] != '\t' { - problems = append(problems, fmt.Sprintf("line %d: the name in brackets is not followed by a space; the one-line form is %s", i+1, SMBOneLineForm)) - continue - } - c, ok := readOneLineLogin(rest) - if !ok { - problems = append(problems, fmt.Sprintf("line %d: what follows the name in brackets is not key=value pairs; the one-line form is %s, password last", i+1, SMBOneLineForm)) - continue - } - if c.unknownKey { - problems = append(problems, fmt.Sprintf("[%s] has a key this module does not read; it reads username, password and domain", name)) - } - out[name] = c.SMBCredential - } - // A login that cannot be used is not answered at all: apply says the source as not set up, rather than - // writing a credentials file with a hole in it. - for name, c := range out { - if c.Username == "" { - problems = append(problems, fmt.Sprintf("[%s] gives no username; a login is %s", name, SMBOneLineForm)) - delete(out, 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) + for _, e := range entries(v) { + name, user, ok := strings.Cut(e, "=") + switch { + case !ok: + problems = append(problems, fmt.Sprintf("the smb-users entry %q is not name=username", e)) + case !nameRe.MatchString(name): + problems = append(problems, fmt.Sprintf("the smb-users name %q is not lower-case letters, digits and dashes", name)) + case !userRe.MatchString(user): + problems = append(problems, fmt.Sprintf("the smb-users entry for %s is not a username without spaces, commas or =", name)) + case out[name] != "": + problems = append(problems, fmt.Sprintf("the source %s is named twice in smb-users; the first is kept", name)) + default: + out[name] = user } } return out, problems } -// read puts one key's value in the login and says whether the key is one this module reads. The key is -// matched lower-cased and trimmed; a username or domain is trimmed, a password is kept as given. -func (c *SMBCredential) read(k, v string) bool { - switch strings.ToLower(strings.TrimSpace(k)) { - case "username", "user": - c.Username = strings.TrimSpace(v) - case "password", "pass": - c.Password = v - case "domain": - c.Domain = strings.TrimSpace(v) - default: - return false +// ReadPassword reads a source's password secret as one line, as typed at the desk's hidden prompt (ADR +// 0277): one trailing line break is dropped, everything else is kept as typed, so a password may hold +// spaces and `=`. Empty is not given. A second line is refused: it is said, never what it holds. +func ReadPassword(text string) (password string, given bool, err error) { + text = strings.TrimSuffix(text, "\n") + text = strings.TrimSuffix(text, "\r") + if text == "" { + return "", false, nil } - return true -} - -// oneLineLogin is a login read from the one-line form, and whether a key in it is one this module does -// not read (said by the caller; the key itself is never said). -type oneLineLogin struct { - SMBCredential - unknownKey bool -} - -// readOneLineLogin reads what follows `[name]` on a line: space-separated key=value pairs, where -// `password=` takes everything after it to the end of the line and so is last. It is not ok when a -// word is not key=value: that word is never said, as it may be the password typed without its key. -func readOneLineLogin(rest string) (oneLineLogin, bool) { - var c oneLineLogin - for { - rest = strings.TrimLeft(rest, " \t") - if rest == "" { - return c, true - } - word := rest - if sp := strings.IndexAny(rest, " \t"); sp >= 0 { - word = rest[:sp] - } - k, v, ok := strings.Cut(word, "=") - key := strings.ToLower(strings.TrimSpace(k)) - if !ok || key == "" { - return oneLineLogin{}, false - } - switch key { - case "password", "pass": - // To the end of the line: a password may hold spaces and `=`. - c.read(key, rest[len(k)+1:]) - return c, true - } - if !c.read(key, v) { - c.unknownKey = true - } - rest = rest[len(word):] + if strings.ContainsAny(text, "\n\r") { + return "", true, fmt.Errorf("holds more than one line; a password is one line") } + return text, true, nil } // 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 + return "username=" + c.Username + "\npassword=" + c.Password + "\n" } diff --git a/modules/mounts/cmd/mounts/failed_test.go b/modules/mounts/cmd/mounts/failed_test.go index 2b03ae2e..72843c75 100644 --- a/modules/mounts/cmd/mounts/failed_test.go +++ b/modules/mounts/cmd/mounts/failed_test.go @@ -43,9 +43,9 @@ func TestAMountThatFailedBecauseItsSourceWasAwayIsCleared(t *testing.T) { f := newSystem(t) f.write(ConfigFile, "shares=library=/srv/library\nsources=games=smb://192.0.2.76/Share@/mnt/games\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") f.write(ExportsFile, exportsAnswer(t, library)) - 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") a := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC)) a.Look(context.Background()) // writes and arms both pairs; neither source answers f.calls = nil diff --git a/modules/mounts/cmd/mounts/health.go b/modules/mounts/cmd/mounts/health.go index cdd5ce0b..e966a293 100644 --- a/modules/mounts/cmd/mounts/health.go +++ b/modules/mounts/cmd/mounts/health.go @@ -5,20 +5,23 @@ package main // 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. +// not present since when, and raises nothing. A source waiting for the operator's username or password is +// not a fault: when nothing else is found the answer is *waits*, naming each (novox/hq ADR 0283). import ( "fmt" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/health" ) -// Health is the answer the node-engine reads from a tool check. +// Health is the answer the node-engine reads from a tool check: mesh-sdk's answer (state, healthy, why, +// waits), with what was found and what was said beside it. type Health struct { - Healthy bool `json:"healthy"` - Why string `json:"why"` - Found []string `json:"found,omitempty"` - Said []string `json:"said,omitempty"` + health.Answer + 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. @@ -38,10 +41,11 @@ func clock(ts string) string { // 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() + ").", + return Health{Answer: health.Unhealthy("The module's process has left no account of this machine's mounts (" + readErr.Error() + ")."), Found: []string{"no status"}} } - var found, said []string + var found, said, waiting []string + var waits []health.Wait 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))) } @@ -58,6 +62,10 @@ func Judge(st Status, readErr error, now time.Time) Health { 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 StateWaiting: + waiting = append(waiting, fmt.Sprintf("The source %s at %s is not mounted since %s: %s.", s.Name, s.Mountpoint, clock(s.Since), + strings.TrimSuffix(s.Why, "."))) + waits = append(waits, s.Waits...) case StateAbsent: said = append(said, fmt.Sprintf("%s at %s: not present since %s.", s.Name, s.Mountpoint, clock(s.Since))) case StateUnreachable: @@ -73,7 +81,7 @@ func Judge(st Status, readErr error, now time.Time) Health { 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") { + if long || strings.Contains(s.Why, "not armed") || strings.Contains(s.Why, "not the module's") { found = append(found, line) } else { said = append(said, line+" Raised after five minutes.") @@ -81,13 +89,16 @@ func Judge(st Status, readErr error, now time.Time) Health { } } if len(found) > 0 { - return Health{Healthy: false, Why: strings.Join(found, " "), Found: found, Said: said} + return Health{Answer: health.Unhealthy(strings.Join(append(found, waiting...), " "), waits...), Found: found, Said: append(said, waiting...)} + } + if len(waits) > 0 { + return Health{Answer: health.Waiting(strings.Join(waiting, " "), waits...), Said: append(said, waiting...)} } 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} + return Health{Answer: health.Healthy(why), Said: said} } // Health reads the process's last look and judges it. diff --git a/modules/mounts/cmd/mounts/manifest_test.go b/modules/mounts/cmd/mounts/manifest_test.go index 9676f26f..9e61ac99 100644 --- a/modules/mounts/cmd/mounts/manifest_test.go +++ b/modules/mounts/cmd/mounts/manifest_test.go @@ -82,11 +82,16 @@ func TestTheManifestNamesWhatTheBundleServesAndWhatItNeeds(t *testing.T) { 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) + // Each SMB source's password is its own one-line secret, a member of a family the mesh never makes a value + // for (novox/hq ADR 0283), placed where the process looks for it. + sec := m.OwnSecrets["smb-password-*"] + if len(m.OwnSecrets) != 1 || sec["issued-by"] != "outside" || sec["path"] != "${dir:state}/"+PasswordSecret("*")+".secret" { + t.Errorf("the SMB passwords are an outside party's, one secret per source: %v", m.OwnSecrets) } - for _, key := range []string{"shares", "sources"} { + if got := PasswordFile("${dir:state}", "games"); got != strings.Replace(sec["path"], "*", "games", 1) { + t.Errorf("the process reads %s, the mesh places %s", got, sec["path"]) + } + for _, key := range []string{"shares", "sources", "smb-users"} { 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) @@ -134,7 +139,7 @@ func TestTheProcessesAreRootAndJudgedByTheirUnitAndTheHealthTool(t *testing.T) { 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"} { + for _, want := range []string{"server=${bound:nfs-share:from}", "at=${bound:nfs-share:at}", "passwords=${dir:state}", "smb-users=${setting:smb-users}"} { if !strings.Contains(content, want) { t.Errorf("the config lacks %s", want) } @@ -142,7 +147,7 @@ func TestTheProcessesAreRootAndJudgedByTheirUnitAndTheHealthTool(t *testing.T) { 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") { + if strings.Contains(raw, "password=") { t.Error("the manifest holds no password") } } diff --git a/modules/mounts/cmd/mounts/verbs.go b/modules/mounts/cmd/mounts/verbs.go index aadf29bd..ceda87dd 100644 --- a/modules/mounts/cmd/mounts/verbs.go +++ b/modules/mounts/cmd/mounts/verbs.go @@ -49,7 +49,7 @@ func Verbs(m *Machine) []stdio.Tool { 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 "+ + "mounted, absent, unreachable, failed, waiting), since when and why; a waiting source names the setting or secret it waits for. 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) { @@ -142,11 +142,13 @@ func Verbs(m *Machine) []stdio.Tool { }), { 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)", + Description: "Whether this machine's shares are healthy, as the module's health check (novox/hq ADR 0263, ADR 0283): " + + "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 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. A source whose username (the setting smb-users) or " + + "password (the secret smb-password-) is not given waits for the operator: with nothing else found the " + + "answer is waits, naming each, never unhealthy. (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 index 9ae35456..6a4bfa61 100644 --- a/modules/mounts/cmd/mounts/write_test.go +++ b/modules/mounts/cmd/mounts/write_test.go @@ -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, ""), strings.ReplaceAll(got, testPassword, ""), 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, "")) + } + // 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 mounts smb-credentials --from ") { + 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, "")) - } - // 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) } } diff --git a/modules/mounts/go.mod b/modules/mounts/go.mod index 9549ae83..76732a34 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.7 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c diff --git a/modules/mounts/go.sum b/modules/mounts/go.sum index 017ed9ad..e655124d 100644 --- a/modules/mounts/go.sum +++ b/modules/mounts/go.sum @@ -2,3 +2,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJ 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= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d h1:BR8uwv4BqyLaBwITuLylrd9DRUnu0D5DjJw0RuQSXbk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mounts/module.json b/modules/mounts/module.json index c11f0283..fa9ccf91 100644 --- a/modules/mounts/module.json +++ b/modules/mounts/module.json @@ -48,12 +48,16 @@ "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)" + }, + "smb-users": { + "kind": "preference", + "default": "none", + "why": "the username each SMB source logs in with: space-separated name=username; not a secret, so a setting, beside each source's password in its own secret smb-password- (hq ADR 0283)" } }, "own-secrets": { - "smb-credentials": { - "path": "${dir:state}/smb-credentials.secret", - "taken": "at-start", + "smb-password-*": { + "path": "${dir:state}/smb-password-*.secret", "issued-by": "outside" } }, @@ -85,7 +89,7 @@ "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}\nnode=${machine:name}\nsecret=${dir:state}/smb-credentials.secret\nstate=${dir:state}\n" + "content": "# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings\n# shares, sources and smb-users, 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}\nsmb-users=${setting:smb-users}\nserver=${bound:nfs-share:from}\nat=${bound:nfs-share:at}\naccount=${machine:account}\nnode=${machine:name}\npasswords=${dir:state}\nstate=${dir:state}\n" }, { "id": "bus-dir-parent",