package main // The commands this bundle runs on its machine, and who runs them. // // Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), // and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — // HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only // root may do goes through `sudo -n`, as the packet filter's, the service manager's and the // intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the // account may (to-be 42, research 027). A refusal is named by how it failed, never read as an // empty answer. // // The runner is injected, so every tool is tested over a fake one without the machine. import ( "bytes" "context" "errors" "fmt" "io/fs" "os" "os/exec" "strings" "time" ) // Ran is what one command did: its output, its exit status, and why it never ran to an answer. type Ran struct { Stdout string Stderr string Status int // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. Err string } // Runner runs one command, so the tools can be tested without the machine. type Runner func(ctx context.Context, name string, args ...string) Ran // CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a // command that hangs is answered as such rather than as a call the runtime gave up on. const CallTimeout = 20 * time.Second // outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the // process; well above anything a tool answers. const outputLimit = 16 << 20 type bounded struct { bytes.Buffer cut bool } func (b *bounded) Write(p []byte) (int, error) { if room := outputLimit - b.Len(); room < len(p) { if room > 0 { b.Buffer.Write(p[:room]) } b.cut = true return len(p), nil } return b.Buffer.Write(p) } // ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. func ExecRunner(ctx context.Context, name string, args ...string) Ran { ctx, cancel := context.WithTimeout(ctx, CallTimeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) cmd.Env = append(os.Environ(), "LC_ALL=C") var out, errb bounded cmd.Stdout, cmd.Stderr = &out, &errb err := cmd.Run() r := Ran{Stdout: out.String(), Stderr: errb.String()} if ctx.Err() == context.DeadlineExceeded { r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) return r } var exit *exec.ExitError switch { case err == nil: case errors.As(err, &exit): r.Status = exit.ExitCode() case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): r.Status, r.Err = 127, "ENOENT" default: r.Status, r.Err = 126, err.Error() } return r } // Escalated is the command as it is run: as given when this process is root, else through sudo // without a prompt. func Escalated(uid int, name string, args ...string) (string, []string) { if uid == 0 { return name, args } return "sudo", append([]string{"-n", name}, args...) } // Machine is this machine as the tools see it: a runner, who this process is, and its files. type Machine struct { Run Runner UID int User string Account string ReadFile func(path string) ([]byte, error) Now func() time.Time Sleep func(time.Duration) } // ThisMachine is the machine the runtime launched this bundle on. func ThisMachine() *Machine { user := os.Getenv("USER") if user == "" { user = os.Getenv("LOGNAME") } account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) if account == "" { account = user } return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} } // Out runs a command that only reads, and fails with what went wrong named. func (m *Machine) Out(name string, args ...string) (string, error) { r := m.Run(context.Background(), name, args...) if r.Status == 0 && r.Err == "" { return r.Stdout, nil } return r.Stdout, failure(name, name, r) } // Root runs a command that needs root, escalated when this process is not. func (m *Machine) Root(name string, args ...string) (string, error) { program, argv := Escalated(m.UID, name, args...) r := m.Run(context.Background(), program, argv...) if r.Status == 0 && r.Err == "" { return r.Stdout, nil } return r.Stdout, failure(name, program, r) } // RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. func (m *Machine) RootRan(name string, args ...string) (Ran, error) { program, argv := Escalated(m.UID, name, args...) r := m.Run(context.Background(), program, argv...) if r.Err != "" || (program == "sudo" && sudoRefused(r)) { return r, failure(name, program, r) } return r, nil } func sudoRefused(r Ran) bool { return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") } // failure names what failed by how it failed: the program missing is a spawn error, sudo missing // or refusing speaks for itself, and the rest is the command's own first line. func failure(cmd, program string, r Ran) error { said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) if r.Err == "ENOENT" { if program == "sudo" { return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) } return fmt.Errorf("%s is not installed on this machine", cmd) } if r.Err != "" { return fmt.Errorf("%s did not answer: %s", cmd, r.Err) } if program == "sudo" && sudoRefused(r) { if strings.Contains(said, "command not found") { return fmt.Errorf("%s is not installed on this machine", cmd) } return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) } if line := firstLine(said); line != "" { return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) } return fmt.Errorf("%s failed with status %d", cmd, r.Status) } func firstLine(text string) string { for _, l := range strings.Split(text, "\n") { if l = strings.TrimSpace(l); l != "" { return l } } return "" } func lines(text string) []string { var out []string for _, l := range strings.Split(text, "\n") { if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { out = append(out, l) } } return out } // text is a string argument; required says whether it may be absent. It is never something a // command would read as an option, which under sudo would be root's option. func text(args map[string]any, key string, required bool) (string, error) { raw, present := args[key] if !present || raw == nil { if required { return "", fmt.Errorf("%s is required", key) } return "", nil } s, ok := raw.(string) if !ok { return "", fmt.Errorf("%s must be a string", key) } s = strings.TrimSpace(s) if required && s == "" { return "", fmt.Errorf("%s is required", key) } if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) } return s, nil } // whole is a whole-number argument with a default, kept within bounds. func whole(args map[string]any, key string, def, least, most int) (int, error) { raw, present := args[key] if !present || raw == nil { return def, nil } f, ok := raw.(float64) if !ok || f != float64(int(f)) { return 0, fmt.Errorf("%s must be a whole number", key) } n := int(f) if n < least { return 0, fmt.Errorf("%s must be at least %d", key, least) } if n > most { n = most } return n, nil } // flag is a boolean argument, false when absent. func flag(args map[string]any, key string) (bool, error) { raw, present := args[key] if !present || raw == nil { return false, nil } b, ok := raw.(bool) if !ok { return false, fmt.Errorf("%s must be true or false", key) } return b, nil } // schema is a tool's input: its properties and the ones it requires. func schema(properties map[string]any, required ...string) map[string]any { s := map[string]any{"type": "object", "properties": properties} if len(required) > 0 { s["required"] = required } return s } // unitProps reads a unit's properties as systemctl shows them. func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { args := []string{"show", unit, "--no-pager"} for _, p := range props { args = append(args, "--property="+p) } out, err := m.Out("systemctl", args...) if err != nil { return nil, err } return keyValues(out, "="), nil } // keyValues reads `keyvalue` lines; a line without the separator is skipped. func keyValues(out, sep string) map[string]string { kv := map[string]string{} for _, l := range strings.Split(out, "\n") { k, v, ok := strings.Cut(l, sep) if ok { kv[strings.TrimSpace(k)] = strings.TrimSpace(v) } } return kv }