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-, 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-.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://@/`), 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:///@` or // `name=disk:@`, 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=@, 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 (…@)")) 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:")) continue } s.Kind, s.UUID = "disk", uuid default: problems = append(problems, malformed(i, "it is neither smb://[@]/ nor disk:")) 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-` (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 mounts smb-password-`). 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: `[@]/`. 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://[@]/, 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-, 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" }