mesh/merge-gate pass: builds mounts → shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1791663540935383995 (novox/mesh-catalog at 7534167f into main,…
The secret smb-credentials was an INI file alone, which the desk's one-line prompt (secret ask, hq ADR 0277) cannot type: on the desktop the Pi's source said "line 1 is not key=value inside a [section]". A login is now also one line, `[recalbox] username=root password=…`, password last and to the end of the line so it may hold spaces and `=`; the file form still reads, and a file may mix both. A line the module cannot read is refused with the form it expected, never with what was typed, and a login without a username is not answered. The source's "login is not given" says the desk verb and the line to type.
437 lines
16 KiB
Go
437 lines
16 KiB
Go
package main
|
|
|
|
// What this machine is asked to mount (novox/hq ADR 0263, to-be 52 §2), and where the module keeps
|
|
// what it learns.
|
|
//
|
|
// 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.
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Where the module keeps its things. ConfigFile is placed by the mesh; the rest by the module's code.
|
|
const (
|
|
ConfigFile = "/etc/mesh-mounts/mounts.conf"
|
|
// BusDir is written by the bundle, which runs as the operator account and reaches the bus; the
|
|
// process, which runs as root and does not, reads what the sharing machine said from it.
|
|
BusDir = "/var/lib/mesh-mounts/from-bus"
|
|
ExportsFile = BusDir + "/exports.json"
|
|
// RunDir is the process's: what it found on its last look, which the tools and the bundle read.
|
|
RunDir = "/run/mesh-mounts"
|
|
StatusFile = RunDir + "/status.json"
|
|
// UnitDir is where the process writes each unit pair, beside the operator's own units.
|
|
UnitDir = "/etc/systemd/system"
|
|
)
|
|
|
|
// None is the value of an empty setting: a definition's default may not be empty (ADR 0262).
|
|
const None = "none"
|
|
|
|
// Config is the rendered file, read.
|
|
type Config struct {
|
|
Shares []Wanted `json:"shares"`
|
|
Sources []Source `json:"sources"`
|
|
// Server is the machine sharing the provision `nfs-share` (the binding's `from`), and At where it is
|
|
// 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"`
|
|
// 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.
|
|
Problems []string `json:"problems,omitempty"`
|
|
// Unreadable are the entries of shares or sources that could not be read. While there is one, no
|
|
// pair is removed: an entry the operator mistyped names a mount point nobody can tell, so the
|
|
// module cannot tell which of its pairs it still names (review of 2026-10-08, finding 8).
|
|
Unreadable []string `json:"unreadable,omitempty"`
|
|
}
|
|
|
|
// Wanted is one NFS share this machine mounts: the share's name on the sharing machine, and where.
|
|
type Wanted struct {
|
|
Name string `json:"name"`
|
|
Mountpoint string `json:"mountpoint"`
|
|
ReadOnly bool `json:"read_only"`
|
|
}
|
|
|
|
// 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"`
|
|
UUID string `json:"uuid,omitempty"`
|
|
Mountpoint string `json:"mountpoint"`
|
|
ReadOnly bool `json:"read_only"`
|
|
}
|
|
|
|
var (
|
|
nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
|
hostRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$`)
|
|
shareRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._$-]{0,79}$`)
|
|
uuidRe = regexp.MustCompile(`^[A-Za-z0-9-]{4,64}$`)
|
|
)
|
|
|
|
// CheckMountpoint refuses a mount point the module may not use: not absolute, not clean, the root, or
|
|
// under a directory the system owns.
|
|
func CheckMountpoint(p string) error {
|
|
switch {
|
|
case !strings.HasPrefix(p, "/"):
|
|
return fmt.Errorf("the mount point %q is not an absolute path", p)
|
|
case filepath.Clean(p) != p:
|
|
return fmt.Errorf("the mount point %q is not written plainly (it would be %q)", p, filepath.Clean(p))
|
|
case strings.ContainsAny(p, " \t\n\\\"'"):
|
|
return fmt.Errorf("the mount point %q holds a space, a quote or a backslash", p)
|
|
}
|
|
for _, sys := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/proc", "/sys", "/dev", "/run", "/var", "/home", "/root", "/tmp"} {
|
|
if p == sys {
|
|
return fmt.Errorf("the mount point %q is the system's own directory", p)
|
|
}
|
|
}
|
|
for _, sys := range []string{"/etc/", "/usr/", "/bin/", "/sbin/", "/lib/", "/boot/", "/proc/", "/sys/", "/dev/", "/run/"} {
|
|
if strings.HasPrefix(p, sys) {
|
|
return fmt.Errorf("the mount point %q is under %s, which is the system's", p, strings.TrimSuffix(sys, "/"))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func entries(v string) []string {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" || v == None {
|
|
return nil
|
|
}
|
|
return strings.FieldsFunc(v, func(r rune) bool { return r == ' ' || r == ',' || r == '\n' || r == '\t' })
|
|
}
|
|
|
|
func readOnly(s string) (string, bool) {
|
|
if strings.HasSuffix(s, ":ro") {
|
|
return strings.TrimSuffix(s, ":ro"), true
|
|
}
|
|
return strings.TrimSuffix(s, ":rw"), false
|
|
}
|
|
|
|
// ParseShares reads the setting `shares`: space-separated `name=mountpoint`, each optionally `:ro`.
|
|
// What cannot be read is said, and only that entry is left out.
|
|
func ParseShares(v string) ([]Wanted, []string) {
|
|
var out []Wanted
|
|
var problems []string
|
|
seen := map[string]bool{}
|
|
for _, e := range entries(v) {
|
|
name, rest, ok := strings.Cut(e, "=")
|
|
if !ok {
|
|
problems = append(problems, fmt.Sprintf("the share %q is not name=mountpoint", e))
|
|
continue
|
|
}
|
|
point, ro := readOnly(rest)
|
|
if !nameRe.MatchString(name) {
|
|
problems = append(problems, fmt.Sprintf("the share name %q is not lower-case letters, digits and dashes", name))
|
|
continue
|
|
}
|
|
if err := CheckMountpoint(point); err != nil {
|
|
problems = append(problems, fmt.Sprintf("the share %s: %v", name, err))
|
|
continue
|
|
}
|
|
if seen[name] || seen["@"+point] {
|
|
problems = append(problems, fmt.Sprintf("the share %s or its mount point %s is named twice", name, point))
|
|
continue
|
|
}
|
|
seen[name], seen["@"+point] = true, true
|
|
out = append(out, Wanted{Name: name, Mountpoint: point, ReadOnly: ro})
|
|
}
|
|
return out, problems
|
|
}
|
|
|
|
// ParseSources reads the setting `sources`: space-separated `name=smb://<host>/<share>@<mountpoint>` or
|
|
// `name=disk:<uuid>@<mountpoint>`, each optionally `:ro`.
|
|
func ParseSources(v string) ([]Source, []string) {
|
|
var out []Source
|
|
var problems []string
|
|
seen := map[string]bool{}
|
|
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))
|
|
continue
|
|
}
|
|
if !nameRe.MatchString(name) {
|
|
problems = append(problems, fmt.Sprintf("the source name %q is not lower-case letters, digits and dashes", name))
|
|
continue
|
|
}
|
|
at := strings.LastIndex(rest, "@")
|
|
if at < 0 {
|
|
problems = append(problems, fmt.Sprintf("the source %s names no mount point (…@<mountpoint>)", name))
|
|
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))
|
|
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))
|
|
continue
|
|
}
|
|
s.Kind, s.Host, s.Share = "smb", 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))
|
|
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))
|
|
continue
|
|
}
|
|
if seen[name] || seen["@"+point] {
|
|
problems = append(problems, fmt.Sprintf("the source %s or its mount point %s is named twice", name, point))
|
|
continue
|
|
}
|
|
seen[name], seen["@"+point] = true, true
|
|
out = append(out, s)
|
|
}
|
|
return out, problems
|
|
}
|
|
|
|
// ParseConfig reads the rendered file: `key=value` lines.
|
|
func ParseConfig(text string) Config {
|
|
kv := map[string]string{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
k, v, ok := strings.Cut(line, "=")
|
|
if ok {
|
|
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
var c Config
|
|
var p []string
|
|
c.Shares, p = ParseShares(kv["shares"])
|
|
c.Problems = append(c.Problems, p...)
|
|
c.Unreadable = append(c.Unreadable, p...)
|
|
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.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))
|
|
c.At = ""
|
|
}
|
|
points := map[string]string{}
|
|
for _, w := range c.Shares {
|
|
points[w.Mountpoint] = w.Name
|
|
}
|
|
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))
|
|
continue
|
|
}
|
|
kept = append(kept, s)
|
|
}
|
|
c.Sources = kept
|
|
return c
|
|
}
|
|
|
|
// ServerNode is the sharing machine, the key of its exports on the bus: the binding's `from`, else the first label of `at`.
|
|
func (c Config) ServerNode() string {
|
|
if c.Server != "" {
|
|
return c.Server
|
|
}
|
|
if c.At == "" {
|
|
return ""
|
|
}
|
|
first, _, _ := strings.Cut(c.At, ".")
|
|
return first
|
|
}
|
|
|
|
// SMBCredential is one source's login, from the module's own secret.
|
|
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=…"
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return true
|
|
}
|
|
|
|
// oneLineLogin is a login read from the one-line form, and whether a key in it is one this module does
|
|
// not read (said by the caller; the key itself is never said).
|
|
type oneLineLogin struct {
|
|
SMBCredential
|
|
unknownKey bool
|
|
}
|
|
|
|
// readOneLineLogin reads what follows `[name]` on a line: space-separated key=value pairs, where
|
|
// `password=` takes everything after it to the end of the line and so is last. It is not ok when a
|
|
// word is not key=value: that word is never said, as it may be the password typed without its key.
|
|
func readOneLineLogin(rest string) (oneLineLogin, bool) {
|
|
var c oneLineLogin
|
|
for {
|
|
rest = strings.TrimLeft(rest, " \t")
|
|
if rest == "" {
|
|
return c, true
|
|
}
|
|
word := rest
|
|
if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
|
|
word = rest[:sp]
|
|
}
|
|
k, v, ok := strings.Cut(word, "=")
|
|
key := strings.ToLower(strings.TrimSpace(k))
|
|
if !ok || key == "" {
|
|
return oneLineLogin{}, false
|
|
}
|
|
switch key {
|
|
case "password", "pass":
|
|
// To the end of the line: a password may hold spaces and `=`.
|
|
c.read(key, rest[len(k)+1:])
|
|
return c, true
|
|
}
|
|
if !c.read(key, v) {
|
|
c.unknownKey = true
|
|
}
|
|
rest = rest[len(word):]
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|