Files
mesh-catalog/modules/mounts/cmd/mounts/config.go
T
jochen a12b5908aa
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
mounts: name a malformed entry of sources by its position, never by its name
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).
2026-10-10 21:38:17 +02:00

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"
}