Research 026/04 counted two plain dmenu calls failing with dmenu installed nowhere. Measured, they are one line on each workstation: dunst's `dmenu = /usr/bin/dmenu -p dunst:`. Installing the package fixes both by existing; the line stays the dunst module's. No claim: node-launcher is not in the controller's seat table yet, and the module says so. Two Go tools: menu, shaped like that seat's verb (chosen line, index, typed, cancelled, timed out within 25 s), and session.
353 lines
9.8 KiB
Go
353 lines
9.8 KiB
Go
package main
|
|
|
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
|
// than shared; a change to one copy is made to all eight.
|
|
//
|
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
|
// started when it takes longer;
|
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Bounds every command is held to.
|
|
const (
|
|
CallTimeout = 20 * time.Second
|
|
MostOutput = 256 << 10
|
|
)
|
|
|
|
// Cmd is one command a tool runs.
|
|
type Cmd struct {
|
|
Name string
|
|
Args []string
|
|
// Stdin is written to the command's standard input when not empty.
|
|
Stdin string
|
|
// Env is added to this process's own environment.
|
|
Env []string
|
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
|
Root bool
|
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
|
Timeout time.Duration
|
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
|
// until the child exits.
|
|
Detached bool
|
|
}
|
|
|
|
// Result is what a command did.
|
|
type Result struct {
|
|
Stdout string `json:"stdout"`
|
|
Stderr string `json:"stderr"`
|
|
Status int `json:"status"`
|
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
|
Error string `json:"error,omitempty"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
}
|
|
|
|
// Runner runs a command. Tests replace it; nothing else does.
|
|
type Runner func(Cmd) Result
|
|
|
|
var (
|
|
run Runner = execRun
|
|
euid = os.Geteuid
|
|
)
|
|
|
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
|
// process is not root.
|
|
func argv(c Cmd) (string, []string) {
|
|
if c.Root && euid() != 0 {
|
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
|
}
|
|
return c.Name, c.Args
|
|
}
|
|
|
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
|
type bounded struct {
|
|
b bytes.Buffer
|
|
cut bool
|
|
}
|
|
|
|
func (w *bounded) Write(p []byte) (int, error) {
|
|
room := MostOutput - w.b.Len()
|
|
if room <= 0 {
|
|
w.cut = w.cut || len(p) > 0
|
|
return len(p), nil
|
|
}
|
|
if len(p) > room {
|
|
w.b.Write(p[:room])
|
|
w.cut = true
|
|
return len(p), nil
|
|
}
|
|
return w.b.Write(p)
|
|
}
|
|
|
|
func execRun(c Cmd) Result {
|
|
timeout := c.Timeout
|
|
if timeout <= 0 {
|
|
timeout = CallTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
name, args := argv(c)
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
|
if !c.Detached {
|
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
cmd.Cancel = func() error {
|
|
if cmd.Process != nil {
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
cmd.WaitDelay = 2 * time.Second
|
|
if c.Stdin != "" {
|
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
|
}
|
|
var out, errs bounded
|
|
var outFile, errFile *os.File
|
|
if c.Detached {
|
|
var err error
|
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
|
return Result{Status: 127, Error: err.Error()}
|
|
}
|
|
defer os.Remove(outFile.Name())
|
|
defer outFile.Close()
|
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
|
return Result{Status: 127, Error: err.Error()}
|
|
}
|
|
defer os.Remove(errFile.Name())
|
|
defer errFile.Close()
|
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
|
} else {
|
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
|
}
|
|
err := cmd.Run()
|
|
if c.Detached {
|
|
for _, f := range []struct {
|
|
file *os.File
|
|
into *bounded
|
|
}{{outFile, &out}, {errFile, &errs}} {
|
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
|
_, _ = io.Copy(f.into, f.file)
|
|
}
|
|
}
|
|
}
|
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
|
var exit *exec.ExitError
|
|
switch {
|
|
case err == nil:
|
|
case ctx.Err() == context.DeadlineExceeded:
|
|
r.Status, r.Error = 124, "timeout"
|
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
|
r.Status, r.Error = 127, "not-found"
|
|
case errors.As(err, &exit):
|
|
r.Status = exit.ExitCode()
|
|
default:
|
|
r.Status, r.Error = 127, err.Error()
|
|
}
|
|
return r
|
|
}
|
|
|
|
// call runs a command and answers its result, or an error naming what went wrong.
|
|
func call(c Cmd) (Result, error) {
|
|
r := run(c)
|
|
if r.Status == 0 && r.Error == "" {
|
|
return r, nil
|
|
}
|
|
return r, failure(c, r)
|
|
}
|
|
|
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
|
// status with the end of what it said.
|
|
func failure(c Cmd, r Result) error {
|
|
program, _ := argv(c)
|
|
switch {
|
|
case r.Error == "not-found" && program == "sudo":
|
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
|
case r.Error == "not-found":
|
|
if hint, ok := providedBy[c.Name]; ok {
|
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
|
}
|
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
|
case r.Error == "timeout":
|
|
limit := c.Timeout
|
|
if limit <= 0 {
|
|
limit = CallTimeout
|
|
}
|
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
|
case r.Error != "":
|
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
|
if hint, ok := providedBy[c.Name]; ok {
|
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
|
}
|
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
|
c.Name, firstLine(r.Stderr))
|
|
}
|
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
|
if said == "" {
|
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
|
}
|
|
if said == "" {
|
|
said = "and said nothing"
|
|
}
|
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
return s[:i]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func tail(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return "…" + s[len(s)-n:]
|
|
}
|
|
|
|
// lines are a command's output lines, blank ones dropped.
|
|
func lines(s string) []string {
|
|
out := []string{}
|
|
for _, l := range strings.Split(s, "\n") {
|
|
if strings.TrimSpace(l) != "" {
|
|
out = append(out, strings.TrimRight(l, "\r"))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Arguments, read the way a tool's JSON arguments arrive.
|
|
|
|
func text(args map[string]any, key string) (string, error) {
|
|
v, ok := args[key]
|
|
if !ok || v == nil {
|
|
return "", fmt.Errorf("%s is required", key)
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("%s must be a string", key)
|
|
}
|
|
if strings.TrimSpace(s) == "" {
|
|
return "", fmt.Errorf("%s must not be empty", key)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func optText(args map[string]any, key, def string) (string, error) {
|
|
v, ok := args[key]
|
|
if !ok || v == nil {
|
|
return def, nil
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("%s must be a string", key)
|
|
}
|
|
if strings.TrimSpace(s) == "" {
|
|
return def, nil
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
|
v, ok := args[key]
|
|
if !ok || v == nil {
|
|
return def, nil
|
|
}
|
|
f, ok := v.(float64)
|
|
if !ok {
|
|
if i, isInt := v.(int); isInt {
|
|
f = float64(i)
|
|
} else {
|
|
return 0, fmt.Errorf("%s must be a number", key)
|
|
}
|
|
}
|
|
if 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
|
|
}
|
|
|
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
|
v, ok := args[key]
|
|
if !ok || v == nil {
|
|
return def, nil
|
|
}
|
|
b, ok := v.(bool)
|
|
if !ok {
|
|
return false, fmt.Errorf("%s must be true or false", key)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func optList(args map[string]any, key string) ([]string, error) {
|
|
v, ok := args[key]
|
|
if !ok || v == nil {
|
|
return nil, nil
|
|
}
|
|
items, ok := v.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
|
}
|
|
out := make([]string, 0, len(items))
|
|
for _, it := range items {
|
|
s, ok := it.(string)
|
|
if !ok || strings.TrimSpace(s) == "" {
|
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// oneOf refuses a value outside a closed set.
|
|
func oneOf(key, value string, allowed ...string) error {
|
|
for _, a := range allowed {
|
|
if value == a {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
|
}
|
|
|
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
|
// snap, application and printer names never do.
|
|
func plainName(key, value string) error {
|
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
|
}
|
|
return nil
|
|
}
|