mounts: take an SMB source's username from its entry of sources, not a setting of its own

The live module check refused smb-users: a key naming a user is the
operator's value and may have no default, and a setting without one would
refuse to compose mounts on every node until set. The username now goes in
the source's URL (smb://<user>@<host>/<share>), percent-decoded; a
password there is refused unsaid, and a source without a user waits for
sources (hq ADR 0283, issue 386).
This commit is contained in:
jochen
2026-10-10 21:30:31 +02:00
parent c620173966
commit 6f78899827
8 changed files with 117 additions and 89 deletions
+47 -40
View File
@@ -6,11 +6,12 @@ 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: an SMB source's username is the
// setting smb-users, and its password the module's own secret smb-password-<source>, in a file only root reads.
// 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"
@@ -41,13 +42,12 @@ type Config struct {
// reached on the private network (the binding's `at`).
Server string `json:"server,omitempty"`
At string `json:"at,omitempty"`
// SMBUsers is the setting `smb-users`: each SMB source's username. Passwords is the directory where
// the mesh places each source's password, `smb-password-<source>.secret` (ADR 0283); State is the
// module's state directory, where the per-source credentials files are written.
SMBUsers map[string]string `json:"smb_users,omitempty"`
Passwords string `json:"passwords,omitempty"`
State string `json:"state,omitempty"`
Account string `json:"account,omitempty"`
// 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.
@@ -67,10 +67,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"`
@@ -162,7 +165,11 @@ func ParseSources(v string) ([]Source, []string) {
for _, 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))
if strings.Contains(e, "://") {
problems = append(problems, "an entry of sources is not name=<where>@<mountpoint>")
} else {
problems = append(problems, fmt.Sprintf("the source %q is not name=<where>@<mountpoint>", e))
}
continue
}
if !nameRe.MatchString(name) {
@@ -183,12 +190,13 @@ func ParseSources(v string) ([]Source, []string) {
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))
// Nothing of an SMB entry is said back: it may carry a password where the user goes.
user, host, share, problem := parseSMB(strings.TrimPrefix(where, "smb://"))
if problem != "" {
problems = append(problems, fmt.Sprintf("the source %s: %s", name, 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) {
@@ -231,8 +239,6 @@ func ParseConfig(text string) Config {
c.Sources, p = ParseSources(kv["sources"])
c.Problems = append(c.Problems, p...)
c.Unreadable = append(c.Unreadable, p...)
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
@@ -270,7 +276,7 @@ func (c Config) ServerNode() string {
return first
}
// SMBCredential is one source's login: the username from the setting `smb-users`, the password from the
// 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
@@ -290,27 +296,28 @@ func PasswordFile(dir, source string) string {
// would end the line early.
var userRe = regexp.MustCompile(`^[^\s=,]{1,64}$`)
// ParseSMBUsers reads the setting `smb-users`: space-separated `name=username`, one per SMB source. Not a
// secret, so it is a setting (ADR 0283). What cannot be read is said, and only that entry is left out.
func ParseSMBUsers(v string) (map[string]string, []string) {
out := map[string]string{}
var problems []string
for _, e := range entries(v) {
name, user, ok := strings.Cut(e, "=")
switch {
case !ok:
problems = append(problems, fmt.Sprintf("the smb-users entry %q is not name=username", e))
case !nameRe.MatchString(name):
problems = append(problems, fmt.Sprintf("the smb-users name %q is not lower-case letters, digits and dashes", name))
case !userRe.MatchString(user):
problems = append(problems, fmt.Sprintf("the smb-users entry for %s is not a username without spaces, commas or =", name))
case out[name] != "":
problems = append(problems, fmt.Sprintf("the source %s is named twice in smb-users; the first is kept", name))
default:
out[name] = user
// 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>"
if at := strings.LastIndex(body, "@"); at >= 0 {
raw := body[:at]
body = body[at+1:]
if strings.Contains(raw, ":") {
return "", "", "", "its entry carries a password in the URL; a password is given as the secret smb-password-<source>, never in a setting"
}
decoded, err := url.PathUnescape(raw)
if err != nil || !userRe.MatchString(decoded) {
return "", "", "", "its user is not a username without spaces, commas or = (percent-encode a \\ or an @ in it)"
}
user = decoded
}
return out, problems
host, share, ok := strings.Cut(body, "/")
if !ok || !hostRe.MatchString(host) || !shareRe.MatchString(share) {
return "", "", "", form
}
return user, host, share, ""
}
// ReadPassword reads a source's password secret as one line, as typed at the desk's hidden prompt (ADR