338 lines
12 KiB
Go
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
|
|
}
|