Files
mesh-catalog/modules/nfs-server/cmd/nfs-server/machine.go
T

306 lines
9.4 KiB
Go

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, BackupFile: 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
}