Research 027 asked for cups with the printer's driver. Measured: both Brother queues already print through IPP Everywhere, so cups and cups-filters are the whole driver, and the AUR vendor packages beside them serve no queue. The desktop's Canon is the exception: its 2012 driver is AUR-only and waits for the mesh's package repository; it stays as found. Seven Go tools: printers (state, device, driverless or not, supply levels), queue, cancel (the account first, sudo -n when CUPS refuses it), print, default, resume, and drivers (which packages bring drivers, which are foreign, which no queue uses).
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
|
|
}
|