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 }