diff --git a/modules/bluetooth/README.md b/modules/bluetooth/README.md new file mode 100644 index 0000000..08c6a71 --- /dev/null +++ b/modules/bluetooth/README.md @@ -0,0 +1,53 @@ +# bluetooth + +Bluetooth on the two workstations (novox/hq research 027/02, to-be 42 phase 2 step 9). + +## Owns + +| what | where | +|---|---| +| the Bluetooth stack and its daemon | package `bluez` | +| `bluetoothctl`, which the tools speak through | package `bluez-utils` | +| the daemon, running and enabled | `bluetooth.service` | + +All official. `/etc/bluetooth/main.conf` is the package's file, unchanged on both workstations +(every setting commented out). The module states nothing in it, so it declares nothing there. + +## Improves + +- **The stack is declared, not a dependency of something else.** On both workstations `bluez` is + installed only as a dependency. Removing the applet that pulled it in would have left it an orphan + for the next clean-up to take, and Bluetooth with it. +- **An owner for the daemon**, running and enabled on both today with nothing recording why. +- **Headphones from the mesh.** `bluetooth_connect` and `bluetooth_devices` (with battery) answer from + any machine, without the applet. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. bluez's bus policy lets +the account act; if it ever refuses (`AccessDenied`), the act is repeated through `sudo -n`. An act +whose output says it failed (`Failed to …`, `org.bluez.Error…`, `not available`) is an error, whatever +bluetoothctl's exit status. + +| tool | what | +|---|---| +| `bluetooth_controller` (r) | address, name, powered, discoverable, pairable, discovering | +| `bluetooth_power` (r/a) | read, or switch the controller on or off | +| `bluetooth_devices` (r) | all, paired, connected or trusted devices: kind, paired, bonded, trusted, blocked, connected, battery where reported | +| `bluetooth_scan` (r) | discover for 1 to 15 s (default 8); the unpaired devices found, strongest signal first | +| `bluetooth_connect` / `bluetooth_disconnect` (a) | one device; connect waits up to 15 s | +| `bluetooth_trust` (a) | trust, or untrust | +| `bluetooth_pair` (a) | pair with an agent that confirms nothing (headphones, speakers), then trust. A device that shows a code is paired from the desktop | +| `bluetooth_remove` (a) | forget a device | + +## What changes when it is assigned + +Nothing on disk on either workstation: both packages are installed, and the service is enabled and +running. `bluez` becomes explicitly the mesh's. + +## Leaves as found + +- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state). +- `blueman` on both workstations, and its applet, which the window manager's configuration starts. + That line is the `i3` module's to keep or drop. +- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop. diff --git a/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go b/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go new file mode 100644 index 0000000..3022882 --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go @@ -0,0 +1,281 @@ +package main + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var macAddress = regexp.MustCompile(`^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$`) + +func addressOf(args map[string]any) (string, error) { + a, err := text(args, "address") + if err != nil { + return "", err + } + if !macAddress.MatchString(a) { + return "", fmt.Errorf("%q is not a Bluetooth address (six hex pairs separated by colons)", a) + } + return strings.ToUpper(a), nil +} + +var ansi = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\x01|\x02`) + +// btFailed are the words bluetoothctl uses for an act that did not happen, whatever its exit status. +var btFailed = regexp.MustCompile(`(?m)(Failed to \w+|not available|org\.bluez\.Error\.\w+|No default controller available)`) + +// bt runs bluetoothctl once, non-interactively, as the account; bluez's bus policy lets the +// account act, and if it refuses, the act is run through sudo -n. +func bt(timeout time.Duration, args ...string) (string, error) { + c := Cmd{Name: "bluetoothctl", Args: args, Timeout: timeout} + r := run(c) + if strings.Contains(r.Stdout+r.Stderr, "AccessDenied") || strings.Contains(r.Stdout+r.Stderr, "Not authorized") { + c.Root = true + r = run(c) + } + out := ansi.ReplaceAllString(r.Stdout+"\n"+r.Stderr, "") + if r.Error != "" { + return out, failure(c, r) + } + if strings.Contains(out, "No default controller available") { + return out, fmt.Errorf("this machine has no Bluetooth controller bluez can use: none is present, it is blocked (rfkill), or bluetooth.service is not running") + } + if m := btFailed.FindString(out); m != "" || r.Status != 0 { + said := strings.TrimSpace(out) + if said == "" { + said = fmt.Sprintf("exit status %d", r.Status) + } + return out, fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), tail(said, 1000)) + } + return out, nil +} + +// fields reads bluetoothctl's "\tKey: value" lines; a key seen twice keeps its first value. +func fields(s string) map[string]string { + out := map[string]string{} + for _, l := range strings.Split(s, "\n") { + if !strings.HasPrefix(l, "\t") { + continue + } + k, v, ok := strings.Cut(strings.TrimSpace(l), ":") + if !ok { + continue + } + if _, seen := out[k]; !seen { + out[k] = strings.TrimSpace(v) + } + } + return out +} + +func yes(v string) bool { return v == "yes" } + +// ControllerAnswer is what bluetooth_controller answers. +type ControllerAnswer struct { + Address string `json:"address"` + Name string `json:"name"` + Alias string `json:"alias"` + Powered bool `json:"powered"` + PowerState string `json:"power_state,omitempty"` + Discoverable bool `json:"discoverable"` + Pairable bool `json:"pairable"` + Discovering bool `json:"discovering"` +} + +// Controller answers the default controller. +func Controller() (ControllerAnswer, error) { + out, err := bt(CallTimeout, "show") + if err != nil { + return ControllerAnswer{}, err + } + c := ControllerAnswer{} + for _, l := range strings.Split(out, "\n") { + if f := strings.Fields(l); len(f) >= 2 && f[0] == "Controller" { + c.Address = f[1] + break + } + } + if c.Address == "" { + return ControllerAnswer{}, fmt.Errorf("bluetoothctl show answered no controller: %s", tail(strings.TrimSpace(out), 500)) + } + f := fields(out) + c.Name, c.Alias, c.PowerState = f["Name"], f["Alias"], f["PowerState"] + c.Powered, c.Discoverable, c.Pairable, c.Discovering = yes(f["Powered"]), yes(f["Discoverable"]), yes(f["Pairable"]), yes(f["Discovering"]) + return c, nil +} + +// Power switches the controller on or off. +func Power(on bool) (map[string]any, error) { + word := "off" + if on { + word = "on" + } + if _, err := bt(CallTimeout, "power", word); err != nil { + return nil, err + } + c, err := Controller() + if err != nil { + return nil, err + } + return map[string]any{"powered": c.Powered, "asked": word}, nil +} + +// Device is one device bluez knows. +type Device struct { + Address string `json:"address"` + Name string `json:"name"` + Icon string `json:"kind,omitempty"` + Paired bool `json:"paired"` + Bonded bool `json:"bonded"` + Trusted bool `json:"trusted"` + Blocked bool `json:"blocked"` + Connected bool `json:"connected"` + Battery *int `json:"battery_percent,omitempty"` + RSSI *int `json:"rssi,omitempty"` +} + +var inParens = regexp.MustCompile(`\((-?[0-9]+)\)`) + +// number reads "0x50 (80)" or "-62" as a number. +func number(v string) *int { + if m := inParens.FindStringSubmatch(v); m != nil { + v = m[1] + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return nil + } + return &n +} + +// deviceInfo asks bluez for one device. +func deviceInfo(address string) (Device, error) { + out, err := bt(CallTimeout, "info", address) + if err != nil { + return Device{}, err + } + f := fields(out) + d := Device{Address: address, Name: f["Name"], Icon: f["Icon"], Paired: yes(f["Paired"]), Bonded: yes(f["Bonded"]), + Trusted: yes(f["Trusted"]), Blocked: yes(f["Blocked"]), Connected: yes(f["Connected"])} + if d.Name == "" { + d.Name = f["Alias"] + } + if v, ok := f["Battery Percentage"]; ok { + d.Battery = number(v) + } + if v, ok := f["RSSI"]; ok { + d.RSSI = number(v) + } + return d, nil +} + +// listed reads "Device
" lines. +func listed(out string) []string { + seen := map[string]bool{} + addrs := []string{} + for _, l := range strings.Split(out, "\n") { + f := strings.Fields(strings.TrimSpace(l)) + if len(f) >= 2 && f[0] == "Device" && macAddress.MatchString(f[1]) && !seen[f[1]] { + seen[f[1]] = true + addrs = append(addrs, f[1]) + } + } + return addrs +} + +// Devices answers the devices bluez knows, with each one's state. +func Devices(which string) (map[string]any, error) { + if err := oneOf("which", which, "all", "paired", "connected", "trusted"); err != nil { + return nil, err + } + args := []string{"devices"} + if which != "all" { + args = append(args, strings.ToUpper(which[:1])+which[1:]) + } + out, err := bt(CallTimeout, args...) + if err != nil { + return nil, err + } + devices := []Device{} + for _, a := range listed(out) { + d, err := deviceInfo(a) + if err != nil { + return nil, err + } + devices = append(devices, d) + } + sort.SliceStable(devices, func(i, k int) bool { + if devices[i].Connected != devices[k].Connected { + return devices[i].Connected + } + return devices[i].Name < devices[k].Name + }) + return map[string]any{"which": which, "count": len(devices), "devices": devices}, nil +} + +// Scan discovers for a while and answers the devices found that are not paired. +func Scan(seconds int) (map[string]any, error) { + // bluetoothctl's own --timeout ends the scan; the command's bound is a little longer. + limit := time.Duration(seconds+4) * time.Second + if _, err := bt(limit, "--timeout", strconv.Itoa(seconds), "scan", "on"); err != nil { + return nil, err + } + out, err := bt(CallTimeout, "devices") + if err != nil { + return nil, err + } + found := []Device{} + for _, a := range listed(out) { + // A device seen a moment ago may have gone out of reach: it is skipped, not a failure. + d, err := deviceInfo(a) + if err != nil { + continue + } + if !d.Paired { + found = append(found, d) + } + } + sort.SliceStable(found, func(i, k int) bool { + ri, rk := -1000, -1000 + if found[i].RSSI != nil { + ri = *found[i].RSSI + } + if found[k].RSSI != nil { + rk = *found[k].RSSI + } + return ri > rk + }) + return map[string]any{"seconds": seconds, "count": len(found), "found": found}, nil +} + +// Act runs one act on a device and answers the device's state afterwards. +func Act(verb, address string) (map[string]any, error) { + limit := CallTimeout + if verb == "connect" { + // A connect waits for the device; bluetoothctl's own timeout ends it first. + if _, err := bt(limit, "--timeout", "15", verb, address); err != nil { + return nil, err + } + } else if _, err := bt(limit, verb, address); err != nil { + return nil, err + } + if verb == "remove" { + return map[string]any{"act": verb, "address": address, "removed": true}, nil + } + d, err := deviceInfo(address) + if err != nil { + return nil, err + } + return map[string]any{"act": verb, "device": d}, nil +} + +// Pair pairs a device with an agent that confirms nothing, then trusts it. +func Pair(address string) (map[string]any, error) { + if _, err := bt(CallTimeout, "--agent", "NoInputNoOutput", "--timeout", "15", "pair", address); err != nil { + return nil, err + } + return Act("trust", address) +} diff --git a/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go b/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go new file mode 100644 index 0000000..117c5e5 --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "strings" + "testing" +) + +func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "bluetooth") + if got := strings.Join(m.packages(), ","); got != "bluez,bluez-utils" { + t.Errorf("packages %s: the applet and the TUI are the operator's", got) + } + s := m.services()["bluetooth.service"] + if s == nil || s["state"] != "running" || s["boot"] != "enabled" { + t.Errorf("%v", s) + } + if len(m.Resources) != 3 { + t.Errorf("no configuration file: /etc/bluetooth/main.conf is the package's, unchanged on both workstations: %v", m.Resources) + } +} + +const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n" + +func headphones(connected bool) string { + c := "no" + extra := "" + if connected { + c, extra = "yes", "\tBattery Percentage: 0x50 (80)\n" + } + return "Device 80:99:E7:C2:29:DA (public)\n\tName: WH-1000XM4\n\tAlias: WH-1000XM4\n\tIcon: audio-headset\n\tPaired: yes\n\tBonded: yes\n\tTrusted: yes\n\tBlocked: no\n\tConnected: " + c + "\n" + extra + "\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n" +} + +func TestControllerReadsShow(t *testing.T) { + using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) }) + c, err := Controller() + if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable { + t.Fatalf("%+v %v", c, err) + } + using(t, func(string, Cmd) Result { return ok("No default controller available\n") }) + if _, err := Controller(); err == nil || !strings.Contains(err.Error(), "no Bluetooth controller") { + t.Fatalf("%v", err) + } +} + +func TestDevicesAskEachOneAndReportBatteryWhereGiven(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + switch line { + case "bluetoothctl devices Paired": + return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice 2C:41:A1:E4:EC:86 Earmuffs\n") + case "bluetoothctl info 80:99:E7:C2:29:DA": + return ok(headphones(true)) + } + return ok("Device 2C:41:A1:E4:EC:86 (public)\n\tName: Earmuffs\n\tPaired: yes\n\tConnected: no\n") + }) + got, err := Devices("paired") + devices := got["devices"].([]Device) + if err != nil || got["count"] != 2 || !devices[0].Connected || devices[0].Battery == nil || *devices[0].Battery != 80 || devices[1].Battery != nil { + t.Fatalf("%+v %v", got, err) + } + if f.lines()[0] != "bluetoothctl devices Paired" { + t.Errorf("%v", f.lines()) + } + if _, err := Devices("nearby"); err == nil { + t.Error("an unknown which") + } +} + +func TestAnActThatFailsIsAnErrorWhateverTheExitStatus(t *testing.T) { + using(t, func(line string, c Cmd) Result { + return ok("Attempting to connect to 80:99:E7:C2:29:DA\nFailed to connect: org.bluez.Error.Failed br-connection-page-timeout\n") + }) + if _, err := Act("connect", "80:99:E7:C2:29:DA"); err == nil || !strings.Contains(err.Error(), "page-timeout") { + t.Fatalf("%v", err) + } + using(t, func(string, Cmd) Result { return Result{Status: 1, Stdout: "Device 00:11:22:33:44:55 not available\n"} }) + if _, err := Act("trust", "00:11:22:33:44:55"); err == nil || !strings.Contains(err.Error(), "not available") { + t.Fatalf("%v", err) + } +} + +func TestConnectWaitsWithBluetoothctlsOwnTimeoutAndAnswersTheState(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if strings.Contains(line, "connect") { + return ok("Attempting to connect\n[CHG] Device Connected: yes\nConnection successful\n") + } + return ok(headphones(true)) + }) + got, err := Act("connect", "80:99:E7:C2:29:DA") + if err != nil || !got["device"].(Device).Connected { + t.Fatalf("%v %v", got, err) + } + if f.lines()[0] != "bluetoothctl --timeout 15 connect 80:99:E7:C2:29:DA" || f.asked[0].Timeout != CallTimeout { + t.Errorf("%v", f.lines()) + } +} + +func TestAnActBluezRefusesTheAccountIsRetriedThroughSudo(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if strings.HasPrefix(line, "sudo") { + return ok("Changing power off succeeded\n" + show) + } + return ok("Failed to set power off: org.freedesktop.DBus.Error.AccessDenied\n") + }) + if _, err := Power(false); err != nil { + t.Fatal(err) + } + if l := f.lines(); l[0] != "bluetoothctl power off" || l[1] != "sudo -n bluetoothctl power off" { + t.Errorf("%v", l) + } +} + +func TestScanIsBoundedAndAnswersUnpairedDevicesStrongestFirst(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + switch { + case strings.Contains(line, "scan on"): + return ok("Discovery started\n[NEW] Device AA:BB:CC:DD:EE:01 Speaker\n") + case line == "bluetoothctl devices": + return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice AA:BB:CC:DD:EE:01 Speaker\nDevice AA:BB:CC:DD:EE:02 Phone\nDevice AA:BB:CC:DD:EE:03 Gone\n") + case strings.HasSuffix(line, "EE:01"): + return ok("Device AA:BB:CC:DD:EE:01\n\tName: Speaker\n\tPaired: no\n\tRSSI: 0xffffffc4 (-60)\n") + case strings.HasSuffix(line, "EE:02"): + return ok("Device AA:BB:CC:DD:EE:02\n\tName: Phone\n\tPaired: no\n\tRSSI: -40\n") + case strings.HasSuffix(line, "EE:03"): + return Result{Status: 1, Stdout: "Device AA:BB:CC:DD:EE:03 not available\n"} + } + return ok(headphones(false)) + }) + got, err := Scan(8) + found := got["found"].([]Device) + if err != nil || len(found) != 2 || found[0].Name != "Phone" || *found[1].RSSI != -60 { + t.Fatalf("%+v %v", got, err) + } + if f.lines()[0] != "bluetoothctl --timeout 8 scan on" || f.asked[0].Timeout.Seconds() != 12 { + t.Errorf("%v %v", f.lines()[0], f.asked[0].Timeout) + } +} + +func TestPairUsesAnAgentThatConfirmsNothingAndThenTrusts(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if strings.Contains(line, " pair ") { + return ok("Pairing successful\n") + } + return ok(headphones(false)) + }) + if _, err := Pair("80:99:e7:c2:29:da"); err != nil { + t.Fatal(err) + } + if l := f.lines(); l[0] != "bluetoothctl --agent NoInputNoOutput --timeout 15 pair 80:99:e7:c2:29:da" || l[1] != "bluetoothctl trust 80:99:e7:c2:29:da" { + t.Errorf("%v", l) + } +} + +func TestAnAddressIsSixHexPairs(t *testing.T) { + for _, bad := range []string{"", "80:99:E7:C2:29", "80:99:E7:C2:29:DA; rm", "--help", "GG:99:E7:C2:29:DA"} { + if _, err := addressOf(map[string]any{"address": bad}); err == nil { + t.Errorf("%q accepted", bad) + } + } + if a, err := addressOf(map[string]any{"address": "80:99:e7:c2:29:da"}); err != nil || a != "80:99:E7:C2:29:DA" { + t.Errorf("%s %v", a, err) + } +} diff --git a/modules/bluetooth/cmd/bluetooth-tools/kit.go b/modules/bluetooth/cmd/bluetooth-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/kit.go @@ -0,0 +1,352 @@ +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 +} diff --git a/modules/bluetooth/cmd/bluetooth-tools/kit_test.go b/modules/bluetooth/cmd/bluetooth-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/kit_test.go @@ -0,0 +1,147 @@ +package main + +// Tests of kit.go, the same in each workstation module. + +import ( + "strings" + "testing" + "time" +) + +// fake records the commands asked and answers each from a function of the command line. +type fake struct { + asked []Cmd + answer func(line string, c Cmd) Result +} + +func (f *fake) runner() Runner { + return func(c Cmd) Result { + f.asked = append(f.asked, c) + name, args := argv(c) + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + if f.answer == nil { + return Result{} + } + return f.answer(line, c) + } +} + +func (f *fake) lines() []string { + out := []string{} + for _, c := range f.asked { + name, args := argv(c) + out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " "))) + } + return out +} + +// using installs a fake runner and a non-root uid for one test. +func using(t *testing.T, answer func(line string, c Cmd) Result) *fake { + t.Helper() + f := &fake{answer: answer} + wasRun, wasUID := run, euid + run, euid = f.runner(), func() int { return 1000 } + t.Cleanup(func() { run, euid = wasRun, wasUID }) + return f +} + +func ok(stdout string) Result { return Result{Stdout: stdout} } + +func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" { + t.Fatalf("not root: %s %v", name, args) + } + if name, _ := argv(Cmd{Name: "x"}); name != "x" { + t.Fatalf("a read is run as the account: %s", name) + } + euid = func() int { return 0 } + if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" { + t.Fatalf("as root no sudo: %s", name) + } +} + +func TestKitAFailureIsNamedByHowItFailed(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + cases := []struct { + c Cmd + r Result + want string + }{ + {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"}, + {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"}, + {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"}, + {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"}, + } + for _, k := range cases { + err := failure(k.c, k.r) + if err == nil || !strings.Contains(err.Error(), k.want) { + t.Errorf("%+v: %v, want %q", k.r, err, k.want) + } + } +} + +func TestKitOutputIsBoundedAndSaysSo(t *testing.T) { + var w bounded + big := strings.Repeat("a", MostOutput+10) + n, _ := w.Write([]byte(big)) + if n != len(big) || w.b.Len() != MostOutput || !w.cut { + t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut) + } +} + +func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) { + r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}}) + if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" { + t.Fatalf("%+v", r) + } + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond}) + if r.Error != "timeout" { + t.Fatalf("a slow command: %+v", r) + } + r = execRun(Cmd{Name: "no-such-program-anywhere"}) + if r.Error != "not-found" { + t.Fatalf("a missing program: %+v", r) + } + r = execRun(Cmd{Name: "cat", Stdin: "given"}) + if r.Stdout != "given" { + t.Fatalf("stdin: %+v", r) + } + start := time.Now() + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true}) + if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second { + t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start)) + } +} + +func TestKitArgumentsAreReadStrictly(t *testing.T) { + args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}} + if _, err := text(args, "missing"); err == nil { + t.Error("a missing required string") + } + if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 { + t.Errorf("held to most: %d", n) + } + if _, err := optWhole(args, "n", 1, 6, 9); err == nil { + t.Error("below least") + } + if _, err := optWhole(args, "f", 1, 0, 9); err == nil { + t.Error("a fraction") + } + if l, _ := optList(args, "l"); len(l) != 2 { + t.Errorf("list: %v", l) + } + if b, _ := optFlag(args, "b", false); !b { + t.Error("flag") + } + if err := plainName("name", "--all"); err == nil { + t.Error("an option as a name") + } +} diff --git a/modules/bluetooth/cmd/bluetooth-tools/main.go b/modules/bluetooth/cmd/bluetooth-tools/main.go new file mode 100644 index 0000000..f063aaf --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/main.go @@ -0,0 +1,135 @@ +// The bluetooth module's tools (novox/hq research 027/02, 026/05): the controller, the devices with +// their state and battery, scanning, and pairing, connecting, trusting and forgetting a device. A Go +// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it runs as the operator +// account, and speaks to bluez through bluetoothctl. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "bluetoothctl": "the bluez-utils package, which this module installs", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var addressArg = map[string]any{"type": "string", "description": "the device's address, such as 80:99:E7:C2:29:DA, as bluetooth_devices answers it"} + +func withAddress(f func(string) (any, error)) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + a, err := addressOf(args) + if err != nil { + return nil, err + } + return f(a) + } +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "bluetooth_controller", + Description: "The machine's Bluetooth controller: address, name, powered, discoverable, pairable, discovering. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Controller() }, + }, + { + Name: "bluetooth_power", + Description: "Whether the controller is powered; with on, switch it on or off. (r/a)", + Input: map[string]any{"on": map[string]any{"type": "boolean", "description": "power the controller on (true) or off (false)"}}, + Run: func(args map[string]any) (any, error) { + if _, given := args["on"]; !given { + c, err := Controller() + if err != nil { + return nil, err + } + return map[string]any{"powered": c.Powered}, nil + } + on, err := optFlag(args, "on", true) + if err != nil { + return nil, err + } + return Power(on) + }, + }, + { + Name: "bluetooth_devices", + Description: "The devices bluez knows: every one, or only the paired, connected or trusted. Each with its " + + "name, kind, paired, bonded, trusted, blocked, connected, and its battery where the device reports it. (r)", + Input: map[string]any{"which": map[string]any{"type": "string", "enum": []string{"all", "paired", "connected", "trusted"}, "description": "which devices (default all)"}}, + Run: func(args map[string]any) (any, error) { + which, err := optText(args, "which", "all") + if err != nil { + return nil, err + } + return Devices(which) + }, + }, + { + Name: "bluetooth_scan", + Description: "Discover devices nearby for a few seconds (default 8, at most 15), and answer the ones not " + + "paired, with their signal strength. (r)", + Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to scan (default 8, at most 15)"}}, + Run: func(args map[string]any) (any, error) { + s, err := optWhole(args, "seconds", 8, 1, 15) + if err != nil { + return nil, err + } + return Scan(s) + }, + }, + { + Name: "bluetooth_connect", + Description: "Connect a paired device, such as headphones. Answers its state afterwards. (a)", + Input: map[string]any{"address": addressArg}, + Run: withAddress(func(a string) (any, error) { return Act("connect", a) }), + }, + { + Name: "bluetooth_disconnect", + Description: "Disconnect a device. (a)", + Input: map[string]any{"address": addressArg}, + Run: withAddress(func(a string) (any, error) { return Act("disconnect", a) }), + }, + { + Name: "bluetooth_trust", + Description: "Trust a device, so it may connect by itself; or with trusted false, stop trusting it. (a)", + Input: map[string]any{"address": addressArg, "trusted": map[string]any{"type": "boolean", "description": "trust (default) or untrust"}}, + Run: func(args map[string]any) (any, error) { + a, err := addressOf(args) + if err != nil { + return nil, err + } + trusted, err := optFlag(args, "trusted", true) + if err != nil { + return nil, err + } + if trusted { + return Act("trust", a) + } + return Act("untrust", a) + }, + }, + { + Name: "bluetooth_pair", + Description: "Pair a device found by a scan, and trust it. Works for a device that needs no code to be " + + "confirmed, such as headphones; one that shows a code is paired from the desktop. (a)", + Input: map[string]any{"address": addressArg}, + Run: withAddress(func(a string) (any, error) { return Pair(a) }), + }, + { + Name: "bluetooth_remove", + Description: "Forget a device: unpair it and drop what bluez knows of it. (a)", + Input: map[string]any{"address": addressArg}, + Run: withAddress(func(a string) (any, error) { return Act("remove", a) }), + }, + } +} diff --git a/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go b/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go @@ -0,0 +1,107 @@ +package main + +// manifest_kit_test.go is the same file in each workstation module: it reads the module's +// definition so the module's own tests can hold it to what it says. + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +type manifest struct { + Module string `json:"module"` + Capabilities []string `json:"capabilities"` + Claims []any `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m +} + +func (m manifest) resource(id string) map[string]any { + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + return nil +} + +// packages are the packages the module installs, sorted. +func (m manifest) packages() []string { + out := []string{} + for _, r := range m.Resources { + if r["type"] == "package" && r["absent"] != true { + out = append(out, r["package"].(string)) + } + } + sort.Strings(out) + return out +} + +// services are the units the module declares, by unit name. +func (m manifest) services() map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range m.Resources { + if r["type"] == "service" { + out[r["unit"].(string)] = r + } + } + return out +} + +// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered +// is listed and nothing else, each named _…, and the artifact builds this command. +func holdsTheBundle(t *testing.T, m manifest, prefix string) { + t.Helper() + registered := []string{} + for _, tool := range tools() { + registered = append(registered, tool.Name) + if !strings.HasPrefix(tool.Name, prefix+"_") { + t.Errorf("tool %s is not named %s_…", tool.Name, prefix) + } + if tool.Description == "" || tool.Run == nil || tool.Input == nil { + t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name) + } + } + if strings.Join(registered, ",") != strings.Join(m.Tools, ",") { + t.Errorf("registered %v, listed %v", registered, m.Tools) + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("one artifact, got %d", len(m.Build.Artifacts)) + } + cwd, _ := os.Getwd() + binary := filepath.Base(cwd) + a := m.Build.Artifacts[0] + want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary} + for k, v := range want { + if a[k] != v { + t.Errorf("artifact %s = %v, want %v", k, a[k], v) + } + } + if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary { + t.Errorf("artifact loads %v, want [%s]", a["loads"], binary) + } + if m.Claims != nil || m.Seats != nil { + t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats) + } +} diff --git a/modules/bluetooth/go.mod b/modules/bluetooth/go.mod new file mode 100644 index 0000000..790596e --- /dev/null +++ b/modules/bluetooth/go.mod @@ -0,0 +1,5 @@ +module bluetooth + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/bluetooth/go.sum b/modules/bluetooth/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/bluetooth/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/bluetooth/module.json b/modules/bluetooth/module.json new file mode 100644 index 0000000..dfaabf1 --- /dev/null +++ b/modules/bluetooth/module.json @@ -0,0 +1,53 @@ +{ + "module": "bluetooth", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "tools": [ + "bluetooth_controller", + "bluetooth_power", + "bluetooth_devices", + "bluetooth_scan", + "bluetooth_connect", + "bluetooth_disconnect", + "bluetooth_trust", + "bluetooth_pair", + "bluetooth_remove" + ], + "resources": [ + { + "id": "stack", + "type": "package", + "package": "bluez" + }, + { + "id": "utilities", + "type": "package", + "package": "bluez-utils" + }, + { + "id": "daemon", + "type": "service", + "unit": "bluetooth.service", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/bluetooth-tools", + "binary": "bluetooth-tools", + "loads": [ + "bluetooth-tools" + ] + } + ] + } +}