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

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:
jochen
2026-10-10 21:11:16 +02:00
parent a14fa306f2
commit bbb3161ade
11 changed files with 328 additions and 346 deletions
+65 -21
View File
@@ -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-<source>.
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 <node> mounts smb-credentials` and type one line `[%s] username=… password=…`; "+
"or put a [%s] section in a file and `secret accept <node> mounts smb-credentials --from <file>`", 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()
}
+58 -160
View File
@@ -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-<source>, 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-<source>.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-<source>` (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 = "[<source>] 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 <node> mounts smb-password-<source>`).
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 <file>` 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"
}
+2 -2
View File
@@ -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
+22 -11
View File
@@ -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.
+11 -6
View File
@@ -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")
}
}
+8 -6
View File
@@ -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-<source>) 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 },
},
}
+108 -99
View File
@@ -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)
}
}