One machine ran another time zone and a German console keymap with no record why. The module writes /etc/locale.conf and /etc/vconsole.conf whole and sets the zone through a run-once step of its own Go binary (timedatectl, read back): /etc/localtime is a link the mesh may not write (hq ADR 0012) and a module may not declare an action (ADR 0005). Tools: localization_get, _time_zone, _locales, _keymaps (to-be 42 Phase 1).
289 lines
8.7 KiB
Go
289 lines
8.7 KiB
Go
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
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
|
|
// 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 `key<sep>value` 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
|
|
}
|