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 ## What it owns
- The packages `nfs-utils` and `cifs-utils`. - The packages `nfs-utils` and `cifs-utils`.
- `/etc/mesh-mounts/mounts.conf`, placed by the mesh from the assignment: the settings `shares` and - `/etc/mesh-mounts/mounts.conf`, placed by the mesh from the assignment: the settings `shares`,
`sources`, the binding of `nfs-share` (`from`, the sharing machine, and `at`, where it is reached on `sources` and `smb-users`, the binding of `nfs-share` (`from`, the sharing machine, and `at`, where it is
the private network), the operator account, and where the module's secret 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 - 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 `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 `# 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 module's: it is never written over, stopped or removed, and the share whose mount point it holds is
said as not set up. 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` - `/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). (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 ## 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 - `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. 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`: - `sources`: space-separated occasional sources, each optionally `:ro`:
`name=smb://<host>/<share>@<mountpoint>` or `name=disk:<filesystem uuid>@<mountpoint>`. `name=smb://<host>/<share>@<mountpoint>` or `name=disk:<filesystem uuid>@<mountpoint>`.
For example `games=smb://192.0.2.76/Share@/mnt/games`. 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`, 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. `/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 ## The secrets
never makes one in its place. It holds one login per SMB source, named by the source's name in brackets.
The operator gives it at the desk with `secret ask <node> mounts smb-credentials` (ADR 0277): a one-line Each SMB source's password is its own secret, `smb-password-<source>`: a member of the module's secret
prompt opens that shows nothing of what is typed, and the login is typed as one line, the keys family `smb-password-*`, issued by an outside party (the device's owner). The mesh never makes a value for
space-separated after the name: a member (ADR 0283), so one not given is no file, and the module reads that as not given.
``` The operator gives it at the desk: an agent asks for it with the controller's `secret-ask` verb (or the
[games] username=… password=… 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 The file is read as one line: one trailing line break is dropped and everything else is kept as typed, so
and `=`; `domain=`, when the device wants one, comes before it. The line is read trimmed, so a password a password may hold spaces and `=`. An empty file is not given. A file of more than one line is refused,
does not end with a space; after `password=` it is kept as typed, so it may begin with one. Several saying so and never what it holds; give it again. No tool answers a password: the options, fstab lines and
sources are several such lines, which the prompt cannot take at once: a file can, given with mountinfo the tools read are redacted.
`secret accept <node> mounts smb-credentials --from <file>`, holding
one line per source, or one section per source with one key a line:
``` A source whose username or password is not given is not mounted. It is said `waiting`, naming the setting
[games] or the secret it waits for, and the module's health answers *waits* (below).
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.
## The processes and the bus ## 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. 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 - `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. 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 — 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 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 ## 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 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 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 armed, when something the settings name cannot be set up (a unit file that is not the module's, a
module's), or when `watch` has not looked for two minutes. An occasional source that is absent is password of more than one line), or when `watch` has not looked for two minutes. An occasional source that
healthy, and said as not present since when. 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 ## Taking over a hand-written line
+65 -21
View File
@@ -19,7 +19,9 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/fs"
"net" "net"
"os" "os"
"os/user" "os/user"
@@ -28,6 +30,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/health"
) )
// LookEvery is how often the process looks. // 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 StateAbsent = "absent" // an occasional source that is not there: a state, not a fault
StateUnreachable = "unreachable" // should be there and is not StateUnreachable = "unreachable" // should be there and is not
StateFailed = "failed" // tried and refused, or not set up; why says which 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`. // 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"` Why string `json:"why,omitempty"`
MountUnit string `json:"mount_unit,omitempty"` MountUnit string `json:"mount_unit,omitempty"`
AutoUnit string `json:"automount_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. // 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 uid, gid := -1, -1
if cfg.Account != "" && a.Lookup != nil { if cfg.Account != "" && a.Lookup != nil {
if u, g, err := a.Lookup(cfg.Account); err == 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": case "disk":
pairs = append(pairs, DiskPair(s)) pairs = append(pairs, DiskPair(s))
case "smb": case "smb":
c, ok := creds[s.Name] c, waits, why := a.login(cfg, s.Name)
if !ok || cfg.State == "" { 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, 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=…`; "+ Why: "the module's state directory is not named in its configuration, so no credentials file can be written"}
"or put a [%s] section in a file and `secret accept <node> mounts smb-credentials --from <file>`", s.Name, s.Name)}
continue continue
} }
file := filepath.Join(cfg.State, "smb-"+s.Name+".credentials") file := filepath.Join(cfg.State, "smb-"+s.Name+".credentials")
@@ -701,3 +704,44 @@ func orInterval(d, otherwise time.Duration) time.Duration {
} }
return otherwise 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 // 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 // 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 // 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 // account to read what the sharing machine exports from the bus. Nothing in it is secret: an SMB source's username is the
// module's own secret, in a file only root reads. // setting smb-users, and its password the module's own secret smb-password-<source>, in a file only root reads.
import ( import (
"fmt" "fmt"
@@ -41,11 +41,13 @@ type Config struct {
// reached on the private network (the binding's `at`). // reached on the private network (the binding's `at`).
Server string `json:"server,omitempty"` Server string `json:"server,omitempty"`
At string `json:"at,omitempty"` At string `json:"at,omitempty"`
// Secret is the path of the module's own secret `smb-credentials`; State is the module's state // SMBUsers is the setting `smb-users`: each SMB source's username. Passwords is the directory where
// directory, where the per-source credentials files are written. // the mesh places each source's password, `smb-password-<source>.secret` (ADR 0283); State is the
Secret string `json:"secret,omitempty"` // module's state directory, where the per-source credentials files are written.
State string `json:"state,omitempty"` SMBUsers map[string]string `json:"smb_users,omitempty"`
Account string `json:"account,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 is this machine's name: a share this machine serves itself is never mounted onto it.
Node string `json:"node,omitempty"` Node string `json:"node,omitempty"`
// Problems are what in the file could not be read; each is said, and its share is left out. // 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.Sources, p = ParseSources(kv["sources"])
c.Problems = append(c.Problems, p...) c.Problems = append(c.Problems, p...)
c.Unreadable = append(c.Unreadable, 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"] c.Node = kv["node"]
if c.At != "" && !hostRe.MatchString(c.At) { 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)) 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 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 { type SMBCredential struct {
Username string Username string
Password string Password string
Domain string
} }
// SMBOneLineForm is the form of a login the operator types at the desk's one-line prompt (`secret ask`, // PasswordSecret is the name of a source's password: a member of the module's secret family
// novox/hq ADR 0277), said in every refusal so the operator knows what to type next. // `smb-password-*`, given by that full name (`secret ask <node> mounts smb-password-<source>`).
const SMBOneLineForm = "[<source>] username=… password=…" func PasswordSecret(source string) string { return "smb-password-" + source }
// ParseSMBCredentials reads the secret `smb-credentials`: one login per SMB source, named by its section. // PasswordFile is where the mesh places a source's password, in the directory the config names.
// A login is one line, as typed at the desk's prompt (ADR 0277), with the source's name in brackets and func PasswordFile(dir, source string) string {
// the keys after it, space-separated: return filepath.Join(dir, PasswordSecret(source)+".secret")
// }
// [name] username=… password=…
// // userRe is a username mount.cifs takes on its own line: no space, no `=`, no line break, nothing that
// `password=` is last and takes everything after it to the end of the line, so a password may hold spaces // would end the line early.
// and `=`; `domain=`, when given, comes before it. Or a login is a section of a file, as `secret accept var userRe = regexp.MustCompile(`^[^\s=,]{1,64}$`)
// --from <file>` takes it, one key a line:
// // ParseSMBUsers reads the setting `smb-users`: space-separated `name=username`, one per SMB source. Not a
// [name] // secret, so it is a setting (ADR 0283). What cannot be read is said, and only that entry is left out.
// username=… func ParseSMBUsers(v string) (map[string]string, []string) {
// password=… out := map[string]string{}
// 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{}
var problems []string var problems []string
// section is the open section of the file form, whose key=value lines follow; "" is none. A one-line for _, e := range entries(v) {
// login closes it, so a key on a later line cannot land in a login given whole. name, user, ok := strings.Cut(e, "=")
section := "" switch {
for i, raw := range strings.Split(text, "\n") { case !ok:
line := strings.TrimSpace(raw) problems = append(problems, fmt.Sprintf("the smb-users entry %q is not name=username", e))
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { case !nameRe.MatchString(name):
continue problems = append(problems, fmt.Sprintf("the smb-users name %q is not lower-case letters, digits and dashes", name))
} case !userRe.MatchString(user):
if !strings.HasPrefix(line, "[") { problems = append(problems, fmt.Sprintf("the smb-users entry for %s is not a username without spaces, commas or =", name))
if section == "" { case out[name] != "":
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)) problems = append(problems, fmt.Sprintf("the source %s is named twice in smb-users; the first is kept", name))
continue default:
} out[name] = user
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)
} }
} }
return out, problems 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 // ReadPassword reads a source's password secret as one line, as typed at the desk's hidden prompt (ADR
// matched lower-cased and trimmed; a username or domain is trimmed, a password is kept as given. // 0277): one trailing line break is dropped, everything else is kept as typed, so a password may hold
func (c *SMBCredential) read(k, v string) bool { // spaces and `=`. Empty is not given. A second line is refused: it is said, never what it holds.
switch strings.ToLower(strings.TrimSpace(k)) { func ReadPassword(text string) (password string, given bool, err error) {
case "username", "user": text = strings.TrimSuffix(text, "\n")
c.Username = strings.TrimSpace(v) text = strings.TrimSuffix(text, "\r")
case "password", "pass": if text == "" {
c.Password = v return "", false, nil
case "domain":
c.Domain = strings.TrimSpace(v)
default:
return false
} }
return true if strings.ContainsAny(text, "\n\r") {
} return "", true, fmt.Errorf("holds more than one line; a password is one line")
// 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):]
} }
return text, true, nil
} }
// CredentialsFile is a source's credentials in mount.cifs's format. // CredentialsFile is a source's credentials in mount.cifs's format.
func (c SMBCredential) CredentialsFile() string { func (c SMBCredential) CredentialsFile() string {
s := "username=" + c.Username + "\npassword=" + c.Password + "\n" return "username=" + c.Username + "\npassword=" + c.Password + "\n"
if c.Domain != "" {
s += "domain=" + c.Domain + "\n"
}
return s
} }
+2 -2
View File
@@ -43,9 +43,9 @@ func TestAMountThatFailedBecauseItsSourceWasAwayIsCleared(t *testing.T) {
f := newSystem(t) f := newSystem(t)
f.write(ConfigFile, "shares=library=/srv/library\nsources=games=smb://192.0.2.76/Share@/mnt/games\n"+ 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"+ "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(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 := 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 a.Look(context.Background()) // writes and arms both pairs; neither source answers
f.calls = nil 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, // 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 // 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 // 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 ( import (
"fmt" "fmt"
"strings" "strings"
"time" "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 { type Health struct {
Healthy bool `json:"healthy"` health.Answer
Why string `json:"why"` Found []string `json:"found,omitempty"`
Found []string `json:"found,omitempty"` Said []string `json:"said,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. // 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. // Judge is the health of one status at a moment.
func Judge(st Status, readErr error, now time.Time) Health { func Judge(st Status, readErr error, now time.Time) Health {
if readErr != nil { 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"}} 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 { 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))) 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 { switch s.State {
case StateMounted, StateArmed: case StateMounted, StateArmed:
said = append(said, fmt.Sprintf("%s at %s: %s since %s.", s.Name, s.Mountpoint, s.State, clock(s.Since))) 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: case StateAbsent:
said = append(said, fmt.Sprintf("%s at %s: not present since %s.", s.Name, s.Mountpoint, clock(s.Since))) said = append(said, fmt.Sprintf("%s at %s: not present since %s.", s.Name, s.Mountpoint, clock(s.Since)))
case StateUnreachable: case StateUnreachable:
@@ -73,7 +81,7 @@ func Judge(st Status, readErr error, now time.Time) Health {
default: default:
line := fmt.Sprintf("The %s %s at %s cannot be mounted since %s: %s.", map[bool]string{true: "source", false: "share"}[s.Occasional], 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, ".")) 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) found = append(found, line)
} else { } else {
said = append(said, line+" Raised after five minutes.") 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 { 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." 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 { if len(st.Shares) == 0 {
why = "This machine is asked to mount nothing." 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. // 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 { if len(m.State) != 1 || m.State[0]["name"] != SharesState || m.State[0]["per-machine"] != true {
t.Errorf("state %v", m.State) t.Errorf("state %v", m.State)
} }
sec := m.OwnSecrets["smb-credentials"] // Each SMB source's password is its own one-line secret, a member of a family the mesh never makes a value
if sec["issued-by"] != "outside" || sec["taken"] != "at-start" || !strings.HasPrefix(sec["path"], "${dir:state}/") { // for (novox/hq ADR 0283), placed where the process looks for it.
t.Errorf("the SMB logins are an outside party's, given with secret accept: %v", sec) 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] s := m.Settings[key]
if s["kind"] != "preference" || s["default"] != None || s["why"] == "" || !strings.Contains(raw, "${setting:"+key+"}") { if s["kind"] != "preference" || s["default"] != None || s["why"] == "" || !strings.Contains(raw, "${setting:"+key+"}") {
t.Errorf("setting %s: %v", key, s) 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, " "), "${") { if len(cfg.Problems) != 0 && !strings.Contains(strings.Join(cfg.Problems, " "), "${") {
t.Errorf("the config's template reads: %v", 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) { if !strings.Contains(content, want) {
t.Errorf("the config lacks %s", 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}" { 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"]) 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") t.Error("the manifest holds no password")
} }
} }
+8 -6
View File
@@ -49,7 +49,7 @@ func Verbs(m *Machine) []stdio.Tool {
return []stdio.Tool{ return []stdio.Tool{
verb("list", "Every mount point on this machine — its fstab line, its mount and automount units, whether it is "+ 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 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)", "/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)"}}, map[string]any{"network": map[string]any{"type": "boolean", "description": "only network filesystems (default false)"}},
func(a map[string]any) (any, error) { func(a map[string]any) (any, error) {
@@ -142,11 +142,13 @@ func Verbs(m *Machine) []stdio.Tool {
}), }),
{ {
Name: "mounts_health", Name: "mounts_health",
Description: "Whether this machine's shares are healthy, as the module's health check (novox/hq ADR 0263): unhealthy " + Description: "Whether this machine's shares are healthy, as the module's health check (novox/hq ADR 0263, ADR 0283): " +
"when a share that should always be there has been unreachable or failed for five minutes, an automount is not " + "unhealthy when a share that should always be there has been unreachable or failed for five minutes, an automount " +
"armed, something the settings name cannot be set up (a missing login, a unit file that is not the module's), or " + "is not armed, something the settings name cannot be set up (a unit file that is not the module's), or the " +
"the module's process has not looked for two minutes; with why, one sentence per finding. An occasional source " + "module's process has not looked for two minutes; with why, one sentence per finding. An occasional source that " +
"that is absent is healthy, and said as not present since when. (r)", "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 }, Run: func(map[string]any) (any, error) { return m.Health(time.Now()), nil },
}, },
} }
+108 -99
View File
@@ -10,6 +10,8 @@ import (
"sync" "sync"
"testing" "testing"
"time" "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. // 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" + 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" + "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" + "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 { func exportsAnswer(t *testing.T, shares ...ServerShare) string {
raw, err := json.Marshal(ExportsAnswer{Server: "homeserver", At: "192.0.2.10", Shares: shares}) 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 := newSystem(t)
f.write(ConfigFile, config) f.write(ConfigFile, config)
f.write(ExportsFile, exportsAnswer(t, library, downloads)) 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. // 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") 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. // 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"]) t.Errorf("games %+v", states["games"])
} }
// The password is in the secret and in the credentials file, and nowhere else. // 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") t.Error("the password leaked into a file the module wrote")
} }
raw, _ := json.Marshal(f.machine().Health(now)) 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 := 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(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-credentials.secret", "[other]\nusername=x\npassword="+testPassword+"\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() now := time.Now()
st := f.applier(now).Look(context.Background()) st := f.applier(now).Look(context.Background())
if len(st.Shares) != 1 || st.Shares[0].State != StateFailed || if len(st.Shares) != 1 || st.Shares[0].State != StateFailed || !strings.Contains(st.Shares[0].Why, "more than one line") {
!strings.Contains(st.Shares[0].Why, "secret accept <node> mounts smb-credentials --from <file>") {
t.Fatalf("shares %+v", st.Shares) t.Fatalf("shares %+v", st.Shares)
} }
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "mnt-games.mount")); !os.IsNotExist(err) { raw, _ := json.Marshal(f.machine().Health(now))
t.Error("a source without its login gets no units") 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 users, problems := ParseSMBUsers(None); len(users) != 0 || len(problems) != 0 {
if creds["a"].Password != testPassword || len(problems) != 2 || strings.Contains(strings.Join(problems, " "), testPassword) { t.Errorf("none: %v %q", users, problems)
t.Errorf("creds %v problems %q", len(creds), problems)
} }
} }
@@ -537,7 +612,7 @@ func TestApplyActsAndWatchJudgesAndNeitherDoesTheOthersWork(t *testing.T) {
f := newSystem(t) f := newSystem(t)
f.write(ConfigFile, config) f.write(ConfigFile, config)
f.write(ExportsFile, exportsAnswer(t, library, downloads)) 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.write(filepath.Join(UnitDir, "srv-downloads.mount"), "[Mount]\nWhat=elsewhere\n")
f.reachable["192.0.2.10:2049"] = true f.reachable["192.0.2.10:2049"] = true
now := time.Now() now := time.Now()
@@ -573,10 +648,10 @@ func TestApplyActsAndWatchJudgesAndNeitherDoesTheOthersWork(t *testing.T) {
for _, s := range st.Shares { for _, s := range st.Shares {
states[s.Name] = s.State 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) 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") t.Error("the password leaked")
} }
} }
@@ -588,87 +663,21 @@ func (f *fakeSystem) setUnit(unit string, state map[string]string) {
f.units[unit] = state 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 // A wait beside a fault is said and excuses nothing: the answer is unhealthy, naming both (ADR 0283).
// (issue 379): the password is last and runs to the end of the line, with its spaces and its `=`. func TestAWaitBesideAFaultIsUnhealthy(t *testing.T) {
func TestAOneLineLoginIsTheLoginTheFileFormGives(t *testing.T) { now := time.Date(2026, 10, 10, 19, 30, 0, 0, time.UTC)
pw := "a b=c " + testPassword + "==" at := now.Add(-time.Hour).Format(time.RFC3339)
file := "[recalbox]\nusername=root\npassword=" + pw + "\ndomain=WORKGROUP\n" wait := health.Wait{Part: "the source games", Secret: "smb-password-games", What: "the password of the source games"}
line := "[recalbox] username=root domain=WORKGROUP password=" + pw + "\n" games := ShareStatus{Name: "games", Kind: "smb", Occasional: true, Mountpoint: "/mnt/games", State: StateWaiting, Since: at,
fromFile, fileProblems := ParseSMBCredentials(file) Why: "it waits for the operator to give the password of the source games (the secret smb-password-games)", Waits: []health.Wait{wait}}
fromLine, lineProblems := ParseSMBCredentials(line) library := ShareStatus{Name: "library", Kind: "nfs", Mountpoint: "/srv/library", State: StateUnreachable, Since: at, Why: "no answer"}
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 line the module cannot read is refused with the form it expected, and nothing typed is said back: h := Judge(Status{Looked: now.Format(time.RFC3339), Server: "homeserver", Shares: []ShareStatus{games, library}}, nil, now)
// not the password, not a word that may be the password typed without its key. if h.State != health.StateUnhealthy || h.Healthy || len(h.Waits) != 1 || !strings.Contains(h.Why, "library") || !strings.Contains(h.Why, "games") {
func TestAMalformedLoginIsRefusedWithTheExpectedFormAndSaysNothingTyped(t *testing.T) { t.Errorf("a wait beside a fault: %+v", h)
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",
} }
for what, text := range cases { h = Judge(Status{Looked: now.Format(time.RFC3339), Shares: []ShareStatus{games}}, nil, now)
got, problems := ParseSMBCredentials(text) if h.State != health.StateWaits || len(h.Waits) != 1 || len(h.Found) != 0 || !strings.Contains(h.Why, "waits for the operator") {
said := strings.Join(problems, " ") t.Errorf("a wait alone: %+v", h)
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)
}
} }
} }
+1 -1
View File
@@ -2,4 +2,4 @@ module mounts
go 1.22 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.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 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.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", "kind": "preference",
"default": "none", "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)" "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": { "own-secrets": {
"smb-credentials": { "smb-password-*": {
"path": "${dir:state}/smb-credentials.secret", "path": "${dir:state}/smb-password-*.secret",
"taken": "at-start",
"issued-by": "outside" "issued-by": "outside"
} }
}, },
@@ -85,7 +89,7 @@
"type": "file", "type": "file",
"path": "/etc/mesh-mounts/mounts.conf", "path": "/etc/mesh-mounts/mounts.conf",
"mode": "0644", "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", "id": "bus-dir-parent",