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 delivered
A password holding '=' and a space or comma splits the entry, and the tail read as name=... was said as "the source <tail>", carrying part of the secret into the health answer and the operator's conditions (review of mesh-catalog#190, issue 386).
345 lines
14 KiB
Go
345 lines
14 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: 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.
|
|
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"`
|
|
// 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.
|
|
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"`
|
|
// 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"`
|
|
}
|
|
|
|
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{}
|
|
// **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 || !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, malformed(i, "it names no mount point (…@<mountpoint>)"))
|
|
continue
|
|
}
|
|
where, pointRO := rest[:at], rest[at+1:]
|
|
point, ro := readOnly(pointRO)
|
|
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://"):
|
|
user, host, share, problem := parseSMB(strings.TrimPrefix(where, "smb://"))
|
|
if problem != "" {
|
|
problems = append(problems, malformed(i, problem))
|
|
continue
|
|
}
|
|
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, malformed(i, "it is not disk:<filesystem uuid>"))
|
|
continue
|
|
}
|
|
s.Kind, s.UUID = "disk", uuid
|
|
default:
|
|
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 is named twice", name))
|
|
continue
|
|
}
|
|
seen[name], seen["@"+point] = true, true
|
|
out = append(out, s)
|
|
}
|
|
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{}
|
|
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.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))
|
|
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 where the share %s already does", s.Name, 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: 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
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// PasswordFile is where the mesh places a source's password, in the directory the config names.
|
|
func PasswordFile(dir, source string) string {
|
|
return filepath.Join(dir, PasswordSecret(source)+".secret")
|
|
}
|
|
|
|
// userRe is a username mount.cifs takes on its own line: no space, no `=`, no line break, nothing that
|
|
// would end the line early.
|
|
var userRe = regexp.MustCompile(`^[^\s=,]{1,64}$`)
|
|
|
|
// 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
|
|
}
|
|
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
|
|
// 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 {
|
|
return "username=" + c.Username + "\npassword=" + c.Password + "\n"
|
|
}
|