package main import ( "bytes" "context" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "strings" "sync" "syscall" "time" ) // The machine as the module sees it (novox/hq ADR 0263). // // **Two processes, two accounts.** The node's tool runtime runs the tools as the operator account, not // root (novox/hq ADR 0175 §4): they read, and what only root may do — `exportfs -ra`, `zfs set` — goes // through `sudo -n`, as the packet filter's and the service manager's tools do. The module's long-running // process runs as root: it renders the export file and writes the status the tools read. It cannot reach // the bus, so the tools' bundle puts that status in the module's state. // // **It writes two files and nothing else.** The export file and the status file are the module's own. // Nothing here can create, chown, chmod or remove anything under a shared path: the machine offers no // such call, and Write refuses every path but the module's two (novox/hq ADR 0051). // The module's files on the machine. const ( // ConfigFile is what the mesh renders from the assignment: the shares and the private network's range. ConfigFile = "/etc/nfs-server/shares.conf" // ExportFile is the module's own export file, which the distribution's exportfs reads with the rest of // /etc/exports.d. ExportFile = "/etc/exports.d/mesh.exports" // StatusFile is the process's last look, for the tools. StatusFile = "/run/nfs-server/status.json" // Etab is the kernel's export table as nfs-utils keeps it. Etab = "/var/lib/nfs/etab" // Unit is the NFS server's unit. Unit = "nfs-server.service" // RosterFile is the mesh's machines, name and private address, rendered by the mesh (a fact). RosterFile = "/etc/nfs-server/machines" // WantsDir is where the tools, as the operator account, write what they read on the bus. WantsDir = "/var/lib/nfs-server/from-bus" // WantsFile is every node's wants of this machine, from the `mounts` modules' state on the bus. WantsFile = WantsDir + "/wants.json" // MountInfo is the kernel's mount table, read for `mp`. MountInfo = "/proc/self/mountinfo" ) // CommandTimeout bounds every command. None touches a shared path's contents. const CommandTimeout = 20 * time.Second // Ran is what one command did. type Ran struct { Out string Err string Code int } // Runner runs one command; injected, so every tool is tested over recorded answers. type Runner func(ctx context.Context, name string, args ...string) Ran // ExecRunner runs a command on the machine, bounded by CommandTimeout. func ExecRunner(ctx context.Context, name string, args ...string) Ran { ctx, cancel := context.WithTimeout(ctx, CommandTimeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr err := cmd.Run() r := Ran{Out: stdout.String(), Err: strings.TrimSpace(stderr.String())} if err != nil { var exit *exec.ExitError if errors.As(err, &exit) { r.Code = exit.ExitCode() } else { r.Code = -1 if r.Err == "" { r.Err = err.Error() } } } return r } // Owner is who owns a path, and whether it is a directory. type Owner struct { UID, GID int Dir bool } // Machine is a filesystem root (the real one, or a test's tree), a runner, who this process is, and a // clock. type Machine struct { Root string Run Runner UID int Now func() time.Time // WalkLimit bounds the look for a backup repository below a share. WalkLimit int owners map[string]Owner // ownerOf reads a file's owner from its inode; nil is the inode as the kernel says it. Tests set it // so the folders they make stand for a non-root owner, whoever runs them. ownerOf func(info os.FileInfo) (uid, gid int) // clean is when a look below each real path last found no backup repository (LookKept). clean map[string]time.Time cleanMu sync.Mutex } // Here is the machine this process runs on. func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner, UID: os.Getuid(), Now: time.Now} } func (m *Machine) at(path string) string { return filepath.Join(m.Root, path) } // Read is one file's text. func (m *Machine) Read(path string) (string, error) { b, err := os.ReadFile(m.at(path)) return string(b), err } // Exists says whether a path is there. func (m *Machine) Exists(path string) bool { _, err := os.Lstat(m.at(path)) return err == nil } // Stat is a path's owner. It reads the inode and nothing else. func (m *Machine) Stat(path string) (Owner, error) { if o, ok := m.owners[path]; ok { return o, nil } info, err := os.Stat(m.at(path)) if err != nil { return Owner{}, err } o := Owner{Dir: info.IsDir()} if m.ownerOf != nil { o.UID, o.GID = m.ownerOf(info) return o, nil } o.UID, o.GID = inodeOwner(info) return o, nil } // Real is a path with every symlink resolved, as the machine names it. A path that resolves outside the // machine's root is refused. func (m *Machine) Real(p string) (string, error) { root, err := filepath.EvalSymlinks(m.Root) if err != nil { return "", err } full, err := filepath.EvalSymlinks(m.at(p)) if err != nil { return "", err } rel, err := filepath.Rel(root, full) if err != nil || rel == ".." || strings.HasPrefix(rel, "../") { return "", fmt.Errorf("%s resolves outside the machine (%s)", p, full) } return filepath.Clean("/" + rel), nil } // MountPoints are the kernel's mount points, from its mount table; "/" alone when it cannot be read. func (m *Machine) MountPoints() []string { text, err := m.Read(MountInfo) if err != nil { return []string{"/"} } var out []string for _, line := range strings.Split(text, "\n") { f := strings.Fields(line) if len(f) >= 5 { out = append(out, unescapeMount(f[4])) } } return out } // unescapeMount undoes the kernel's octal escapes in a mount point (\040 for a space). func unescapeMount(s string) string { if !strings.Contains(s, "\\") { return s } var b strings.Builder for i := 0; i < len(s); i++ { if s[i] == '\\' && i+3 < len(s) { var v int if _, err := fmt.Sscanf(s[i+1:i+4], "%o", &v); err == nil { b.WriteByte(byte(v)) i += 3 continue } } b.WriteByte(s[i]) } return b.String() } // Children are the names of a directory's subdirectories. Used only to look for a backup repository one // level below a share; it lists names and opens nothing. func (m *Machine) Children(path string) []string { entries, err := os.ReadDir(m.at(path)) if err != nil { return nil } var out []string for _, e := range entries { if e.IsDir() { out = append(out, e.Name()) } } return out } // Glob is the paths under the root matching a pattern, as the machine names them. func (m *Machine) Glob(pattern string) []string { found, _ := filepath.Glob(m.at(pattern)) out := make([]string, 0, len(found)) for _, f := range found { rel, err := filepath.Rel(m.Root, f) if err != nil { continue } out = append(out, "/"+rel) } return out } // written are the only paths Write may touch. var written = map[string]bool{ExportFile: true, StatusFile: true, WantsFile: true} // Write replaces one of the module's own files, atomically. Every other path is refused: the module owns // these three files (the export file and the status, written as root; the wants, written by the tools) // and nothing else on the machine. func (m *Machine) Write(path, content string) error { if !written[path] { return fmt.Errorf("the module writes only %s, %s and %s, not %s", ExportFile, StatusFile, WantsFile, path) } full := m.at(path) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil && !errors.Is(err, fs.ErrExist) { return err } tmp := full + ".tmp" if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { return err } return os.Rename(tmp, full) } // unexport removes the module's own export file, and nothing else: the one removal the module makes, // when it was unassigned (Stopped). func (m *Machine) unexport() error { if err := os.Remove(m.at(ExportFile)); err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("the export file %s could not be removed: %v", ExportFile, err) } return nil } // Escalated is a command as it is run: as given when this process is root, else through sudo without a // prompt. func (m *Machine) Escalated(name string, args ...string) (string, []string) { if m.UID == 0 { return name, args } return "sudo", append([]string{"-n", name}, args...) } // AsRoot runs what only root may do, and names a refusal by how it failed. func (m *Machine) AsRoot(name string, args ...string) (Ran, error) { program, argv := m.Escalated(name, args...) r := m.Run(context.Background(), program, argv...) if r.Code != 0 { why := r.Err if why == "" { why = strings.TrimSpace(r.Out) } if program == "sudo" && (strings.Contains(why, "a password is required") || strings.Contains(why, "not allowed")) { return r, fmt.Errorf("%s needs root, and sudo refused the operator account without a prompt: %s", name, why) } return r, fmt.Errorf("%s %s: exit %d: %s", name, strings.Join(args, " "), r.Code, why) } return r, nil } // ServiceState is what the service manager says of the NFS server: active, inactive, failed, … func (m *Machine) ServiceState() string { r := m.Run(context.Background(), "systemctl", "is-active", Unit) if s := strings.TrimSpace(r.Out); s != "" { return s } return "unknown" } // inodeOwner is a file's owner as its inode says. func inodeOwner(info os.FileInfo) (uid, gid int) { if st, ok := info.Sys().(*syscall.Stat_t); ok { return int(st.Uid), int(st.Gid) } return -1, -1 }