Files
mesh-catalog/modules/mounts/cmd/mounts/config.go
T

338 lines
12 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
}
// ParseSMBCredentials reads the secret `smb-credentials`: one section per source,
//
// [name]
// username=…
// password=…
// domain=… (optional)
//
// Its values are never answered by any tool; a problem names the section and the key, never the value.
func ParseSMBCredentials(text string) (map[string]SMBCredential, []string) {
out := map[string]SMBCredential{}
var problems []string
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, "[") && strings.HasSuffix(line, "]") {
section = strings.TrimSpace(line[1 : len(line)-1])
if _, dup := out[section]; dup {
problems = append(problems, fmt.Sprintf("the section [%s] is given twice; the first is kept", section))
section = ""
continue
}
out[section] = SMBCredential{}
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok || section == "" {
problems = append(problems, fmt.Sprintf("line %d is not key=value inside a [section]", i+1))
continue
}
c := out[section]
switch strings.ToLower(strings.TrimSpace(k)) {
case "username", "user":
c.Username = strings.TrimSpace(v)
case "password", "pass":
c.Password = strings.TrimRight(v, "\r")
case "domain":
c.Domain = strings.TrimSpace(v)
default:
problems = append(problems, fmt.Sprintf("[%s] has a key %q this module does not read", section, strings.TrimSpace(k)))
}
out[section] = c
}
for name, c := range out {
if c.Username == "" {
problems = append(problems, fmt.Sprintf("[%s] gives no username", 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
}
// 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
}