Merge pull request 'mounts: an SMB source's username in its sources entry, its password as its own one-line secret, and a missing one waits (issue 386, ADR 0283)' (#190) from feat/386-a-wait-for-the-operator into main

This commit was merged in pull request #190.
This commit is contained in:
2026-10-10 20:18:32 +00:00
12 changed files with 456 additions and 369 deletions
+1
View File
@@ -16,3 +16,4 @@ modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-delivery/mesh-delivery
modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
+45 -38
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`,
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 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,53 +59,49 @@ 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).
Both 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.
For example `library=/srv/library downloads=/srv/downloads:ro`.
- `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`.
`name=smb://[<user>@]<host>/<share>@<mountpoint>` or `name=disk:<filesystem uuid>@<mountpoint>`.
For example `games=smb://root@192.0.2.76/Share@/mnt/games`; the host is a name or an IPv4 address. An SMB
source's username is in its entry: it is not a secret (novox/hq ADR 0283). It is percent-decoded, so a device that needs a domain takes it as
`WORKGROUP%5Cuser` (for `WORKGROUP\user`), and an `@` in it is written `%40`; it holds no space, comma or
`=`. A password never goes in the entry: `user:password@` is refused, saying so and never what was
written. An entry that cannot be read whole is said as malformed by its position alone ("entry 2 of sources
is malformed: …"), never with its name or anything else it holds.
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
Both 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 +141,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 `sources` ("the username of the source games, in
its entry of sources") 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
+72 -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,9 @@ 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: its username in the setting sources, or its secret
// smb-password-<source>.
Waits []health.Wait `json:"waits,omitempty"`
}
// Status is what one look found.
@@ -272,23 +282,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 +293,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)
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 +705,50 @@ 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, s Source) (SMBCredential, []health.Wait, string) {
var waits []health.Wait
source, user := s.Name, s.User
part := "the source " + source
if user == "" {
waits = append(waits, health.Wait{Part: part, Setting: "sources",
What: "the username of the source " + source + ", in its entry of sources"})
}
// Never a relative path: without the directory the mesh places the passwords in, nothing is read.
if !filepath.IsAbs(cfg.Passwords) {
return SMBCredential{}, nil, "the module's configuration names no directory for its passwords, so " +
PasswordSecret(source) + " cannot be read"
}
var password string
raw, err := a.M.read(PasswordFile(cfg.Passwords, source))
switch {
case 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()
}
+92 -184
View File
@@ -6,14 +6,16 @@ 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 in its
// entry of sources, and its password the module's own secret smb-password-<source>, in a file only root reads.
import (
"fmt"
"net/url"
"path/filepath"
"regexp"
"strings"
"unicode"
)
// Where the module keeps its things. ConfigFile is placed by the mesh; the rest by the module's code.
@@ -41,11 +43,12 @@ 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"`
// Passwords is the directory where the mesh places each SMB source's password,
// `smb-password-<source>.secret` (ADR 0283); State is the module's state directory, where the
// per-source credentials files are written.
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.
@@ -65,10 +68,13 @@ type Wanted struct {
// Source is one occasional source: a device that is not a node and is not always there.
type Source struct {
Name string `json:"name"`
Kind string `json:"kind"` // smb or disk
Host string `json:"host,omitempty"`
Share string `json:"share,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"` // smb or disk
Host string `json:"host,omitempty"`
Share string `json:"share,omitempty"`
// User is an SMB source's username, from its entry (`smb://<user>@<host>/<share>`), percent-decoded;
// empty when the entry names none, and the source then waits for it (ADR 0283). Not a secret.
User string `json:"user,omitempty"`
UUID string `json:"uuid,omitempty"`
Mountpoint string `json:"mountpoint"`
ReadOnly bool `json:"read_only"`
@@ -157,49 +163,48 @@ func ParseSources(v string) ([]Source, []string) {
var out []Source
var problems []string
seen := map[string]bool{}
for _, e := range entries(v) {
// **Nothing an entry holds is said back until the whole entry has read**: an entry may carry a password
// where the user goes, in any scheme, and a password with a space or a comma splits it in two, whose
// tail may itself look like name=…. A malformed entry is named by its position alone.
for i, e := range entries(v) {
name, rest, ok := strings.Cut(e, "=")
if !ok {
problems = append(problems, fmt.Sprintf("the source %q is not name=<where>@<mountpoint>", e))
continue
}
if !nameRe.MatchString(name) {
problems = append(problems, fmt.Sprintf("the source name %q is not lower-case letters, digits and dashes", name))
if !ok || !nameRe.MatchString(name) {
problems = append(problems, malformed(i, "it is not name=<where>@<mountpoint>, its name lower-case letters, digits and dashes"))
continue
}
at := strings.LastIndex(rest, "@")
if at < 0 {
problems = append(problems, fmt.Sprintf("the source %s names no mount point (…@<mountpoint>)", name))
problems = append(problems, malformed(i, "it names no mount point (…@<mountpoint>)"))
continue
}
where, pointRO := rest[:at], rest[at+1:]
point, ro := readOnly(pointRO)
if err := CheckMountpoint(point); err != nil {
problems = append(problems, fmt.Sprintf("the source %s: %v", name, err))
if CheckMountpoint(point) != nil {
problems = append(problems, malformed(i, "its mount point is not an absolute, plainly written path outside the system's own directories"))
continue
}
s := Source{Name: name, Mountpoint: point, ReadOnly: ro}
switch {
case strings.HasPrefix(where, "smb://"):
host, share, ok := strings.Cut(strings.TrimPrefix(where, "smb://"), "/")
if !ok || !hostRe.MatchString(host) || !shareRe.MatchString(share) {
problems = append(problems, fmt.Sprintf("the source %s: %q is not smb://<host>/<share>", name, where))
user, host, share, problem := parseSMB(strings.TrimPrefix(where, "smb://"))
if problem != "" {
problems = append(problems, malformed(i, problem))
continue
}
s.Kind, s.Host, s.Share = "smb", host, share
s.Kind, s.User, s.Host, s.Share = "smb", user, host, share
case strings.HasPrefix(where, "disk:"):
uuid := strings.TrimPrefix(where, "disk:")
if !uuidRe.MatchString(uuid) {
problems = append(problems, fmt.Sprintf("the source %s: %q is not disk:<filesystem uuid>", name, where))
problems = append(problems, malformed(i, "it is not disk:<filesystem uuid>"))
continue
}
s.Kind, s.UUID = "disk", uuid
default:
problems = append(problems, fmt.Sprintf("the source %s: %q is neither smb://<host>/<share> nor disk:<uuid>", name, where))
problems = append(problems, malformed(i, "it is neither smb://[<user>@]<host>/<share> nor disk:<uuid>"))
continue
}
if seen[name] || seen["@"+point] {
problems = append(problems, fmt.Sprintf("the source %s or its mount point %s is named twice", name, point))
problems = append(problems, fmt.Sprintf("the source %s or its mount point is named twice", name))
continue
}
seen[name], seen["@"+point] = true, true
@@ -208,6 +213,11 @@ func ParseSources(v string) ([]Source, []string) {
return out, problems
}
// malformed is a problem with the i-th entry of sources, by its position alone: nothing it holds is said.
func malformed(i int, why string) string {
return fmt.Sprintf("entry %d of sources is malformed: %s", i+1, why)
}
// ParseConfig reads the rendered file: `key=value` lines.
func ParseConfig(text string) Config {
kv := map[string]string{}
@@ -229,7 +239,10 @@ 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.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))
@@ -242,7 +255,7 @@ func ParseConfig(text string) Config {
kept := c.Sources[:0]
for _, s := range c.Sources {
if other, dup := points[s.Mountpoint]; dup {
c.Problems = append(c.Problems, fmt.Sprintf("the source %s mounts at %s, where the share %s already does", s.Name, s.Mountpoint, other))
c.Problems = append(c.Problems, fmt.Sprintf("the source %s mounts where the share %s already does", s.Name, other))
continue
}
kept = append(kept, s)
@@ -263,174 +276,69 @@ 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 its entry of `sources`, 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{}
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)
}
}
return out, problems
// 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")
}
// 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
// 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}$`)
// parseSMB reads what follows smb:// in a source's entry: `[<user>@]<host>/<share>`. The user is
// percent-decoded, so `DOMAIN%5Cuser` or a user holding `@` can be written; a password in its place
// (`user:pass@`) is refused, and no problem says anything of what was written.
func parseSMB(body string) (user, host, share, problem string) {
const form = "is not smb://[<user>@]<host>/<share>, the host a name or an IPv4 address"
if at := strings.LastIndex(body, "@"); at >= 0 {
raw := body[:at]
body = body[at+1:]
switch {
case strings.Contains(raw, ":"):
return "", "", "", "it carries a password in the URL; a password is given as the secret smb-password-<source>, never in a setting"
case strings.Contains(raw, "@"):
return "", "", "", "its user holds an @; write it %40"
}
decoded, err := url.PathUnescape(raw)
if err != nil || !userRe.MatchString(decoded) || strings.IndexFunc(decoded, unicode.IsControl) >= 0 {
return "", "", "", "its user is not a username without spaces, commas, = or control characters (percent-encode a \\ or an @ in it)"
}
user = decoded
}
return true
host, share, ok := strings.Cut(body, "/")
if !ok || !hostRe.MatchString(host) || !shareRe.MatchString(share) {
return "", "", "", form
}
return user, host, share, ""
}
// 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):]
// 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
}
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"
}
+3 -3
View File
@@ -41,11 +41,11 @@ func TestTheFailureOfTheUnitTheModuleTakesOverIsCleared(t *testing.T) {
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"+
f.write(ConfigFile, "shares=library=/srv/library\nsources=games=smb://root@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")
"passwords=/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.
+13 -5
View File
@@ -82,9 +82,17 @@ 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)
}
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"])
}
if _, has := m.Settings["smb-users"]; has || len(m.Settings) != 2 {
t.Errorf("an SMB source's username is in its entry of sources, not a setting of its own: %v", m.Settings)
}
for _, key := range []string{"shares", "sources"} {
s := m.Settings[key]
@@ -134,7 +142,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}"} {
if !strings.Contains(content, want) {
t.Errorf("the config lacks %s", want)
}
@@ -142,7 +150,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 (in its entry of the setting sources) 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 },
},
}
+191 -95
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.
@@ -147,9 +149,9 @@ 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" +
"sources=games=smb://root@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"
"passwords=/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,153 @@ func TestASilentServerRemovesNoMount(t *testing.T) {
}
}
func TestAMissingLoginIsSaidWithItsRemedyAndTheSourceIsNotMounted(t *testing.T) {
func TestASourceWithoutItsLoginWaitsForTheOperatorAndIsNotMounted(t *testing.T) {
for name, c := range map[string]struct {
user, password string
writePassword bool
waitsFor []string
}{
"no username": {"", testPassword, true, []string{"setting sources"}},
"no password": {"root@", "", false, []string{"secret smb-password-games"}},
"an empty password file": {"root@", "", true, []string{"secret smb-password-games"}},
"a line break only": {"root@", "\n", true, []string{"secret smb-password-games"}},
"neither": {"", "", false, []string{"setting sources", "secret smb-password-games"}},
} {
f := newSystem(t)
f.write(ConfigFile, "sources=games=smb://"+c.user+"192.0.2.76/Share@/mnt/games\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://root@192.0.2.76/Share@/mnt/games\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 TestAnSMBSourcesUserIsInItsEntryAndAPasswordThereIsRefusedUnsaid(t *testing.T) {
sources, problems := ParseSources("a=smb://root@192.0.2.76/Share@/mnt/a b=smb://192.0.2.76/Share@/mnt/b " +
"c=smb://WORKGROUP%5Cme@nas/Media@/mnt/c d=smb://me%40example@nas/Media@/mnt/d:ro")
users := map[string]string{}
for _, s := range sources {
users[s.Name] = s.User
if s.Host == "" || s.Share == "" {
t.Errorf("%s: %+v", s.Name, s)
}
}
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 len(problems) != 0 || users["a"] != "root" || users["b"] != "" || users["c"] != `WORKGROUP\me` || users["d"] != "me@example" {
t.Errorf("users %v problems %q", users, problems)
}
for what, entry := range map[string]string{
"a password in the URL": "e=smb://root:" + testPassword + "@nas/Media@/mnt/e",
"a password with a slash": "e=smb://root:a/" + testPassword + "@nas/Media@/mnt/e",
"a user with a space, encoded": "e=smb://a%20" + testPassword + "@nas/Media@/mnt/e",
"a bad percent escape": "e=smb://%zz" + testPassword + "@nas/Media@/mnt/e",
"no mount point, with a password": "smb://root:" + testPassword + "@nas/Media",
"another scheme, upper case": "e=SMB://root:" + testPassword + "@nas/Media@/mnt/e",
"a cifs URL": "e=cifs://root:" + testPassword + "@nas/Media@/mnt/e",
"two @ before the host": "e=smb://me@example@nas/Media@/mnt/e" + testPassword,
"a control character in the user": "e=smb://me%07" + testPassword + "@nas/Media@/mnt/e",
} {
got, problems := ParseSources(entry)
said := strings.Join(problems, " ")
if len(got) != 0 || len(problems) != 1 || strings.Contains(said, testPassword) {
t.Errorf("%s: %+v %q", what, got, said)
}
}
if _, problems := ParseSources("e=smb://root:" + testPassword + "@nas/Media@/mnt/e"); !strings.Contains(problems[0], "smb-password-<source>") {
t.Errorf("a password in the URL is said with where it goes: %q", problems)
}
// A password with a space or a comma splits the entry: neither half, nor a name that is not one, is said.
const secret = "SECRETPW"
for _, v := range []string{
"e=smb://root:SEC RETPW@nas/Media@/mnt/e",
"e=smb://root:SEC,RETPW@nas/Media@/mnt/e",
"e=SMB://root:" + secret + "@nas/Media@/mnt/e",
"e=cifs://root:" + secret + "@nas/Media@/mnt/e",
"SECRET" + "PW=smb://nas/Media@/mnt/e",
// A password holding '=' and a space, or '=' and a comma: the tail reads like name=… and is still not said.
"e=smb://root:sec RETPW=x@nas/Media@/mnt/e",
"e=smb://root:sec,RETPW=x@nas/Media@/mnt/e",
"e=smb://root:SEC RETPW=smb://nas/Media@/mnt/e",
} {
_, problems := ParseSources(v)
said := strings.Join(problems, " ")
if len(problems) == 0 || strings.Contains(strings.ToUpper(said), "RETPW") || strings.Contains(said, "SEC") ||
strings.Contains(said, "sec") && !strings.Contains(said, "secret smb-password") {
t.Errorf("%q says %q", strings.ReplaceAll(v, "RETPW", "<pw>"), said)
}
// And nothing of it reaches the status or the health answer.
f := newSystem(t)
f.write(ConfigFile, "sources="+v+"\npasswords=/var/lib/mesh/mounts\nstate=/var/lib/mesh/mounts\n")
now := time.Now()
f.applier(now).Look(context.Background())
raw, _ := json.Marshal(f.machine().Health(now))
if strings.Contains(strings.ToUpper(string(raw)), "RETPW") || strings.Contains(strings.ToUpper(f.everything("mounts.conf")), "RETPW") {
t.Errorf("%q leaked into the status or the health: %s", strings.ReplaceAll(v, "RETPW", "<pw>"), raw)
}
}
}
@@ -537,7 +666,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 +702,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 +717,54 @@ 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)
// 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"}
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)
}
// 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)
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)
}
}
// 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",
// A password that exists and cannot be read, and a configuration naming no directory for the passwords, are
// refused as faults: said without the value and without the path, and nothing is read from a relative path.
func TestAPasswordThatCannotBeReadIsAFaultSaidWithoutValueOrPath(t *testing.T) {
// A directory where the file should be: read fails, and not as "not there" (as root a mode would not stop it).
f := newSystem(t)
f.write(ConfigFile, "sources=games=smb://root@192.0.2.76/Share@/mnt/games\npasswords=/var/lib/mesh/mounts\nstate=/var/lib/mesh/mounts\n")
f.write("/var/lib/mesh/mounts/smb-password-games.secret/inside", testPassword)
st := f.applier(time.Now()).Look(context.Background())
if len(st.Shares) != 1 || st.Shares[0].State != StateFailed || !strings.Contains(st.Shares[0].Why, "could not be read") ||
strings.Contains(st.Shares[0].Why, "/var/lib") || strings.Contains(st.Shares[0].Why, testPassword) {
t.Fatalf("an unreadable password: %+v", st.Shares)
}
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
// No passwords= and no state=: the source is not set up, and no relative path is read.
for _, cfg := range []string{
"sources=games=smb://root@192.0.2.76/Share@/mnt/games\n",
"sources=games=smb://root@192.0.2.76/Share@/mnt/games\npasswords=relative/dir\n",
} {
g := newSystem(t)
g.write(ConfigFile, cfg)
g.write("smb-password-games.secret", testPassword)
g.write("relative/dir/smb-password-games.secret", testPassword)
st := g.applier(time.Now()).Look(context.Background())
if len(st.Shares) != 1 || st.Shares[0].State != StateFailed || !strings.Contains(st.Shares[0].Why, "names no directory for its passwords") ||
strings.Contains(st.Shares[0].Why, testPassword) || strings.Contains(st.Shares[0].Why, "relative") {
t.Errorf("%q: %+v", cfg, st.Shares)
}
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)
if _, err := os.Stat(filepath.Join(g.root, "/var/lib/mesh/mounts/smb-games.credentials")); !os.IsNotExist(err) {
t.Errorf("%q: a credentials file was written", cfg)
}
}
}
+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=
+4 -5
View File
@@ -47,13 +47,12 @@
"sources": {
"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)"
"why": "a machine has no occasional source until its assignment names one: space-separated name=smb://[<user>@]<host>/<share>@<mountpoint> or name=disk:<uuid>@<mountpoint>, each optionally :ro (hq ADR 0263 rule 6); an SMB source's username is in its entry, its password the secret smb-password-<name> (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 +84,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 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}\npasswords=${dir:state}\nstate=${dir:state}\n"
},
{
"id": "bus-dir-parent",