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
+41 -36
View File
@@ -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://<host>/<share>@<mountpoint>` or `name=disk:<filesystem uuid>@<mountpoint>`.
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 <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 <node> 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-<source>`: 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 <node> mounts smb-password-<source>`), 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 <node> mounts smb-password-<source> --from <file>`.
`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 <node> mounts smb-credentials --from <file>`, 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 <machine> 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
+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)
}
}
+1 -1
View File
@@ -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
+4
View File
@@ -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=
+8 -4
View File
@@ -48,12 +48,16 @@
"kind": "preference",
"default": "none",
"why": "a machine has no occasional source until its assignment names one: space-separated name=smb://<host>/<share>@<mountpoint> or name=disk:<uuid>@<mountpoint>, 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-<source> (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",