Compare commits
30
Commits
feat/dbus-module
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f74e1f0309 | ||
|
|
97b1b2b36b | ||
|
|
d0d5546088 | ||
|
|
2a99b3c3e9 | ||
|
|
ade4901d7f | ||
|
|
f2b5168647 | ||
|
|
75f25fca21 | ||
|
|
1fc10b0323 | ||
|
|
4f3e52b47a | ||
|
|
1a03caf8a5 | ||
|
|
54ba97f901 | ||
|
|
92f78db970 | ||
|
|
1d8f1ceff8 | ||
|
|
83bd2afe66 | ||
|
|
46e3a5a6b7 | ||
|
|
d67e786bf9 | ||
|
|
7786507d45 | ||
|
|
a70227a40a | ||
|
|
08c7a79f79 | ||
|
|
d9120c6848 | ||
|
|
96ee0d7ad6 | ||
|
|
d43de93e49 | ||
|
|
3a88bca21a | ||
|
|
dd1035a27d | ||
|
|
37bb53ab95 | ||
|
|
45f27eb300 | ||
|
|
785d32407b | ||
|
|
170b3296e9 | ||
|
|
32cd92baeb | ||
|
|
9582208984 |
@@ -1,2 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
|
||||
modules/slack/cmd/slack-tools/slack-tools
|
||||
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# blueman
|
||||
|
||||
The Bluetooth tray applet on the workstations, as a module (novox/hq ADR 0208). It requires
|
||||
`x11-display`, so it is assigned only where a display server is held on the same machine.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the applet, the manager window, send-to | package `blueman`, from the official repositories |
|
||||
|
||||
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||
|
||||
- **No AUR.** Both workstations run the official package (`extra`), installed explicitly.
|
||||
- **The Bluetooth stack is not this module's.** `bluez`, `bluez-utils` and `bluetooth.service` belong
|
||||
to the `bluetooth` module. This module declares none of them (ADR 0210 §4: one package, one module).
|
||||
The devices, pairing and power are that module's tools (`bluetooth_*`). This module's tools are
|
||||
about the applet.
|
||||
- **The operator's applet settings are found.** blueman keeps them in dconf (`org.blueman.*`): the
|
||||
plugin switches, the recent connections, auto-connect and auto-power-on. The module neither sets nor
|
||||
resets them. `blueman_status` shows the plugin switches.
|
||||
|
||||
## How it starts: the package's autostart entry, and nothing else
|
||||
|
||||
One process has one starter (the rule `picom` states for the desktop modules). The package ships
|
||||
`/etc/xdg/autostart/blueman.desktop` (`blueman-applet`). The session runs it once at login through
|
||||
the `i3` module's `dex --autostart --environment i3`. **That entry is the applet's one start.** The
|
||||
module adds no `xinitrc` slot and no `node-display-session` exec, because either would start it a
|
||||
second time.
|
||||
|
||||
- **Excluded:** the window manager's `exec … blueman-applet`, which the `i3` module's configuration
|
||||
dropped.
|
||||
- **Not a start:** the package's user unit `blueman-applet.service` is static. It exists for D-Bus
|
||||
activation of `org.blueman.Applet`. A program that calls the applet's bus name while no applet runs
|
||||
starts one through it. The applet is single-instance on that name, so this never makes a second
|
||||
one. The tools always ask the bus with `--auto-start=no`, so asking never starts it.
|
||||
- The applet starts its tray icon, `blueman-tray`, itself.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `blueman_status` (r) | <ul><li>whether the applet and its tray icon run: pid, since, and the scope or unit they run in</li><li>the installed version, and what starts it at login</li><li>the plugins the running applet has loaded and those it has not (asked of the applet on the session bus)</li><li>the plugin switches in `org.blueman.general plugin-list`</li><li>whether the applet sees Bluetooth on</li></ul> |
|
||||
| `blueman_restart` (a) | asks the applet and the tray icon to end (SIGTERM), forces them after 5 s, and starts `blueman-applet` in the operator's session. The start is a transient user unit `mesh-blueman-applet`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop |
|
||||
| `blueman_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one applet runs in a desktop session</li><li>`bluetooth.service` is active</li></ul>Each finding says what to do |
|
||||
|
||||
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment,
|
||||
as `clipmenu` and `screen-lock` do. Every command has a timeout and capped output. Everything runs
|
||||
through an injected runner and a fake root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
|
||||
| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
|
||||
| settings (dconf) | defaults, no plugin switched; recent connections only | no plugin switched; auto-connect for one headset, auto-power-on set |
|
||||
|
||||
The workstations are already in the state this module describes.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
|
||||
the applet runs from its one start. `blueman_check` then answers `ok`.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- The applet's dconf settings (`/org/blueman/`).
|
||||
- `/etc/xdg/autostart/blueman.desktop`, the package's own file.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The `bluetooth` module, for bluez and its daemon.** Without them the applet has nothing to
|
||||
manage. There is no dependency mechanism between two modules that hold no seat. So nothing refuses
|
||||
`blueman` on a node without `bluetooth`. `blueman_check` reports it instead, from
|
||||
`bluetooth.service`. **Assign both.** When the stack becomes a seat (`node-bluetooth`), this module
|
||||
depends on the seat.
|
||||
- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat.
|
||||
Assigned without `i3`, the applet is installed and does not start. `blueman_check` says so.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || 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 is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
// blueman's applet as the tools see it: its processes, its XDG autostart entry (the package's), the
|
||||
// applet's own answers on the session bus, and its settings in gsettings. Every bus call is made with
|
||||
// --auto-start=no: the applet is D-Bus activatable, and a question must never start it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
appletComm = "blueman-applet"
|
||||
trayComm = "blueman-tray"
|
||||
appletBin = "/usr/bin/blueman-applet"
|
||||
entryName = "blueman.desktop"
|
||||
restartAs = "mesh-blueman-applet"
|
||||
packageFor = "blueman"
|
||||
busName = "org.blueman.Applet"
|
||||
busPath = "/org/blueman/Applet"
|
||||
stackUnit = "bluetooth.service"
|
||||
)
|
||||
|
||||
// appletCall asks the running applet one question over the session bus and answers busctl's JSON.
|
||||
func (m *Machine) appletCall(method string) (json.RawMessage, error) {
|
||||
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, busPath, busName, method}
|
||||
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
|
||||
if err := failed(o, "busctl", args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
|
||||
return nil, fmt.Errorf("the applet's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
|
||||
}
|
||||
return doc.Data[0], nil
|
||||
}
|
||||
|
||||
func (m *Machine) appletStrings(method string) ([]string, error) {
|
||||
raw, err := m.appletCall(method)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("the applet's %s is not a list of names: %w", method, err)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// gvariantStrings reads gsettings' printed array of strings: "@as []" or "['a', '!b']".
|
||||
func gvariantStrings(s string) []string {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "@as"))
|
||||
s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]")
|
||||
out := []string{}
|
||||
for _, item := range strings.Split(s, ",") {
|
||||
item = strings.Trim(strings.TrimSpace(item), `'"`)
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Plugins is which applet plugins run, and what the operator's settings say about them.
|
||||
type Plugins struct {
|
||||
// Loaded are the plugins the running applet answers it has loaded.
|
||||
Loaded []string `json:"loaded"`
|
||||
// NotLoaded are the plugins it knows but did not load: switched off, or not fit for this machine.
|
||||
NotLoaded []string `json:"not_loaded"`
|
||||
// Switched is the operator's plugin-list setting: a name to load it, !name to keep it off. Empty
|
||||
// is blueman's defaults.
|
||||
Switched []string `json:"switched"`
|
||||
}
|
||||
|
||||
// AppletStatus is what blueman_status answers.
|
||||
type AppletStatus struct {
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Applet []Proc `json:"applet"`
|
||||
Tray []Proc `json:"tray"`
|
||||
StartedBy Autostart `json:"started_by"`
|
||||
// Bluetooth is the applet's own view of the adapter's power, when it runs.
|
||||
Bluetooth *bool `json:"bluetooth_on,omitempty"`
|
||||
Plugins Plugins `json:"plugins"`
|
||||
// Unanswered says why the running applet's view is missing.
|
||||
Unanswered string `json:"unanswered,omitempty"`
|
||||
}
|
||||
|
||||
// Status reads the applet: running or not, how it starts, and its plugins.
|
||||
func (m *Machine) Status() (AppletStatus, error) {
|
||||
s := AppletStatus{Applet: m.procs(appletComm), Tray: m.procs(trayComm), StartedBy: m.autostart(entryName),
|
||||
Plugins: Plugins{Loaded: []string{}, NotLoaded: []string{}, Switched: []string{}}}
|
||||
if s.Applet == nil {
|
||||
s.Applet = []Proc{}
|
||||
}
|
||||
if s.Tray == nil {
|
||||
s.Tray = []Proc{}
|
||||
}
|
||||
if v, err := m.installed(packageFor); err == nil {
|
||||
s.Installed = v
|
||||
}
|
||||
o := m.cmd(5*time.Second, m.bus(), "gsettings", "get", "org.blueman.general", "plugin-list")
|
||||
if o.Err == nil && o.Code == 0 {
|
||||
s.Plugins.Switched = gvariantStrings(o.Stdout)
|
||||
}
|
||||
if len(s.Applet) == 0 {
|
||||
s.Unanswered = "the applet is not running"
|
||||
return s, nil
|
||||
}
|
||||
loaded, err := m.appletStrings("QueryPlugins")
|
||||
if err != nil {
|
||||
s.Unanswered = err.Error()
|
||||
return s, nil
|
||||
}
|
||||
s.Plugins.Loaded = loaded
|
||||
if all, err := m.appletStrings("QueryAvailablePlugins"); err == nil {
|
||||
in := map[string]bool{}
|
||||
for _, p := range loaded {
|
||||
in[p] = true
|
||||
}
|
||||
for _, p := range all {
|
||||
if !in[p] {
|
||||
s.Plugins.NotLoaded = append(s.Plugins.NotLoaded, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, err := m.appletCall("GetBluetoothStatus"); err == nil {
|
||||
var on bool
|
||||
if json.Unmarshal(raw, &on) == nil {
|
||||
s.Bluetooth = &on
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what blueman_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Ended []int `json:"ended"`
|
||||
Killed []int `json:"killed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Session Session `json:"session"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// Restart ends the applet and its tray icon, and starts the applet again in the operator's session
|
||||
// under the account's service manager. The applet starts its tray icon itself.
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return RestartAnswer{}, err
|
||||
}
|
||||
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||
a.Ended, a.Killed = m.stop(5*time.Second, appletComm, trayComm)
|
||||
if err := m.detach(s, restartAs, appletBin); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Running = m.waitFor(appletComm, 4*time.Second)
|
||||
if len(a.Running) == 0 {
|
||||
return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+
|
||||
"see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what blueman_check answers.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies what the module promises and relies on: the package; one start (the package's
|
||||
// autostart entry, which the session's dex runs); the applet running once in a session; and the
|
||||
// Bluetooth daemon it manages, which is the bluetooth module's.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("the package blueman is not installed", "push the module to the node")
|
||||
}
|
||||
entry := m.autostart(entryName)
|
||||
if entry.Starts {
|
||||
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
||||
if entry.From != "/etc/xdg/autostart/"+entryName {
|
||||
add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start")
|
||||
}
|
||||
} else {
|
||||
add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry")
|
||||
}
|
||||
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
|
||||
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
|
||||
}
|
||||
for _, l := range m.i3Starts(appletComm) {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start")
|
||||
}
|
||||
if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" {
|
||||
add("the Bluetooth daemon ("+stackUnit+") is not running: the applet has nothing to manage",
|
||||
"assign the bluetooth module, which owns bluez and its daemon")
|
||||
}
|
||||
running := m.procs(appletComm)
|
||||
if _, err := m.session(); err == nil {
|
||||
switch {
|
||||
case len(running) == 0:
|
||||
add("no applet runs in the desktop session", "blueman_restart")
|
||||
case len(running) > 1:
|
||||
add(fmt.Sprintf("%d applets run", len(running)), "blueman_restart ends them all and starts one")
|
||||
}
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newApplet(t *testing.T, running bool) *fake {
|
||||
f := newFake(t)
|
||||
f.write("/etc/xdg/autostart/blueman.desktop", "[Desktop Entry]\nName=Blueman Applet\nExec=blueman-applet\nType=Application\n")
|
||||
if running {
|
||||
f.proc(3859, 1000, appletComm, []string{"/usr/bin/python", "/usr/bin/blueman-applet"}, "session-c1.scope")
|
||||
f.proc(4084, 1000, trayComm, []string{"/usr/bin/python", "/usr/bin/blueman-tray"}, "session-c1.scope")
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
call := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "pacman":
|
||||
return Output{Stdout: "blueman 2.4.6-2\n"}
|
||||
case name == "gsettings":
|
||||
return Output{Stdout: "['!NetUsage', 'DhcpClient']\n"}
|
||||
case strings.HasSuffix(call, " QueryPlugins"):
|
||||
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager"]]}`}
|
||||
case strings.HasSuffix(call, " QueryAvailablePlugins"):
|
||||
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager","NetUsage"]]}`}
|
||||
case strings.HasSuffix(call, " GetBluetoothStatus"):
|
||||
return Output{Stdout: `{"type":"b","data":[true]}`}
|
||||
case name == "systemctl" && len(args) > 1 && args[0] == "is-active":
|
||||
return Output{Stdout: "active\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestStatusAsksTheRunningAppletAndNeverStartsIt(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Installed != "2.4.6-2" || len(s.Applet) != 1 || len(s.Tray) != 1 || !s.StartedBy.Starts || s.Bluetooth == nil || !*s.Bluetooth {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if strings.Join(s.Plugins.Loaded, ",") != "AuthAgent,PowerManager,StatusIcon" || strings.Join(s.Plugins.NotLoaded, ",") != "NetUsage" ||
|
||||
strings.Join(s.Plugins.Switched, ",") != "!NetUsage,DhcpClient" {
|
||||
t.Fatalf("%+v", s.Plugins)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
|
||||
t.Fatalf("a bus call that could start the applet: %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
stopped := newApplet(t, false)
|
||||
s, err = stopped.Status()
|
||||
if err != nil || s.Unanswered != "the applet is not running" || len(s.Applet) != 0 || s.Bluetooth != nil {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
if stopped.called("busctl") {
|
||||
t.Fatalf("asked the bus with no applet running: %q", stopped.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsListsAreReadAsGSettingsPrintsThem(t *testing.T) {
|
||||
for in, want := range map[string]string{"@as []\n": "", "['a']": "a", "['!a', 'b']\n": "!a,b"} {
|
||||
if got := strings.Join(gvariantStrings(in), ","); got != want {
|
||||
t.Errorf("%q: %q, not %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartEndsAppletAndTrayAndStartsTheApplet(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("without a desktop: %v", err)
|
||||
}
|
||||
f.desktopSession()
|
||||
f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") }
|
||||
a, err := f.Restart()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Ended) != 2 || len(a.Running) != 1 || a.Running[0].PID != 9100 || a.Running[0].Command != appletBin {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
f.desktopSession()
|
||||
c, err := f.Check()
|
||||
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/blueman.desktop" {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id blueman-applet\n")
|
||||
f.write(testHome+"/.config/autostart/blueman.desktop", "[Desktop Entry]\nExec=blueman-applet\nHidden=true\n")
|
||||
f.proc(3860, 1000, appletComm, []string{"blueman-applet"}, "session-c1.scope")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch name {
|
||||
case "pacman":
|
||||
return Output{Code: 1}
|
||||
case "systemctl":
|
||||
return Output{Stdout: "inactive\n", Code: 3}
|
||||
case "dex":
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
c, _ = f.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1",
|
||||
"bluetooth.service", "2 applets run"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no finding %q in\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// 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
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// The blueman module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Bluetooth tray
|
||||
// applet in the operator's session, served by the node's runtime as the operator account. The module
|
||||
// holds no seat, so every tool is its own. The devices themselves are the bluetooth module's tools.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "blueman_status",
|
||||
Description: "The Bluetooth applet: whether it and its tray icon run (pid, since, and the unit or " +
|
||||
"session scope they run in), the installed version, what starts it at login, the plugins the " +
|
||||
"running applet has loaded and those it has not, the plugin switches in the operator's settings, " +
|
||||
"and whether the applet sees Bluetooth on. Never starts the applet. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "blueman_restart",
|
||||
Description: "End the applet and its tray icon (asked first, then forced after 5 s) and start the " +
|
||||
"applet again in the operator's desktop session, under the account's service manager. Answers " +
|
||||
"the pids ended and the new one. Needs someone logged in to the desktop. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "blueman_check",
|
||||
Description: "Check what the module promises and relies on: the package is installed; the applet has " +
|
||||
"exactly one start (the package's XDG autostart entry, which the session's dex runs; no " +
|
||||
"window-manager exec); it runs once in a desktop session; and the Bluetooth daemon is running " +
|
||||
"(the bluetooth module's). Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// blueman's shape (novox/hq ADR 0208, ADR 0210): one official package, no seat, the X display on its
|
||||
// own machine, no start of its own (the package's autostart entry is the one start), nothing of the
|
||||
// bluetooth module's (bluez, its utilities, its daemon), and the Go bundle serving exactly the listed
|
||||
// blueman_ tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []any `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestItInstallsTheAppletAndNothingElse(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if m.Module != "blueman" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
|
||||
t.Fatalf("resources: %v", m.Resources)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil {
|
||||
t.Fatal("it holds no seat and sets no environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItAddsNoSecondStartAndDeclaresNothingOfTheBluetoothModule(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
// The package ships its XDG autostart entry, which the session's dex runs: an xinitrc slot or a
|
||||
// window-manager exec would start it twice.
|
||||
if m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions)
|
||||
}
|
||||
for _, never := range []string{"autostart", "service", "bluez", "/etc/bluetooth"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %q: the start is the package's, the stack the bluetooth module's", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "blueman_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed blueman_ and described", tool.Name)
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/blueman-tools" || b["binary"] != "blueman-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module blueman
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"module": "blueman",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"tools": [
|
||||
"blueman_status",
|
||||
"blueman_restart",
|
||||
"blueman_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "blueman"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/blueman-tools",
|
||||
"binary": "blueman-tools",
|
||||
"loads": [
|
||||
"blueman-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,6 @@ 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.
|
||||
- `blueman` and its applet: the `blueman` module's, which relies on this one for the stack and
|
||||
declares none of its packages. The applet starts from the package's XDG autostart entry.
|
||||
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
|
||||
|
||||
@@ -22,11 +22,17 @@ whenever the node's tool runtime collects the module's tools:
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, then the settings registered through this module (the mesh's, then this node's), under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>`. Replaced whole, staged beside and swapped in |
|
||||
|
||||
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
|
||||
this node a subscription token. Nothing else under the home is read or written.
|
||||
Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
|
||||
subscription token; and what is registered at the **home** scope for this node — a skill, subagent,
|
||||
command, output style, or instructions as a rule file — each path recorded in the module's state
|
||||
(`home-placed.json`). It writes, changes and removes only those — never a path the person made, even one with the
|
||||
same content, and never through a directory that is a symbolic link. A placed file changed by hand is left
|
||||
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
|
||||
items to report them; nothing else is read or written.
|
||||
|
||||
## Over NATS
|
||||
|
||||
@@ -41,6 +47,7 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
||||
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
||||
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
||||
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
|
||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name |
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -49,6 +56,24 @@ manager), `claude_code_mcp_list`,
|
||||
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
|
||||
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
||||
|
||||
The agent's configuration (hq ADR 0216), each registered at a **scope** — `mesh` (the default), `node`
|
||||
(`nodes`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the
|
||||
operator account's own `~/.claude` on those nodes):
|
||||
|
||||
- for each kind — `skill`, `agent`, `command`, `hook`, `output_style`, `instructions` —
|
||||
`claude_code_<kind>_list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a
|
||||
lone SKILL.md); a hook is an `event`, a `matcher`, a `command` and its scripts as `files`, with
|
||||
`${HOOK_DIR}` in the command naming their directory. Hooks and settings take no home scope;
|
||||
- `claude_code_settings_get`, `_set` (merged into the scope, or `replace`), `_clear`;
|
||||
`claude_code_permission_add` and `_remove` for one allow, ask or deny rule. The agent refuses to loosen
|
||||
its own settings: these are the operator's to call;
|
||||
- `claude_code_config_list`, `_show` (one registration in full), `_status` (what applies here, the plugin
|
||||
as written, and the home's own items — which the mesh placed, which share a name with a mesh item, which
|
||||
call a tool server not loaded here) and `_import` (an item of this node's home, registered at a scope;
|
||||
the original stays).
|
||||
|
||||
A new session takes a change; a running one at `/reload-plugins`.
|
||||
|
||||
## Settings
|
||||
|
||||
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
|
||||
|
||||
@@ -69,19 +69,25 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
||||
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
|
||||
var a, b any
|
||||
_ = json.Unmarshal([]byte(got[file]), &a)
|
||||
// The plugin's two keys are new since the TypeScript (ADR 0216); everything else means the same.
|
||||
if m, ok := a.(map[string]any); ok && file == "managed-settings.json" {
|
||||
for k := range MarketplaceKeys() {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
|
||||
}
|
||||
}
|
||||
}
|
||||
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered), f.WithKey)
|
||||
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain)
|
||||
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered, Config{}), f.WithKey)
|
||||
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}, Config{}), f.Plain)
|
||||
}
|
||||
|
||||
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
|
||||
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
|
||||
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil)
|
||||
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil, Config{})
|
||||
var mcp struct {
|
||||
MCPServers map[string]map[string]any `json:"mcpServers"`
|
||||
}
|
||||
@@ -89,7 +95,7 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
|
||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||
t.Fatalf("%v", mcp.MCPServers)
|
||||
}
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) {
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
||||
t.Fatal("rendering is not deterministic")
|
||||
}
|
||||
}
|
||||
@@ -104,7 +110,7 @@ func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
|
||||
}}
|
||||
read := func(binding *Binding) map[string]any {
|
||||
var m map[string]any
|
||||
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil)
|
||||
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil, Config{})
|
||||
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
package main
|
||||
|
||||
// The agent's configuration, registered through this module at three scopes (novox/hq ADR 0216, to-be 36 §8).
|
||||
//
|
||||
// Every registration is one key in the module's `config` state (ADR 0201):
|
||||
//
|
||||
// mesh.<kind>.<name> every node running the agent
|
||||
// node.<node>.<kind>.<name> one node — a list of nodes is one key each
|
||||
// home.<node>.<kind>.<name> the operator account's own agent directory on one node
|
||||
//
|
||||
// Every instance watches the state and takes what applies to it. What it takes lands in one place per kind,
|
||||
// the one place the vendor honours for it:
|
||||
//
|
||||
// skill, agent, command, hook, output-style the plugin `nox-mesh`, in a marketplace in the managed directory
|
||||
// (at the home scope: the home's own directories)
|
||||
// instructions sections of the managed instruction file (home: a rule file)
|
||||
// settings the managed settings file, mesh then node, under the mesh's keys
|
||||
//
|
||||
// Tool servers keep their own state and file (`servers`, the managed tool-server file): the exclusive file
|
||||
// would block a plugin's.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Plugin is the plugin's name, and its marketplace's: what its items are called in a session, `nox-mesh:<name>`.
|
||||
const Plugin = "nox-mesh"
|
||||
|
||||
// MarketplaceDir is where the marketplace is written, inside the managed directory.
|
||||
const MarketplaceDir = "marketplace"
|
||||
|
||||
// MaxItemBytes is the most one registration may carry, its files included: well under the bus's message limit.
|
||||
const MaxItemBytes = 256 << 10
|
||||
|
||||
// The kinds a registration may be.
|
||||
const (
|
||||
KindSkill = "skill"
|
||||
KindAgent = "agent"
|
||||
KindCommand = "command"
|
||||
KindHook = "hook"
|
||||
KindOutputStyle = "output-style"
|
||||
KindInstructions = "instructions"
|
||||
KindSettings = "settings"
|
||||
)
|
||||
|
||||
// Kinds is every kind, in the order a list shows them.
|
||||
var Kinds = []string{KindSkill, KindAgent, KindCommand, KindHook, KindOutputStyle, KindInstructions, KindSettings}
|
||||
|
||||
// The scopes.
|
||||
const (
|
||||
ScopeMesh = "mesh"
|
||||
ScopeNode = "node"
|
||||
ScopeHome = "home"
|
||||
)
|
||||
|
||||
// SettingsName is the one name a settings registration has: a scope holds one set of settings.
|
||||
const SettingsName = "settings"
|
||||
|
||||
var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
||||
|
||||
// nodeName is what a node may be called in a key.
|
||||
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// meshOwnedKeys are the settings a registration may not set: the mesh's own, and those that would deny
|
||||
// the mesh's console or its marketplace by another way.
|
||||
var meshOwnedKeys = []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins",
|
||||
"allowedMcpServers", "deniedMcpServers", "allowManagedMcpServersOnly", "strictKnownMarketplaces", "blockedMarketplaces"}
|
||||
|
||||
// hookEvents are the events a hook may be registered for.
|
||||
var hookEvents = map[string]bool{"PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, "Notification": true,
|
||||
"Stop": true, "SubagentStop": true, "SessionStart": true, "SessionEnd": true, "PreCompact": true}
|
||||
|
||||
// Item is one registration, as the state keeps it.
|
||||
type Item struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
// Files are the item's files by path relative to the item: a skill's SKILL.md and whatever sits beside
|
||||
// it; for the one-file kinds, `<name>.md`; a hook's scripts.
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
// A hook's event, matcher and command. In the command, ${HOOK_DIR} is the directory its files are in.
|
||||
Event string `json:"event,omitempty"`
|
||||
Matcher string `json:"matcher,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
// Settings, for the settings kind: keys of the vendor's settings file.
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
// Where it was registered from, and when.
|
||||
By string `json:"by,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
}
|
||||
|
||||
// Key is where an item lives in the state, for one node (ignored at the mesh scope).
|
||||
func (it Item) Key(node string) string {
|
||||
if it.Scope == ScopeMesh {
|
||||
return ScopeMesh + "." + it.Kind + "." + it.Name
|
||||
}
|
||||
return it.Scope + "." + node + "." + it.Kind + "." + it.Name
|
||||
}
|
||||
|
||||
// ParseKey reads a key back: its scope, the node it is for ("" at the mesh scope), the kind and the name.
|
||||
func ParseKey(key string) (scope, node, kind, name string, ok bool) {
|
||||
parts := strings.Split(key, ".")
|
||||
switch {
|
||||
case len(parts) == 3 && parts[0] == ScopeMesh:
|
||||
return ScopeMesh, "", parts[1], parts[2], true
|
||||
case len(parts) == 4 && (parts[0] == ScopeNode || parts[0] == ScopeHome):
|
||||
return parts[0], parts[1], parts[2], parts[3], true
|
||||
}
|
||||
return "", "", "", "", false
|
||||
}
|
||||
|
||||
func knownKind(k string) bool {
|
||||
for _, x := range Kinds {
|
||||
if x == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Problem says why an item cannot be registered, or "".
|
||||
func (it Item) Problem() string {
|
||||
if !knownKind(it.Kind) {
|
||||
return fmt.Sprintf("%q is not a kind: %s", it.Kind, strings.Join(Kinds, ", "))
|
||||
}
|
||||
if it.Scope != ScopeMesh && it.Scope != ScopeNode && it.Scope != ScopeHome {
|
||||
return fmt.Sprintf("%q is not a scope: mesh, node or home", it.Scope)
|
||||
}
|
||||
if it.Kind == KindSettings {
|
||||
if it.Scope == ScopeHome {
|
||||
return "settings take the mesh and node scopes only: the home's settings file is the person's"
|
||||
}
|
||||
if it.Name != SettingsName {
|
||||
return "a scope holds one set of settings, named " + SettingsName
|
||||
}
|
||||
if len(it.Settings) == 0 {
|
||||
return "no settings given"
|
||||
}
|
||||
for _, key := range meshOwnedKeys {
|
||||
if _, ok := it.Settings[key]; ok {
|
||||
return fmt.Sprintf("%q is one of the mesh's own keys, or would turn off the mesh's console or plugin; a registration cannot set it", key)
|
||||
}
|
||||
}
|
||||
} else if !itemName.MatchString(it.Name) {
|
||||
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
|
||||
}
|
||||
if it.Kind == KindHook {
|
||||
if it.Scope == ScopeHome {
|
||||
return "a hook takes the mesh and node scopes only: at home it would live in the person's settings file"
|
||||
}
|
||||
if !hookEvents[it.Event] {
|
||||
return fmt.Sprintf("%q is not a hook event the agent knows", it.Event)
|
||||
}
|
||||
if strings.TrimSpace(it.Command) == "" {
|
||||
return "a hook needs a command"
|
||||
}
|
||||
}
|
||||
for rel := range it.Files {
|
||||
if problem := pathProblem(rel); problem != "" {
|
||||
return problem
|
||||
}
|
||||
for other := range it.Files {
|
||||
if strings.HasPrefix(other, rel+"/") {
|
||||
return fmt.Sprintf("%q is a file and also the directory of %q", rel, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
if _, ok := it.Files["SKILL.md"]; !ok {
|
||||
return "a skill needs a SKILL.md"
|
||||
}
|
||||
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||
if len(it.Files) != 1 || strings.TrimSpace(it.Files[it.Name+".md"]) == "" {
|
||||
return "this kind is one file, " + it.Name + ".md, with content"
|
||||
}
|
||||
}
|
||||
if n := it.size(); n > MaxItemBytes {
|
||||
return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
|
||||
// segment a real name — never empty, `.` or `..`.
|
||||
func pathProblem(rel string) string {
|
||||
if rel == "" || path.IsAbs(rel) || strings.ContainsAny(rel, "\\\x00") {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (it Item) size() int {
|
||||
raw, _ := json.Marshal(it)
|
||||
return len(raw)
|
||||
}
|
||||
|
||||
// ---- the view -------------------------------------------------------------------------------------
|
||||
|
||||
// ConfigView is what this node takes from the `config` state: every mesh item, and the node and home items
|
||||
// for this node — kept in memory from the watch and written through to the module's own file, so the
|
||||
// managed directory renders without the bus.
|
||||
type ConfigView struct {
|
||||
p Paths
|
||||
mu sync.Mutex
|
||||
items map[string]Item
|
||||
}
|
||||
|
||||
// NewConfigView is the view as last written through, or empty.
|
||||
func NewConfigView(p Paths) *ConfigView {
|
||||
v := &ConfigView{p: p, items: map[string]Item{}}
|
||||
_ = readJSON(p.config(), &v.items)
|
||||
return v
|
||||
}
|
||||
|
||||
// Applies says whether a key is this node's to take.
|
||||
func (v *ConfigView) Applies(key string) bool {
|
||||
scope, node, _, _, ok := ParseKey(key)
|
||||
return ok && (scope == ScopeMesh || node == v.p.Node)
|
||||
}
|
||||
|
||||
// Take takes one change, and answers whether what applies to this node changed.
|
||||
func (v *ConfigView) Take(key, op string, item *Item) bool {
|
||||
if !v.Applies(key) {
|
||||
return false
|
||||
}
|
||||
_, node, _, _, _ := ParseKey(key)
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
// Only an item that is what its key says: a key decides which nodes take an item, the item where it
|
||||
// lands, and the two must agree — a mesh key holding a home item would land in every home.
|
||||
if op == "put" && item != nil && item.Problem() == "" && item.Key(node) == key {
|
||||
v.items[key] = *item
|
||||
} else {
|
||||
delete(v.items, key)
|
||||
}
|
||||
return v.writeThroughLocked()
|
||||
}
|
||||
|
||||
// Prune drops what the view holds and the state no longer does: a registration removed while this node
|
||||
// was away is never handed over by the watch, which hands over what is there, not what went.
|
||||
func (v *ConfigView) Prune(present []string) bool {
|
||||
keep := map[string]bool{}
|
||||
for _, k := range present {
|
||||
keep[k] = true
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
for k := range v.items {
|
||||
if !keep[k] {
|
||||
delete(v.items, k)
|
||||
}
|
||||
}
|
||||
return v.writeThroughLocked()
|
||||
}
|
||||
|
||||
// Items is what applies here, by key.
|
||||
func (v *ConfigView) Items() map[string]Item {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
out := make(map[string]Item, len(v.items))
|
||||
for k, it := range v.items {
|
||||
out[k] = it
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeThroughLocked writes the view to its file, with v.mu held: the snapshot and the write are one step, so
|
||||
// an older snapshot never lands after a newer one.
|
||||
func (v *ConfigView) writeThroughLocked() bool {
|
||||
now, _ := indented(v.items)
|
||||
before, _ := os.ReadFile(v.p.config())
|
||||
if string(before) == string(now) {
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(v.p.config(), now, 0o600)
|
||||
return true
|
||||
}
|
||||
|
||||
// Config is what applies to this node, sorted for rendering.
|
||||
type Config struct {
|
||||
Mesh, Node, Home []Item
|
||||
}
|
||||
|
||||
// ConfigOf sorts the items that apply here by scope, each scope by kind and name.
|
||||
func ConfigOf(items map[string]Item) Config {
|
||||
var c Config
|
||||
for _, it := range items {
|
||||
switch it.Scope {
|
||||
case ScopeMesh:
|
||||
c.Mesh = append(c.Mesh, it)
|
||||
case ScopeNode:
|
||||
c.Node = append(c.Node, it)
|
||||
case ScopeHome:
|
||||
c.Home = append(c.Home, it)
|
||||
}
|
||||
}
|
||||
for _, list := range [][]Item{c.Mesh, c.Node, c.Home} {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].Kind != list[j].Kind {
|
||||
return list[i].Kind < list[j].Kind
|
||||
}
|
||||
return list[i].Name < list[j].Name
|
||||
})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ---- rendering --------------------------------------------------------------------------------------
|
||||
|
||||
// PluginFile is one file of the marketplace: its content and whether it is run.
|
||||
type PluginFile struct {
|
||||
Content string
|
||||
Executable bool
|
||||
}
|
||||
|
||||
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
|
||||
// over a mesh item of the same kind and name: the node's wins.
|
||||
func Marketplace(c Config) map[string]PluginFile {
|
||||
root := "plugins/" + Plugin + "/"
|
||||
out := map[string]PluginFile{
|
||||
".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{
|
||||
"name": Plugin,
|
||||
"owner": map[string]any{"name": "the mesh"},
|
||||
"plugins": []any{map[string]any{"name": Plugin, "source": "./plugins/" + Plugin,
|
||||
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand."}},
|
||||
})},
|
||||
root + ".claude-plugin/plugin.json": {Content: jsonFile(map[string]any{
|
||||
"name": Plugin, "version": "1.0.0",
|
||||
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand.",
|
||||
"author": map[string]any{"name": "the mesh"},
|
||||
})},
|
||||
}
|
||||
chosen := map[string]Item{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
chosen[it.Kind+"/"+it.Name] = it
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(chosen))
|
||||
for k := range chosen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
hooks := map[string][]any{}
|
||||
for _, k := range keys {
|
||||
it := chosen[k]
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
for rel, content := range it.Files {
|
||||
out[root+"skills/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: isScript(rel, content)}
|
||||
}
|
||||
case KindAgent:
|
||||
out[root+"agents/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindCommand:
|
||||
out[root+"commands/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindOutputStyle:
|
||||
out[root+"output-styles/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindHook:
|
||||
for rel, content := range it.Files {
|
||||
out[root+"hooks/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: true}
|
||||
}
|
||||
command := strings.ReplaceAll(it.Command, "${HOOK_DIR}", `"${CLAUDE_PLUGIN_ROOT}/hooks/`+it.Name+`"`)
|
||||
entry := map[string]any{"hooks": []any{map[string]any{"type": "command", "command": command}}}
|
||||
if it.Matcher != "" {
|
||||
entry["matcher"] = it.Matcher
|
||||
}
|
||||
hooks[it.Event] = append(hooks[it.Event], entry)
|
||||
}
|
||||
}
|
||||
if len(hooks) > 0 {
|
||||
out[root+"hooks/hooks.json"] = PluginFile{Content: jsonFile(map[string]any{"hooks": hooks})}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isScript(rel, content string) bool {
|
||||
return strings.HasPrefix(content, "#!") || strings.HasSuffix(rel, ".sh")
|
||||
}
|
||||
|
||||
// MarketplaceKeys are the two managed settings keys that name the marketplace and enable the plugin.
|
||||
func MarketplaceKeys() map[string]any {
|
||||
return map[string]any{
|
||||
"extraKnownMarketplaces": map[string]any{Plugin: map[string]any{
|
||||
"source": map[string]any{"source": "directory", "path": filepath.Join(ManagedDir, MarketplaceDir)}}},
|
||||
"enabledPlugins": map[string]any{Plugin + "@" + Plugin: true},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredSettings is the settings registered for the mesh, with those registered for this node laid over.
|
||||
func RegisteredSettings(c Config) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
if it.Kind == KindSettings {
|
||||
out = mergeSettings(out, it.Settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSettings lays b over a: objects are merged key by key, lists are joined without repeats (a
|
||||
// permission rule or an auto-mode rule added for a node adds to the mesh's), and anything else is b's.
|
||||
func mergeSettings(a, b map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range a {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range b {
|
||||
switch nb := v.(type) {
|
||||
case map[string]any:
|
||||
if na, ok := out[k].(map[string]any); ok {
|
||||
out[k] = mergeSettings(na, nb)
|
||||
continue
|
||||
}
|
||||
case []any:
|
||||
if la, ok := out[k].([]any); ok {
|
||||
joined := append([]any{}, la...)
|
||||
seen := map[string]bool{}
|
||||
for _, x := range la {
|
||||
raw, _ := json.Marshal(x)
|
||||
seen[string(raw)] = true
|
||||
}
|
||||
for _, x := range nb {
|
||||
raw, _ := json.Marshal(x)
|
||||
if !seen[string(raw)] {
|
||||
seen[string(raw)] = true
|
||||
joined = append(joined, x)
|
||||
}
|
||||
}
|
||||
out[k] = joined
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InstructionSections is what the managed instruction file adds after the mesh's own text: the sections
|
||||
// registered for the mesh, then those for this node.
|
||||
func InstructionSections(c Config) string {
|
||||
var b strings.Builder
|
||||
for _, part := range []struct {
|
||||
title string
|
||||
items []Item
|
||||
}{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} {
|
||||
var sections []Item
|
||||
for _, it := range part.items {
|
||||
if it.Kind == KindInstructions {
|
||||
sections = append(sections, it)
|
||||
}
|
||||
}
|
||||
if len(sections) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title)
|
||||
for _, it := range sections {
|
||||
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// HomeFiles is what the home scope places in the operator account's agent directory, by path relative to
|
||||
// that directory.
|
||||
func HomeFiles(c Config) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, it := range c.Home {
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
for rel, content := range it.Files {
|
||||
out["skills/"+it.Name+"/"+rel] = content
|
||||
}
|
||||
case KindAgent:
|
||||
out["agents/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindCommand:
|
||||
out["commands/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindOutputStyle:
|
||||
out["output-styles/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindInstructions:
|
||||
out["rules/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the home ---------------------------------------------------------------------------------------
|
||||
|
||||
// Placed is what this module placed in the home, by path relative to the agent directory, with the digest
|
||||
// of what it wrote (ADR 0182: the mesh owns what it places, and only that).
|
||||
type Placed map[string]string
|
||||
|
||||
func digest(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// deletedByHand marks, in the record, a path the mesh placed and the person then deleted: their choice,
|
||||
// kept until the item is unregistered.
|
||||
const deletedByHand = "deleted-by-hand"
|
||||
|
||||
// PlaceHome brings the home in line with what the home scope wants (ADR 0182): it writes what is wanted and
|
||||
// absent, or its own; it never writes a path the person made, nor through a directory that is a symbolic
|
||||
// link; it removes what it placed and is no longer wanted, unless the person changed it since; and a file it
|
||||
// placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what
|
||||
// it left alone, and why.
|
||||
func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
var placed Placed
|
||||
if !readJSON(p.placed(), &placed) {
|
||||
placed = Placed{}
|
||||
}
|
||||
paths := make([]string, 0, len(want))
|
||||
for rel := range want {
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, rel := range paths {
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
content := want[rel]
|
||||
if why := linkedParent(dir, rel); why != "" {
|
||||
left = append(left, rel+": "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
exists := err == nil
|
||||
ours, wasPlaced := placed[rel]
|
||||
switch {
|
||||
case !exists && wasPlaced && ours == deletedByHand:
|
||||
continue
|
||||
case !exists && wasPlaced:
|
||||
placed[rel] = deletedByHand
|
||||
left = append(left, rel+": deleted by hand, left deleted until the item is unregistered")
|
||||
continue
|
||||
case exists && !wasPlaced:
|
||||
left = append(left, rel+": the person's own, left alone")
|
||||
continue
|
||||
case exists && ours == deletedByHand:
|
||||
left = append(left, rel+": made again by hand after the mesh's was deleted, left alone")
|
||||
continue
|
||||
case exists && string(current) == content:
|
||||
continue
|
||||
case exists && digest(string(current)) != ours:
|
||||
left = append(left, rel+": changed by hand since it was placed, left alone")
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
left = append(left, rel+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if isScript(rel, content) {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||
left = append(left, rel+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
placed[rel] = digest(content)
|
||||
done = append(done, rel+": placed")
|
||||
}
|
||||
for rel, ours := range placed {
|
||||
if _, wanted := want[rel]; wanted {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if ours == deletedByHand {
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
if why := linkedParent(dir, rel); why != "" {
|
||||
left = append(left, rel+": no longer registered, but "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
if err == nil && digest(string(current)) != ours {
|
||||
left = append(left, rel+": no longer registered, but changed by hand, left alone")
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
left = append(left, rel+": no longer registered, and could not be removed: "+err.Error())
|
||||
continue
|
||||
}
|
||||
// Up to the kind's own directory, never it: `skills/<name>` goes when empty, `skills` stays.
|
||||
removeEmptyParents(filepath.Join(dir, strings.SplitN(rel, "/", 2)[0]), filepath.Dir(full))
|
||||
delete(placed, rel)
|
||||
done = append(done, rel+": removed")
|
||||
}
|
||||
raw, _ := indented(placed)
|
||||
if err := os.WriteFile(p.placed(), raw, 0o600); err != nil {
|
||||
left = append(left, "the record of what was placed could not be saved: "+err.Error())
|
||||
}
|
||||
return done, left
|
||||
}
|
||||
|
||||
// linkedParent says, when a directory between the agent directory and a file is a symbolic link, which one:
|
||||
// writing through it would write wherever it points.
|
||||
func linkedParent(dir, rel string) string {
|
||||
parts := strings.Split(rel, "/")
|
||||
at := dir
|
||||
for _, seg := range parts[:len(parts)-1] {
|
||||
at = filepath.Join(at, seg)
|
||||
if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return at + " is a symbolic link"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// removeEmptyParents removes empty directories from dir up to, not including, root.
|
||||
func removeEmptyParents(root, dir string) {
|
||||
for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) {
|
||||
if err := os.Remove(dir); err != nil {
|
||||
return
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// HomeConflict says whether registering an item at the home scope here would collide with something the
|
||||
// person made: "" when it would not.
|
||||
func HomeConflict(p Paths, it Item) string {
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
for rel := range HomeFiles(Config{Home: []Item{it}}) {
|
||||
if _, ours := placed[rel]; ours {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil {
|
||||
return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- what the module did not place ------------------------------------------------------------------
|
||||
|
||||
// HomeItem is one item found in the home.
|
||||
type HomeItem struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Placed bool `json:"placedByTheMesh"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`)
|
||||
|
||||
// HomeItems lists the home's skills, subagents, commands, output styles and rule files, says which the mesh
|
||||
// placed, and notes those that share a name with a mesh item or call a tool server that is not loaded here.
|
||||
func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
inPlugin := map[string]bool{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
inPlugin[it.Kind+"/"+it.Name] = true
|
||||
}
|
||||
}
|
||||
var out []HomeItem
|
||||
add := func(kind, name, rel, body string) {
|
||||
h := HomeItem{Kind: kind, Name: name, Path: filepath.Join(dir, filepath.FromSlash(rel))}
|
||||
_, h.Placed = placed[rel]
|
||||
if inPlugin[kind+"/"+name] {
|
||||
h.Notes = append(h.Notes, "the mesh also registers a "+kind+" of this name, offered as "+Plugin+":"+name)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range mcpToolRef.FindAllStringSubmatch(body, -1) {
|
||||
if server := m[1]; !seen[server] && server != meshEntry && loaded[server] == nil {
|
||||
seen[server] = true
|
||||
h.Notes = append(h.Notes, "calls tools of `"+server+"`, a tool server not loaded on this node")
|
||||
}
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md"))
|
||||
if err != nil {
|
||||
continue // the vendor's own synced folders carry none at their top
|
||||
}
|
||||
add(KindSkill, e.Name(), "skills/"+e.Name()+"/SKILL.md", string(body))
|
||||
}
|
||||
}
|
||||
for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} {
|
||||
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name()))
|
||||
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return out[i].Kind < out[j].Kind
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ImportFromHome reads one item of this node's home as an item to register. A rule file becomes instructions.
|
||||
func ImportFromHome(p Paths, kind, name string) (Item, error) {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
it := Item{Kind: kind, Name: name, Files: map[string]string{}}
|
||||
switch kind {
|
||||
case KindSkill:
|
||||
root := filepath.Join(dir, "skills", name)
|
||||
err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
raw, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
it.Files[filepath.ToSlash(rel)] = string(raw)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("the skill %s in this home: %w", name, err)
|
||||
}
|
||||
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||
sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind]
|
||||
raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md"))
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err)
|
||||
}
|
||||
it.Files[name+".md"] = string(raw)
|
||||
default:
|
||||
return Item{}, fmt.Errorf("a %s is not imported from a home", kind)
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// ---- registering ------------------------------------------------------------------------------------
|
||||
|
||||
// ConfigState is the `config` state as this module reaches it through the runtime.
|
||||
type ConfigState interface {
|
||||
Put(key string, value any) error
|
||||
Delete(key string) error
|
||||
Keys() ([]string, error)
|
||||
Get(key string) (json.RawMessage, bool, error)
|
||||
}
|
||||
|
||||
// Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the
|
||||
// node and home scopes one key per node — this node when none is given. Taken into this node's view at once,
|
||||
// so the answer says what it did here.
|
||||
func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) {
|
||||
if !unregister {
|
||||
it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339)
|
||||
if problem := it.Problem(); problem != "" {
|
||||
return map[string]any{"registered": false, "reason": problem}, nil
|
||||
}
|
||||
} else if !knownKind(it.Kind) {
|
||||
return map[string]any{"unregistered": false, "reason": fmt.Sprintf("%q is not a kind", it.Kind)}, nil
|
||||
}
|
||||
if it.Scope == ScopeMesh {
|
||||
nodes = []string{""}
|
||||
} else if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
} else if problem := nodesProblem(nodes); problem != "" {
|
||||
return map[string]any{verbOf(unregister): false, "reason": problem}, nil
|
||||
}
|
||||
if !unregister && it.Scope == ScopeHome {
|
||||
for _, n := range nodes {
|
||||
if n == p.Node {
|
||||
if conflict := HomeConflict(p, it); conflict != "" {
|
||||
return map[string]any{"registered": false, "reason": conflict}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compared before and after rather than read from Take: this node's own watch may take the same change
|
||||
// first, and then Take here finds nothing new although this call made it.
|
||||
before, _ := json.Marshal(v.Items())
|
||||
var keys []string
|
||||
for _, n := range nodes {
|
||||
key := it.Key(n)
|
||||
keys = append(keys, key)
|
||||
var err error
|
||||
if unregister {
|
||||
err = state.Delete(key)
|
||||
} else {
|
||||
err = state.Put(key, it)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op := "put"
|
||||
if unregister {
|
||||
op = "delete"
|
||||
}
|
||||
item := it
|
||||
v.Take(key, op, &item)
|
||||
}
|
||||
after, _ := json.Marshal(v.Items())
|
||||
here := string(before) != string(after)
|
||||
for _, k := range keys {
|
||||
here = here || v.Applies(k)
|
||||
}
|
||||
verb := map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||
answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys}
|
||||
if !unregister {
|
||||
switch {
|
||||
case it.Scope == ScopeHome && it.Kind == KindCommand:
|
||||
answer["offered as"] = "/" + it.Name
|
||||
case it.Scope == ScopeHome:
|
||||
answer["offered as"] = it.Name + ", from the home"
|
||||
case it.Kind == KindSkill || it.Kind == KindAgent || it.Kind == KindOutputStyle:
|
||||
answer["offered as"] = Plugin + ":" + it.Name
|
||||
case it.Kind == KindCommand:
|
||||
answer["offered as"] = "/" + Plugin + ":" + it.Name
|
||||
}
|
||||
}
|
||||
if here {
|
||||
// Rendered whenever it applies here, so the answer says what this node wrote, whichever of the
|
||||
// watch and this call took the change.
|
||||
rendered, err := RenderNow(p, write)
|
||||
answer["rendered here"] = rendered
|
||||
if err != nil {
|
||||
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
|
||||
}
|
||||
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
|
||||
} else {
|
||||
answer["here"] = "not this node: each node it is for takes it from the bus"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func verbOf(unregister bool) string {
|
||||
return map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||
}
|
||||
|
||||
// nodesProblem says why a list of nodes cannot name keys, or "". "all" has been expanded before this.
|
||||
func nodesProblem(nodes []string) string {
|
||||
for _, n := range nodes {
|
||||
if !nodeName.MatchString(n) {
|
||||
return fmt.Sprintf("%q is not a node's name", n)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExpandNodes turns `all` into every node running the module; anything else is kept.
|
||||
func ExpandNodes(nodes []string, running func() ([]string, error)) ([]string, error) {
|
||||
if len(nodes) != 1 || nodes[0] != "all" {
|
||||
return nodes, nil
|
||||
}
|
||||
all, err := running()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("which nodes run claude-code: %w", err)
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, fmt.Errorf("no node is known to run claude-code")
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state.
|
||||
func List(state ConfigState, kind string) (map[string]any, error) {
|
||||
keys, err := state.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := map[string]any{}
|
||||
for _, key := range keys {
|
||||
_, _, k, _, ok := ParseKey(key)
|
||||
if !ok || (kind != "" && k != kind) {
|
||||
continue
|
||||
}
|
||||
raw, found, err := state.Get(key)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
var it Item
|
||||
if json.Unmarshal(raw, &it) != nil {
|
||||
continue
|
||||
}
|
||||
files := make([]string, 0, len(it.Files))
|
||||
for f := range it.Files {
|
||||
files = append(files, f)
|
||||
}
|
||||
sort.Strings(files)
|
||||
summary := map[string]any{"by": it.By, "at": it.At}
|
||||
if len(files) > 0 {
|
||||
summary["files"] = files
|
||||
}
|
||||
if it.Kind == KindHook {
|
||||
summary["event"], summary["matcher"], summary["command"] = it.Event, it.Matcher, it.Command
|
||||
}
|
||||
if it.Kind == KindSettings {
|
||||
summary["settings"] = it.Settings
|
||||
}
|
||||
out[key] = summary
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Show is one registration in full: its files' content included.
|
||||
func Show(state ConfigState, key string) (any, error) {
|
||||
raw, found, err := state.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("nothing is registered at %s", key)
|
||||
}
|
||||
var it Item
|
||||
if err := json.Unmarshal(raw, &it); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package main
|
||||
|
||||
// The agent's configuration registered at three scopes (novox/hq ADR 0216): each test is one row of the
|
||||
// record's "How it is checked".
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// memConfig is the `config` state as a map, shared by the nodes of a test the way the bus shares it.
|
||||
type memConfig map[string]json.RawMessage
|
||||
|
||||
func (m memConfig) Put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
m[key] = raw
|
||||
return err
|
||||
}
|
||||
func (m memConfig) Delete(key string) error { delete(m, key); return nil }
|
||||
func (m memConfig) Keys() ([]string, error) {
|
||||
out := []string{}
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m memConfig) Get(key string) (json.RawMessage, bool, error) {
|
||||
raw, ok := m[key]
|
||||
return raw, ok, nil
|
||||
}
|
||||
|
||||
// deliver hands every key of the state to a node's view, as its watch would.
|
||||
func deliver(m memConfig, v *ConfigView) {
|
||||
for key, raw := range m {
|
||||
var it Item
|
||||
_ = json.Unmarshal(raw, &it)
|
||||
v.Take(key, "put", &it)
|
||||
}
|
||||
}
|
||||
|
||||
func one(name, content string) map[string]string { return map[string]string{name + ".md": content} }
|
||||
|
||||
func pluginOf(t *testing.T, w map[string]string) map[string]PluginFile {
|
||||
t.Helper()
|
||||
var files map[string]PluginFile
|
||||
if err := json.Unmarshal([]byte(w[MarketplaceDir+"/"]), &files); err != nil {
|
||||
t.Fatalf("no marketplace written: %v", err)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func TestEachKindLandsInItsOnePlace(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
register := func(it Item) {
|
||||
t.Helper()
|
||||
answer, err := Register(p, it, nil, false, state, view, writer(w))
|
||||
if err != nil || answer["registered"] == false {
|
||||
t.Fatalf("%s %s: %v %v", it.Kind, it.Name, answer, err)
|
||||
}
|
||||
}
|
||||
register(Item{Kind: KindSkill, Name: "review", Scope: ScopeMesh,
|
||||
Files: map[string]string{"SKILL.md": "---\nname: review\ndescription: d\n---\nbody", "scripts/run.sh": "#!/bin/sh\necho hi\n"}})
|
||||
register(Item{Kind: KindAgent, Name: "reviewer", Scope: ScopeMesh, Files: one("reviewer", "---\nname: reviewer\n---\nx")})
|
||||
register(Item{Kind: KindCommand, Name: "ship", Scope: ScopeMesh, Files: one("ship", "ship it")})
|
||||
register(Item{Kind: KindOutputStyle, Name: "terse", Scope: ScopeMesh, Files: one("terse", "---\nname: terse\n---\nshort")})
|
||||
register(Item{Kind: KindHook, Name: "guard", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
|
||||
Command: "${HOOK_DIR}/guard.sh", Files: map[string]string{"guard.sh": "#!/bin/sh\nexit 0\n"}})
|
||||
register(Item{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: one("conventions", "Commit in the imperative.")})
|
||||
register(Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh,
|
||||
Settings: map[string]any{"permissions": map[string]any{"deny": []any{"Bash(rm -rf:*)"}}}})
|
||||
|
||||
plugin := pluginOf(t, w)
|
||||
root := "plugins/" + Plugin + "/"
|
||||
for _, want := range []string{".claude-plugin/marketplace.json", root + ".claude-plugin/plugin.json",
|
||||
root + "skills/review/SKILL.md", root + "skills/review/scripts/run.sh", root + "agents/reviewer.md",
|
||||
root + "commands/ship.md", root + "output-styles/terse.md", root + "hooks/hooks.json", root + "hooks/guard/guard.sh"} {
|
||||
if _, ok := plugin[want]; !ok {
|
||||
t.Errorf("the plugin lacks %s", want)
|
||||
}
|
||||
}
|
||||
if !plugin[root+"skills/review/scripts/run.sh"].Executable || !plugin[root+"hooks/guard/guard.sh"].Executable {
|
||||
t.Error("a script is not executable")
|
||||
}
|
||||
var hooks struct {
|
||||
Hooks map[string][]struct {
|
||||
Matcher string `json:"matcher"`
|
||||
Hooks []struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"hooks"`
|
||||
} `json:"hooks"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks)
|
||||
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 1 || pre[0].Matcher != "Bash" ||
|
||||
pre[0].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
|
||||
t.Errorf("the hook's command does not name its directory, quoted: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.") {
|
||||
t.Errorf("the instruction section is not in the managed instruction file:\n%s", w["CLAUDE.md"])
|
||||
}
|
||||
var managed map[string]any
|
||||
_ = json.Unmarshal([]byte(w["managed-settings.json"]), &managed)
|
||||
if managed["permissions"] == nil || managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
|
||||
t.Errorf("managed settings: %v", managed)
|
||||
}
|
||||
if _, inPlugin := plugin[root+"settings.json"]; inPlugin {
|
||||
t.Error("settings were put in the plugin, which drops them")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T) {
|
||||
a, wa := node(t, "laptop")
|
||||
b, wb := node(t, "server")
|
||||
state := memConfig{}
|
||||
va, vb := NewConfigView(a), NewConfigView(b)
|
||||
for _, r := range []struct {
|
||||
it Item
|
||||
nodes []string
|
||||
}{
|
||||
{Item{Kind: KindCommand, Name: "everywhere", Scope: ScopeMesh, Files: one("everywhere", "x")}, nil},
|
||||
{Item{Kind: KindCommand, Name: "server-only", Scope: ScopeNode, Files: one("server-only", "x")}, []string{"server"}},
|
||||
{Item{Kind: KindCommand, Name: "at-home", Scope: ScopeHome, Files: one("at-home", "x")}, []string{"laptop"}},
|
||||
} {
|
||||
if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
deliver(state, vb)
|
||||
if _, err := RenderNow(b, writer(wb)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := "plugins/" + Plugin + "/commands/"
|
||||
pa, pb := pluginOf(t, wa), pluginOf(t, wb)
|
||||
if _, ok := pa[root+"everywhere.md"]; !ok {
|
||||
t.Error("the mesh item is missing on the laptop")
|
||||
}
|
||||
if _, ok := pb[root+"everywhere.md"]; !ok {
|
||||
t.Error("the mesh item is missing on the server")
|
||||
}
|
||||
if _, ok := pa[root+"server-only.md"]; ok {
|
||||
t.Error("the server's item reached the laptop")
|
||||
}
|
||||
if _, ok := pb[root+"server-only.md"]; !ok {
|
||||
t.Error("the server's item is missing on the server")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(a.Home, ".claude", "commands", "at-home.md")); err != nil {
|
||||
t.Error("the home item was not placed in the laptop's home")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(b.Home, ".claude", "commands", "at-home.md")); err == nil {
|
||||
t.Error("the laptop's home item reached the server's home")
|
||||
}
|
||||
if _, ok := pa[root+"at-home.md"]; ok {
|
||||
t.Error("a home item went into the plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMeshsOwnKeysCannotBeSetOrReplaced(t *testing.T) {
|
||||
for _, key := range []string{"extraKnownMarketplaces", "enabledPlugins", "attribution", "apiKeyHelper"} {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{key: true}}
|
||||
if it.Problem() == "" {
|
||||
t.Errorf("a registration could set %s", key)
|
||||
}
|
||||
}
|
||||
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"enabledPlugins": map[string]any{Plugin + "@" + Plugin: false}}},
|
||||
nil, "/h", nil, Config{})
|
||||
var managed map[string]any
|
||||
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||
if managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
|
||||
t.Error("the operator's setting turned the mesh's plugin off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsLayMeshThenNodeAndJoinTheirLists(t *testing.T) {
|
||||
c := Config{
|
||||
Mesh: []Item{{Kind: KindSettings, Scope: ScopeMesh, Settings: map[string]any{
|
||||
"permissions": map[string]any{"deny": []any{"A"}}, "model": "mesh-model"}}},
|
||||
Node: []Item{{Kind: KindSettings, Scope: ScopeNode, Settings: map[string]any{
|
||||
"permissions": map[string]any{"deny": []any{"A", "B"}}, "model": "node-model"}}},
|
||||
}
|
||||
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"permissions": map[string]any{"allow": []any{"C"}}}},
|
||||
nil, "/h", nil, c)
|
||||
var managed struct {
|
||||
Permissions map[string][]string `json:"permissions"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||
if strings.Join(managed.Permissions["deny"], ",") != "A,B" || strings.Join(managed.Permissions["allow"], ",") != "C" || managed.Model != "node-model" {
|
||||
t.Fatalf("%+v", managed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHomeScopeOwnsOnlyWhatItPlaced(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
mine := filepath.Join(p.Home, ".claude", "agents", "mine.md")
|
||||
_ = os.MkdirAll(filepath.Dir(mine), 0o755)
|
||||
writeFile(t, mine, "the person's own")
|
||||
|
||||
answer, _ := Register(p, Item{Kind: KindAgent, Name: "mine", Scope: ScopeHome, Files: one("mine", "the mesh's")}, nil, false, state, view, writer(w))
|
||||
if answer["registered"] != false {
|
||||
t.Fatalf("a name the person uses was taken: %v", answer)
|
||||
}
|
||||
if raw, _ := os.ReadFile(mine); string(raw) != "the person's own" {
|
||||
t.Fatal("the person's file was overwritten")
|
||||
}
|
||||
|
||||
placed := filepath.Join(p.Home, ".claude", "agents", "placed.md")
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, _ := os.ReadFile(placed); string(raw) != "v1" {
|
||||
t.Fatal("the home item was not placed")
|
||||
}
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(placed); err == nil {
|
||||
t.Fatal("unregistering did not remove what the mesh placed")
|
||||
}
|
||||
if _, err := os.Stat(mine); err != nil {
|
||||
t.Fatal("unregistering removed the person's file")
|
||||
}
|
||||
|
||||
// Changed by hand after it was placed: left alone when unregistered.
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome, Files: one("edited", "v1")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edited := filepath.Join(p.Home, ".claude", "agents", "edited.md")
|
||||
writeFile(t, edited, "changed by hand")
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, _ := os.ReadFile(edited); string(raw) != "changed by hand" {
|
||||
t.Fatal("a placed file changed by hand was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnItemAboveTheLimitOrMisshapenIsRefused(t *testing.T) {
|
||||
big := Item{Kind: KindSkill, Name: "big", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": strings.Repeat("x", MaxItemBytes+1)}}
|
||||
cases := map[string]Item{
|
||||
"too big": big,
|
||||
"a bad name": {Kind: KindAgent, Name: "Bad Name", Scope: ScopeMesh, Files: one("Bad Name", "x")},
|
||||
"a skill without one": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"other.md": "x"}},
|
||||
"a path outside": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": "x", "../escape": "x"}},
|
||||
"a hook at home": {Kind: KindHook, Name: "h", Scope: ScopeHome, Event: "Stop", Command: "true"},
|
||||
"settings at home": {Kind: KindSettings, Name: SettingsName, Scope: ScopeHome, Settings: map[string]any{"model": "x"}},
|
||||
"an unknown event": {Kind: KindHook, Name: "h", Scope: ScopeMesh, Event: "Whenever", Command: "true"},
|
||||
}
|
||||
for label, it := range cases {
|
||||
if it.Problem() == "" {
|
||||
t.Errorf("%s was accepted", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\nuse mcp__gone__do_it")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||
it, err := ImportFromHome(p, KindSkill, "old")
|
||||
if err != nil || len(it.Files) != 2 || it.Files["scripts/a.sh"] == "" {
|
||||
t.Fatalf("%+v %v", it, err)
|
||||
}
|
||||
items := HomeItems(p, Config{Mesh: []Item{{Kind: KindSkill, Name: "old"}}}, Servers{})
|
||||
if len(items) != 1 || len(items[0].Notes) != 2 {
|
||||
t.Fatalf("%+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced.
|
||||
func TestTheManifestListsEveryToolServed(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
listed := map[string]bool{}
|
||||
for _, n := range m.Tools {
|
||||
listed[n] = true
|
||||
}
|
||||
p, _ := node(t, "laptop")
|
||||
served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p))
|
||||
for _, tool := range served {
|
||||
if !listed[tool.Name] {
|
||||
t.Errorf("%s is served and not in the manifest", tool.Name)
|
||||
}
|
||||
delete(listed, tool.Name)
|
||||
}
|
||||
for n := range listed {
|
||||
t.Errorf("%s is in the manifest and not served", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The tree writer's comparison: a directory holding exactly the files given, executable bits included.
|
||||
func TestATreeIsTheSameOnlyWhenEveryFileAndModeIs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]PluginFile{"a.md": {Content: "a"}, "s/run.sh": {Content: "#!/bin/sh\n", Executable: true}}
|
||||
_ = os.MkdirAll(filepath.Join(dir, "s"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "a.md"), "a")
|
||||
writeFile(t, filepath.Join(dir, "s", "run.sh"), "#!/bin/sh\n")
|
||||
if sameTree(dir, files) {
|
||||
t.Fatal("a script without its executable bit counted as the same")
|
||||
}
|
||||
_ = os.Chmod(filepath.Join(dir, "s", "run.sh"), 0o755)
|
||||
if !sameTree(dir, files) {
|
||||
t.Fatal("the same tree counted as different")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "extra.md"), "x")
|
||||
if sameTree(dir, files) {
|
||||
t.Fatal("an extra file counted as the same")
|
||||
}
|
||||
}
|
||||
|
||||
// A registration removed while a node was away is dropped when it is back: the watch hands over only what is
|
||||
// there, so the view is pruned to the keys the state still holds.
|
||||
func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
for _, name := range []string{"kept", "gone"} {
|
||||
if _, err := Register(p, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
delete(state, "mesh.command.gone") // removed from another node while this one was off
|
||||
back := NewConfigView(p) // the node starts again from what it last wrote
|
||||
if len(back.Items()) != 2 {
|
||||
t.Fatalf("the view did not start from what was last written: %v", back.Items())
|
||||
}
|
||||
keys, _ := state.Keys()
|
||||
if !back.Prune(keys) || len(back.Items()) != 1 {
|
||||
t.Fatalf("after pruning: %v", back.Items())
|
||||
}
|
||||
}
|
||||
|
||||
// The review's findings, each held by a test.
|
||||
|
||||
func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
|
||||
for label, files := range map[string]map[string]string{
|
||||
"a dot": {"SKILL.md": "x", ".": "x"},
|
||||
"an empty segment": {"SKILL.md": "x", "a//b": "x"},
|
||||
"a parent segment": {"SKILL.md": "x", "a/../b": "x"},
|
||||
"a file and directory": {"SKILL.md": "x", "a": "x", "a/b": "x"},
|
||||
} {
|
||||
if (Item{Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: files}).Problem() == "" {
|
||||
t.Errorf("%s was accepted", label)
|
||||
}
|
||||
}
|
||||
p, _ := node(t, "laptop")
|
||||
v := NewConfigView(p)
|
||||
home := Item{Kind: KindCommand, Name: "x", Scope: ScopeHome, Files: one("x", "y")}
|
||||
if v.Take("mesh.command.x", "put", &home); len(v.Items()) != 0 {
|
||||
t.Fatal("a mesh key holding a home item was taken")
|
||||
}
|
||||
for _, key := range []string{"mesh.command.x", "mesh.agent.x"} {
|
||||
mesh := Item{Kind: KindCommand, Name: "x", Scope: ScopeMesh, Files: one("x", "y")}
|
||||
v.Take(key, "put", &mesh)
|
||||
}
|
||||
if len(v.Items()) != 1 {
|
||||
t.Fatalf("an item under a key of another kind was taken: %v", v.Items())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
|
||||
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
|
||||
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
|
||||
t.Fatalf("%v %v", nodes, err)
|
||||
}
|
||||
p, w := node(t, "laptop")
|
||||
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
|
||||
if answer["registered"] != false {
|
||||
t.Fatalf("a node name with a dot was used in a key: %v", answer)
|
||||
}
|
||||
if (Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{"deniedMcpServers": []any{}}}).Problem() == "" {
|
||||
t.Fatal("a registration could deny the mesh's console")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOperatorsOwnPluginsAndMarketplacesAreKept(t *testing.T) {
|
||||
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{
|
||||
"enabledPlugins": map[string]any{"theirs@market": true},
|
||||
"extraKnownMarketplaces": map[string]any{"market": map[string]any{"source": map[string]any{"source": "github", "repo": "o/r"}}},
|
||||
}}, nil, "/h", nil, Config{})
|
||||
var managed struct {
|
||||
Enabled map[string]any `json:"enabledPlugins"`
|
||||
Known map[string]any `json:"extraKnownMarketplaces"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||
if managed.Enabled["theirs@market"] != true || managed.Enabled[Plugin+"@"+Plugin] != true || managed.Known["market"] == nil || managed.Known[Plugin] == nil {
|
||||
t.Fatalf("%+v", managed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHomeLeavesThePersonsChoicesAlone(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
// An identical file the person made is not taken over, so unregistering never removes it.
|
||||
_ = os.MkdirAll(filepath.Join(dir, "agents"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "agents", "same.md"), "x")
|
||||
if _, left := PlaceHome(p, map[string]string{"agents/same.md": "x"}); len(left) != 1 {
|
||||
t.Fatalf("an identical file of the person's was taken over: %v", left)
|
||||
}
|
||||
PlaceHome(p, map[string]string{})
|
||||
if _, err := os.Stat(filepath.Join(dir, "agents", "same.md")); err != nil {
|
||||
t.Fatal("the person's file was removed")
|
||||
}
|
||||
// A placed file the person deleted stays deleted while it is registered.
|
||||
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||
_ = os.Remove(filepath.Join(dir, "commands", "c.md"))
|
||||
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||
if _, err := os.Stat(filepath.Join(dir, "commands", "c.md")); err == nil {
|
||||
t.Fatal("a file the person deleted was placed again")
|
||||
}
|
||||
// The kind's own directory stays when the mesh's last item in it goes.
|
||||
PlaceHome(p, map[string]string{"skills/s/SKILL.md": "x"})
|
||||
PlaceHome(p, map[string]string{})
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); err == nil {
|
||||
t.Fatal("the item's own directory was left")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills")); err != nil {
|
||||
t.Fatal("the kind's directory was removed")
|
||||
}
|
||||
// Never through a symbolic link.
|
||||
elsewhere := t.TempDir()
|
||||
if err := os.Symlink(elsewhere, filepath.Join(dir, "output-styles")); err != nil {
|
||||
t.Skip("no symbolic links here")
|
||||
}
|
||||
PlaceHome(p, map[string]string{"output-styles/o.md": "x"})
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "o.md")); err == nil {
|
||||
t.Fatal("a file was written through a symbolic link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
w := map[string]string{}
|
||||
failing := func(name, content string) (string, error) {
|
||||
if name == MarketplaceDir+"/" {
|
||||
return "", os.ErrPermission
|
||||
}
|
||||
w[name] = content
|
||||
return name + ": written", nil
|
||||
}
|
||||
if _, err := RenderNow(p, failing); err == nil {
|
||||
t.Fatal("the failure was not reported")
|
||||
}
|
||||
if w["managed-settings.json"] == "" || w["managed-mcp.json"] == "" || w["CLAUDE.md"] == "" {
|
||||
t.Fatalf("the other files were not written: %v", w)
|
||||
}
|
||||
}
|
||||
|
||||
// The answer says what this node wrote even when its own watch took the change first.
|
||||
func TestTheAnswerSaysWhatWasWrittenWhenTheWatchWasFirst(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
it := Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}
|
||||
first := it
|
||||
view.Take(it.Key("laptop"), "put", &first) // the watch, first
|
||||
answer, err := Register(p, it, nil, false, state, view, writer(w))
|
||||
if err != nil || answer["rendered here"] == nil || answer["here"] != nil {
|
||||
t.Fatalf("%v %v", answer, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
// The tools that register the agent's configuration (novox/hq ADR 0216): for each kind a list, a register and an
|
||||
// unregister; for settings, a read, a set, a clear and the permission rules; and the status and import tools
|
||||
// for what the module did not place.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// kindTool is how one kind is named in its tools and described to whoever calls them.
|
||||
type kindTool struct {
|
||||
kind, tool, what, lands string
|
||||
}
|
||||
|
||||
var kindTools = []kindTool{
|
||||
{KindSkill, "skill", "a skill: a folder with a SKILL.md (front matter `name` and `description`) and any files beside it",
|
||||
"the plugin's skills/<name>/ (home: ~/.claude/skills/<name>/)"},
|
||||
{KindAgent, "agent", "a subagent: one markdown file with front matter (`name`, `description`, optionally `tools`, `model`)",
|
||||
"the plugin's agents/<name>.md (home: ~/.claude/agents/<name>.md)"},
|
||||
{KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)",
|
||||
"the plugin's commands/<name>.md, run as /" + Plugin + ":<name> (home: ~/.claude/commands/<name>.md, run as /<name>)"},
|
||||
{KindHook, "hook", "a hook: an event, an optional matcher, a command, and optionally the scripts it runs — ${HOOK_DIR} in the command is their directory",
|
||||
"the plugin's hooks/hooks.json, its scripts in hooks/<name>/ (mesh and node scopes only)"},
|
||||
{KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)",
|
||||
"the plugin's output-styles/<name>.md (home: ~/.claude/output-styles/<name>.md)"},
|
||||
{KindInstructions, "instructions", "a section of instructions every session reads",
|
||||
"a section of the managed instruction file, after the mesh's own text (home: a rule file, ~/.claude/rules/<name>.md)"},
|
||||
}
|
||||
|
||||
func boolArg(a map[string]any, k string) bool { b, _ := a[k].(bool); return b }
|
||||
func strArg(a map[string]any, k string) string {
|
||||
s, _ := a[k].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list.
|
||||
func targetNodes(a map[string]any) ([]string, error) {
|
||||
return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe)
|
||||
}
|
||||
|
||||
// scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for.
|
||||
func scopeOf(a map[string]any) string {
|
||||
if s := strArg(a, "scope"); s != "" {
|
||||
return s
|
||||
}
|
||||
return ScopeMesh
|
||||
}
|
||||
|
||||
// filesOf reads an item's files: `content` for a one-file kind, or `files`, a map of path to content.
|
||||
func filesOf(kind, name string, a map[string]any) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if raw, ok := a["files"].(map[string]any); ok {
|
||||
for rel, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("files.%s is not text", rel)
|
||||
}
|
||||
out[rel] = s
|
||||
}
|
||||
}
|
||||
if content, ok := a["content"].(string); ok && content != "" {
|
||||
switch kind {
|
||||
case KindSkill:
|
||||
out["SKILL.md"] = content
|
||||
case KindHook:
|
||||
return nil, errors.New("a hook's scripts are given as files")
|
||||
default:
|
||||
out[name+".md"] = content
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`)
|
||||
nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`)
|
||||
var out []stdio.Tool
|
||||
for _, k := range kindTools {
|
||||
k := k
|
||||
input := map[string]any{
|
||||
"name": str("the name: lower-case letters, digits and -"),
|
||||
"scope": scopeArg,
|
||||
"nodes": nodesArg,
|
||||
}
|
||||
switch k.kind {
|
||||
case KindSkill:
|
||||
input["content"] = str("the SKILL.md, when the skill is that one file")
|
||||
input["files"] = map[string]any{"type": "object", "description": "the skill's files by path inside it, SKILL.md among them, e.g. {\"SKILL.md\": \"...\", \"scripts/run.sh\": \"#!/bin/sh ...\"}"}
|
||||
case KindHook:
|
||||
input["event"] = str("PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart, SessionEnd or PreCompact")
|
||||
input["matcher"] = str("for tool events, which tools, e.g. Bash or Edit|Write; absent is every one")
|
||||
input["command"] = str("the shell command; ${HOOK_DIR} is the directory of the files given, e.g. ${HOOK_DIR}/check.sh")
|
||||
input["files"] = map[string]any{"type": "object", "description": "the scripts the command runs, by path, e.g. {\"check.sh\": \"#!/bin/sh ...\"}"}
|
||||
default:
|
||||
input["content"] = str("the file's content, front matter included")
|
||||
}
|
||||
out = append(out,
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_list",
|
||||
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
|
||||
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
|
||||
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, or at home where the person already has one of that name.",
|
||||
Input: input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
name := strArg(a, "name")
|
||||
files, err := filesOf(k.kind, name, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
|
||||
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_unregister",
|
||||
Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.",
|
||||
Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||
}},
|
||||
)
|
||||
}
|
||||
|
||||
settingsScope := str(`mesh (every node; the default) or node (the nodes given, or this one)`)
|
||||
out = append(out,
|
||||
stdio.Tool{Name: "claude_code_settings_get",
|
||||
Description: "Claude Code's managed settings on this node as written, and where each part came from: the operator's `managed_settings` setting (ADR 0213), the settings registered for the mesh and for this node, and the mesh's own keys, which always win.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
written := map[string]any{}
|
||||
_ = readJSON(filepath.Join(ManagedDir, "managed-settings.json"), &written)
|
||||
var settings Settings
|
||||
_ = readJSON(p.Settings, &settings)
|
||||
c := ConfigOf(view.Items())
|
||||
layers := map[string]any{"managed_settings setting": settings.ManagedSettings}
|
||||
for _, it := range c.Mesh {
|
||||
if it.Kind == KindSettings {
|
||||
layers["registered for the mesh"] = it.Settings
|
||||
}
|
||||
}
|
||||
for _, it := range c.Node {
|
||||
if it.Kind == KindSettings {
|
||||
layers["registered for this node"] = it.Settings
|
||||
}
|
||||
}
|
||||
return map[string]any{"written": written, "layers": layers,
|
||||
"order": "managed_settings setting, then mesh, then node — objects merged, lists joined — then the mesh's own keys"}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_set",
|
||||
Description: "Set Claude Code settings (the vendor's settings keys: permissions, autoMode, env, model, hooks, statusLine, …) for every node or some. Merged into what that scope holds — objects key by key, lists joined — unless replace is set. The mesh's own keys (attribution, the connectors key, the key-helper, the plugin's marketplace) cannot be set. The agent refuses to loosen its own settings: this is the operator's act.",
|
||||
Input: map[string]any{
|
||||
"settings": map[string]any{"type": "object", "description": "settings keys, e.g. {\"permissions\": {\"deny\": [\"Bash(rm -rf:*)\"]}}"},
|
||||
"replace": map[string]any{"type": "boolean", "description": "replace what the scope holds instead of merging into it"},
|
||||
"scope": settingsScope, "nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
given, _ := a["settings"].(map[string]any)
|
||||
if len(given) == 0 {
|
||||
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
if boolArg(a, "replace") {
|
||||
return given
|
||||
}
|
||||
return mergeSettings(held, given)
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_clear",
|
||||
Description: "Remove the Claude Code settings registered at a scope.",
|
||||
Input: map[string]any{"scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_permission_add",
|
||||
Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.",
|
||||
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
|
||||
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}})
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_permission_remove",
|
||||
Description: "Remove a permission rule added through this module, at its scope.",
|
||||
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
perms, _ := held["permissions"].(map[string]any)
|
||||
rules, _ := perms[list].([]any)
|
||||
if len(rules) == 0 {
|
||||
return held // nothing to remove: what the scope holds stays as it is
|
||||
}
|
||||
kept := []any{}
|
||||
for _, r := range rules {
|
||||
if r != rule {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
next := mergeSettings(map[string]any{}, held)
|
||||
np := mergeSettings(map[string]any{}, perms)
|
||||
np[list] = kept
|
||||
next["permissions"] = np
|
||||
return next
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_config_list",
|
||||
Description: "Everything registered for Claude Code on the mesh through this module — skills, subagents, commands, hooks, output styles, instruction sections, settings — by key, with who registered each and its files.",
|
||||
Run: func(map[string]any) (any, error) { return List(state, "") }},
|
||||
stdio.Tool{Name: "claude_code_config_show",
|
||||
Description: "One registration in full, its files' content included, by its key as a list shows it (e.g. mesh.skill.review).",
|
||||
Input: map[string]any{"key": str("the key")},
|
||||
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
|
||||
stdio.Tool{Name: "claude_code_config_status",
|
||||
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles and rule files — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
c := ConfigOf(view.Items())
|
||||
names := func(items []Item) []string {
|
||||
out := []string{}
|
||||
for _, it := range items {
|
||||
out = append(out, it.Kind+" "+it.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
var mcp struct {
|
||||
MCPServers Servers `json:"mcpServers"`
|
||||
}
|
||||
_ = readJSON(filepath.Join(ManagedDir, "managed-mcp.json"), &mcp)
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
plugin := []string{}
|
||||
root := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin)
|
||||
_ = filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() {
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
plugin = append(plugin, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return map[string]any{
|
||||
"applies here": map[string]any{"mesh": names(c.Mesh), "node": names(c.Node), "home": names(c.Home)},
|
||||
"plugin": map[string]any{"name": Plugin, "files": plugin},
|
||||
"home": HomeItems(p, c, mcp.MCPServers),
|
||||
"placed": placed,
|
||||
}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_config_import",
|
||||
Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.",
|
||||
Input: map[string]any{
|
||||
"kind": str("skill, agent, command, output-style or instructions (a rule file)"),
|
||||
"name": str("its name in the home: the skill's folder, or the file without .md"),
|
||||
"scope": scopeArg, "nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it, err := ImportFromHome(p, strArg(a, "kind"), strArg(a, "name"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Scope = scopeOf(a)
|
||||
if it.Scope == ScopeHome {
|
||||
return nil, errors.New("it is already at home; import it to the mesh or node scope")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetSettings changes the settings registered at a scope, one key per node, by what change makes of what
|
||||
// the key holds. An empty result removes the key.
|
||||
func SetSettings(p Paths, scope string, nodes []string, change func(held map[string]any) map[string]any,
|
||||
state ConfigState, view *ConfigView) (map[string]any, error) {
|
||||
if scope != ScopeMesh && scope != ScopeNode {
|
||||
return map[string]any{"set": false, "reason": "settings take the mesh and node scopes only"}, nil
|
||||
}
|
||||
if scope == ScopeMesh {
|
||||
nodes = []string{""}
|
||||
} else if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
} else if problem := nodesProblem(nodes); problem != "" {
|
||||
return map[string]any{"set": false, "reason": problem}, nil
|
||||
}
|
||||
answers := map[string]any{}
|
||||
for _, n := range nodes {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scope}
|
||||
held := map[string]any{}
|
||||
if raw, found, err := state.Get(it.Key(n)); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
var was Item
|
||||
if json.Unmarshal(raw, &was) == nil && was.Settings != nil {
|
||||
held = was.Settings
|
||||
}
|
||||
}
|
||||
it.Settings = change(held)
|
||||
target := []string(nil)
|
||||
if n != "" {
|
||||
target = []string{n}
|
||||
}
|
||||
var answer map[string]any
|
||||
var err error
|
||||
if len(it.Settings) == 0 {
|
||||
answer, err = Register(p, it, target, true, state, view, writeManaged)
|
||||
} else {
|
||||
answer, err = Register(p, it, target, false, state, view, writeManaged)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer["settings"] = it.Settings
|
||||
answers[it.Key(n)] = answer
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
@@ -30,6 +30,9 @@ func say(format string, args ...any) {
|
||||
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
||||
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
||||
func writeManaged(name, content string) (string, error) {
|
||||
if strings.HasSuffix(name, "/") {
|
||||
return writeManagedTree(strings.TrimSuffix(name, "/"), content)
|
||||
}
|
||||
path := filepath.Join(ManagedDir, name)
|
||||
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
||||
return name + ": unchanged", nil
|
||||
@@ -54,6 +57,74 @@ func writeManaged(name, content string) (string, error) {
|
||||
return name + ": written", nil
|
||||
}
|
||||
|
||||
// writeManagedTree replaces one directory of the managed directory whole — the plugin's marketplace (ADR
|
||||
// 0216) — when what it holds differs from the files given (a JSON map of path to PluginFile). Staged
|
||||
// beside, then swapped in as root, so a session never reads half of it.
|
||||
func writeManagedTree(name, content string) (string, error) {
|
||||
var files map[string]PluginFile
|
||||
if err := json.Unmarshal([]byte(content), &files); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(ManagedDir, name)
|
||||
if sameTree(target, files) {
|
||||
return name + "/: unchanged", nil
|
||||
}
|
||||
staged, err := os.MkdirTemp("", "claude-code-tree-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(staged)
|
||||
for rel, f := range files {
|
||||
full := filepath.Join(staged, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if f.Executable {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(f.Content), mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Chmod(full, mode)
|
||||
}
|
||||
_ = os.Chmod(staged, 0o755)
|
||||
script := `set -e; rm -rf "$2.next" "$2.old"; cp -r "$1" "$2.next"; chown -R root:root "$2.next"; chmod -R go-w,a+rX "$2.next";
|
||||
if [ -d "$2" ]; then mv "$2" "$2.old"; fi; mv "$2.next" "$2"; rm -rf "$2.old"`
|
||||
args := []string{"sh", "-c", script, "sh", staged, target}
|
||||
if os.Geteuid() != 0 {
|
||||
args = append([]string{"sudo", "-n"}, args...)
|
||||
}
|
||||
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("%s/: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
||||
name, ManagedDir, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return fmt.Sprintf("%s/: written, %d file(s)", name, len(files)), nil
|
||||
}
|
||||
|
||||
// sameTree says whether a directory holds exactly these files, with these contents and executable bits.
|
||||
func sameTree(dir string, files map[string]PluginFile) bool {
|
||||
found := 0
|
||||
err := filepath.WalkDir(dir, func(full string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, full)
|
||||
f, ok := files[filepath.ToSlash(rel)]
|
||||
if !ok {
|
||||
return errors.New("not wanted")
|
||||
}
|
||||
raw, err := os.ReadFile(full)
|
||||
info, ierr := d.Info()
|
||||
if err != nil || ierr != nil || string(raw) != f.Content || (info.Mode()&0o111 != 0) != f.Executable {
|
||||
return errors.New("differs")
|
||||
}
|
||||
found++
|
||||
return nil
|
||||
})
|
||||
return err == nil && found == len(files)
|
||||
}
|
||||
|
||||
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
||||
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
||||
|
||||
@@ -63,6 +134,13 @@ type stateOf struct{ s stdio.KeptState }
|
||||
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
||||
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
||||
e, err := s.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
|
||||
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
||||
// tool's question.
|
||||
@@ -152,9 +230,9 @@ func nodesOf(v any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
||||
func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, configView *ConfigView) []stdio.Tool {
|
||||
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
||||
return []stdio.Tool{
|
||||
out := []stdio.Tool{
|
||||
{Name: "claude_code_status",
|
||||
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
||||
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
||||
@@ -239,6 +317,7 @@ func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
||||
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||
}},
|
||||
}
|
||||
return append(out, configTools(p, config, configView)...)
|
||||
}
|
||||
|
||||
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
||||
@@ -283,15 +362,53 @@ func main() {
|
||||
}
|
||||
servers := stateOf{stdio.State("servers")}
|
||||
view := NewServerView(p)
|
||||
go run(p, view)
|
||||
if err := stdio.Serve("", tools(p, servers, view)); err != nil {
|
||||
config := stateOf{stdio.State("config")}
|
||||
configView := NewConfigView(p)
|
||||
go run(p, view, configView)
|
||||
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run is the module's long-running half, beside the tools (ADR 0198).
|
||||
func run(p Paths, view *ServerView) {
|
||||
func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
// The agent's configuration, at every scope (ADR 0216): the whole current set first, then each change.
|
||||
go persist("watching the agent's configuration", func() error {
|
||||
keys, err := stdio.State("config").Keys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if configView.Prune(keys) {
|
||||
if _, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("rendering after what was removed while away: %v", err)
|
||||
}
|
||||
}
|
||||
return stdio.State("config").Watch("", func(c stdio.StateChange) error {
|
||||
var item *Item
|
||||
if c.Op == "put" {
|
||||
item = &Item{}
|
||||
if json.Unmarshal(c.Value, item) != nil {
|
||||
item = nil
|
||||
}
|
||||
}
|
||||
if !configView.Take(c.Key, c.Op, item) {
|
||||
return nil
|
||||
}
|
||||
if out, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("taking %s %s: %v", c.Op, c.Key, err)
|
||||
} else {
|
||||
say("took %s %s", c.Op, c.Key)
|
||||
for _, line := range out {
|
||||
if !strings.HasSuffix(line, "unchanged") {
|
||||
say("%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("watching the agent's configuration%s", refusals(n)) })
|
||||
|
||||
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
||||
go persist("watching the MCP servers", func() error {
|
||||
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
||||
|
||||
@@ -62,6 +62,8 @@ func (p Paths) binding() string { return filepath.Join(p.State, "licence.jso
|
||||
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
||||
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
||||
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
||||
func (p Paths) config() string { return filepath.Join(p.State, "config.json") }
|
||||
func (p Paths) placed() string { return filepath.Join(p.State, "home-placed.json") }
|
||||
|
||||
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
||||
type Ask func(address string, args any) (json.RawMessage, error)
|
||||
@@ -102,9 +104,15 @@ func Registered(p Paths) Servers {
|
||||
return s
|
||||
}
|
||||
|
||||
var renderMu sync.Mutex
|
||||
|
||||
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
||||
// registered here.
|
||||
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
// One at a time: the watches and the tools all render, and the home's record of what was placed is
|
||||
// read and written whole.
|
||||
renderMu.Lock()
|
||||
defer renderMu.Unlock()
|
||||
var facts Facts
|
||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||
@@ -116,21 +124,44 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
if readJSON(p.binding(), &b) {
|
||||
binding = &b
|
||||
}
|
||||
files := Render(facts, settings, binding, p.helper(), Registered(p))
|
||||
var items map[string]Item
|
||||
_ = readJSON(p.config(), &items)
|
||||
config := ConfigOf(items)
|
||||
files := Render(facts, settings, binding, p.helper(), Registered(p), config)
|
||||
tree, _ := json.Marshal(Marketplace(config))
|
||||
files[MarketplaceDir+"/"] = string(tree)
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
// The marketplace first: the settings that enable its plugin must never name one that is not there yet.
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
if (names[i] == MarketplaceDir+"/") != (names[j] == MarketplaceDir+"/") {
|
||||
return names[i] == MarketplaceDir+"/"
|
||||
}
|
||||
return names[i] < names[j]
|
||||
})
|
||||
// Every file is attempted: one that cannot be written — a registration the vendor's layout refuses, a
|
||||
// failed escalation — must not keep the licence, the tool servers or the instructions from landing.
|
||||
var out []string
|
||||
var failed []error
|
||||
for _, n := range names {
|
||||
line, err := write(n, files[n])
|
||||
if err != nil {
|
||||
return out, err
|
||||
failed = append(failed, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, nil
|
||||
// The home scope: what this module places in the account's own agent directory (ADR 0182).
|
||||
done, left := PlaceHome(p, HomeFiles(config))
|
||||
for _, line := range done {
|
||||
out = append(out, "home "+line)
|
||||
}
|
||||
for _, line := range left {
|
||||
out = append(out, "home "+line)
|
||||
}
|
||||
return out, errors.Join(failed...)
|
||||
}
|
||||
|
||||
// ---- the licence ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -96,8 +96,10 @@ func jsonFile(v any) string {
|
||||
}
|
||||
|
||||
// Render composes the three files. registered — what was registered through this module and applies
|
||||
// here — is laid over the servers the operator set in its settings.
|
||||
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string {
|
||||
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
||||
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
||||
// after the mesh's own text. The plugin itself is Marketplace's, and the home's PlaceHome's.
|
||||
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config) map[string]string {
|
||||
servers := map[string]any{}
|
||||
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
||||
for name, entry := range layer {
|
||||
@@ -109,14 +111,27 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
|
||||
}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||
|
||||
managed := map[string]any{}
|
||||
for key, value := range settings.ManagedSettings {
|
||||
managed[key] = value
|
||||
}
|
||||
// The operator's `managed_settings` (ADR 0213), then what was registered for the mesh, then for this node.
|
||||
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
|
||||
managed = mergeSettings(managed, RegisteredSettings(config))
|
||||
// The mesh's own keys are laid last: a setting never replaces them.
|
||||
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
|
||||
managed["allowAllClaudeAiMcps"] = true
|
||||
delete(managed, "apiKeyHelper")
|
||||
// The plugin's marketplace and the plugin itself, as entries in the operator's own maps, the mesh's
|
||||
// entry winning: the operator may know more marketplaces and enable more plugins.
|
||||
for key, value := range MarketplaceKeys() {
|
||||
entries := map[string]any{}
|
||||
if held, ok := managed[key].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
entries[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range value.(map[string]any) {
|
||||
entries[k] = v
|
||||
}
|
||||
managed[key] = entries
|
||||
}
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
@@ -127,6 +142,6 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
|
||||
return map[string]string{
|
||||
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
||||
"managed-settings.json": jsonFile(managed),
|
||||
"CLAUDE.md": instructionsText(facts.Node, role),
|
||||
"CLAUDE.md": instructionsText(facts.Node, role) + InstructionSections(config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
},
|
||||
"state": [
|
||||
"servers",
|
||||
"holdings"
|
||||
"holdings",
|
||||
"config"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
@@ -26,7 +27,34 @@
|
||||
"claude_code_add_api_key",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister"
|
||||
"claude_code_mcp_unregister",
|
||||
"claude_code_skill_list",
|
||||
"claude_code_skill_register",
|
||||
"claude_code_skill_unregister",
|
||||
"claude_code_agent_list",
|
||||
"claude_code_agent_register",
|
||||
"claude_code_agent_unregister",
|
||||
"claude_code_command_list",
|
||||
"claude_code_command_register",
|
||||
"claude_code_command_unregister",
|
||||
"claude_code_hook_list",
|
||||
"claude_code_hook_register",
|
||||
"claude_code_hook_unregister",
|
||||
"claude_code_output_style_list",
|
||||
"claude_code_output_style_register",
|
||||
"claude_code_output_style_unregister",
|
||||
"claude_code_instructions_list",
|
||||
"claude_code_instructions_register",
|
||||
"claude_code_instructions_unregister",
|
||||
"claude_code_settings_get",
|
||||
"claude_code_settings_set",
|
||||
"claude_code_settings_clear",
|
||||
"claude_code_permission_add",
|
||||
"claude_code_permission_remove",
|
||||
"claude_code_config_list",
|
||||
"claude_code_config_show",
|
||||
"claude_code_config_status",
|
||||
"claude_code_config_import"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
|
||||
@@ -78,6 +78,25 @@ today), remove the unused vendor drivers, once:
|
||||
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
|
||||
the Canon queue is used.
|
||||
|
||||
## The print applet: not this module's
|
||||
|
||||
The desktop has `system-config-printer` (official, explicit, installed 2026-10-04). Its package ships
|
||||
`/etc/xdg/autostart/print-applet.desktop`, so from the desktop's next login `dex` starts
|
||||
`system-config-printer-applet`, a tray icon for print jobs and printer problems. The laptop does not
|
||||
have the package.
|
||||
|
||||
This module does not take it:
|
||||
|
||||
- `cups` needs no display and declares nothing graphical. The applet requires `x11-display`, and a
|
||||
GTK package in this module would put it on any machine that prints, the laptop included, where the
|
||||
operator never installed it.
|
||||
- The queues and the default printer are already this module's tools (`cups_printers`, `cups_queue`,
|
||||
`cups_default`), which is most of what the applet's window offers.
|
||||
|
||||
So the applet is left as found on the desktop, started by its package's entry. If it is wanted on both
|
||||
workstations, it becomes a `system-config-printer` desktop module of its own, beside `blueman` and
|
||||
`nm-applet`, requiring `x11-display`. If not, removing the package on the desktop ends it.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
|
||||
|
||||
@@ -63,6 +63,23 @@
|
||||
"env": {
|
||||
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collect",
|
||||
"type": "container",
|
||||
"name": "mesh-registry-collect",
|
||||
"image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
||||
"volumes": [
|
||||
"/var/lib/mesh-registry:/var/lib/registry"
|
||||
],
|
||||
"args": [
|
||||
"garbage-collect",
|
||||
"/etc/docker/registry/config.yml"
|
||||
],
|
||||
"schedule": "30 3 * * *",
|
||||
"while-stopped": [
|
||||
"store"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from
|
||||
// the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept
|
||||
// running by one. This code exists only to read and steer the *live* state the daemon owns: who is
|
||||
// banned now and until when, and the ban or release an operator asks for — the node-intrusion-
|
||||
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
|
||||
// mesh composes the jails and never writes the ban list.
|
||||
//
|
||||
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
|
||||
// package this module declares on the machine, and the socket is root's: root is the module's
|
||||
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be
|
||||
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import { isIP } from "node:net";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
/** A command runner, so the verbs can be tested without a daemon. */
|
||||
export type Runner = (cmd: string, args: string[]) => Promise<string>;
|
||||
|
||||
/** The command as it is run: as given when this process is root, else through sudo without a
|
||||
* prompt. The daemon's socket answers only to root. */
|
||||
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
|
||||
if (uid === 0) return [cmd, args];
|
||||
return ["sudo", ["-n", cmd, ...args]];
|
||||
}
|
||||
|
||||
/** Whether a tool is on this machine: an executable of that name on the path, or where the
|
||||
* system keeps its administration. */
|
||||
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
|
||||
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
|
||||
return dirs.some((dir) => {
|
||||
try {
|
||||
accessSync(join(dir, tool), constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const execRunner: Runner = async (cmd, args) => {
|
||||
if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`);
|
||||
const [program, argv] = escalated(cmd, args);
|
||||
try {
|
||||
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
|
||||
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
|
||||
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
|
||||
// on its own stderr line, and the rest is the client's own answer.
|
||||
if (program === "sudo") {
|
||||
if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
|
||||
if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
|
||||
}
|
||||
if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) {
|
||||
throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account");
|
||||
}
|
||||
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
|
||||
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`));
|
||||
}
|
||||
};
|
||||
|
||||
/** One jail as the daemon reports it. */
|
||||
export interface JailStatus {
|
||||
jail: string;
|
||||
/** What the jail is reading: files or journal matches, as fail2ban names them. */
|
||||
watching: string[];
|
||||
/** Addresses with failures counted against them right now, and all failures since the jail started. */
|
||||
failing: { now: number; total: number };
|
||||
/** Addresses held right now, and all bans since the jail started. */
|
||||
banned: { now: number; total: number; addresses: string[] };
|
||||
}
|
||||
|
||||
/** One ban as the daemon holds it. */
|
||||
export interface Ban {
|
||||
ip: string;
|
||||
jail: string;
|
||||
/** When the ban was placed, in the machine's local time as fail2ban prints it. */
|
||||
since: string;
|
||||
/** When the ban ends; "never" for a permanent ban. */
|
||||
until: string;
|
||||
}
|
||||
|
||||
export interface JailSettings {
|
||||
jail: string;
|
||||
bantime: string;
|
||||
findtime: string;
|
||||
maxretry: number;
|
||||
ignoreip: string[];
|
||||
actions: string[];
|
||||
/** The log files the jail reads, when it reads files. */
|
||||
logpath: string[];
|
||||
/** The journal match the jail reads, when it reads the journal. */
|
||||
journalmatch: string;
|
||||
}
|
||||
|
||||
export class Fail2banClient {
|
||||
private readonly run: Runner;
|
||||
|
||||
constructor(run: Runner = execRunner) {
|
||||
this.run = run;
|
||||
}
|
||||
|
||||
/** The daemon as this machine has it, through its own client. */
|
||||
static onThisMachine(): Fail2banClient {
|
||||
return new Fail2banClient();
|
||||
}
|
||||
|
||||
private client(...args: string[]): Promise<string> {
|
||||
return this.run("fail2ban-client", args);
|
||||
}
|
||||
|
||||
/** The jails the daemon runs, by name. */
|
||||
async jails(): Promise<string[]> {
|
||||
const out = await this.client("status");
|
||||
const m = out.match(/Jail list:\s*(.*)/);
|
||||
if (!m) return [];
|
||||
return m[1].split(",").map((j) => j.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Every jail with what it watches and holds, or one jail's detail. */
|
||||
async status(jail?: string): Promise<{ jails: JailStatus[] }> {
|
||||
const names = jail ? [jail] : await this.jails();
|
||||
const jails: JailStatus[] = [];
|
||||
for (const name of names) {
|
||||
jails.push(parseJailStatus(name, await this.client("status", name)));
|
||||
}
|
||||
return { jails };
|
||||
}
|
||||
|
||||
/** Every address banned now, with the jail holding it and when the ban ends. */
|
||||
async banned(jail?: string): Promise<{ banned: Ban[] }> {
|
||||
const names = jail ? [jail] : await this.jails();
|
||||
const banned: Ban[] = [];
|
||||
for (const name of names) {
|
||||
banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time")));
|
||||
}
|
||||
banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip));
|
||||
return { banned };
|
||||
}
|
||||
|
||||
/** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */
|
||||
async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> {
|
||||
address(ip);
|
||||
name(jail);
|
||||
const out = await this.client("set", jail, "banip", ip);
|
||||
const added = Number.parseInt(out.trim(), 10) || 0;
|
||||
const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null;
|
||||
return { banned: held, added };
|
||||
}
|
||||
|
||||
/** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */
|
||||
async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> {
|
||||
address(ip);
|
||||
let out: string;
|
||||
if (jail) {
|
||||
name(jail);
|
||||
out = await this.client("set", jail, "unbanip", ip);
|
||||
} else {
|
||||
out = await this.client("unban", ip);
|
||||
}
|
||||
return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" };
|
||||
}
|
||||
|
||||
/** One jail's effective settings — the module's own tool, beside the seat's verbs. */
|
||||
async settings(jail: string): Promise<JailSettings> {
|
||||
name(jail);
|
||||
const get = (key: string) => this.client("get", jail, key);
|
||||
const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([
|
||||
get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"),
|
||||
get("journalmatch"),
|
||||
]);
|
||||
return {
|
||||
jail,
|
||||
bantime: bantime.trim(),
|
||||
findtime: findtime.trim(),
|
||||
maxretry: Number.parseInt(maxretry.trim(), 10),
|
||||
ignoreip: listed(ignoreip),
|
||||
actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean),
|
||||
logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath),
|
||||
journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */
|
||||
function listed(out: string): string[] {
|
||||
return out
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/^[\s|`-]+/, "").trim())
|
||||
.filter((l, i) => i > 0 && l.length > 0);
|
||||
}
|
||||
|
||||
export function parseJailStatus(jail: string, out: string): JailStatus {
|
||||
const field = (label: string) => {
|
||||
const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)"));
|
||||
return m ? m[1].trim() : "";
|
||||
};
|
||||
const num = (label: string) => Number.parseInt(field(label), 10) || 0;
|
||||
const watching = [field("File list"), field("Journal matches")].filter(Boolean);
|
||||
return {
|
||||
jail,
|
||||
watching,
|
||||
failing: { now: num("Currently failed"), total: num("Total failed") },
|
||||
banned: {
|
||||
now: num("Currently banned"),
|
||||
total: num("Total banned"),
|
||||
addresses: field("Banned IP list").split(/\s+/).filter(Boolean),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `get <jail> banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */
|
||||
export function parseBans(jail: string, out: string): Ban[] {
|
||||
const bans: Ban[] = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const m = line.match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)/);
|
||||
if (!m) continue;
|
||||
bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] });
|
||||
}
|
||||
return bans;
|
||||
}
|
||||
|
||||
function address(ip: string): void {
|
||||
if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`);
|
||||
}
|
||||
|
||||
function name(jail: string): void {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from the
|
||||
// modules a machine runs (to-be 31) and written as declared resources; the daemon is kept running by
|
||||
// one. This code exists only to read and steer the *live* state the daemon owns: who is banned now
|
||||
// and until when, and the ban or release an operator asks for — the node-intrusion-prevention seat's
|
||||
// four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the mesh composes the
|
||||
// jails and never writes the ban list.
|
||||
//
|
||||
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
|
||||
// package this module declares on the machine, and the socket is root's: root is the module's
|
||||
// concern (ADR 0175 §4), and the runtime launching this binary runs as the operator's account (to-be
|
||||
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner runs one command and answers what it printed, so the verbs can be tested without a daemon.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root, else through sudo
|
||||
// without a prompt. The daemon's socket answers only to root.
|
||||
func escalated(uid int, name string, args []string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
// installed is whether a tool is on this machine: an executable of that name on the path, or where
|
||||
// the system keeps its administration.
|
||||
func installed(tool, path string) bool {
|
||||
dirs := append(filepath.SplitList(path), "/usr/sbin", "/sbin", "/usr/bin")
|
||||
for _, dir := range dirs {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, tool)); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var socketTrouble = regexp.MustCompile(`(?i)Failed to access socket path|Is fail2ban running|Permission denied to socket`)
|
||||
|
||||
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if !installed(name, os.Getenv("PATH")) {
|
||||
return "", fmt.Errorf("%s is not installed on this machine", name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
program, argv := escalated(os.Getuid(), name, args)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), nil
|
||||
}
|
||||
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks on
|
||||
// its own stderr line, and the rest is the client's own answer.
|
||||
if program == "sudo" {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
}
|
||||
if socketTrouble.MatchString(said) {
|
||||
return "", errors.New("fail2ban is not running on this machine, or its socket does not answer the runtime's account")
|
||||
}
|
||||
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
|
||||
var lines []string
|
||||
for _, l := range strings.Split(said, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
return "", errors.New(lines[len(lines)-1])
|
||||
}
|
||||
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||
}
|
||||
|
||||
// Counted is a jail's count now and since it started.
|
||||
type Counted struct {
|
||||
Now int `json:"now"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// Held is what a jail holds: the count now and since it started, and the addresses.
|
||||
type Held struct {
|
||||
Now int `json:"now"`
|
||||
Total int `json:"total"`
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
|
||||
// JailStatus is one jail as the daemon reports it.
|
||||
type JailStatus struct {
|
||||
Jail string `json:"jail"`
|
||||
// Watching is what the jail is reading: files or journal matches, as fail2ban names them.
|
||||
Watching []string `json:"watching"`
|
||||
// Failing is the addresses with failures counted against them now, and all failures since the
|
||||
// jail started.
|
||||
Failing Counted `json:"failing"`
|
||||
// Banned is the addresses held right now, and all bans since the jail started.
|
||||
Banned Held `json:"banned"`
|
||||
}
|
||||
|
||||
// Ban is one ban as the daemon holds it.
|
||||
type Ban struct {
|
||||
IP string `json:"ip"`
|
||||
Jail string `json:"jail"`
|
||||
// Since is when the ban was placed, in the machine's local time as fail2ban prints it.
|
||||
Since string `json:"since"`
|
||||
// Until is when the ban ends; "never" for a permanent ban.
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
// JailSettings is one jail's effective settings.
|
||||
type JailSettings struct {
|
||||
Jail string `json:"jail"`
|
||||
Bantime string `json:"bantime"`
|
||||
Findtime string `json:"findtime"`
|
||||
Maxretry int `json:"maxretry"`
|
||||
Ignoreip []string `json:"ignoreip"`
|
||||
Actions []string `json:"actions"`
|
||||
Logpath []string `json:"logpath"`
|
||||
Journal string `json:"journalmatch"`
|
||||
}
|
||||
|
||||
// Fail2ban is the daemon as this machine has it, through its own client.
|
||||
type Fail2ban struct {
|
||||
Run Runner
|
||||
}
|
||||
|
||||
func (f Fail2ban) client(ctx context.Context, args ...string) (string, error) {
|
||||
return f.Run(ctx, "fail2ban-client", args...)
|
||||
}
|
||||
|
||||
var jailList = regexp.MustCompile(`Jail list:[ \t]*(.*)`)
|
||||
|
||||
// Jails is the jails the daemon runs, by name.
|
||||
func (f Fail2ban) Jails(ctx context.Context) ([]string, error) {
|
||||
out, err := f.client(ctx, "status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := jailList.FindStringSubmatch(out)
|
||||
if m == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
var jails []string
|
||||
for _, j := range strings.Split(m[1], ",") {
|
||||
if j = strings.TrimSpace(j); j != "" {
|
||||
jails = append(jails, j)
|
||||
}
|
||||
}
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
func (f Fail2ban) named(ctx context.Context, jail string) ([]string, error) {
|
||||
if jail != "" {
|
||||
return []string{jail}, nil
|
||||
}
|
||||
return f.Jails(ctx)
|
||||
}
|
||||
|
||||
// Status is every jail with what it watches and holds, or one jail's detail.
|
||||
func (f Fail2ban) Status(ctx context.Context, jail string) (map[string][]JailStatus, error) {
|
||||
names, err := f.named(ctx, jail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jails := []JailStatus{}
|
||||
for _, name := range names {
|
||||
out, err := f.client(ctx, "status", name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jails = append(jails, parseJailStatus(name, out))
|
||||
}
|
||||
return map[string][]JailStatus{"jails": jails}, nil
|
||||
}
|
||||
|
||||
// Banned is every address banned now, with the jail holding it and when the ban ends, soonest to
|
||||
// end first.
|
||||
func (f Fail2ban) Banned(ctx context.Context, jail string) (map[string][]Ban, error) {
|
||||
names, err := f.named(ctx, jail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
banned := []Ban{}
|
||||
for _, name := range names {
|
||||
out, err := f.client(ctx, "get", name, "banip", "--with-time")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
banned = append(banned, parseBans(name, out)...)
|
||||
}
|
||||
sort.SliceStable(banned, func(a, b int) bool {
|
||||
if banned[a].Until != banned[b].Until {
|
||||
return banned[a].Until < banned[b].Until
|
||||
}
|
||||
return banned[a].IP < banned[b].IP
|
||||
})
|
||||
return map[string][]Ban{"banned": banned}, nil
|
||||
}
|
||||
|
||||
// BanOutcome is a ban as held, and how many addresses the daemon said it added.
|
||||
type BanOutcome struct {
|
||||
Banned *Ban `json:"banned"`
|
||||
Added int `json:"added"`
|
||||
}
|
||||
|
||||
// Ban bans one address in one jail now. The daemon's own answer is how many addresses it added.
|
||||
func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) {
|
||||
if err := address(ip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := jailName(jail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := f.client(ctx, "set", jail, "banip", ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
added, _ := strconv.Atoi(strings.TrimSpace(out))
|
||||
held, err := f.Banned(ctx, jail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome := &BanOutcome{Added: added}
|
||||
for _, b := range held["banned"] {
|
||||
if b.IP == ip {
|
||||
b := b
|
||||
outcome.Banned = &b
|
||||
}
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// Released is how many bans the daemon let go, of which address, from where.
|
||||
type Released struct {
|
||||
Released int `json:"released"`
|
||||
IP string `json:"ip"`
|
||||
Jail string `json:"jail"`
|
||||
}
|
||||
|
||||
// Unban lets one address go, from one jail or from every jail. The daemon's answer is how many it
|
||||
// released.
|
||||
func (f Fail2ban) Unban(ctx context.Context, ip, jail string) (*Released, error) {
|
||||
if err := address(ip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out string
|
||||
var err error
|
||||
if jail != "" {
|
||||
if err := jailName(jail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = f.client(ctx, "set", jail, "unbanip", ip)
|
||||
} else {
|
||||
out, err = f.client(ctx, "unban", ip)
|
||||
jail = "every jail"
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
released, _ := strconv.Atoi(strings.TrimSpace(out))
|
||||
return &Released{Released: released, IP: ip, Jail: jail}, nil
|
||||
}
|
||||
|
||||
// Settings is one jail's effective settings — the module's own tool, beside the seat's verbs.
|
||||
func (f Fail2ban) Settings(ctx context.Context, jail string) (*JailSettings, error) {
|
||||
if err := jailName(jail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, key := range []string{"bantime", "findtime", "maxretry", "ignoreip", "actions", "logpath", "journalmatch"} {
|
||||
out, err := f.client(ctx, "get", jail, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
got[key] = out
|
||||
}
|
||||
maxretry, _ := strconv.Atoi(strings.TrimSpace(got["maxretry"]))
|
||||
s := &JailSettings{
|
||||
Jail: jail,
|
||||
Bantime: strings.TrimSpace(got["bantime"]),
|
||||
Findtime: strings.TrimSpace(got["findtime"]),
|
||||
Maxretry: maxretry,
|
||||
Ignoreip: listed(got["ignoreip"]),
|
||||
Actions: afterHeading(got["actions"]),
|
||||
Logpath: []string{},
|
||||
Journal: strings.Join(afterHeading(got["journalmatch"]), " "),
|
||||
}
|
||||
if !strings.Contains(got["logpath"], "No file is currently monitored") {
|
||||
s.Logpath = listed(got["logpath"])
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
var treeMarks = regexp.MustCompile("^[\\s|`-]+")
|
||||
|
||||
// listed reads fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading.
|
||||
func listed(out string) []string {
|
||||
items := []string{}
|
||||
for i, l := range strings.Split(out, "\n") {
|
||||
l = strings.TrimSpace(treeMarks.ReplaceAllString(l, ""))
|
||||
if i > 0 && l != "" {
|
||||
items = append(items, l)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// afterHeading is every non-empty line after the first, trimmed.
|
||||
func afterHeading(out string) []string {
|
||||
items := []string{}
|
||||
for i, l := range strings.Split(out, "\n") {
|
||||
if l = strings.TrimSpace(l); i > 0 && l != "" {
|
||||
items = append(items, l)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func parseJailStatus(jail, out string) JailStatus {
|
||||
field := func(label string) string {
|
||||
m := regexp.MustCompile(regexp.QuoteMeta(label) + `:\t?[ \t]*(.*)`).FindStringSubmatch(out)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
num := func(label string) int {
|
||||
n, _ := strconv.Atoi(field(label))
|
||||
return n
|
||||
}
|
||||
watching := []string{}
|
||||
for _, w := range []string{field("File list"), field("Journal matches")} {
|
||||
if w != "" {
|
||||
watching = append(watching, w)
|
||||
}
|
||||
}
|
||||
addresses := strings.Fields(field("Banned IP list"))
|
||||
if addresses == nil {
|
||||
addresses = []string{}
|
||||
}
|
||||
return JailStatus{
|
||||
Jail: jail,
|
||||
Watching: watching,
|
||||
Failing: Counted{Now: num("Currently failed"), Total: num("Total failed")},
|
||||
Banned: Held{Now: num("Currently banned"), Total: num("Total banned"), Addresses: addresses},
|
||||
}
|
||||
}
|
||||
|
||||
var banLine = regexp.MustCompile(`^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)`)
|
||||
|
||||
// parseBans reads `get <jail> banip --with-time`, one ban per line: "IP \tsince + seconds = until".
|
||||
func parseBans(jail, out string) []Ban {
|
||||
bans := []Ban{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
m := banLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
until := m[4]
|
||||
if seconds, _ := strconv.Atoi(m[3]); seconds < 0 {
|
||||
until = "never"
|
||||
}
|
||||
bans = append(bans, Ban{IP: m[1], Jail: jail, Since: m[2], Until: until})
|
||||
}
|
||||
return bans
|
||||
}
|
||||
|
||||
func address(ip string) error {
|
||||
if net.ParseIP(ip) == nil {
|
||||
return fmt.Errorf("%q is not an address", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var jailNamed = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
func jailName(jail string) error {
|
||||
if !jailNamed.MatchString(jail) {
|
||||
return fmt.Errorf("%q is not a jail's name", jail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
|
||||
// on the control node on 2026-10-02 (novox/hq ADR 0179).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const statusAll = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n"
|
||||
const recidive = "Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
|
||||
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
|
||||
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n"
|
||||
const sshd = "Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
|
||||
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
|
||||
" |- Total banned:\t150\n `- Banned IP list:\t\n"
|
||||
const withTime = "195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
|
||||
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n"
|
||||
|
||||
func fake(answers map[string]string, calls *[][]string) Runner {
|
||||
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if calls != nil {
|
||||
*calls = append(*calls, append([]string{name}, args...))
|
||||
}
|
||||
if out, ok := answers[strings.Join(args, " ")]; ok {
|
||||
return out, nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected %s %s", name, strings.Join(args, " "))
|
||||
}
|
||||
}
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func TestAJailsStatusIsReadIntoNumbersWhatItWatchesAndWhoItHolds(t *testing.T) {
|
||||
got := parseJailStatus("recidive", recidive)
|
||||
want := JailStatus{Jail: "recidive", Watching: []string{"/var/log/fail2ban.log"}, Failing: Counted{36, 149},
|
||||
Banned: Held{9, 13, []string{"195.178.110.30", "45.148.10.240", "92.118.39.71"}}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
j := parseJailStatus("sshd", sshd)
|
||||
if !reflect.DeepEqual(j.Watching, []string{"_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}) {
|
||||
t.Errorf("watching %v", j.Watching)
|
||||
}
|
||||
if !reflect.DeepEqual(j.Banned, Held{0, 150, []string{}}) {
|
||||
t.Errorf("banned %+v", j.Banned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCoversEveryJailTheDaemonListsOrTheOneNamed(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{"status": statusAll, "status recidive": recidive, "status sshd": sshd}, &calls)}
|
||||
all, err := f.Status(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all["jails"]) != 2 || all["jails"][0].Jail != "recidive" || all["jails"][1].Jail != "sshd" {
|
||||
t.Errorf("%+v", all)
|
||||
}
|
||||
one, err := f.Status(ctx, "sshd")
|
||||
if err != nil || len(one["jails"]) != 1 {
|
||||
t.Fatalf("%+v %v", one, err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[len(calls)-1], []string{"fail2ban-client", "status", "sshd"}) {
|
||||
t.Errorf("last call %v", calls[len(calls)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansAreReadWithWhenTheyEndAPermanentOneAsNever(t *testing.T) {
|
||||
bans := parseBans("recidive", withTime+"203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n")
|
||||
if len(bans) != 3 {
|
||||
t.Fatalf("%+v", bans)
|
||||
}
|
||||
if bans[0] != (Ban{IP: "195.178.110.30", Jail: "recidive", Since: "2026-09-26 23:18:47", Until: "2026-10-03 23:18:47"}) {
|
||||
t.Errorf("%+v", bans[0])
|
||||
}
|
||||
if bans[2].Until != "never" {
|
||||
t.Errorf("a permanent ban ends %q", bans[2].Until)
|
||||
}
|
||||
if got := parseBans("sshd", "\n"); len(got) != 0 {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannedGathersEveryJailsBansSoonestToEndFirst(t *testing.T) {
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"status": statusAll,
|
||||
"get recidive banip --with-time": withTime,
|
||||
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
|
||||
}, nil)}
|
||||
got, err := f.Banned(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []string
|
||||
for _, b := range got["banned"] {
|
||||
order = append(order, b.IP+"@"+b.Jail)
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"}) {
|
||||
t.Errorf("%v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"set recidive banip 198.51.100.7": "1\n",
|
||||
"get recidive banip --with-time": withTime + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
|
||||
}, &calls)}
|
||||
r, err := f.Ban(ctx, "198.51.100.7", "recidive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Added != 1 || r.Banned == nil || r.Banned.Until != "2026-10-09 17:00:00" {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[0], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) {
|
||||
t.Errorf("first call %v", calls[0])
|
||||
}
|
||||
if _, err := f.Ban(ctx, "not-an-ip", "recidive"); err == nil || !strings.Contains(err.Error(), "is not an address") {
|
||||
t.Errorf("a non-address: %v", err)
|
||||
}
|
||||
if _, err := f.Ban(ctx, "198.51.100.7", "a jail; rm"); err == nil || !strings.Contains(err.Error(), "is not a jail's name") {
|
||||
t.Errorf("a non-name: %v", err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Errorf("a refused ban reached the daemon: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanReleasesFromOneJailOrFromEveryJail(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{"set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n"}, &calls)}
|
||||
one, err := f.Unban(ctx, "198.51.100.7", "sshd")
|
||||
if err != nil || *one != (Released{1, "198.51.100.7", "sshd"}) {
|
||||
t.Errorf("%+v %v", one, err)
|
||||
}
|
||||
every, err := f.Unban(ctx, "198.51.100.7", "")
|
||||
if err != nil || *every != (Released{2, "198.51.100.7", "every jail"}) {
|
||||
t.Errorf("%+v %v", every, err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[1], []string{"fail2ban-client", "unban", "198.51.100.7"}) {
|
||||
t.Errorf("%v", calls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJailsSettingsAreReadFromTheDaemonsListings(t *testing.T) {
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
|
||||
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
|
||||
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
|
||||
"get sshd logpath": "No file is currently monitored\n",
|
||||
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
|
||||
}, nil)}
|
||||
got, err := f.Settings(ctx, "sshd")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := &JailSettings{Jail: "sshd", Bantime: "86400", Findtime: "86400", Maxretry: 3,
|
||||
Ignoreip: []string{"127.0.0.0/8", "10.10.0.0/24", "::1"}, Actions: []string{"iptables-allports-dualchain"},
|
||||
Logpath: []string{}, Journal: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheClientRunsAsGivenByRootAndThroughSudoByAnyoneElse(t *testing.T) {
|
||||
if p, a := escalated(0, "fail2ban-client", []string{"status"}); p != "fail2ban-client" || !reflect.DeepEqual(a, []string{"status"}) {
|
||||
t.Errorf("as root: %s %v", p, a)
|
||||
}
|
||||
if p, a := escalated(1000, "fail2ban-client", []string{"set", "sshd", "banip", "198.51.100.7"}); p != "sudo" ||
|
||||
!reflect.DeepEqual(a, []string{"-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"}) {
|
||||
t.Errorf("as an account: %s %v", p, a)
|
||||
}
|
||||
if !installed("sh", "/bin:/usr/bin") || installed("no-such-client-of-the-mesh", "/bin:/usr/bin") {
|
||||
t.Error("installed is wrong about sh or about a tool nobody has")
|
||||
}
|
||||
}
|
||||
|
||||
// The tools carry the seat's four verbs under the seat's name, and the module's own under its own.
|
||||
func TestTheSeatsVerbsAndTheModulesOwnToolAreServed(t *testing.T) {
|
||||
var names []string
|
||||
for _, tool := range tools(Fail2ban{Run: fake(nil, nil)}) {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
want := []string{"node-intrusion-prevention.status", "node-intrusion-prevention.banned", "node-intrusion-prevention.ban",
|
||||
"node-intrusion-prevention.unban", "fail2ban_settings"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Errorf("%v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// The daemon on this machine, read only — status, bans and one jail's settings — when asked for with
|
||||
// FAIL2BAN_LIVE=1: the shapes above are what fail2ban-client printed once, and this is what it prints
|
||||
// now.
|
||||
func TestTheLiveDaemonReadsBack(t *testing.T) {
|
||||
if os.Getenv("FAIL2BAN_LIVE") != "1" {
|
||||
t.Skip("set FAIL2BAN_LIVE=1 to read the daemon on this machine")
|
||||
}
|
||||
f := Fail2ban{Run: execRunner}
|
||||
status, err := f.Status(ctx, "")
|
||||
if err != nil || len(status["jails"]) == 0 {
|
||||
t.Fatalf("status: %+v %v", status, err)
|
||||
}
|
||||
for _, j := range status["jails"] {
|
||||
t.Logf("%s: watching %v, failing %d, banned %d now of %d", j.Jail, j.Watching, j.Failing.Now, j.Banned.Now, j.Banned.Total)
|
||||
if len(j.Watching) == 0 {
|
||||
t.Errorf("%s watches nothing as read", j.Jail)
|
||||
}
|
||||
}
|
||||
banned, err := f.Banned(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("banned: %v", err)
|
||||
}
|
||||
t.Logf("%d bans held", len(banned["banned"]))
|
||||
settings, err := f.Settings(ctx, "sshd")
|
||||
if err != nil || settings.Maxretry == 0 || len(settings.Ignoreip) == 0 {
|
||||
t.Fatalf("settings: %+v %v", settings, err)
|
||||
}
|
||||
t.Logf("sshd: bantime %s, maxretry %d, ignores %v", settings.Bantime, settings.Maxretry, settings.Ignoreip)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// fail2ban-tools (novox/hq to-be 31, ADR 0179): the intrusion prevention's tools. One binary, launched
|
||||
// by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR
|
||||
// 0198): the node-intrusion-prevention seat's four verbs — who is banned, the jails' state, ban one,
|
||||
// let one go — and the module's own reading of a jail's settings. The jails themselves are composed
|
||||
// by the mesh from the modules a machine runs and written as declared resources; these touch only
|
||||
// what the running daemon holds.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-intrusion-prevention"
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(Fail2ban{Run: execRunner})); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
v, _ := a[k].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||
// subject. The module's own tools keep their bare names.
|
||||
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||
}
|
||||
|
||||
func tools(f Fail2ban) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
oneJail := map[string]any{"jail": str("one jail (optional)")}
|
||||
return []stdio.Tool{
|
||||
verb("status", "Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
|
||||
oneJail, func(a map[string]any) (any, error) { return f.Status(ctx, arg(a, "jail")) }),
|
||||
verb("banned", "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
|
||||
oneJail, func(a map[string]any) (any, error) { return f.Banned(ctx, arg(a, "jail")) }),
|
||||
verb("ban", "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
|
||||
map[string]any{"ip": str("the address"), "jail": str("the jail to hold it (recidive for the long ban)")},
|
||||
func(a map[string]any) (any, error) { return f.Ban(ctx, arg(a, "ip"), arg(a, "jail")) }),
|
||||
verb("unban", "Let one address go, from one jail or from every jail when none is named.",
|
||||
map[string]any{"ip": str("the address"), "jail": str("one jail (optional)")},
|
||||
func(a map[string]any) (any, error) { return f.Unban(ctx, arg(a, "ip"), arg(a, "jail")) }),
|
||||
{Name: "fail2ban_settings",
|
||||
Description: "One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
||||
Input: map[string]any{"jail": str("the jail")},
|
||||
Run: func(a map[string]any) (any, error) { return f.Settings(ctx, arg(a, "jail")) }},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module fail2ban
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -116,9 +116,12 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/fail2ban-tools",
|
||||
"binary": "fail2ban-tools",
|
||||
"loads": [
|
||||
"fail2ban-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@novox/module-fail2ban",
|
||||
"version": "0.1.0",
|
||||
"description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
|
||||
// on the control node on 2026-10-02 (novox/hq ADR 0179).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts";
|
||||
|
||||
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
|
||||
const RECIDIVE =
|
||||
"Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
|
||||
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
|
||||
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n";
|
||||
const SSHD =
|
||||
"Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
|
||||
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
|
||||
" |- Total banned:\t150\n `- Banned IP list:\t\n";
|
||||
const WITH_TIME =
|
||||
"195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
|
||||
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n";
|
||||
|
||||
function fake(answers: Record<string, string>, calls: string[][] = []): Runner {
|
||||
return async (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
const key = args.join(" ");
|
||||
if (key in answers) return answers[key];
|
||||
throw new Error(`unexpected ${cmd} ${key}`);
|
||||
};
|
||||
}
|
||||
|
||||
test("a jail's status is read into numbers, what it watches and who it holds", () => {
|
||||
const s = parseJailStatus("recidive", RECIDIVE);
|
||||
assert.deepEqual(s, {
|
||||
jail: "recidive",
|
||||
watching: ["/var/log/fail2ban.log"],
|
||||
failing: { now: 36, total: 149 },
|
||||
banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] },
|
||||
});
|
||||
const j = parseJailStatus("sshd", SSHD);
|
||||
assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]);
|
||||
assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] });
|
||||
});
|
||||
|
||||
test("status covers every jail the daemon lists, or the one named", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls));
|
||||
const all = await f.status();
|
||||
assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]);
|
||||
const one = await f.status("sshd");
|
||||
assert.equal(one.jails.length, 1);
|
||||
assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]);
|
||||
});
|
||||
|
||||
test("bans are read with when they were placed and when they end, a permanent one as never", () => {
|
||||
const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n");
|
||||
assert.equal(bans.length, 3);
|
||||
assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" });
|
||||
assert.equal(bans[2].until, "never");
|
||||
assert.deepEqual(parseBans("sshd", "\n"), []);
|
||||
});
|
||||
|
||||
test("banned gathers every jail's bans, soonest to end first", async () => {
|
||||
const f = new Fail2banClient(fake({
|
||||
status: STATUS,
|
||||
"get recidive banip --with-time": WITH_TIME,
|
||||
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
|
||||
}));
|
||||
const { banned } = await f.banned();
|
||||
assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]);
|
||||
});
|
||||
|
||||
test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({
|
||||
"set recidive banip 198.51.100.7": "1\n",
|
||||
"get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
|
||||
}, calls));
|
||||
const r = await f.ban("198.51.100.7", "recidive");
|
||||
assert.equal(r.added, 1);
|
||||
assert.equal(r.banned?.until, "2026-10-09 17:00:00");
|
||||
assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]);
|
||||
await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/);
|
||||
await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test("unban releases from one jail or from every jail", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls));
|
||||
assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" });
|
||||
assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" });
|
||||
assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]);
|
||||
});
|
||||
|
||||
test("a jail's settings are read from the daemon's listings", async () => {
|
||||
const f = new Fail2banClient(fake({
|
||||
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
|
||||
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
|
||||
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
|
||||
"get sshd logpath": "No file is currently monitored\n",
|
||||
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
|
||||
}));
|
||||
assert.deepEqual(await f.settings("sshd"), {
|
||||
jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3,
|
||||
ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"],
|
||||
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
|
||||
});
|
||||
});
|
||||
|
||||
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
|
||||
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
|
||||
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
|
||||
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
|
||||
assert.equal(installed("sh"), true);
|
||||
assert.equal(installed("no-such-client-of-the-mesh"), false);
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
// The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned,
|
||||
// the jails' state, ban one, let one go — and the module's own reading of a jail's settings
|
||||
// (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a
|
||||
// machine runs and written as declared resources; these touch only what the running daemon holds.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { Fail2banClient } from "../client.js";
|
||||
|
||||
export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "status",
|
||||
description:
|
||||
"Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
|
||||
input: { jail: { type: "string", description: "one jail (optional)" } },
|
||||
run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
{
|
||||
name: "banned",
|
||||
description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
|
||||
input: { jail: { type: "string", description: "one jail (optional)" } },
|
||||
run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
{
|
||||
name: "ban",
|
||||
description:
|
||||
"Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
|
||||
input: {
|
||||
ip: { type: "string", description: "the address" },
|
||||
jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" },
|
||||
},
|
||||
run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")),
|
||||
},
|
||||
{
|
||||
name: "unban",
|
||||
description: "Let one address go, from one jail or from every jail when none is named.",
|
||||
input: {
|
||||
ip: { type: "string", description: "the address" },
|
||||
jail: { type: "string", description: "one jail (optional)" },
|
||||
},
|
||||
run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "fail2ban_settings",
|
||||
description:
|
||||
"One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
||||
input: { jail: { type: "string", description: "the jail" } },
|
||||
run: async (args) => fail2ban.settings(String(args.jail ?? "")),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const fail2ban = Fail2banClient.onThisMachine();
|
||||
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
|
||||
// module holds it (ADR 0159, 0160). The module's own under its own.
|
||||
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
|
||||
registerModuleTools("fail2ban", () => getFail2banTools(fail2ban));
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"client.ts",
|
||||
"tools/index.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# forticlient
|
||||
|
||||
The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the
|
||||
operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned
|
||||
only where a display server is held on the same machine.
|
||||
|
||||
**This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no
|
||||
credential, no gateway, no certificate is declared, read, printed or stored by the module or its
|
||||
tools. The tools report running and connected state only.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled |
|
||||
|
||||
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||
|
||||
- **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on
|
||||
both workstations it is a foreign (AUR) package that repackages the vendor's build, installed
|
||||
explicitly. The host installs from the official repositories only, so the module cannot declare it.
|
||||
ADR 0205's pinned archive does not fit: it is a vendor binary set with a root service, a firewall
|
||||
helper and an install script. It waits for the mesh's package repository (research 027 question 1,
|
||||
option P2). Until then a fresh workstation installs it by hand.
|
||||
- **The service is declared, and so depended on.** `forticlient.service` is the package's unit, running
|
||||
and enabled on both workstations. Declared running and enabled, it is held in that state, and on a
|
||||
machine without the package the host refuses it by name (*does not exist on this machine*): loud,
|
||||
never a silent pass. The `asus-zephyrus-g14` module does the same with its foreign daemons. The
|
||||
module never restarts it: a change to nothing of the module's would, and nothing of the module's
|
||||
changes.
|
||||
- **The configuration stays the operator's, and unread.** `/etc/forticlient/`, the client's database
|
||||
under `/opt/forticlient/`, the account's FortiClient settings, the VPN profiles, saved credentials
|
||||
and certificates are set in the client's own window. They are found (ADR 0182), and unlike any other
|
||||
found file, the tools do not even read them.
|
||||
|
||||
## How it starts: the vendor's autostart entry, and nothing else
|
||||
|
||||
The tray has two processes: `fortitraylauncher`, which starts and watches `fortitray`. The package's
|
||||
install script links `/etc/xdg/autostart/Fortitray.desktop` to the package's
|
||||
`/opt/forticlient/Fortitray.desktop` (`Exec=/opt/forticlient/fortitraylauncher`). The session runs it
|
||||
once at login through the `i3` module's `dex --autostart --environment i3`. **That entry is the tray's
|
||||
one start.** The module adds no `xinitrc` slot and no `node-display-session` exec, because either would
|
||||
start it a second time. The link is the vendor's, made by its install script; the mesh does not make
|
||||
or remove it.
|
||||
|
||||
The tunnel is not the tray's: the service's processes hold it, as root. Ending the tray leaves a
|
||||
connected tunnel connected.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `forticlient_status` (r) | <ul><li>the installed version, and that it is from outside the official repositories</li><li>the service: active, enabled</li><li>whether the launcher and the tray run: pid, since, and the scope or unit they run in</li><li>what starts the tray at login</li><li>connected or not, as the number of the client's tunnel interfaces that are up</li></ul> |
|
||||
| `forticlient_restart` (a) | asks the tray and its launcher to end (SIGTERM), forces them after 5 s, and starts the launcher in the operator's session as a transient user unit `mesh-forticlient-tray`, so it outlives the tools runtime. The launcher starts the tray. The service and the tunnel are not touched. Refused plainly when nobody is logged in to the desktop |
|
||||
| `forticlient_check` (r) | <ul><li>the package is installed</li><li>the service is running and enabled</li><li>exactly one start: the vendor's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one launcher and one tray run in a desktop session</li></ul>Being connected is never a finding: that is the operator's to decide. Each finding says what to do |
|
||||
|
||||
**What the tools never touch**, held by the tests (a fake machine carries a profile, a gateway, an
|
||||
address, a secret and a certificate where the client keeps them, and no answer may hold any of them):
|
||||
|
||||
- no file under `/etc/forticlient` or the account's FortiClient settings is opened, and under
|
||||
`/opt/forticlient` only the tray's autostart entry, through its link (it names the launcher and
|
||||
nothing else);
|
||||
- the vendor's command-line client and `fortivpn` are never run, and its logs are never read;
|
||||
- a process is named by its command name only, never by its arguments;
|
||||
- *connected* is whether an interface named `fctvpn…` is up, from its flags. The interface's name
|
||||
(it carries an identifier) and its addresses are never answered. A tunnel of a kind that brings up
|
||||
no such interface (IPsec) is not seen, and the answer says *not connected*.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | laptop | desktop |
|
||||
|---|---|---|
|
||||
| package | none: `forticlient-vpn` 7.4.3.5411, explicit, foreign | the same |
|
||||
| service | none: running and enabled | the same |
|
||||
| tray | none: dex starts it from the vendor's entry, in the login session's scope | none on disk. **No tray runs now:** that session began before `dex` was installed, and the predecessor's window manager never started it. The next login is the first that starts it |
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
Nothing is required on either machine. On the desktop, log out and in once, or run
|
||||
`forticlient_restart`, and the tray runs from its one start. `forticlient_check` then answers `ok`.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
Everything of the client's: its configuration and database, the VPN profiles and credentials, its
|
||||
logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package itself.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The package, installed by hand.** Without it the host refuses the service by name.
|
||||
- **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does
|
||||
not start. `forticlient_check` says so.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// 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
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
// FortiClient as the tools see it: the vendor's scheduler service, the tray and the launcher that
|
||||
// starts it, the tray's XDG autostart entry, and whether a tunnel is up. This is the operator's work
|
||||
// VPN, so the tools never read its profiles, its credentials, its gateways or its certificates: no file
|
||||
// under /etc/forticlient or the account's FortiClient settings is opened, and under /opt/forticlient
|
||||
// only the tray's autostart entry (through its link in /etc/xdg/autostart); the vendor's command-line
|
||||
// client is never run, its logs are never read, a process is named by its command name only (never its
|
||||
// arguments), and a tunnel is counted, never named or addressed.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
launcherComm = "fortitraylaunch" // the kernel keeps 15 characters of fortitraylauncher
|
||||
launcherWord = "fortitraylauncher"
|
||||
trayComm = "fortitray"
|
||||
launcherBin = "/opt/forticlient/fortitraylauncher"
|
||||
entryName = "Fortitray.desktop"
|
||||
restartAs = "mesh-forticlient-tray"
|
||||
packageFor = "forticlient-vpn"
|
||||
serviceUnit = "forticlient.service"
|
||||
// tunnelPrefix starts the name of the interface the client brings up for a connected tunnel.
|
||||
tunnelPrefix = "fctvpn"
|
||||
)
|
||||
|
||||
// named keeps a process's command name and drops its arguments.
|
||||
func named(ps []Proc, comm string) []Proc {
|
||||
out := []Proc{}
|
||||
for _, p := range ps {
|
||||
p.Command = comm
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Tunnel is whether the client holds a tunnel up: how many of its tunnel interfaces exist and are up.
|
||||
// Their names and addresses are never answered.
|
||||
type Tunnel struct {
|
||||
Connected bool `json:"connected"`
|
||||
Up int `json:"tunnels_up"`
|
||||
}
|
||||
|
||||
func (m *Machine) tunnel() Tunnel {
|
||||
t := Tunnel{}
|
||||
entries, _ := os.ReadDir(m.path("/sys/class/net"))
|
||||
for _, e := range entries {
|
||||
if !strings.HasPrefix(e.Name(), tunnelPrefix) {
|
||||
continue
|
||||
}
|
||||
flags, err := strconv.ParseUint(strings.TrimPrefix(readTrimmed(m.path(filepath.Join("/sys/class/net", e.Name(), "flags"))), "0x"), 16, 32)
|
||||
if err == nil && flags&1 == 1 { // IFF_UP
|
||||
t.Up++
|
||||
}
|
||||
}
|
||||
t.Connected = t.Up > 0
|
||||
return t
|
||||
}
|
||||
|
||||
// Service is the vendor's scheduler, which holds the tunnel.
|
||||
type Service struct {
|
||||
Active string `json:"active"`
|
||||
Enabled string `json:"enabled"`
|
||||
}
|
||||
|
||||
func (m *Machine) service() Service {
|
||||
word := func(verb string) string {
|
||||
o := m.cmd(5*time.Second, nil, "systemctl", verb, serviceUnit)
|
||||
if f := strings.Fields(o.Stdout); o.Err == nil && len(f) > 0 {
|
||||
return f[0]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
return Service{Active: word("is-active"), Enabled: word("is-enabled")}
|
||||
}
|
||||
|
||||
// foreign says whether the package came from outside the official repositories (pacman -Qm).
|
||||
func (m *Machine) foreign() bool {
|
||||
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
|
||||
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
|
||||
}
|
||||
|
||||
// StatusAnswer is what forticlient_status answers.
|
||||
type StatusAnswer struct {
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Foreign bool `json:"outside_official_repositories"`
|
||||
Service Service `json:"service"`
|
||||
Launcher []Proc `json:"launcher"`
|
||||
Tray []Proc `json:"tray"`
|
||||
StartedBy Autostart `json:"started_by"`
|
||||
Tunnel Tunnel `json:"tunnel"`
|
||||
}
|
||||
|
||||
// Status reads the client's running state, and nothing of its configuration.
|
||||
func (m *Machine) Status() (StatusAnswer, error) {
|
||||
s := StatusAnswer{Service: m.service(), Launcher: named(m.procs(launcherComm), launcherWord),
|
||||
Tray: named(m.procs(trayComm), trayComm), StartedBy: m.autostart(entryName), Tunnel: m.tunnel()}
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Installed = v
|
||||
if v != "" {
|
||||
s.Foreign = m.foreign()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what forticlient_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Ended []int `json:"ended"`
|
||||
Killed []int `json:"killed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Session Session `json:"session"`
|
||||
Unit string `json:"unit"`
|
||||
// Tunnel is after the restart: the tray is only the client's face, the service holds the tunnel.
|
||||
Tunnel Tunnel `json:"tunnel"`
|
||||
}
|
||||
|
||||
// Restart ends the tray and its launcher and starts the launcher again in the operator's session,
|
||||
// under the account's service manager. The launcher starts the tray. The tunnel is the service's,
|
||||
// and is not touched.
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return RestartAnswer{}, err
|
||||
}
|
||||
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||
a.Ended, a.Killed = m.stop(5*time.Second, launcherComm, trayComm)
|
||||
if err := m.detach(s, restartAs, launcherBin); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Running = named(m.waitFor(launcherComm, 4*time.Second), launcherWord)
|
||||
a.Tunnel = m.tunnel()
|
||||
if len(a.Running) == 0 {
|
||||
return a, fmt.Errorf("the launcher was started as %s but no %s process appeared within 4 s",
|
||||
a.Unit, launcherWord)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what forticlient_check answers. Being connected or not is never a finding: that is
|
||||
// the operator's choice, and forticlient_status says which.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies what the module promises and relies on: the package (found, not installed by the
|
||||
// mesh); the service running and enabled; one start for the tray (the vendor's autostart entry, which
|
||||
// the session's dex runs); and the tray running once in a desktop session.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("forticlient-vpn is not installed. It is outside the official repositories, so the mesh does not install it",
|
||||
"install it from the AUR by hand")
|
||||
}
|
||||
if s := m.service(); s.Active != "active" || s.Enabled != "enabled" {
|
||||
add(fmt.Sprintf("the service %s is %s and %s", serviceUnit, s.Active, s.Enabled),
|
||||
"push the module, which declares it running and enabled")
|
||||
}
|
||||
entry := m.autostart(entryName)
|
||||
if entry.Starts {
|
||||
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
||||
if entry.From != "/etc/xdg/autostart/"+entryName {
|
||||
add("the account's own "+entry.From+" replaces the vendor's entry", "remove it, so the vendor's entry is the one start")
|
||||
}
|
||||
} else {
|
||||
add("the tray does not start with the session ("+entry.Because+")",
|
||||
"remove ~/.config/autostart/"+entryName+" if it hides the vendor's entry; if the vendor's is gone, reinstall forticlient-vpn, whose install links it")
|
||||
}
|
||||
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
|
||||
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
|
||||
}
|
||||
for _, word := range []string{launcherWord, trayComm} {
|
||||
for _, l := range m.i3Starts(word) {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
add("a second start: "+l, "remove the line; the vendor's autostart entry is the tray's one start")
|
||||
}
|
||||
}
|
||||
if _, err := m.session(); err == nil {
|
||||
for _, p := range []struct{ comm, word string }{{launcherComm, launcherWord}, {trayComm, trayComm}} {
|
||||
switch running := m.procs(p.comm); {
|
||||
case len(running) == 0:
|
||||
add("no "+p.word+" runs in the desktop session", "forticlient_restart")
|
||||
case len(running) > 1:
|
||||
add(fmt.Sprintf("%d of %s run", len(running), p.word), "forticlient_restart ends them all and starts one")
|
||||
}
|
||||
}
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// secrets are what the operator's VPN configuration holds on a real machine. The fake machine carries
|
||||
// them where the client keeps them, and no answer may.
|
||||
var secrets = []string{"vpn.example.invalid", "192.0.2.10", "s3cret-psk", "work-profile", "BEGIN CERTIFICATE", "65d16a50"}
|
||||
|
||||
func newClient(t *testing.T, running, connected bool) *fake {
|
||||
f := newFake(t)
|
||||
f.write("/opt/forticlient/Fortitray.desktop", "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
|
||||
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
|
||||
f.write("/etc/forticlient/config.db", "work-profile vpn.example.invalid 192.0.2.10 s3cret-psk\n")
|
||||
f.write(testHome+"/.config/FortiClient/state.json", `{"gateway":"vpn.example.invalid","cert":"-----BEGIN CERTIFICATE-----"}`)
|
||||
f.write("/sys/class/net/wlp3s0/flags", "0x1003\n")
|
||||
if connected {
|
||||
f.write("/sys/class/net/fctvpn65d16a50/flags", "0x1091\n")
|
||||
f.write("/sys/class/net/fctvpn65d16a50/address", "192.0.2.10\n")
|
||||
}
|
||||
if running {
|
||||
f.proc(3857, 1000, launcherComm, []string{launcherBin, "--profile=work-profile"}, "session-c1.scope")
|
||||
f.proc(4509, 1000, trayComm, []string{"/opt/forticlient/fortitray", "--gateway", "vpn.example.invalid"}, "session-c1.scope")
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch {
|
||||
case name == "pacman" && args[0] == "-Q":
|
||||
return Output{Stdout: "forticlient-vpn 7.4.3.5411-1\n"}
|
||||
case name == "pacman" && args[0] == "-Qqm":
|
||||
return Output{Stdout: "forticlient-vpn\n"}
|
||||
case name == "systemctl" && args[0] == "is-active":
|
||||
return Output{Stdout: "active\n"}
|
||||
case name == "systemctl" && args[0] == "is-enabled":
|
||||
return Output{Stdout: "enabled\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// carries fails the test when an answer holds anything of the VPN's configuration.
|
||||
func carries(t *testing.T, answer any) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(answer)
|
||||
for _, s := range secrets {
|
||||
if strings.Contains(string(raw), s) {
|
||||
t.Fatalf("the answer carries %q: %s", s, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsRunningAndConnectedStateAndNothingOfTheConfiguration(t *testing.T) {
|
||||
f := newClient(t, true, true)
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Installed != "7.4.3.5411-1" || !s.Foreign || s.Service != (Service{"active", "enabled"}) || len(s.Launcher) != 1 || len(s.Tray) != 1 ||
|
||||
!s.StartedBy.Starts || !s.Tunnel.Connected || s.Tunnel.Up != 1 {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if s.Launcher[0].Command != launcherWord || s.Tray[0].Command != trayComm {
|
||||
t.Fatalf("processes by command name only: %+v %+v", s.Launcher, s.Tray)
|
||||
}
|
||||
carries(t, s)
|
||||
for _, c := range f.calls {
|
||||
for _, never := range []string{"forticlient-cli", "fortivpn", "journalctl", "sqlite", "/etc/forticlient", "/opt/forticlient/.config"} {
|
||||
if strings.Contains(c, never) {
|
||||
t.Fatalf("ran %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
down := newClient(t, true, false)
|
||||
down.write("/sys/class/net/fctvpn0/flags", "0x1090\n")
|
||||
if s, _ := down.Status(); s.Tunnel.Connected || s.Tunnel.Up != 0 {
|
||||
t.Fatalf("an interface that is down is no tunnel: %+v", s.Tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassesTheVendorsOneStartAndNeverJudgesTheConnection(t *testing.T) {
|
||||
f := newClient(t, true, false)
|
||||
f.desktopSession()
|
||||
c, err := f.Check()
|
||||
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
carries(t, c)
|
||||
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id /opt/forticlient/fortitraylauncher\n")
|
||||
f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=/opt/forticlient/fortitraylauncher\nHidden=true\n")
|
||||
f.proc(3858, 1000, launcherComm, []string{launcherBin}, "session-c1.scope")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch name {
|
||||
case "pacman":
|
||||
return Output{Code: 1}
|
||||
case "systemctl":
|
||||
return Output{Stdout: "inactive\n", Code: 3}
|
||||
case "dex":
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
c, _ = f.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
for _, want := range []string{"not installed", "forticlient.service is inactive", "does not start with the session (Hidden=true)", "dex",
|
||||
"a second start: ~/.config/i3/config:1", "2 of fortitraylauncher run"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no finding %q in\n%s", want, got)
|
||||
}
|
||||
}
|
||||
carries(t, c)
|
||||
}
|
||||
|
||||
func TestRestartStartsTheLauncherAndLeavesTheTunnel(t *testing.T) {
|
||||
f := newClient(t, true, true)
|
||||
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("without a desktop: %v", err)
|
||||
}
|
||||
f.desktopSession()
|
||||
f.onStart = func(argv []string) { f.proc(9100, 1000, launcherComm, argv, "app.slice/"+restartAs+".service") }
|
||||
a, err := f.Restart()
|
||||
if err != nil || len(a.Ended) != 2 || len(a.Running) != 1 || !a.Tunnel.Connected {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if strings.Contains(c, serviceUnit) {
|
||||
t.Fatalf("the restart touched the service: %q", c)
|
||||
}
|
||||
}
|
||||
carries(t, a)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient
|
||||
// VPN client's tray in the operator's session and the vendor's service behind it, served by the node's
|
||||
// runtime as the operator account. The module holds no seat, so every tool is its own. The tools
|
||||
// report running and connected state only: never a profile, a credential, a gateway or a certificate.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "forticlient_status",
|
||||
Description: "The VPN client: the installed version and whether it came from outside the official " +
|
||||
"repositories, the vendor's service (active, enabled), whether the tray and its launcher run " +
|
||||
"(pid, since, scope or unit), what starts the tray at login, and whether a tunnel is up (a " +
|
||||
"count; never a name, an address, a gateway or a profile). (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "forticlient_restart",
|
||||
Description: "End the tray and its launcher (asked first, then forced after 5 s) and start the " +
|
||||
"launcher again in the operator's desktop session, under the account's service manager. The " +
|
||||
"tunnel is the service's and is not touched. Needs someone logged in to the desktop. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "forticlient_check",
|
||||
Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
|
||||
"outside the official repositories); the service runs and is enabled; the tray has exactly one " +
|
||||
"start (the vendor's XDG autostart entry, which the session's dex runs; no window-manager exec); " +
|
||||
"and the tray and its launcher run once in a desktop session. Being connected is not checked. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// forticlient's shape (novox/hq ADR 0205, ADR 0207, ADR 0208): no package (the client is outside the
|
||||
// official repositories and kept as found), the vendor's service declared running and enabled, the X
|
||||
// display on its own machine, no start of its own (the vendor's autostart entry is the tray's one
|
||||
// start), nothing of the VPN's configuration, and the Go bundle serving exactly the listed forticlient_
|
||||
// tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "forticlient_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed %s and described", tool.Name, "forticlient_")
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/forticlient-tools" || b["binary"] != "forticlient-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||
!reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
|
||||
m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil {
|
||||
t.Fatalf("resources: %v", m.Resources)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatal("no seat, no environment, and no second start")
|
||||
}
|
||||
for _, never := range []string{"/etc/forticlient", "/opt/forticlient", "autostart", "\"package\"", "vpn.", "gateway", "profile"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %s", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module forticlient
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"module": "forticlient",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"tools": [
|
||||
"forticlient_status",
|
||||
"forticlient_restart",
|
||||
"forticlient_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "scheduler",
|
||||
"type": "service",
|
||||
"unit": "forticlient.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/forticlient-tools",
|
||||
"binary": "forticlient-tools",
|
||||
"loads": [
|
||||
"forticlient-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -222,5 +222,12 @@
|
||||
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
|
||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||
}
|
||||
],
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-backup",
|
||||
"kind": "backup",
|
||||
"content": "path ${dir:data}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ file because each module carries them now:
|
||||
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
|
||||
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
|
||||
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
|
||||
| the peripherals' tray (`exec … polychromatic-tray-applet`) | `polychromatic`'s contribution to node-display-session |
|
||||
|
||||
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
|
||||
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
|
||||
@@ -98,9 +99,9 @@ because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDrop
|
||||
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
|
||||
key.
|
||||
|
||||
**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the
|
||||
operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each
|
||||
leaves when the module that owns it is written.
|
||||
**Kept until their owners exist.** A marked section holds the operator's own scripts: volume, games
|
||||
volume, the sessions launcher and the screenshot binding. Each leaves when the module that owns it is
|
||||
written. The peripherals' tray applet left it for the `polychromatic` module.
|
||||
|
||||
## What it leaves found
|
||||
|
||||
|
||||
@@ -127,7 +127,9 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin
|
||||
for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm",
|
||||
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
|
||||
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
|
||||
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} {
|
||||
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring",
|
||||
// the peripherals' tray: polychromatic's contribution
|
||||
"polychromatic"} {
|
||||
if strings.Contains(code, gone) {
|
||||
t.Errorf("the configuration still holds %q", gone)
|
||||
}
|
||||
|
||||
@@ -174,11 +174,9 @@ client.urgent #900000 #900000 #ffffff #900000 #900000
|
||||
#########################################
|
||||
# Each line below belongs to something other than i3, named on its line. When that module is written
|
||||
# it contributes the line to node-display-session, and the line goes from here in the same change.
|
||||
# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute
|
||||
# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14).
|
||||
|
||||
# the peripherals' tray (the operator's application)
|
||||
exec --no-startup-id polychromatic-tray-applet
|
||||
# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'
|
||||
# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,
|
||||
# polychromatic).
|
||||
|
||||
# the operator's scripts: volume, games volume, sessions, screenshot
|
||||
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
|
||||
@@ -194,7 +192,8 @@ bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/script
|
||||
###### Other modules' lines ####
|
||||
#########################################
|
||||
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
|
||||
# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.
|
||||
# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's
|
||||
# under a line naming it.
|
||||
${contribution:node-display-session:config}
|
||||
#########################################
|
||||
###### Your own files ####
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -132,5 +132,12 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-backup",
|
||||
"kind": "backup",
|
||||
"content": "path ${dir:data}\npath ${dir:config}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -558,5 +558,12 @@
|
||||
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
|
||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||
}
|
||||
],
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-backup",
|
||||
"kind": "backup",
|
||||
"content": "path ${dir:data-mail}\npath ${dir:data-dkim}\npath ${dir:data-data}\npath ${dir:data-dav}\npath ${dir:data-webmail}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -79,5 +79,12 @@
|
||||
"name": "mesh-vault",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-backup",
|
||||
"kind": "backup",
|
||||
"content": "path ${dir:state}\npath ${dir:ledger}\npath ${dir:root}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -151,5 +151,12 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-backup",
|
||||
"kind": "backup",
|
||||
"content": "path ${dir:data}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "dumps",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
@@ -113,5 +118,12 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-backup",
|
||||
"kind": "backup",
|
||||
"content": "run docker exec mongodb-server sh -c 'printf \"password: %s\\n\" \"$(cat /run/secrets/root)\" > /tmp/.backup.yaml && mongodump --quiet --config /tmp/.backup.yaml --username root --authenticationDatabase admin --archive; s=$?; rm -f /tmp/.backup.yaml; exit $s' > ${dir:dumps}/all.archive.partial && mv ${dir:dumps}/all.archive.partial ${dir:dumps}/all.archive\npath ${dir:dumps}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
"mode": "0700",
|
||||
"owner": "10001:0"
|
||||
},
|
||||
{
|
||||
"id": "dumps",
|
||||
"type": "directory",
|
||||
"path": "${dir:data}/backup",
|
||||
"mode": "0700",
|
||||
"owner": "10001:0"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
@@ -86,6 +93,13 @@
|
||||
"${dir:data}:/var/opt/mssql"
|
||||
],
|
||||
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
|
||||
},
|
||||
{
|
||||
"id": "backup-sql",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/backup.sql",
|
||||
"mode": "0600",
|
||||
"content": "SET NOCOUNT ON;\nDECLARE @n sysname, @s nvarchar(max);\nDECLARE c CURSOR LOCAL FAST_FORWARD FOR\n SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL;\nOPEN c;\nFETCH NEXT FROM c INTO @n;\nWHILE @@FETCH_STATUS = 0\nBEGIN\n SET @s = N'BACKUP DATABASE ' + QUOTENAME(@n) + N' TO DISK = N''/var/opt/mssql/backup/' + REPLACE(@n, N'''', N'''''') + N'.bak'' WITH INIT, COPY_ONLY, CHECKSUM';\n EXEC (@s);\n FETCH NEXT FROM c INTO @n;\nEND\nCLOSE c;\nDEALLOCATE c;\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -112,5 +126,12 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-backup",
|
||||
"kind": "backup",
|
||||
"content": "run { cat ${dir:state}/sa.secret; echo; cat ${dir:state}/backup.sql; } | docker exec -i mssql sh -c 'read -r p; SQLCMDPASSWORD=\"$p\" exec /opt/mssql-tools18/bin/sqlcmd -C -b -S localhost -U sa -i /dev/stdin'\npath ${dir:dumps}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# nextcloud-client
|
||||
|
||||
The Nextcloud desktop sync client on the workstations, as a module (novox/hq ADR 0208). It requires
|
||||
`x11-display`, so it is assigned only where a display server is held on the same machine.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the client | package `nextcloud-client`, from the official repositories |
|
||||
|
||||
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||
|
||||
- **No AUR, no vendored copy.** Both workstations run the official package (`extra`), installed
|
||||
explicitly. ADR 0205 does not apply.
|
||||
- **The account configuration stays the operator's.** `~/.config/Nextcloud/nextcloud.cfg` is the
|
||||
client's own file, and the client rewrites it. That makes it *found* in ADR 0182's terms: the module
|
||||
never declares, reads into or writes it. The tools only read it. The accounts, the sync folders, the
|
||||
server and the credentials are set in the client.
|
||||
|
||||
## How it starts: the client's own autostart entry, and nothing else
|
||||
|
||||
One process has one starter (the rule `picom` states for the desktop modules). The client's starter is
|
||||
**its own XDG autostart entry**, `~/.config/autostart/Nextcloud.desktop` (`nextcloud --background`).
|
||||
|
||||
- The client writes that entry itself while its setting *Launch on system startup* is ticked, and
|
||||
removes it when the setting is unticked.
|
||||
- The session runs every XDG autostart entry once at login: the `i3` module's
|
||||
`dex --autostart --environment i3`.
|
||||
- The package ships no `/etc/xdg/autostart` entry.
|
||||
|
||||
**Why not a contribution to `node-display-session` or the `xinitrc` slot:** the client would still
|
||||
write its own entry whenever the setting is ticked, and the session would start it twice. The module
|
||||
cannot own the entry either, because the client rewrites it on every start. Declaring that file would
|
||||
make two writers of one file. So the module adds no start, and `nextcloud_check` holds the rule
|
||||
instead: it names any second start it finds.
|
||||
|
||||
- **Excluded:** the window manager's `exec … nextcloud` (the `i3` module's configuration dropped it),
|
||||
and the package's user unit `com.nextcloud.desktopclient.nextcloud.service`, which stays disabled.
|
||||
User-scoped units are not declarable yet (mesh-host #72).
|
||||
- **One caveat:** `dex` ignores the entry's `X-GNOME-Autostart-Delay=10`, so the client starts with
|
||||
the session. It retries its connection by itself, so that is harmless.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175), and are read-only except
|
||||
`restart`. **No answer carries the server's address, the account's user ids or a credential.**
|
||||
|
||||
- `nextcloud.cfg` is read only to learn what to hide.
|
||||
- The log tools replace the server's host with `<server>` and the user ids with `<account>`.
|
||||
- Anything shaped like a credential (`Authorization:`, `token=`, `password=`, a cookie) becomes `<hidden>`.
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `nextcloud_status` (r) | <ul><li>whether the client runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and what starts it at login</li><li>each account by display name and auth type, with each sync folder: local path (`~/…`), remote path, paused, virtual files, journal present</li><li>each folder's **last sync run**: started, finished or still running, items, errors, the first ten failing files</li><li>the latest warnings and worse in the client's log</li></ul> |
|
||||
| `nextcloud_log` (r) | the last `lines` (default 100, at most 2000) of the client's log (`source: client`). The log rotates every two hours, and older gzipped files are read until the count is reached. `problems: true` keeps warnings and worse. `source: sync` gives the sync runs' log. Answers are capped at 256 KiB |
|
||||
| `nextcloud_restart` (a) | asks the client to end (SIGTERM), forces it after 6 s, and starts `nextcloud --background` in the operator's session. The start is a transient user unit `mesh-nextcloud-client`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop |
|
||||
| `nextcloud_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the entry is present and enabled, and `dex` is installed</li><li>no window-manager exec and no enabled user unit</li><li>one client runs in a desktop session</li><li>an account exists, its folders exist with a journal, and none is paused</li></ul>Each finding says what to do |
|
||||
|
||||
**Where the tools read:**
|
||||
|
||||
- The client's settings are read from `~/.config/Nextcloud/nextcloud.cfg`.
|
||||
- Its log is read from `~/.config/Nextcloud/logs/*_nextcloud.log*`.
|
||||
- Each folder's sync runs are read from the `*_sync.log` whose first line is that folder's path. That
|
||||
file is in `~/.local/share/Nextcloud/`, or in `~/.config/Nextcloud/` for older clients, and the
|
||||
newest one wins.
|
||||
|
||||
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment,
|
||||
as `clipmenu` and `screen-lock` do. Every command has a timeout and capped output. Everything runs
|
||||
through an injected runner and a fake root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | none: `nextcloud-client` 34.0.4 is installed, explicitly, from `extra` | the same |
|
||||
| start | none: dex starts it from the client's own entry (`--background`, in the login session's scope) | none on disk. **The client running now came from the predecessor's window-manager line** (`nextcloud`, a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from its entry |
|
||||
| settings | one account, one folder (`~/Nextcloud/`, whole server), not paused, no virtual files; *Launch on system startup* on | the same |
|
||||
|
||||
The workstations are already in the state this module describes.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
Nothing is required on either machine.
|
||||
|
||||
- **shanks:** log out and in once, or run `nextcloud_restart`, and the client runs from its one start.
|
||||
`nextcloud_check` then answers `ok`.
|
||||
- **Optional, both:** the client has kept a `nextcloud.cfg.backup_<date>_<version>` from every upgrade
|
||||
since 2023 (about twenty on each machine). It also keeps a sync log that it no longer writes, at
|
||||
`~/.config/Nextcloud/Nextcloud_sync.log`, from 2024 on g14 and 2023 on shanks. They are the
|
||||
operator's to delete. The module leaves them.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- `~/.config/Nextcloud/`: the settings, their backups, `cookies0.db`, `sync-exclude.lst`, the logs.
|
||||
- `~/.local/share/Nextcloud/`: the sync runs' log.
|
||||
- `~/.config/autostart/Nextcloud.desktop`, the client's.
|
||||
- Every sync folder and its `.sync_*.db` journal.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **`i3`'s `dex` line for the start.** Nothing in the mesh says so yet: XDG autostart has no seat, and
|
||||
a module without a seat or contribution has no way to depend on another module. Assigned without
|
||||
`i3`, the client is installed and does not start. `nextcloud_check` says so.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3). Under sway the client runs on
|
||||
Wayland as well. A Wayland twin then requires `wayland-display`.
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || 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 is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// 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
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// The nextcloud-client module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the
|
||||
// Nextcloud desktop sync client in the operator's session, served by the node's runtime as the
|
||||
// operator account. The module holds no seat, so every tool is its own.
|
||||
//
|
||||
// The client's account configuration is the operator's: the tools read the client's own files and
|
||||
// never write them, and no answer carries the server's address, the account's ids or a credential.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "nextcloud_status",
|
||||
Description: "The Nextcloud desktop client: whether it runs (pid, since, and the unit or session scope " +
|
||||
"it runs in), the installed version, what starts it at login, each account by name (never the " +
|
||||
"server's address or a credential) with its sync folders' local paths, remote paths, paused, and " +
|
||||
"the last sync run (started, finished, items, errors and the first failing files), and the latest " +
|
||||
"warnings in the client's log. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "nextcloud_log",
|
||||
Description: "The last lines of the client's log (source client, the default) or of the sync runs' " +
|
||||
"log (source sync, file by file), newest last. With problems, only warnings and worse. The " +
|
||||
"server's address and the account's ids are replaced by <server> and <account>. (r)",
|
||||
Input: map[string]any{
|
||||
"lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"},
|
||||
"source": map[string]any{"type": "string", "enum": []string{"client", "sync"}, "description": "client (default) or sync"},
|
||||
"problems": map[string]any{"type": "boolean", "description": "only warning, critical and fatal lines of the client's log (default false)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := whole(args, "lines", 100, 1, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := text(args, "source", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if source == "" {
|
||||
source = "client"
|
||||
}
|
||||
problems, err := flag(args, "problems", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.Log(source, n, problems)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "nextcloud_restart",
|
||||
Description: "End the running client (asked first, then forced after 6 s) and start it again in the " +
|
||||
"operator's desktop session, in the tray, under the account's service manager. Answers the pids " +
|
||||
"ended and the new one. Needs someone logged in to the desktop. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "nextcloud_check",
|
||||
Description: "Check what the module promises: the package is installed; the client has exactly one " +
|
||||
"start (its own XDG autostart entry, which the session's dex runs; no window-manager exec, no " +
|
||||
"enabled user unit); it runs once in a desktop session; it has an account and its folders exist " +
|
||||
"and are not paused. Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// nextcloud-client's shape (novox/hq ADR 0208, ADR 0210, ADR 0182): one official package, no seat,
|
||||
// the X display on its own machine, no start of its own (the client's own autostart entry is the one
|
||||
// start), no file of the client's, and the Go bundle serving exactly the listed nextcloud_ tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []any `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestItInstallsTheClientAndNothingElse(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if m.Module != "nextcloud-client" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
|
||||
t.Fatalf("resources: %v", m.Resources)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil {
|
||||
t.Fatal("it holds no seat and sets no environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItAddsNoSecondStartAndOwnsNoneOfTheClientsFiles(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
// The client writes its own XDG autostart entry, which the session's dex runs: an xinitrc slot
|
||||
// or a window-manager exec would start it twice.
|
||||
if m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions)
|
||||
}
|
||||
for _, never := range []string{"nextcloud.cfg", "autostart", "service"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %q: the client's files and start are its own", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "nextcloud_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed nextcloud_ and described", tool.Name)
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/nextcloud-client-tools" || b["binary"] != "nextcloud-client-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
package main
|
||||
|
||||
// The Nextcloud desktop client as the tools see it, read from the client's own files only:
|
||||
// - ~/.config/Nextcloud/nextcloud.cfg, the client's settings (Qt's INI form), which the client
|
||||
// rewrites and the module never writes. Accounts and sync folders are read from it. The server's
|
||||
// address, the account's user ids and every credential-like key are never answered: they are
|
||||
// read only to be hidden in what the log tools answer;
|
||||
// - the sync-run log of each folder (<appdata>/Nextcloud/*_sync.log), whose first line is the
|
||||
// folder's local path, and whose run markers and per-file lines give the last sync's state and
|
||||
// errors;
|
||||
// - the client's log directory, ~/.config/Nextcloud/logs, rotated by the client every two hours
|
||||
// (the newest file plain, the older ones gzipped).
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the client keeps things, under the operator's home.
|
||||
const (
|
||||
configFile = ".config/Nextcloud/nextcloud.cfg"
|
||||
logDir = ".config/Nextcloud/logs"
|
||||
entryName = "Nextcloud.desktop"
|
||||
clientComm = "nextcloud"
|
||||
clientBin = "/usr/bin/nextcloud"
|
||||
restartAs = "mesh-nextcloud-client"
|
||||
packageFor = "nextcloud-client"
|
||||
userUnit = "com.nextcloud.desktopclient.nextcloud.service"
|
||||
)
|
||||
|
||||
// syncLogDirs are where the client has kept its sync-run logs, newest convention first.
|
||||
var syncLogDirs = []string{".local/share/Nextcloud", ".config/Nextcloud"}
|
||||
|
||||
// ini is a Qt INI file: section → key → value. Keys keep Qt's backslash-separated groups.
|
||||
type ini map[string]map[string]string
|
||||
|
||||
func parseINI(raw []byte) ini {
|
||||
out := ini{}
|
||||
section := "General"
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
s.Buffer(make([]byte, 64<<10), 4<<20) // a certificate is one long line
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
if l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") {
|
||||
section = l[1 : len(l)-1]
|
||||
continue
|
||||
}
|
||||
i := strings.IndexByte(l, '=')
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimSpace(l[i+1:])
|
||||
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
if out[section] == nil {
|
||||
out[section] = map[string]string{}
|
||||
}
|
||||
out[section][strings.TrimSpace(l[:i])] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Folder is one sync folder as the client is configured.
|
||||
type Folder struct {
|
||||
LocalPath string `json:"-"`
|
||||
Local string `json:"local_path"`
|
||||
RemotePath string `json:"remote_path"`
|
||||
Paused bool `json:"paused"`
|
||||
VirtualFiles string `json:"virtual_files,omitempty"`
|
||||
Journal string `json:"-"`
|
||||
JournalFound bool `json:"journal_found"`
|
||||
LastSync *Run `json:"last_sync,omitempty"`
|
||||
}
|
||||
|
||||
// Account is one account, by name only.
|
||||
type Account struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
Folders []Folder `json:"folders"`
|
||||
// hidden are the values never answered: the server's host and the account's user ids.
|
||||
hosts []string
|
||||
users []string
|
||||
}
|
||||
|
||||
// Config is what the tools read from nextcloud.cfg.
|
||||
type Config struct {
|
||||
Found bool `json:"found"`
|
||||
ClientVersion string `json:"client_version,omitempty"`
|
||||
LaunchAtStartup *bool `json:"launch_on_system_startup,omitempty"`
|
||||
Accounts []Account `json:"accounts"`
|
||||
}
|
||||
|
||||
func (m *Machine) config() (Config, error) {
|
||||
raw, err := readBounded(m.home(configFile))
|
||||
if os.IsNotExist(err) {
|
||||
return Config{Accounts: []Account{}}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("reading the client's settings: %w", err)
|
||||
}
|
||||
f := parseINI(raw)
|
||||
c := Config{Found: true, ClientVersion: f["General"]["clientVersion"], Accounts: []Account{}}
|
||||
if v, ok := f["General"]["launchOnSystemStartup"]; ok {
|
||||
b := v == "true"
|
||||
c.LaunchAtStartup = &b
|
||||
}
|
||||
byID := map[string]*Account{}
|
||||
folders := map[string]map[string]*Folder{}
|
||||
var ids []string
|
||||
for k, v := range f["Accounts"] {
|
||||
parts := strings.Split(k, `\`)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
id := parts[0]
|
||||
if _, err := strconv.Atoi(id); err != nil {
|
||||
continue
|
||||
}
|
||||
a := byID[id]
|
||||
if a == nil {
|
||||
a = &Account{ID: id, Folders: []Folder{}}
|
||||
byID[id] = a
|
||||
folders[id] = map[string]*Folder{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
switch parts[1] {
|
||||
case "displayName":
|
||||
a.Name = v
|
||||
case "authType":
|
||||
a.Auth = v
|
||||
case "url":
|
||||
if u, err := url.Parse(v); err == nil && u.Host != "" {
|
||||
a.hosts = append(a.hosts, u.Host)
|
||||
if u.Hostname() != u.Host {
|
||||
a.hosts = append(a.hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
case "user", "dav_user", "webflow_user", "http_user":
|
||||
if v != "" {
|
||||
a.users = append(a.users, v)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// <id>\Folders\<n>\<key>, and the other folder groups (FoldersWithPlaceholders, Multifolders).
|
||||
if len(parts) == 4 && strings.HasPrefix(parts[1], "Folders") || len(parts) == 4 && parts[1] == "Multifolders" {
|
||||
key := parts[1] + `\` + parts[2]
|
||||
fo := folders[id][key]
|
||||
if fo == nil {
|
||||
fo = &Folder{}
|
||||
folders[id][key] = fo
|
||||
}
|
||||
switch parts[3] {
|
||||
case "localPath":
|
||||
fo.LocalPath = v
|
||||
case "targetPath":
|
||||
fo.RemotePath = v
|
||||
case "paused":
|
||||
fo.Paused = v == "true"
|
||||
case "virtualFilesMode":
|
||||
fo.VirtualFiles = v
|
||||
case "journalPath":
|
||||
fo.Journal = v
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
a := byID[id]
|
||||
if a.Name == "" {
|
||||
a.Name = "account " + id
|
||||
}
|
||||
var keys []string
|
||||
for k := range folders[id] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fo := *folders[id][k]
|
||||
if fo.LocalPath == "" {
|
||||
continue
|
||||
}
|
||||
fo.Local = m.tilde(fo.LocalPath)
|
||||
if fo.Journal != "" {
|
||||
fo.JournalFound = exists(filepath.Join(m.Root, fo.LocalPath, fo.Journal))
|
||||
}
|
||||
a.Folders = append(a.Folders, fo)
|
||||
}
|
||||
c.Accounts = append(c.Accounts, *a)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// redactor hides what an answer must never carry: the servers' hosts, the accounts' user ids, and
|
||||
// anything shaped like a credential.
|
||||
type redactor struct{ hosts, users []string }
|
||||
|
||||
var credential = regexp.MustCompile(`(?i)\b(authorization|bearer|basic|token|apppassword|app_password|password|passwd|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:(?:bearer|basic)\s+)?[^\s"',;&]+`)
|
||||
|
||||
func (c Config) redactor() redactor {
|
||||
var r redactor
|
||||
for _, a := range c.Accounts {
|
||||
r.hosts = append(r.hosts, a.hosts...)
|
||||
r.users = append(r.users, a.users...)
|
||||
}
|
||||
// Longest first, so a host's port form is replaced before its bare name.
|
||||
sort.Slice(r.hosts, func(i, j int) bool { return len(r.hosts[i]) > len(r.hosts[j]) })
|
||||
sort.Slice(r.users, func(i, j int) bool { return len(r.users[i]) > len(r.users[j]) })
|
||||
return r
|
||||
}
|
||||
|
||||
func (r redactor) clean(s string) string {
|
||||
s = credential.ReplaceAllString(s, "${1}${2}<hidden>")
|
||||
for _, u := range r.users {
|
||||
s = strings.ReplaceAll(s, u, "<account>")
|
||||
}
|
||||
for _, h := range r.hosts {
|
||||
s = strings.ReplaceAll(s, h, "<server>")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Run is one folder's last sync run, from its sync-run log.
|
||||
type Run struct {
|
||||
Started string `json:"started,omitempty"`
|
||||
Finished string `json:"finished,omitempty"`
|
||||
// State is "finished", "running", or "never" when the log has no run.
|
||||
State string `json:"state"`
|
||||
Changes int `json:"items"`
|
||||
Errors int `json:"errors"`
|
||||
Problems []Problem `json:"problems,omitempty"`
|
||||
Log string `json:"log"`
|
||||
}
|
||||
|
||||
// Problem is one item of a run that ended with an error.
|
||||
type Problem struct {
|
||||
File string `json:"file"`
|
||||
Error string `json:"error"`
|
||||
HTTP string `json:"http,omitempty"`
|
||||
}
|
||||
|
||||
const mostProblems = 10
|
||||
|
||||
// syncLogFor finds the sync-run log whose first line is the folder's local path; the newest wins.
|
||||
func (m *Machine) syncLogFor(local string) string {
|
||||
want := strings.TrimSuffix(local, "/")
|
||||
best, bestAt := "", time.Time{}
|
||||
for _, d := range syncLogDirs {
|
||||
files, _ := filepath.Glob(m.home(d, "*_sync.log"))
|
||||
for _, f := range files {
|
||||
h, err := os.Open(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
first, _ := bufio.NewReader(io.LimitReader(h, 4096)).ReadString('\n')
|
||||
h.Close()
|
||||
if strings.TrimSuffix(strings.TrimSpace(first), "/") != want {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(f); err == nil && st.ModTime().After(bestAt) {
|
||||
best, bestAt = f, st.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// lastRun reads the end of a sync-run log: the last run's markers and its items.
|
||||
func (m *Machine) lastRun(path string, r redactor) (*Run, error) {
|
||||
lines, err := tailLines(path, 4000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
run := &Run{State: "never", Log: m.tilde(strings.TrimPrefix(path, m.Root))}
|
||||
start := -1
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if strings.HasPrefix(lines[i], "#=#=#=# Syncrun started ") {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return run, nil
|
||||
}
|
||||
run.Started = marker(lines[start], "#=#=#=# Syncrun started ")
|
||||
run.State = "running"
|
||||
for _, l := range lines[start+1:] {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "#=#=#=# Syncrun finished "):
|
||||
run.Finished = marker(l, "#=#=#=# Syncrun finished ")
|
||||
run.State = "finished"
|
||||
case strings.HasPrefix(l, "#"):
|
||||
default:
|
||||
f := strings.Split(l, "|")
|
||||
if len(f) < 12 {
|
||||
continue
|
||||
}
|
||||
run.Changes++
|
||||
if e := strings.TrimSpace(f[10]); e != "" {
|
||||
run.Errors++
|
||||
if len(run.Problems) < mostProblems {
|
||||
run.Problems = append(run.Problems, Problem{File: r.clean(f[2]), Error: r.clean(e), HTTP: strings.TrimSpace(f[11])})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func marker(line, prefix string) string {
|
||||
f := strings.Fields(strings.TrimPrefix(line, prefix))
|
||||
if len(f) == 0 {
|
||||
return ""
|
||||
}
|
||||
return f[0]
|
||||
}
|
||||
|
||||
// tailLines answers the last n lines of a file, plain or gzipped, read to at most MostRead.
|
||||
func tailLines(path string, n int) ([]string, error) {
|
||||
h, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer h.Close()
|
||||
var r io.Reader = h
|
||||
if strings.HasSuffix(path, ".gz") {
|
||||
z, err := gzip.NewReader(h)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", filepath.Base(path), err)
|
||||
}
|
||||
defer z.Close()
|
||||
r = z
|
||||
} else if st, err := h.Stat(); err == nil && st.Size() > MostRead {
|
||||
// A plain file is read from its end.
|
||||
if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r, MostRead))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
|
||||
if len(all) == 1 && all[0] == "" {
|
||||
all = nil
|
||||
}
|
||||
if len(all) > n {
|
||||
all = all[len(all)-n:]
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// clientLogs are the client's log files, newest first.
|
||||
func (m *Machine) clientLogs() []string {
|
||||
entries, err := os.ReadDir(m.home(logDir))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
type file struct {
|
||||
path string
|
||||
at time.Time
|
||||
}
|
||||
var files []file
|
||||
for _, e := range entries {
|
||||
// The client's own log, not its permanent-delete records beside it.
|
||||
if e.IsDir() || !strings.Contains(e.Name(), "_nextcloud.log") {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil {
|
||||
files = append(files, file{m.home(logDir, e.Name()), info.ModTime()})
|
||||
}
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].at.After(files[j].at) })
|
||||
out := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
out[i] = f.path
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// level is a client log line's level: "[ warning category file:line ]:" → warning.
|
||||
func level(line string) string {
|
||||
i := strings.Index(line, "[ ")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
f := strings.Fields(line[i+2:])
|
||||
if len(f) == 0 {
|
||||
return ""
|
||||
}
|
||||
return f[0]
|
||||
}
|
||||
|
||||
func isProblem(line string) bool {
|
||||
switch level(line) {
|
||||
case "warning", "critical", "fatal":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LogAnswer is what nextcloud_log answers.
|
||||
type LogAnswer struct {
|
||||
Source string `json:"source"`
|
||||
Files []string `json:"files"`
|
||||
Lines []string `json:"lines"`
|
||||
Cut bool `json:"cut,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
const mostAnswer = 256 << 10
|
||||
|
||||
// Log answers the last n lines of the client's log (or only its warnings and worse), or of the sync
|
||||
// runs' log, the server's address and the account's ids hidden.
|
||||
func (m *Machine) Log(source string, n int, problemsOnly bool) (LogAnswer, error) {
|
||||
c, err := m.config()
|
||||
if err != nil {
|
||||
return LogAnswer{}, err
|
||||
}
|
||||
r := c.redactor()
|
||||
a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}}
|
||||
var files []string
|
||||
switch source {
|
||||
case "client":
|
||||
files = m.clientLogs()
|
||||
if len(files) == 0 {
|
||||
a.Note = "the client keeps no log files in ~/" + logDir + ": it writes them there only while its logging is switched on"
|
||||
return a, nil
|
||||
}
|
||||
case "sync":
|
||||
for _, acc := range c.Accounts {
|
||||
for _, f := range acc.Folders {
|
||||
if p := m.syncLogFor(f.LocalPath); p != "" {
|
||||
files = append(files, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
a.Note = "no sync-run log found for any configured folder"
|
||||
return a, nil
|
||||
}
|
||||
default:
|
||||
return a, fmt.Errorf("source is client or sync, not %q", source)
|
||||
}
|
||||
// The newest file first; older ones until n lines are found, at most four files.
|
||||
var got []string
|
||||
for i, f := range files {
|
||||
if i == 4 || len(got) >= n {
|
||||
break
|
||||
}
|
||||
lines, err := tailLines(f, 1<<20)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if problemsOnly {
|
||||
var keep []string
|
||||
for _, l := range lines {
|
||||
if isProblem(l) {
|
||||
keep = append(keep, l)
|
||||
}
|
||||
}
|
||||
lines = keep
|
||||
}
|
||||
if len(lines) > n-len(got) {
|
||||
lines = lines[len(lines)-(n-len(got)):]
|
||||
}
|
||||
got = append(lines, got...)
|
||||
a.Files = append(a.Files, m.tilde(strings.TrimPrefix(f, m.Root)))
|
||||
if source == "sync" {
|
||||
// Each folder's log is its own story: the newest folder's only, unless asked again.
|
||||
break
|
||||
}
|
||||
}
|
||||
size := 0
|
||||
for i := len(got) - 1; i >= 0; i-- {
|
||||
l := r.clean(got[i])
|
||||
if size+len(l) > mostAnswer {
|
||||
a.Cut = true
|
||||
got = got[i+1:]
|
||||
break
|
||||
}
|
||||
size += len(l) + 1
|
||||
got[i] = l
|
||||
}
|
||||
if got == nil {
|
||||
got = []string{}
|
||||
}
|
||||
a.Lines = got
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Status is what nextcloud_status answers.
|
||||
type Status struct {
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Config Config `json:"settings"`
|
||||
Problems []string `json:"recent_client_problems"`
|
||||
StartedBy Autostart `json:"started_by"`
|
||||
}
|
||||
|
||||
// Status reads the client: whether it runs, its accounts and folders with their last sync, and the
|
||||
// warnings and worse at the end of its log.
|
||||
func (m *Machine) Status() (Status, error) {
|
||||
c, err := m.config()
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
r := c.redactor()
|
||||
s := Status{Running: m.procs(clientComm), Config: c, Problems: []string{}, StartedBy: m.autostart(entryName)}
|
||||
if s.Running == nil {
|
||||
s.Running = []Proc{}
|
||||
}
|
||||
if v, err := m.installed(packageFor); err == nil {
|
||||
s.Installed = v
|
||||
}
|
||||
for ai := range s.Config.Accounts {
|
||||
for fi := range s.Config.Accounts[ai].Folders {
|
||||
f := &s.Config.Accounts[ai].Folders[fi]
|
||||
if p := m.syncLogFor(f.LocalPath); p != "" {
|
||||
if run, err := m.lastRun(p, r); err == nil {
|
||||
f.LastSync = run
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The two newest files: the log rotates every two hours, and may just have.
|
||||
logs := m.clientLogs()
|
||||
if len(logs) > 2 {
|
||||
logs = logs[:2]
|
||||
}
|
||||
for i := len(logs) - 1; i >= 0; i-- {
|
||||
if lines, err := tailLines(logs[i], 1<<20); err == nil {
|
||||
for _, l := range lines {
|
||||
if isProblem(l) {
|
||||
s.Problems = append(s.Problems, r.clean(l))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.Problems) > 10 {
|
||||
s.Problems = s.Problems[len(s.Problems)-10:]
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what nextcloud_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Ended []int `json:"ended"`
|
||||
Killed []int `json:"killed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Session Session `json:"session"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// Restart ends the running client and starts it again in the operator's session, under the account's
|
||||
// service manager, as its autostart entry does (--background: to the tray, no window).
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return RestartAnswer{}, err
|
||||
}
|
||||
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||
a.Ended, a.Killed = m.stop(6*time.Second, clientComm)
|
||||
if err := m.detach(s, restartAs, clientBin, "--background"); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Running = m.waitFor(clientComm, 4*time.Second)
|
||||
if len(a.Running) == 0 {
|
||||
return a, fmt.Errorf("the client was started as %s but no %s process appeared within 4 s: "+
|
||||
"see `journalctl --user -u %s`", a.Unit, clientComm, a.Unit)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what nextcloud_check answers.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies the module's promises: the package, one start (the client's own autostart entry,
|
||||
// which the session's dex runs), the client running once in a session, and its folders present.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("the package "+packageFor+" is not installed", "push the module to the node")
|
||||
}
|
||||
c, err := m.config()
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
entry := m.autostart(entryName)
|
||||
if entry.Starts {
|
||||
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
||||
if !strings.Contains(entry.Exec, clientComm) {
|
||||
add("the autostart entry runs "+entry.Exec+", not the client", "untick and tick again 'Launch on system startup' in the client's settings")
|
||||
}
|
||||
} else {
|
||||
add("the client does not start with the session ("+entry.Because+")",
|
||||
"tick 'Launch on system startup' in the client's General settings: the client writes its own entry")
|
||||
}
|
||||
if c.LaunchAtStartup != nil && !*c.LaunchAtStartup && entry.Starts {
|
||||
add("the client's setting says not to launch at startup, but its autostart entry is there", "tick and untick the setting, or remove ~/.config/autostart/"+entryName)
|
||||
}
|
||||
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
|
||||
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
|
||||
}
|
||||
for _, l := range m.i3Starts(clientComm) {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
add("a second start: "+l, "remove the line; the autostart entry is the client's one start")
|
||||
}
|
||||
if o := m.cmd(0, m.bus(), "systemctl", "--user", "is-enabled", userUnit); o.Err == nil && strings.TrimSpace(o.Stdout) == "enabled" {
|
||||
a.Starts = append(a.Starts, "user unit: "+userUnit)
|
||||
add("a second start: the packaged user unit "+userUnit+" is enabled", "systemctl --user disable "+userUnit)
|
||||
}
|
||||
running := m.procs(clientComm)
|
||||
if _, err := m.session(); err == nil {
|
||||
switch {
|
||||
case len(running) == 0:
|
||||
add("no client runs in the desktop session", "nextcloud_restart")
|
||||
case len(running) > 1:
|
||||
add(fmt.Sprintf("%d clients run", len(running)), "nextcloud_restart ends them all and starts one")
|
||||
}
|
||||
}
|
||||
if !c.Found {
|
||||
add("the client has no settings yet (~/"+configFile+")", "open the client and add the account: its configuration is the operator's")
|
||||
} else if len(c.Accounts) == 0 {
|
||||
add("the client has no account", "add the account in the client")
|
||||
}
|
||||
for _, acc := range c.Accounts {
|
||||
for _, f := range acc.Folders {
|
||||
if !exists(filepath.Join(m.Root, f.LocalPath)) {
|
||||
add("the sync folder "+m.tilde(f.LocalPath)+" of "+acc.Name+" does not exist", "recreate it, or remove the folder from the client")
|
||||
} else if f.Journal != "" && !f.JournalFound {
|
||||
add("the sync folder "+m.tilde(f.LocalPath)+" has no sync journal yet", "it is written at the first sync")
|
||||
}
|
||||
if f.Paused {
|
||||
add("the sync folder "+m.tilde(f.LocalPath)+" is paused", "resume it in the client")
|
||||
}
|
||||
}
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A client's settings as the client writes them, with a certificate, a server and user ids that no
|
||||
// answer may carry.
|
||||
const cfg = `[General]
|
||||
clientVersion=34.0.4daily
|
||||
launchOnSystemStartup=true
|
||||
|
||||
[Accounts]
|
||||
0\Folders\1\journalPath=.sync_abc.db
|
||||
0\Folders\1\localPath=/home/operator/Nextcloud/
|
||||
0\Folders\1\paused=false
|
||||
0\Folders\1\targetPath=/
|
||||
0\Folders\1\virtualFilesMode=off
|
||||
0\Folders\2\localPath=/home/operator/Photos/
|
||||
0\Folders\2\paused=true
|
||||
0\Folders\2\targetPath=/Photos
|
||||
0\General\CaCertificates="@ByteArray(-----BEGIN CERTIFICATE-----\nMIIE6zCC\n-----END CERTIFICATE-----)"
|
||||
0\authType=webflow
|
||||
0\dav_user=kc-1234-uid
|
||||
0\displayName=The Operator
|
||||
0\url=https://cloud.example.test:8443
|
||||
0\user=kc-1234-uid@cloud.example.test
|
||||
0\webflow_user=kc-1234-uid
|
||||
version=13
|
||||
`
|
||||
|
||||
const syncLog = `/home/operator/Nextcloud/
|
||||
# timestamp | duration | file | instruction | dir | modtime | etag | size | fileId | status | errorString | http result code | other size | other modtime | X-Request-ID
|
||||
#=#=#=# Syncrun started 2026-10-05T07:27:35Z
|
||||
||old.md|2|2|1|e|1|i|13||0|1|1||
|
||||
#=#=#=# Syncrun finished 2026-10-05T07:27:35Z (last step: 16 msec, total: 170 msec)
|
||||
#=#=#=# Syncrun started 2026-10-05T09:27:40Z
|
||||
#=#=#=#=# Propagation starts 2026-10-05T09:27:40Z (last step: 131 msec, total: 131 msec)
|
||||
13:58:53||Notes/a.md|8|1|1|e|1|i|13||201|0|0|r|
|
||||
13:58:54||Notes/b.md|8|1|1|e|1|i|13|"/Notes/b.md" is locked|423|0|0|r|
|
||||
13:58:55||kc-1234-uid/c.md|8|1|1|e|1|i|13|File has changed since discovery|200|0|0|r|
|
||||
#=#=#=# Syncrun finished 2026-10-05T09:27:40Z (last step: 17 msec, total: 148 msec)
|
||||
`
|
||||
|
||||
func clientLine(level, text string) string {
|
||||
return "2026-10-05 11:27:41:151 [ " + level + " nextcloud.gui.folder /src/folder.cpp:1 ]:\t" + text
|
||||
}
|
||||
|
||||
func newClient(t *testing.T) *fake {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/"+configFile, cfg)
|
||||
f.write(testHome+"/Nextcloud/.sync_abc.db", "")
|
||||
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", syncLog)
|
||||
// An older log of the same folder, in the old place: the newer one wins.
|
||||
f.write(testHome+"/.config/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2023-11-17T00:00:00Z\n")
|
||||
old := filepath.Join(f.Root, testHome, ".config/Nextcloud/Nextcloud_sync.log")
|
||||
_ = os.Chtimes(old, time.Unix(1_700_000_000, 0), time.Unix(1_700_000_000, 0))
|
||||
var gz bytes.Buffer
|
||||
z := gzip.NewWriter(&gz)
|
||||
_, _ = z.Write([]byte(clientLine("info", "older") + "\n" + clientLine("warning", "Network error on https://cloud.example.test:8443/x Authorization: Bearer abc.def") + "\n"))
|
||||
_ = z.Close()
|
||||
f.write(testHome+"/"+logDir+"/20261005_0927_nextcloud.log.0.gz", gz.String())
|
||||
f.write(testHome+"/"+logDir+"/20261005_1127_permanent_delete.log.0", "not the client's log\n")
|
||||
f.write(testHome+"/"+logDir+"/20261005_1127_nextcloud.log.0",
|
||||
clientLine("info", "Sync finished for folder of account [kc-1234-uid@cloud.example.test]")+"\n"+clientLine("critical", "Could not read journal")+"\n")
|
||||
gzPath := filepath.Join(f.Root, testHome, logDir, "20261005_0927_nextcloud.log.0.gz")
|
||||
_ = os.Chtimes(gzPath, time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
|
||||
f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nName=Nextcloud\nExec=\"/usr/bin/nextcloud\" --background\nX-GNOME-Autostart-enabled=true\n")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch {
|
||||
case name == "pacman":
|
||||
return Output{Stdout: "nextcloud-client 2:34.0.4-1\n"}
|
||||
case name == "systemctl" && len(args) > 1 && args[1] == "is-enabled":
|
||||
return Output{Stdout: "disabled\n", Code: 1}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func noSecrets(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
for _, never := range []string{"example.test", "kc-1234", "CERTIFICATE", "abc.def", "/home/operator"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("the answer carries %q: %s", never, s)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestStatusNamesAccountsAndFoldersWithTheirLastSyncAndNothingSecret(t *testing.T) {
|
||||
f := newClient(t)
|
||||
f.proc(3865, 1000, clientComm, []string{"/usr/bin/nextcloud", "--background"}, "session-c1.scope")
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noSecrets(t, s)
|
||||
if s.Installed != "2:34.0.4-1" || len(s.Running) != 1 || s.Running[0].StartedIn != "session-c1.scope" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if !s.StartedBy.Starts || s.StartedBy.From != "~/.config/autostart/Nextcloud.desktop" {
|
||||
t.Fatalf("%+v", s.StartedBy)
|
||||
}
|
||||
if s.Config.ClientVersion != "34.0.4daily" || s.Config.LaunchAtStartup == nil || !*s.Config.LaunchAtStartup {
|
||||
t.Fatalf("%+v", s.Config)
|
||||
}
|
||||
if len(s.Config.Accounts) != 1 {
|
||||
t.Fatalf("%+v", s.Config.Accounts)
|
||||
}
|
||||
a := s.Config.Accounts[0]
|
||||
if a.Name != "The Operator" || a.Auth != "webflow" || len(a.Folders) != 2 {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
main, photos := a.Folders[0], a.Folders[1]
|
||||
if main.Local != "~/Nextcloud/" || main.RemotePath != "/" || main.Paused || !main.JournalFound || main.VirtualFiles != "off" {
|
||||
t.Fatalf("%+v", main)
|
||||
}
|
||||
run := main.LastSync
|
||||
if run == nil || run.State != "finished" || run.Started != "2026-10-05T09:27:40Z" || run.Finished != "2026-10-05T09:27:40Z" ||
|
||||
run.Changes != 3 || run.Errors != 2 || run.Log != "~/.local/share/Nextcloud/Nextcloud_sync.log" {
|
||||
t.Fatalf("%+v", run)
|
||||
}
|
||||
if run.Problems[0].File != "Notes/b.md" || run.Problems[0].HTTP != "423" || run.Problems[1].File != "<account>/c.md" {
|
||||
t.Fatalf("%+v", run.Problems)
|
||||
}
|
||||
if !photos.Paused || photos.RemotePath != "/Photos" || photos.LastSync != nil {
|
||||
t.Fatalf("%+v", photos)
|
||||
}
|
||||
// Warnings and worse of the two newest client logs, oldest first, hidden.
|
||||
if len(s.Problems) != 2 || !strings.Contains(s.Problems[0], "<server>/x Authorization: <hidden>") ||
|
||||
!strings.Contains(s.Problems[1], "Could not read journal") {
|
||||
t.Fatalf("%q", s.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunWithoutItsEndIsRunningAndALogWithoutRunsIsNever(t *testing.T) {
|
||||
f := newClient(t)
|
||||
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2026-10-05T10:00:00Z\n||x|1|1|1|e|1|i|13||200|0|0||\n")
|
||||
s, _ := f.Status()
|
||||
if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "running" || r.Finished != "" || r.Changes != 1 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n")
|
||||
s, _ = f.Status()
|
||||
if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "never" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSettingsIsNoAccountNotAnError(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.answer = func(string, []string) Output { return Output{Code: 1} }
|
||||
s, err := f.Status()
|
||||
if err != nil || s.Config.Found || len(s.Config.Accounts) != 0 || s.Installed != "" {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLogIsBoundedHiddenAndReadAcrossRotations(t *testing.T) {
|
||||
f := newClient(t)
|
||||
l, err := f.Log("client", 3, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noSecrets(t, l)
|
||||
if len(l.Lines) != 3 || !strings.Contains(l.Lines[0], "Authorization: <hidden>") || !strings.Contains(l.Lines[1], "[<account>]") ||
|
||||
len(l.Files) != 2 || !strings.HasSuffix(l.Files[0], "_1127_nextcloud.log.0") {
|
||||
t.Fatalf("%+v", l)
|
||||
}
|
||||
l, _ = f.Log("client", 1, false)
|
||||
if len(l.Lines) != 1 || len(l.Files) != 1 || !strings.Contains(l.Lines[0], "Could not read journal") {
|
||||
t.Fatalf("%+v", l)
|
||||
}
|
||||
l, _ = f.Log("client", 50, true)
|
||||
if len(l.Lines) != 2 {
|
||||
t.Fatalf("%+v", l)
|
||||
}
|
||||
l, _ = f.Log("sync", 2, false)
|
||||
noSecrets(t, l)
|
||||
if len(l.Lines) != 2 || !strings.HasPrefix(l.Lines[1], "#=#=#=# Syncrun finished") || l.Files[0] != "~/.local/share/Nextcloud/Nextcloud_sync.log" {
|
||||
t.Fatalf("%+v", l)
|
||||
}
|
||||
if _, err := f.Log("server", 2, false); err == nil {
|
||||
t.Fatal("an unknown source is refused")
|
||||
}
|
||||
empty := newFake(t)
|
||||
if l, err := empty.Log("client", 5, false); err != nil || l.Note == "" || l.Lines == nil {
|
||||
t.Fatalf("%+v %v", l, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartEndsTheClientAndStartsOneInTheSession(t *testing.T) {
|
||||
f := newClient(t)
|
||||
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("without a desktop: %v", err)
|
||||
}
|
||||
f.desktopSession()
|
||||
f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-4.scope")
|
||||
f.onStart = func(argv []string) {
|
||||
f.proc(9000, 1000, clientComm, argv, "app.slice/"+restartAs+".service")
|
||||
}
|
||||
a, err := f.Restart()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Ended) != 1 || a.Ended[0] != 3865 || len(a.Running) != 1 || a.Running[0].PID != 9000 ||
|
||||
a.Running[0].StartedIn != restartAs+".service" || a.Running[0].Command != "/usr/bin/nextcloud --background" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.onStart = nil
|
||||
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "journalctl --user -u "+restartAs) {
|
||||
t.Fatalf("a start that shows no process: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassesOneStartAndNamesEveryOther(t *testing.T) {
|
||||
f := newClient(t)
|
||||
f.desktopSession()
|
||||
f.write(testHome+"/Photos/.keep", "")
|
||||
f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope")
|
||||
c, err := f.Check()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The one finding is the paused folder.
|
||||
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "~/Photos/ is paused") || len(c.Starts) != 1 {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
noSecrets(t, c)
|
||||
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id nextcloud\n")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch name {
|
||||
case "pacman":
|
||||
return Output{Code: 1, Stderr: "error: package 'nextcloud-client' was not found"}
|
||||
case "systemctl":
|
||||
return Output{Stdout: "enabled\n"}
|
||||
case "dex":
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.proc(3866, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope")
|
||||
f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nExec=nextcloud\nHidden=true\n")
|
||||
c, _ = f.Check()
|
||||
all := noSecrets(t, c)
|
||||
for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1",
|
||||
"packaged user unit", "2 clients run"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("no finding %q in %s", want, all)
|
||||
}
|
||||
}
|
||||
if len(c.Starts) != 2 {
|
||||
t.Fatalf("%q", c.Starts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module nextcloud-client
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"module": "nextcloud-client",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"tools": [
|
||||
"nextcloud_status",
|
||||
"nextcloud_log",
|
||||
"nextcloud_restart",
|
||||
"nextcloud_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "nextcloud-client"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/nextcloud-client-tools",
|
||||
"binary": "nextcloud-client-tools",
|
||||
"loads": [
|
||||
"nextcloud-client-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -117,5 +117,12 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-backup",
|
||||
"kind": "backup",
|
||||
"content": "path ${dir:html}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# nm-applet
|
||||
|
||||
NetworkManager's tray applet on the workstations, as a module (novox/hq ADR 0208). It requires
|
||||
`x11-display`, so it is assigned only where a display server is held on the same machine.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the applet (and, as its dependencies, the connection editor and libnma) | package `network-manager-applet`, from the official repositories |
|
||||
|
||||
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||
|
||||
- **No AUR.** Both workstations run the official package (`extra`), installed explicitly.
|
||||
- **NetworkManager is not this module's.** The `networkmanager` module holds the node's uplink
|
||||
(`node-uplink`) with it, on servers as well as workstations. It declares the `networkmanager`
|
||||
package, its one drop-in and its service. This module declares none of them (ADR 0210 §4: one
|
||||
package, one module), and it does not declare the applet's own dependencies (`nm-connection-editor`,
|
||||
`libnma`) either: the package brings them.
|
||||
- **Why a module of its own, and not a part of `networkmanager`:** that module runs on machines with no
|
||||
display, where a tray applet has nothing to draw on and the package would pull in GTK. The applet is
|
||||
a desktop piece, beside `blueman` (for `bluetooth`) in the same way.
|
||||
- **The connections stay the operator's.** The applet is NetworkManager's secret agent: it asks for a
|
||||
network's password and can keep it in the keyring. Profiles, networks, secrets and addresses are
|
||||
joined at the machine (the `networkmanager` module's README). The tools never ask for them.
|
||||
- **The applet's settings are found.** It keeps its switches in dconf (`org.gnome.nm-applet`): the
|
||||
notifications it shows and whether it shows itself. The module neither sets nor resets them.
|
||||
|
||||
## How it starts: the package's autostart entry, and nothing else
|
||||
|
||||
The package ships `/etc/xdg/autostart/nm-applet.desktop` (`nm-applet`, `NotShowIn=KDE;GNOME;`). The
|
||||
session runs it once at login through the `i3` module's `dex --autostart --environment i3`. **That
|
||||
entry is the applet's one start.** The module adds no `xinitrc` slot and no `node-display-session`
|
||||
exec, because either would start it a second time.
|
||||
|
||||
- **Excluded:** the window manager's `exec … nm-applet`, which the `i3` module's configuration dropped.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `nm_applet_status` (r) | <ul><li>whether the applet runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and what starts it at login</li><li>its switches in `org.gnome.nm-applet`</li><li>NetworkManager's overall state: state, connectivity, Wi-Fi and networking on or off. Never a connection, a network, a secret or an address</li></ul> |
|
||||
| `nm_applet_restart` (a) | asks the applet to end (SIGTERM), forces it after 5 s, and starts `nm-applet` in the operator's session as a transient user unit `mesh-nm-applet`, so it outlives the tools runtime. NetworkManager and its connections are not touched; for those few seconds no secret agent answers a password prompt. Refused plainly when nobody is logged in to the desktop |
|
||||
| `nm_applet_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one applet runs in a desktop session</li><li>`NetworkManager.service` is active</li></ul>Each finding says what to do |
|
||||
|
||||
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment, as
|
||||
`blueman` does. Every command has a timeout and capped output. Everything runs through an injected
|
||||
runner and a fake root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | laptop | desktop |
|
||||
|---|---|---|
|
||||
| package | none: `network-manager-applet` 1.36.0, explicit, from `extra` | the same |
|
||||
| start | none: dex starts the applet from the package's entry, in the login session's scope | none on disk. **The applet running now came from the predecessor's window-manager line** (`nm-applet --sm-disable`, a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
Nothing is required on either machine. On the desktop, log out and in once, or run
|
||||
`nm_applet_restart`, and the applet runs from its one start. `nm_applet_check` then answers `ok`.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- The applet's dconf settings (`/org/gnome/nm-applet/`).
|
||||
- `/etc/xdg/autostart/nm-applet.desktop`, the package's own file.
|
||||
- Every connection profile and secret.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The `networkmanager` module, for NetworkManager.** There is no dependency mechanism between two
|
||||
modules that hold no seat, so nothing refuses `nm-applet` without it; `nm_applet_check` reports it.
|
||||
`networkmanager` holds `node-uplink`, but a module cannot depend on a seat without a resource or a
|
||||
contribution that derives it (ADR 0207, ADR 0210 §3). **Assign both.**
|
||||
- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat.
|
||||
Assigned without `i3`, the applet is installed and does not start. `nm_applet_check` says so.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// 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
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// The nm-applet module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): NetworkManager's
|
||||
// tray applet in the operator's session, served by the node's runtime as the operator account. The
|
||||
// module holds no seat, so every tool is its own. NetworkManager itself is the networkmanager module's.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "nm_applet_status",
|
||||
Description: "NetworkManager's applet: whether it runs (pid, since, and the unit or session scope it " +
|
||||
"runs in), the installed version, what starts it at login, its notification switches, and " +
|
||||
"NetworkManager's overall state (state, connectivity, Wi-Fi and networking on or off; never a " +
|
||||
"connection, a network, a secret or an address). (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "nm_applet_restart",
|
||||
Description: "End the applet (asked first, then forced after 5 s) and start it again in the " +
|
||||
"operator's desktop session, under the account's service manager. NetworkManager and its " +
|
||||
"connections are not touched. Needs someone logged in to the desktop. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "nm_applet_check",
|
||||
Description: "Check what the module promises and relies on: the package is installed; the applet has " +
|
||||
"exactly one start (the package's XDG autostart entry, which the session's dex runs; no " +
|
||||
"window-manager exec); it runs once in a desktop session; and NetworkManager runs (the " +
|
||||
"networkmanager module's). Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// nm-applet's shape (novox/hq ADR 0208, ADR 0210): the applet's one official package, no seat, the X
|
||||
// display on its own machine, no start of its own (the package's autostart entry is the one start),
|
||||
// nothing of the networkmanager module's (its package, its configuration, its service), and the Go
|
||||
// bundle serving exactly the listed nm_applet_ tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "nm_applet_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed %s and described", tool.Name, "nm_applet_")
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/nm-applet-tools" || b["binary"] != "nm-applet-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItInstallsTheAppletAndNothingOfNetworkManager(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "nm-applet" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
|
||||
t.Fatalf("resources: %v", m.Resources)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatal("no seat, no environment, and no second start")
|
||||
}
|
||||
for _, never := range []string{"\"networkmanager\"", "nm-connection-editor", "libnma", "/etc/NetworkManager", "autostart", "service"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %s: the stack is the networkmanager module's, the start the package's", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
// NetworkManager's applet as the tools see it: its process, its XDG autostart entry (the package's),
|
||||
// its settings in gsettings, and NetworkManager's overall state as nmcli gives it. The applet is also
|
||||
// NetworkManager's secret agent, the program that asks for a network's password; the tools never ask
|
||||
// NetworkManager for a connection, a secret or an address. Those are joined at the machine (the
|
||||
// networkmanager module's README).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
appletComm = "nm-applet"
|
||||
appletBin = "/usr/bin/nm-applet"
|
||||
entryName = "nm-applet.desktop"
|
||||
restartAs = "mesh-nm-applet"
|
||||
packageFor = "network-manager-applet"
|
||||
stackUnit = "NetworkManager.service"
|
||||
settingsSet = "org.gnome.nm-applet"
|
||||
)
|
||||
|
||||
// Manager is NetworkManager's overall state: no connection, network or address.
|
||||
type Manager struct {
|
||||
State string `json:"state"`
|
||||
Connectivity string `json:"connectivity"`
|
||||
Wifi string `json:"wifi"`
|
||||
Networking string `json:"networking"`
|
||||
}
|
||||
|
||||
func (m *Machine) manager() (*Manager, error) {
|
||||
args := []string{"-t", "-f", "STATE,CONNECTIVITY,WIFI,NETWORKING", "general"}
|
||||
o := m.cmd(5*time.Second, nil, "nmcli", args...)
|
||||
if err := failed(o, "nmcli", args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := strings.Split(strings.TrimSpace(o.Stdout), ":")
|
||||
if len(f) != 4 {
|
||||
return nil, fmt.Errorf("nmcli's general state is not four fields: %q", tail(o.Stdout, 200))
|
||||
}
|
||||
return &Manager{State: f[0], Connectivity: f[1], Wifi: f[2], Networking: f[3]}, nil
|
||||
}
|
||||
|
||||
// settings are the applet's switches in gsettings: the notifications it shows and whether it shows
|
||||
// itself.
|
||||
func (m *Machine) settings() map[string]string {
|
||||
out := map[string]string{}
|
||||
o := m.cmd(5*time.Second, m.bus(), "gsettings", "list-recursively", settingsSet)
|
||||
if o.Err != nil || o.Code != 0 {
|
||||
return out
|
||||
}
|
||||
for _, l := range strings.Split(o.Stdout, "\n") {
|
||||
f := strings.Fields(l)
|
||||
if len(f) == 3 && f[0] == settingsSet && f[1] != "stamp" {
|
||||
out[f[1]] = f[2]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StatusAnswer is what nm_applet_status answers.
|
||||
type StatusAnswer struct {
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Applet []Proc `json:"applet"`
|
||||
StartedBy Autostart `json:"started_by"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
Manager *Manager `json:"network_manager,omitempty"`
|
||||
Unanswered string `json:"unanswered,omitempty"`
|
||||
}
|
||||
|
||||
// Status reads the applet and NetworkManager's overall state.
|
||||
func (m *Machine) Status() (StatusAnswer, error) {
|
||||
s := StatusAnswer{Applet: m.procs(appletComm), StartedBy: m.autostart(entryName), Settings: m.settings()}
|
||||
if s.Applet == nil {
|
||||
s.Applet = []Proc{}
|
||||
}
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Installed = v
|
||||
if s.Manager, err = m.manager(); err != nil {
|
||||
s.Unanswered = err.Error()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what nm_applet_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Ended []int `json:"ended"`
|
||||
Killed []int `json:"killed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Session Session `json:"session"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// Restart ends the applet and starts it again in the operator's session, under the account's service
|
||||
// manager. NetworkManager and its connections are not touched: the applet is only their face.
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return RestartAnswer{}, err
|
||||
}
|
||||
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||
a.Ended, a.Killed = m.stop(5*time.Second, appletComm)
|
||||
if err := m.detach(s, restartAs, appletBin); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Running = m.waitFor(appletComm, 4*time.Second)
|
||||
if len(a.Running) == 0 {
|
||||
return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+
|
||||
"see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what nm_applet_check answers.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies what the module promises and relies on: the package; one start (the package's
|
||||
// autostart entry, which the session's dex runs); the applet running once in a session; and
|
||||
// NetworkManager, which is the networkmanager module's, running.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("the package "+packageFor+" is not installed", "push the module to the node")
|
||||
}
|
||||
entry := m.autostart(entryName)
|
||||
if entry.Starts {
|
||||
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
||||
if entry.From != "/etc/xdg/autostart/"+entryName {
|
||||
add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start")
|
||||
}
|
||||
} else {
|
||||
add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry")
|
||||
}
|
||||
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
|
||||
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
|
||||
}
|
||||
for _, l := range m.i3Starts(appletComm) {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start")
|
||||
}
|
||||
if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" {
|
||||
add("NetworkManager ("+stackUnit+") is not running: the applet has nothing to show",
|
||||
"assign the networkmanager module, which holds the node's uplink with it")
|
||||
}
|
||||
if _, err := m.session(); err == nil {
|
||||
switch running := m.procs(appletComm); {
|
||||
case len(running) == 0:
|
||||
add("no applet runs in the desktop session", "nm_applet_restart")
|
||||
case len(running) > 1:
|
||||
add(fmt.Sprintf("%d applets run", len(running)), "nm_applet_restart ends them all and starts one")
|
||||
}
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newApplet(t *testing.T, running bool) *fake {
|
||||
f := newFake(t)
|
||||
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nName=Network\nExec=nm-applet\nNotShowIn=KDE;GNOME;\n")
|
||||
if running {
|
||||
f.proc(3860, 1000, appletComm, []string{"nm-applet"}, "session-c1.scope")
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch {
|
||||
case name == "pacman":
|
||||
return Output{Stdout: "network-manager-applet 1.36.0-2\n"}
|
||||
case name == "nmcli":
|
||||
return Output{Stdout: "connected:full:enabled:enabled\n"}
|
||||
case name == "gsettings":
|
||||
return Output{Stdout: "org.gnome.nm-applet disable-connected-notifications false\norg.gnome.nm-applet stamp 0\n" +
|
||||
"org.gnome.nm-applet show-applet true\n"}
|
||||
case name == "systemctl":
|
||||
return Output{Stdout: "active\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestStatusReadsTheAppletAndOnlyTheManagersOverallState(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Installed != "1.36.0-2" || len(s.Applet) != 1 || !s.StartedBy.Starts || s.Manager == nil ||
|
||||
*s.Manager != (Manager{"connected", "full", "enabled", "enabled"}) || len(s.Settings) != 2 || s.Settings["show-applet"] != "true" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
for _, never := range []string{"connection", "device", "secrets", "--show-secrets", " ip"} {
|
||||
if strings.HasPrefix(c, "nmcli") && strings.Contains(c, never) {
|
||||
t.Fatalf("asked NetworkManager for more than its overall state: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
if name == "nmcli" {
|
||||
return Output{Code: 8, Stderr: "Error: NetworkManager is not running."}
|
||||
}
|
||||
return Output{Stdout: "x 1\n"}
|
||||
}
|
||||
if s, _ := f.Status(); s.Manager != nil || !strings.Contains(s.Unanswered, "not running") {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
f.desktopSession()
|
||||
c, err := f.Check()
|
||||
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id nm-applet --sm-disable\n")
|
||||
f.proc(3861, 1000, appletComm, []string{"nm-applet"}, "session-c1.scope")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch name {
|
||||
case "pacman":
|
||||
return Output{Code: 1}
|
||||
case "systemctl":
|
||||
return Output{Stdout: "inactive\n", Code: 3}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
c, _ = f.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
for _, want := range []string{"not installed", "a second start: ~/.config/i3/config:1", "NetworkManager.service", "2 applets run"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no finding %q in\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartEndsTheAppletAndStartsItUnderTheServiceManager(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("without a desktop: %v", err)
|
||||
}
|
||||
f.desktopSession()
|
||||
f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") }
|
||||
a, err := f.Restart()
|
||||
if err != nil || len(a.Ended) != 1 || len(a.Running) != 1 || a.Running[0].Command != appletBin {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if strings.Contains(c, stackUnit) {
|
||||
t.Fatalf("the restart touched NetworkManager: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module nm-applet
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"module": "nm-applet",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"tools": [
|
||||
"nm_applet_status",
|
||||
"nm_applet_restart",
|
||||
"nm_applet_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "network-manager-applet"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/nm-applet-tools",
|
||||
"binary": "nm-applet-tools",
|
||||
"loads": [
|
||||
"nm-applet-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# openrazer
|
||||
|
||||
The Razer peripherals' kernel driver and the account's daemon that drives it, on the workstations, as
|
||||
a module (novox/hq ADR 0208: one module per piece of software). The tray that shows the devices is
|
||||
the `polychromatic` module's. This module needs no display: the daemon speaks to the driver and to its
|
||||
clients on the session bus.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the kernel driver's source, built by DKMS for each installed kernel (`razerkbd`, `razermouse`, `razerkraken`, `razeraccessory`) | package `openrazer-driver-dkms` |
|
||||
| the daemon (`org.razer` on the session bus) and its user unit | package `openrazer-daemon` |
|
||||
| the client library every front end speaks to the daemon through | package `python-openrazer` |
|
||||
|
||||
All three from the official repositories (`extra`). On both workstations they are installed today as
|
||||
dependencies of the AUR tray, and become the mesh's here.
|
||||
|
||||
**Why two modules and not one `razer`:** the driver and the daemon are one project, in the official
|
||||
repositories, and any front end uses them. The tray is another project, outside the official
|
||||
repositories. That is the line between `bluetooth` and `blueman` too. A machine can hold the stack
|
||||
without the tray, for a front end of the operator's own or for the daemon's persistence of the
|
||||
devices' lighting and DPI.
|
||||
|
||||
Not this module's:
|
||||
|
||||
- **The kernel headers DKMS builds against** (`linux-headers`). They are the kernel's, and every DKMS
|
||||
driver on a machine needs them (the laptop also builds `nvidia`, the desktop `vboxhost` and `xone`).
|
||||
No module declares them yet. `openrazer_check` says when the driver is not built for the running
|
||||
kernel.
|
||||
- **`dkms` itself**, which the driver package depends on.
|
||||
- **The account's membership of the `openrazer` group** (below).
|
||||
|
||||
## The group: a step for the operator, once
|
||||
|
||||
The driver's udev rules give each device's files to the group `openrazer`, which the driver package
|
||||
creates (sysusers). The daemon refuses to start for an account outside that group: *User is not a
|
||||
member of the openrazer group*.
|
||||
|
||||
**On both workstations the account is not in it today, so the daemon has failed at every start**
|
||||
since openrazer moved from `plugdev` to its own group. The tray runs, and shows no devices. The
|
||||
account is still in `plugdev`, which openrazer no longer uses. Another device's rules may (the laptop
|
||||
has a Logitech receiver rule that does), so it stays.
|
||||
|
||||
The module cannot declare the membership. The host's `user` resource takes groups, additively, but the
|
||||
`zsh` module already declares the operator's account as its `user` resource. A second module declaring
|
||||
the same account is refused at composition, as two owners of one name. Until the mesh can add a group
|
||||
to the account from a second module, this is the operator's step (below), and `openrazer_check` holds
|
||||
it.
|
||||
|
||||
## How it starts: D-Bus activation, and nothing else
|
||||
|
||||
The package installs `org.razer` as a D-Bus service whose `SystemdService` is the user unit
|
||||
`openrazer-daemon.service`. The first client that asks for `org.razer` starts the daemon through that
|
||||
unit: at login, the tray's helper (`polychromatic-helper --autostart`). **That activation is the
|
||||
daemon's one start.** One unit, so it is never two daemons.
|
||||
|
||||
- The unit is **not enabled** on either workstation, and the module does not enable it. Enabling it
|
||||
would start the same unit at login a moment earlier, with nothing gained.
|
||||
- Asked by a tool, the bus is always called with `--auto-start=no`, so asking never starts it.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `openrazer_status` (r) | <ul><li>the three packages' versions</li><li>the driver: the running kernel, DKMS's state for it, the modules loaded, the devices bound (USB id, driver, interfaces)</li><li>the group: whether the account is in it, and whether the account's running service manager has it</li><li>the daemon: its unit's active and enabled states, its process, its version, and, when its unit failed, the reason it gave</li><li>each device the daemon sees: name, type, firmware, battery and charging where the device has them. Never its serial</li></ul> |
|
||||
| `openrazer_restart` (a) | restarts `openrazer-daemon.service` in the account's service manager and answers the devices it then sees. When it fails, the answer carries the daemon's own reason |
|
||||
| `openrazer_check` (r) | <ul><li>the packages are installed</li><li>the driver is built for the running kernel, and loaded</li><li>a device is bound</li><li>the account is in `openrazer`, and its service manager has the group (a group added after login needs a new login)</li><li>one start: the activation file is present, no window-manager exec</li><li>one daemon runs, from its unit, and answers</li><li>it sees every device the driver holds</li></ul>Each finding says what to do |
|
||||
|
||||
Every command has a timeout and capped output. Everything runs through an injected runner and a fake
|
||||
root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | laptop | desktop |
|
||||
|---|---|---|
|
||||
| packages | none: the three are installed, 3.12.4, as dependencies of the tray | the same |
|
||||
| driver | none: built for the running kernel, `razermouse` loaded, a Basilisk V3 Pro bound | the same, a Basilisk V2 bound |
|
||||
| daemon | none: the unit stays disabled; it failed at login (not in the group) | the same |
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
On each workstation, once:
|
||||
|
||||
1. `sudo gpasswd -a $USER openrazer`
|
||||
2. Log out of every session, or reboot. The account's service manager takes its groups when it
|
||||
starts, and the daemon runs under it.
|
||||
3. `openrazer_check` answers `ok`, and the tray shows the devices.
|
||||
|
||||
`plugdev` stays. Nothing else is required.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- The daemon's settings and persistence under `~/.config/openrazer/`, and its log under
|
||||
`~/.local/share/openrazer/`.
|
||||
- `plugdev` and every other group of the account.
|
||||
- The kernel headers and DKMS.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The account in `openrazer`**, by hand (above).
|
||||
- **A client to start the daemon.** At login that is the `polychromatic` module's tray helper.
|
||||
Without a client nothing asks, and nothing needs it to run.
|
||||
- **The kernel headers of every installed kernel**, for DKMS.
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// 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
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// The openrazer module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Razer
|
||||
// peripherals' kernel driver and the account's daemon that speaks to it, served by the node's runtime
|
||||
// as the operator account. The module holds no seat, so every tool is its own. The tray that drives
|
||||
// the daemon is the polychromatic module's.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "openrazer_status",
|
||||
Description: "The Razer stack: the installed versions, the driver (built by DKMS for the running " +
|
||||
"kernel or not, its kernel modules loaded, the devices bound to it), the account's openrazer " +
|
||||
"group (in the group file, and in its running service manager), the daemon (its unit's state, " +
|
||||
"its process, its version) and each device the daemon sees: name, type, firmware, battery. " +
|
||||
"Asks the daemon without starting it. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "openrazer_restart",
|
||||
Description: "Restart the daemon through its unit in the account's service manager (the unit D-Bus " +
|
||||
"activation starts), and answer its state and the devices it then sees. Needs the account " +
|
||||
"logged in. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "openrazer_check",
|
||||
Description: "Check what the module promises and relies on: the packages are installed; the driver " +
|
||||
"is built for the running kernel and loaded; the account is in the openrazer group, and its " +
|
||||
"service manager has it; the daemon runs once, from its unit, and sees the devices the driver " +
|
||||
"has. Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openrazer's shape (novox/hq ADR 0207, ADR 0208, ADR 0210): the three official packages of the
|
||||
// driver, the daemon and its client library, no seat, no display (the daemon needs none), no start of
|
||||
// its own (D-Bus activation through the package's unit is the one start), nothing of the tray's (the
|
||||
// polychromatic module's), and the Go bundle serving exactly the listed openrazer_ tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "openrazer_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed %s and described", tool.Name, "openrazer_")
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/openrazer-tools" || b["binary"] != "openrazer-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItInstallsTheStackAndStartsNothing(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "openrazer" || m.Requires != nil || !reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
var pkgs []string
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "package" {
|
||||
t.Errorf("only packages: %v", r)
|
||||
}
|
||||
pkgs = append(pkgs, r["package"].(string))
|
||||
}
|
||||
if !reflect.DeepEqual(pkgs, packages) {
|
||||
t.Fatalf("%v", pkgs)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatal("it holds no seat, sets no environment and adds no start")
|
||||
}
|
||||
// The tray is polychromatic's, from outside the official repositories; the kernel headers DKMS
|
||||
// builds against are the kernel's; the account's groups are its user resource's (zsh's).
|
||||
for _, never := range []string{"polychromatic", "linux-headers", "\"dkms\"", "\"user\"", "groups"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %s", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package main
|
||||
|
||||
// openrazer as the tools see it: the kernel driver (built by DKMS, loaded, holding devices), the
|
||||
// account's membership of the group the driver gives its devices to, and the daemon in the account's
|
||||
// service manager, asked over the session bus. Every bus call is made with --auto-start=no: the daemon
|
||||
// is D-Bus activatable, and a question must never start it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dkmsName = "openrazer-driver"
|
||||
group = "openrazer"
|
||||
daemonComm = "openrazer-daemo" // the kernel keeps 15 characters of openrazer-daemon
|
||||
daemonUnit = "openrazer-daemon.service"
|
||||
busName = "org.razer"
|
||||
busRoot = "/org/razer"
|
||||
activation = "/usr/share/dbus-1/services/org.razer.service"
|
||||
// mostDevices bounds the questions one answer asks the daemon.
|
||||
mostDevices = 16
|
||||
)
|
||||
|
||||
// packages are the module's, from the official repositories: the driver's DKMS source, the daemon,
|
||||
// and the Python library every front end (the tray among them) speaks to the daemon through.
|
||||
var packages = []string{"openrazer-driver-dkms", "openrazer-daemon", "python-openrazer"}
|
||||
|
||||
// kernelModules are what the DKMS package builds (its dkms.conf).
|
||||
var kernelModules = []string{"razerkbd", "razermouse", "razerkraken", "razeraccessory"}
|
||||
|
||||
// hidID is a HID device's name under its driver: bus:vendor:product.instance.
|
||||
// stamp is the daemon's own time at the front of its log lines, dropped so repeats are seen as one.
|
||||
var stamp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s*\|\s*`)
|
||||
|
||||
var hidID = regexp.MustCompile(`^[0-9A-Fa-f]{4}:([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4})\.[0-9A-Fa-f]{4}$`)
|
||||
|
||||
// Bound is one device the driver holds: its USB id, the driver module and how many of its HID
|
||||
// interfaces are bound.
|
||||
type Bound struct {
|
||||
USB string `json:"usb"`
|
||||
Driver string `json:"driver"`
|
||||
Interfaces int `json:"interfaces"`
|
||||
}
|
||||
|
||||
// Driver is the kernel side.
|
||||
type Driver struct {
|
||||
Kernel string `json:"running_kernel"`
|
||||
// Built is DKMS's word for the driver and the running kernel: "installed" when it is built and
|
||||
// installed for it, "" when DKMS has nothing for it.
|
||||
Built string `json:"built,omitempty"`
|
||||
Loaded []string `json:"loaded"`
|
||||
Bound []Bound `json:"bound"`
|
||||
}
|
||||
|
||||
// Group is the account's membership of the group the driver's udev rules give the devices to.
|
||||
type Group struct {
|
||||
Name string `json:"name"`
|
||||
Exists bool `json:"exists"`
|
||||
Account bool `json:"account_in_group"`
|
||||
// Manager is whether the account's running service manager has the group. It took its groups when
|
||||
// it started, at the account's first login, and the daemon runs under it.
|
||||
Manager *bool `json:"service_manager_has_it,omitempty"`
|
||||
}
|
||||
|
||||
// Device is one device as the daemon sees it. Its serial is not answered.
|
||||
type Device struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Battery *float64 `json:"battery_percent,omitempty"`
|
||||
Charging *bool `json:"charging,omitempty"`
|
||||
}
|
||||
|
||||
// Daemon is the account's daemon.
|
||||
type Daemon struct {
|
||||
Active string `json:"unit_active"`
|
||||
Enabled string `json:"unit_enabled"`
|
||||
Running []Proc `json:"running"`
|
||||
// StartedBy is what starts it: D-Bus activation of org.razer, through its unit.
|
||||
StartedBy string `json:"started_by"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Unanswered string `json:"unanswered,omitempty"`
|
||||
LastWords []string `json:"last_words,omitempty"`
|
||||
}
|
||||
|
||||
// StatusAnswer is what openrazer_status answers.
|
||||
type StatusAnswer struct {
|
||||
Installed map[string]string `json:"installed"`
|
||||
Driver Driver `json:"driver"`
|
||||
Group Group `json:"group"`
|
||||
Daemon Daemon `json:"daemon"`
|
||||
Devices []Device `json:"devices"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// busCall asks the daemon one question and answers busctl's data.
|
||||
func (m *Machine) busCall(path, iface, method string) (json.RawMessage, error) {
|
||||
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, path, iface, method}
|
||||
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
|
||||
if err := failed(o, "busctl", args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
|
||||
return nil, fmt.Errorf("the daemon's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
|
||||
}
|
||||
return doc.Data[0], nil
|
||||
}
|
||||
|
||||
func (m *Machine) busString(path, iface, method string) (string, error) {
|
||||
raw, err := m.busCall(path, iface, method)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", fmt.Errorf("the daemon's %s is not a string: %w", method, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// devices asks the daemon for the devices it holds, and each one's name, type, firmware and battery.
|
||||
func (m *Machine) devices() ([]Device, string, error) {
|
||||
version, err := m.busString(busRoot, "razer.daemon", "version")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw, err := m.busCall(busRoot, "razer.devices", "getDevices")
|
||||
if err != nil {
|
||||
return nil, version, err
|
||||
}
|
||||
var serials []string
|
||||
if err := json.Unmarshal(raw, &serials); err != nil {
|
||||
return nil, version, fmt.Errorf("the daemon's device list is not a list of names: %w", err)
|
||||
}
|
||||
if len(serials) > mostDevices {
|
||||
serials = serials[:mostDevices]
|
||||
}
|
||||
out := []Device{}
|
||||
for _, serial := range serials {
|
||||
path := busRoot + "/device/" + serial
|
||||
d := Device{}
|
||||
d.Name, _ = m.busString(path, "razer.device.misc", "getDeviceName")
|
||||
d.Type, _ = m.busString(path, "razer.device.misc", "getDeviceType")
|
||||
d.Firmware, _ = m.busString(path, "razer.device.misc", "getFirmware")
|
||||
if raw, err := m.busCall(path, "razer.device.power", "getBattery"); err == nil {
|
||||
var pct float64
|
||||
if json.Unmarshal(raw, &pct) == nil {
|
||||
d.Battery = &pct
|
||||
}
|
||||
}
|
||||
if raw, err := m.busCall(path, "razer.device.power", "isCharging"); err == nil {
|
||||
var on bool
|
||||
if json.Unmarshal(raw, &on) == nil {
|
||||
d.Charging = &on
|
||||
}
|
||||
}
|
||||
if d.Name == "" {
|
||||
d.Name = "(unnamed)"
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, version, nil
|
||||
}
|
||||
|
||||
// driver reads the kernel side: DKMS's state for the running kernel, the loaded modules, and the
|
||||
// devices bound to them.
|
||||
func (m *Machine) driver() Driver {
|
||||
d := Driver{Kernel: readTrimmed(m.path("/proc/sys/kernel/osrelease")), Loaded: []string{}, Bound: []Bound{}}
|
||||
if d.Kernel != "" {
|
||||
o := m.cmd(0, nil, "dkms", "status", dkmsName, "-k", d.Kernel)
|
||||
for _, l := range strings.Split(o.Stdout, "\n") {
|
||||
if strings.HasPrefix(l, dkmsName+"/") && strings.Contains(l, d.Kernel) {
|
||||
if i := strings.LastIndex(l, ":"); i > 0 {
|
||||
d.Built = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range kernelModules {
|
||||
if exists(m.path(filepath.Join("/sys/module", k))) {
|
||||
d.Loaded = append(d.Loaded, k)
|
||||
}
|
||||
}
|
||||
drivers, _ := os.ReadDir(m.path("/sys/bus/hid/drivers"))
|
||||
seen := map[string]int{}
|
||||
for _, drv := range drivers {
|
||||
if !strings.HasPrefix(drv.Name(), "razer") {
|
||||
continue
|
||||
}
|
||||
entries, _ := os.ReadDir(m.path(filepath.Join("/sys/bus/hid/drivers", drv.Name())))
|
||||
for _, e := range entries {
|
||||
if f := hidID.FindStringSubmatch(e.Name()); f != nil {
|
||||
usb := strings.ToLower(f[1] + ":" + f[2])
|
||||
if i, ok := seen[drv.Name()+" "+usb]; ok {
|
||||
d.Bound[i].Interfaces++
|
||||
continue
|
||||
}
|
||||
seen[drv.Name()+" "+usb] = len(d.Bound)
|
||||
d.Bound = append(d.Bound, Bound{USB: usb, Driver: drv.Name(), Interfaces: 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// account is the operator account's name, from the user database by uid.
|
||||
func (m *Machine) account() string {
|
||||
raw, _ := readBounded(m.path("/etc/passwd"))
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(l, ":")
|
||||
if len(f) > 2 && f[2] == strconv.Itoa(m.UID) {
|
||||
return f[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// groupOf reads one group's gid and members from the group file.
|
||||
func (m *Machine) groupOf(name string) (gid int, members []string, found bool) {
|
||||
raw, _ := readBounded(m.path("/etc/group"))
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(l, ":")
|
||||
if len(f) == 4 && f[0] == name {
|
||||
gid, _ = strconv.Atoi(f[2])
|
||||
for _, who := range strings.Split(f[3], ",") {
|
||||
if who = strings.TrimSpace(who); who != "" {
|
||||
members = append(members, who)
|
||||
}
|
||||
}
|
||||
return gid, members, true
|
||||
}
|
||||
}
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// managerGroups are the supplementary groups of the account's own service manager; ok is false when
|
||||
// it does not run.
|
||||
func (m *Machine) managerGroups() (groups []int, ok bool) {
|
||||
for _, p := range m.procs("systemd") {
|
||||
dir := m.path(filepath.Join("/proc", strconv.Itoa(p.PID)))
|
||||
if !strings.Contains(readTrimmed(filepath.Join(dir, "cgroup")), fmt.Sprintf("user@%d.service", m.UID)) {
|
||||
continue
|
||||
}
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 0 && f[0] == "Groups:" {
|
||||
for _, g := range f[1:] {
|
||||
if n, err := strconv.Atoi(g); err == nil {
|
||||
groups = append(groups, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Machine) group() Group {
|
||||
g := Group{Name: group}
|
||||
gid, members, found := m.groupOf(group)
|
||||
if !found {
|
||||
return g
|
||||
}
|
||||
g.Exists = true
|
||||
g.Account = contains(members, m.account())
|
||||
if in, ok := m.managerGroups(); ok {
|
||||
has := false
|
||||
for _, n := range in {
|
||||
has = has || n == gid
|
||||
}
|
||||
g.Manager = &has
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// unit answers the daemon's unit's active and enabled states in the account's service manager.
|
||||
func (m *Machine) unit() (active, enabled string) {
|
||||
word := func(verb string) string {
|
||||
o := m.cmd(5*time.Second, m.bus(), "systemctl", "--user", verb, daemonUnit)
|
||||
if o.Err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
if w := strings.TrimSpace(o.Stdout); w != "" {
|
||||
return strings.Fields(w)[0]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
return word("is-active"), word("is-enabled")
|
||||
}
|
||||
|
||||
// lastWords are the daemon's last lines in the account's journal, for a unit that failed: its critical
|
||||
// and error lines when it wrote any (the reason, not the traceback of its exit), else the last lines.
|
||||
func (m *Machine) lastWords() []string {
|
||||
o := m.cmd(5*time.Second, m.bus(), "journalctl", "--user", "-u", daemonUnit, "-n", "20", "-o", "cat", "--no-pager")
|
||||
var all, said []string
|
||||
seen := map[string]bool{}
|
||||
for _, l := range strings.Split(o.Stdout, "\n") {
|
||||
if l = strings.TrimSpace(l); l == "" {
|
||||
continue
|
||||
}
|
||||
l = tail(stamp.ReplaceAllString(l, ""), 300)
|
||||
all = append(all, l)
|
||||
if (strings.Contains(l, "CRITICAL") || strings.Contains(l, "ERROR")) && !seen[l] {
|
||||
seen[l] = true
|
||||
said = append(said, l)
|
||||
}
|
||||
}
|
||||
if len(said) > 0 {
|
||||
return said
|
||||
}
|
||||
return lastOf(all, 4)
|
||||
}
|
||||
|
||||
func (m *Machine) daemon() (Daemon, []Device) {
|
||||
d := Daemon{Running: m.procs(daemonComm), StartedBy: "D-Bus activation of " + busName + " through " + daemonUnit}
|
||||
if d.Running == nil {
|
||||
d.Running = []Proc{}
|
||||
}
|
||||
if !exists(m.path(activation)) {
|
||||
d.StartedBy = "nothing: the package's activation file " + activation + " is missing"
|
||||
}
|
||||
d.Active, d.Enabled = m.unit()
|
||||
if d.Active == "failed" {
|
||||
d.LastWords = m.lastWords()
|
||||
}
|
||||
devices := []Device{}
|
||||
if len(d.Running) == 0 {
|
||||
d.Unanswered = "the daemon is not running (asked without starting it)"
|
||||
return d, devices
|
||||
}
|
||||
got, version, err := m.devices()
|
||||
d.Version = version
|
||||
if err != nil {
|
||||
d.Unanswered = err.Error()
|
||||
return d, devices
|
||||
}
|
||||
return d, got
|
||||
}
|
||||
|
||||
// Status reads the whole stack.
|
||||
func (m *Machine) Status() (StatusAnswer, error) {
|
||||
s := StatusAnswer{Installed: map[string]string{}, Driver: m.driver(), Group: m.group()}
|
||||
for _, p := range packages {
|
||||
v, err := m.installed(p)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Installed[p] = v
|
||||
}
|
||||
s.Daemon, s.Devices = m.daemon()
|
||||
if _, members, ok := m.groupOf("plugdev"); ok && contains(members, m.account()) {
|
||||
s.Notes = append(s.Notes, "the account is in plugdev, which this openrazer does not use: its udev rules "+
|
||||
"give the devices to the openrazer group. Other devices' rules may use plugdev; the module leaves it")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what openrazer_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Active string `json:"unit_active"`
|
||||
Running []Proc `json:"running"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Devices []Device `json:"devices"`
|
||||
}
|
||||
|
||||
// Restart restarts the daemon's unit in the account's service manager and waits for it to answer.
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
a := RestartAnswer{Devices: []Device{}}
|
||||
args := []string{"--user", "restart", daemonUnit}
|
||||
if err := failed(m.cmd(15*time.Second, m.bus(), "systemctl", args...), "systemctl", args...); err != nil {
|
||||
words := m.lastWords()
|
||||
return a, fmt.Errorf("the daemon did not start: %v. Its last words: %s. See openrazer_check", err, strings.Join(words, " | "))
|
||||
}
|
||||
a.Active, _ = m.unit()
|
||||
a.Running = m.waitFor(daemonComm, 4*time.Second)
|
||||
devices, version, err := m.devices()
|
||||
a.Version = version
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("the daemon started but does not answer: %v", err)
|
||||
}
|
||||
a.Devices = devices
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what openrazer_check answers.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies the stack from the packages to the devices the daemon sees.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
for _, p := range packages {
|
||||
v, err := m.installed(p)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("the package "+p+" is not installed", "push the module to the node")
|
||||
}
|
||||
}
|
||||
|
||||
d := m.driver()
|
||||
switch {
|
||||
case d.Kernel == "":
|
||||
add("the running kernel's release is unreadable", "")
|
||||
case d.Built != "installed":
|
||||
add("the driver is not built for the running kernel "+d.Kernel+" (DKMS: "+orNone(d.Built)+")",
|
||||
"install the headers of the running kernel (linux-headers), then `sudo dkms autoinstall`; or reboot into the kernel it was built for")
|
||||
}
|
||||
if len(d.Bound) == 0 {
|
||||
add("no Razer device is bound to the driver", "plug the device in; if it is plugged in, openrazer_status shows which driver modules are loaded")
|
||||
}
|
||||
if len(d.Loaded) == 0 && d.Built == "installed" {
|
||||
add("none of the driver's modules is loaded", "plug the device in, which loads its module, or `sudo modprobe razermouse` (razerkbd, razerkraken, razeraccessory)")
|
||||
}
|
||||
|
||||
g := m.group()
|
||||
switch {
|
||||
case !g.Exists:
|
||||
add("the group openrazer does not exist", "it comes with openrazer-driver-dkms (sysusers): reinstall it, or `sudo systemd-sysusers`")
|
||||
case !g.Account:
|
||||
add("the account is not in the openrazer group: the daemon refuses to start, and the devices' files are the group's",
|
||||
"`sudo gpasswd -a $USER openrazer`, then log out of every session (or reboot)")
|
||||
case g.Manager != nil && !*g.Manager:
|
||||
add("the account is in the openrazer group, but its running service manager started before it was",
|
||||
"log out of every session (or reboot), so the service manager starts again with the group")
|
||||
}
|
||||
|
||||
if exists(m.path(activation)) {
|
||||
a.Starts = append(a.Starts, "D-Bus activation: "+busName+" through "+daemonUnit)
|
||||
} else {
|
||||
add("the package's activation file "+activation+" is missing: nothing starts the daemon", "reinstall openrazer-daemon")
|
||||
}
|
||||
for _, l := range m.i3Starts("openrazer-daemon") {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
add("a second start: "+l, "remove the line; D-Bus activation starts the daemon when a client asks")
|
||||
}
|
||||
|
||||
daemon, devices := m.daemon()
|
||||
switch {
|
||||
case len(daemon.Running) == 0 && daemon.Active == "failed":
|
||||
add("the daemon failed at its last start: "+strings.Join(lastOf(daemon.LastWords, 2), " | "), "fix what it says, then openrazer_restart")
|
||||
case len(daemon.Running) == 0:
|
||||
add("the daemon is not running", "openrazer_restart; at login the tray's helper asks for it, which starts it")
|
||||
case len(daemon.Running) > 1:
|
||||
add(fmt.Sprintf("%d daemons run", len(daemon.Running)), "openrazer_restart ends them and starts one, from its unit")
|
||||
default:
|
||||
if in := daemon.Running[0].StartedIn; in != daemonUnit {
|
||||
add("the daemon runs outside its unit (in "+in+"): started by hand or by a second start", "openrazer_restart")
|
||||
}
|
||||
}
|
||||
if daemon.Unanswered != "" && len(daemon.Running) > 0 {
|
||||
add("the daemon does not answer: "+daemon.Unanswered, "openrazer_restart")
|
||||
}
|
||||
if len(daemon.Running) > 0 && daemon.Unanswered == "" && len(devices) < len(d.Bound) {
|
||||
add(fmt.Sprintf("the driver holds %d device(s) and the daemon sees %d", len(d.Bound), len(devices)), "openrazer_restart")
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "nothing for it"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func lastOf(lines []string, n int) []string {
|
||||
if len(lines) <= n {
|
||||
return lines
|
||||
}
|
||||
return lines[len(lines)-n:]
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const serial = "PM2148H00000001"
|
||||
|
||||
// newStack is a machine with the stack as it should be: built and loaded, a mouse bound on three
|
||||
// interfaces, the account in the group (and its service manager with it), the daemon running from its
|
||||
// unit and answering. member and running take parts of it away.
|
||||
func newStack(t *testing.T, member, running bool) *fake {
|
||||
f := newFake(t)
|
||||
f.write("/proc/sys/kernel/osrelease", "7.2.8-arch1-2\n")
|
||||
f.write("/sys/module/razermouse/refcnt", "0\n")
|
||||
for _, i := range []string{"0004", "0005", "0006"} {
|
||||
f.write("/sys/bus/hid/drivers/razermouse/0003:1532:00AB."+i+"/device_type", "")
|
||||
}
|
||||
f.write("/sys/bus/hid/drivers/razermouse/bind", "")
|
||||
f.write("/etc/passwd", "root:x:0:0::/root:/bin/sh\noperator:x:1000:1000::/home/operator:/bin/zsh\n")
|
||||
members := "operator"
|
||||
if !member {
|
||||
members = ""
|
||||
}
|
||||
f.write("/etc/group", "plugdev:x:970:operator\nopenrazer:x:958:"+members+"\n")
|
||||
f.write(activation, "[D-BUS Service]\nName=org.razer\nSystemdService=openrazer-daemon.service\n")
|
||||
f.proc(3596, 1000, "systemd", []string{"/usr/lib/systemd/systemd", "--user"}, "user@1000.service/init.scope")
|
||||
groups := "970 958"
|
||||
if !member {
|
||||
groups = "970"
|
||||
}
|
||||
f.write("/proc/3596/status", "Name:\tsystemd\nUid:\t1000\t1000\t1000\t1000\nGroups:\t"+groups+"\n")
|
||||
if running {
|
||||
f.proc(4269, 1000, daemonComm, []string{"openrazer-daemon", "-F"}, "user@1000.service/app.slice/"+daemonUnit)
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
call := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "pacman":
|
||||
return Output{Stdout: args[1] + " 3.12.4-1\n"}
|
||||
case name == "dkms":
|
||||
return Output{Stdout: "openrazer-driver/3.12.4, 7.2.8-arch1-2, x86_64: installed\n"}
|
||||
case name == "systemctl" && strings.Contains(call, "is-active"):
|
||||
if running {
|
||||
return Output{Stdout: "active\n"}
|
||||
}
|
||||
return Output{Stdout: "failed\n", Code: 3}
|
||||
case name == "systemctl" && strings.Contains(call, "is-enabled"):
|
||||
return Output{Stdout: "disabled\n", Code: 1}
|
||||
case name == "journalctl":
|
||||
return Output{Stdout: "Starting daemon.\n2026-10-04 16:26:21 | razer | CRITICAL | User is not a member of the openrazer group\n" +
|
||||
"Traceback (most recent call last):\n2026-10-04 16:26:22 | razer | CRITICAL | User is not a member of the openrazer group\nSystemExit: 0\n"}
|
||||
case name == "busctl" && !running:
|
||||
return Output{Code: 1, Stderr: "Call failed: The name is not activatable"}
|
||||
case strings.HasSuffix(call, " version"):
|
||||
return Output{Stdout: `{"type":"s","data":["3.12.4"]}`}
|
||||
case strings.HasSuffix(call, " getDevices"):
|
||||
return Output{Stdout: `{"type":"as","data":[["` + serial + `"]]}`}
|
||||
case strings.HasSuffix(call, " getDeviceName"):
|
||||
return Output{Stdout: `{"type":"s","data":["Razer Basilisk V3 Pro"]}`}
|
||||
case strings.HasSuffix(call, " getDeviceType"):
|
||||
return Output{Stdout: `{"type":"s","data":["mouse"]}`}
|
||||
case strings.HasSuffix(call, " getFirmware"):
|
||||
return Output{Stdout: `{"type":"s","data":["v1.04"]}`}
|
||||
case strings.HasSuffix(call, " getBattery"):
|
||||
return Output{Stdout: `{"type":"d","data":[85.0]}`}
|
||||
case strings.HasSuffix(call, " isCharging"):
|
||||
return Output{Code: 1, Stderr: "Unknown method"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestStatusReadsTheDriverTheGroupAndTheDaemonWithoutStartingIt(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Installed["openrazer-daemon"] != "3.12.4-1" || s.Driver.Built != "installed" || strings.Join(s.Driver.Loaded, ",") != "razermouse" ||
|
||||
len(s.Driver.Bound) != 1 || s.Driver.Bound[0] != (Bound{USB: "1532:00ab", Driver: "razermouse", Interfaces: 3}) {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if !s.Group.Exists || !s.Group.Account || s.Group.Manager == nil || !*s.Group.Manager {
|
||||
t.Fatalf("%+v", s.Group)
|
||||
}
|
||||
if s.Daemon.Version != "3.12.4" || len(s.Daemon.Running) != 1 || len(s.Devices) != 1 {
|
||||
t.Fatalf("%+v %+v", s.Daemon, s.Devices)
|
||||
}
|
||||
d := s.Devices[0]
|
||||
if d.Name != "Razer Basilisk V3 Pro" || d.Type != "mouse" || d.Battery == nil || *d.Battery != 85 || d.Charging != nil {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "plugdev") {
|
||||
t.Fatalf("%v", s.Notes)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
|
||||
t.Fatalf("a bus call that could start the daemon: %s", c)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(s)
|
||||
if strings.Contains(string(raw), serial) {
|
||||
t.Fatal("the answer carries a device's serial")
|
||||
}
|
||||
|
||||
stopped := newStack(t, false, false)
|
||||
s, _ = stopped.Status()
|
||||
if s.Group.Account || s.Group.Manager == nil || *s.Group.Manager || s.Daemon.Active != "failed" || stopped.called("busctl") {
|
||||
t.Fatalf("%+v %q", s, stopped.calls)
|
||||
}
|
||||
if len(s.Daemon.LastWords) != 1 || s.Daemon.LastWords[0] != "razer | CRITICAL | User is not a member of the openrazer group" {
|
||||
t.Fatalf("the reason, once, not the traceback: %q", s.Daemon.LastWords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassesTheWholeStackAndNamesWhatIsMissing(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
c, err := f.Check()
|
||||
if err != nil || !c.OK || len(c.Starts) != 1 || !strings.HasPrefix(c.Starts[0], "D-Bus activation") {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
|
||||
broken := newStack(t, false, false)
|
||||
broken.write("/proc/sys/kernel/osrelease", "7.3.0-arch1-1\n")
|
||||
broken.write(testHome+"/.config/i3/config", "exec --no-startup-id openrazer-daemon\n")
|
||||
c, _ = broken.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What+" => "+x.Do)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
for _, want := range []string{"not built for the running kernel 7.3.0-arch1-1", "not in the openrazer group", "gpasswd -a $USER openrazer",
|
||||
"a second start: ~/.config/i3/config:1", "failed at its last start: razer | CRITICAL | User is not a member"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no finding %q in\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupAddedAfterLoginIsNamedAsTheServiceManagers(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
f.write("/proc/3596/status", "Name:\tsystemd\nUid:\t1000\t1000\t1000\t1000\nGroups:\t970\n")
|
||||
c, _ := f.Check()
|
||||
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "service manager started before") {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADaemonOutsideItsUnitOrSeeingFewerDevicesIsAFinding(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
f.write("/proc/4269/cgroup", "0::/user.slice/user-1000.slice/session-c1.scope\n")
|
||||
f.write("/sys/bus/hid/drivers/razerkbd/0003:1532:0099.0001/x", "")
|
||||
c, _ := f.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
if !strings.Contains(got, "outside its unit (in session-c1.scope)") || !strings.Contains(got, "holds 2 device(s) and the daemon sees 1") {
|
||||
t.Fatalf("%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartGoesThroughTheUnitAndSaysWhyItFailed(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
a, err := f.Restart()
|
||||
if err != nil || a.Version != "3.12.4" || len(a.Devices) != 1 || !f.called("systemctl --user restart "+daemonUnit) {
|
||||
t.Fatalf("%+v %v %q", a, err, f.calls)
|
||||
}
|
||||
failing := newStack(t, false, false)
|
||||
inner := failing.answer
|
||||
failing.answer = func(name string, args []string) Output {
|
||||
if name == "systemctl" && len(args) > 1 && args[1] == "restart" {
|
||||
return Output{Code: 1, Stderr: "Job for openrazer-daemon.service failed"}
|
||||
}
|
||||
return inner(name, args)
|
||||
}
|
||||
if _, err := failing.Restart(); err == nil || !strings.Contains(err.Error(), "not a member of the openrazer group") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module openrazer
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"module": "openrazer",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"tools": [
|
||||
"openrazer_status",
|
||||
"openrazer_restart",
|
||||
"openrazer_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "driver",
|
||||
"type": "package",
|
||||
"package": "openrazer-driver-dkms"
|
||||
},
|
||||
{
|
||||
"id": "daemon",
|
||||
"type": "package",
|
||||
"package": "openrazer-daemon"
|
||||
},
|
||||
{
|
||||
"id": "library",
|
||||
"type": "package",
|
||||
"package": "python-openrazer"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/openrazer-tools",
|
||||
"binary": "openrazer-tools",
|
||||
"loads": [
|
||||
"openrazer-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# polychromatic
|
||||
|
||||
The Razer peripherals' tray on the workstations, as a module (novox/hq ADR 0208). It requires
|
||||
`x11-display`, so it is assigned only where a display server is held on the same machine. The driver
|
||||
and the daemon it drives are the `openrazer` module's.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the tray's one start | a contribution to `node-display-session` (ADR 0212): `exec --no-startup-id polychromatic-tray-applet`, placed by the `i3` module among the other modules' lines |
|
||||
|
||||
Nothing else. It declares no package, holds no seat and writes no file.
|
||||
|
||||
- **The application is kept as found.** `polychromatic` (0.9.8) is not in the official repositories:
|
||||
on both workstations it is a foreign (AUR) package, installed explicitly. The host installs from the
|
||||
official repositories only, so the module cannot declare it. ADR 0205's pinned archive does not fit
|
||||
either: it is a Qt application with a helper, a tray and a controller, not a set of plain files. Like
|
||||
`snapd` and the laptop's `triggerhappy`, it waits for the mesh's package repository (research 027
|
||||
question 1, option P2). Until then a fresh workstation installs it by hand, and
|
||||
`polychromatic_check` says when it is missing.
|
||||
- **Its dependencies are the `openrazer` module's** (`python-openrazer`, the daemon, the driver). The
|
||||
tray brings them when installed by hand; the module does not declare them twice.
|
||||
- **The application's settings stay the operator's.** `~/.config/polychromatic/` (preferences,
|
||||
presets, effects, device states) is the application's own, rewritten by it. The tools read only the
|
||||
tray's two keys of `preferences.json`.
|
||||
|
||||
## How it starts: the module's window-manager line, and nothing else
|
||||
|
||||
The tray had **two starts** on both workstations:
|
||||
|
||||
1. the window-manager line `exec --no-startup-id polychromatic-tray-applet`, in the `i3` module's
|
||||
section *Until their modules carry them*;
|
||||
2. the package's XDG autostart entry `polychromatic-autostart.desktop`, which `dex` runs at login. It
|
||||
runs `polychromatic-helper --autostart`, which waits for the daemon, resumes each device's
|
||||
software effect, and **starts the tray too while the application's setting *Start the tray applet
|
||||
when I log on* is ticked**. It is ticked on both.
|
||||
|
||||
The tray takes a lock file at its start and stops an earlier instance, so two starts end as one tray,
|
||||
in a race. On the laptop both ran at the login of 2026-10-04 16:26, and one tray was left; which
|
||||
start it came from is not recorded anywhere. On the desktop only the window-manager line ran (that
|
||||
session began before `dex` was installed).
|
||||
|
||||
**This module takes over the window-manager line as its contribution**, and the `i3` module drops it
|
||||
in the same change. That line is the tray's one start:
|
||||
|
||||
- it starts the tray with the session, once (`exec`, so a reload of i3 starts nothing);
|
||||
- it is the mesh's, so it is in the composed configuration only where the module is assigned.
|
||||
|
||||
**The helper stays**, for its other work: the effects it resumes at login. The package's entry is not
|
||||
the module's to hide, and hiding it would need a file in the account's autostart directory. So the
|
||||
operator unticks the tray setting once (migration, below), and `polychromatic_check` names the
|
||||
helper's tray start as a second start for as long as the setting is ticked. The module never writes
|
||||
the setting: the application rewrites its preferences file itself.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `polychromatic_status` (r) | <ul><li>whether the tray runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and that it is from outside the official repositories</li><li>what starts it: the window-manager lines, and the helper while the setting is on</li><li>the helper's entry, and the tray setting with its delay</li><li>whether the openrazer daemon answers, and how many devices it has (asked with `--auto-start=no`: never starts it)</li></ul> |
|
||||
| `polychromatic_restart` (a) | asks the tray to end (SIGTERM), forces it after 5 s, and starts `polychromatic-tray-applet` in the operator's session as a transient user unit `mesh-polychromatic-tray`, so it outlives the tools runtime. Refused plainly when nobody is logged in to the desktop |
|
||||
| `polychromatic_check` (r) | <ul><li>the package is installed</li><li>exactly one start: one window-manager line; the helper does not start the tray too; no autostart entry of the account for it</li><li>one tray runs in a desktop session</li><li>the openrazer daemon answers</li></ul>Each finding says what to do |
|
||||
|
||||
The kernel keeps 15 characters of a command name, and `polychromatic-tray-applet` and this bundle's
|
||||
`polychromatic-tools` share them. The tools match the tray by its full program name, so they never
|
||||
count or end themselves.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | laptop | desktop |
|
||||
|---|---|---|
|
||||
| package | none: `polychromatic` 0.9.8, explicit, foreign | the same |
|
||||
| `~/.config/i3/config` | the tray's line moves from the `i3` module's section to this module's contribution: the same line, and i3's reload starts nothing | the same |
|
||||
| tray | none: one runs, left by the two starts' race | none: one runs, from the predecessor's window-manager line (a child of i3, since the session of 2026-10-04 16:00, before `dex` ran) |
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
On each workstation, once: in Polychromatic, **untick Preferences → Tray → *Start the tray applet
|
||||
when I log on***. From the next login the module's line is the tray's one start, and
|
||||
`polychromatic_check` answers `ok` once the daemon answers too (the `openrazer` module's migration).
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- `~/.config/polychromatic/`: preferences, presets, custom effects, device states.
|
||||
- `/etc/xdg/autostart/polychromatic-autostart.desktop`, the package's.
|
||||
- The package itself, installed by hand.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The `openrazer` module** for the daemon and the driver. The tray without them shows no devices.
|
||||
`polychromatic_check` reports it.
|
||||
- **The `i3` module** to place the contribution (ADR 0212 §4: the contribution depends on
|
||||
`node-display-session`), and to run `dex` for the helper.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// 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
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// The polychromatic module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Razer
|
||||
// peripherals' tray in the operator's session, served by the node's runtime as the operator account.
|
||||
// The module holds no seat, so every tool is its own. The driver and the daemon the tray drives are
|
||||
// the openrazer module's tools.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "polychromatic_status",
|
||||
Description: "The Razer peripherals' tray: whether it runs (pid, since, and the unit or session " +
|
||||
"scope it runs in), the installed version and whether it came from outside the official " +
|
||||
"repositories, what starts it at login, the package's login helper and the application's own " +
|
||||
"tray setting, and whether the openrazer daemon behind it answers, with how many devices. " +
|
||||
"Never starts the tray or the daemon. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "polychromatic_restart",
|
||||
Description: "End the tray (asked first, then forced after 5 s) and start it again in the operator's " +
|
||||
"desktop session, under the account's service manager. Answers the pids ended and the new one. " +
|
||||
"Needs someone logged in to the desktop. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "polychromatic_check",
|
||||
Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
|
||||
"outside the official repositories); the tray has exactly one start (the module's line in the " +
|
||||
"window manager's configuration; the package's login helper must not start it too); it runs " +
|
||||
"once in a desktop session; and the openrazer daemon answers. Answers ok and each finding with " +
|
||||
"what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// polychromatic's shape (novox/hq ADR 0205, ADR 0208, ADR 0212): no package (the tray is outside the
|
||||
// official repositories and kept as found), the X display on its own machine, one contribution to the
|
||||
// window manager's configuration that is the tray's one start, nothing of the openrazer module's, and
|
||||
// the Go bundle serving exactly the listed polychromatic_ tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "polychromatic_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed %s and described", tool.Name, "polychromatic_")
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/polychromatic-tools" || b["binary"] != "polychromatic-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItDeclaresNoPackageAndContributesTheTraysOneStart(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "polychromatic" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if m.Resources != nil || m.Capabilities != nil || m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil {
|
||||
t.Fatal("no package (outside the official repositories), no seat, no environment, no xinitrc start")
|
||||
}
|
||||
if len(m.Contributions) != 1 || m.Contributions[0].Seat != "node-display-session" || m.Contributions[0].Kind != "config" {
|
||||
t.Fatalf("%+v", m.Contributions)
|
||||
}
|
||||
var code []string
|
||||
for _, l := range strings.Split(m.Contributions[0].Content, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
|
||||
code = append(code, l)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(code, []string{"exec --no-startup-id " + trayWord}) {
|
||||
t.Fatalf("one line, the tray's start with exec (a reload must not start it again): %q", code)
|
||||
}
|
||||
for _, never := range []string{"openrazer", "preferences.json", "autostart\""} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %s", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The window manager's own check parses the line, as the i3 module's catalogue-wide test does with
|
||||
// every contribution together.
|
||||
func TestTheContributionParsesInI3(t *testing.T) {
|
||||
i3, err := exec.LookPath("i3")
|
||||
if err != nil {
|
||||
t.Skip("no i3 here to check with")
|
||||
}
|
||||
m, _ := readManifest(t)
|
||||
path := filepath.Join(t.TempDir(), "config")
|
||||
os.WriteFile(path, []byte("set $mod Mod4\n"+m.Contributions[0].Content), 0o644)
|
||||
if out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput(); err != nil || strings.Contains(string(out), "ERROR") {
|
||||
t.Fatalf("i3 -C: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
// Polychromatic's tray as the tools see it: its processes, its one start (the module's line in the
|
||||
// window manager's configuration), the package's login helper and the one setting of the application's
|
||||
// that makes the helper start the tray too, and whether the daemon it drives answers. The daemon is
|
||||
// the openrazer module's; it is asked with --auto-start=no, so a question never starts it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
trayComm = "polychromatic-t" // the kernel keeps 15 characters of polychromatic-tray-applet
|
||||
trayWord = "polychromatic-tray-applet"
|
||||
trayBin = "/usr/bin/polychromatic-tray-applet"
|
||||
helperEntry = "polychromatic-autostart.desktop"
|
||||
restartAs = "mesh-polychromatic-tray"
|
||||
packageFor = "polychromatic"
|
||||
prefsFile = ".config/polychromatic/preferences.json"
|
||||
settingName = "Preferences → Tray → \"Start the tray applet when I log on\""
|
||||
)
|
||||
|
||||
// trays are the tray's processes. Its cut command name is this bundle's own too, so the program is
|
||||
// matched by its full name.
|
||||
func (m *Machine) trays() []Proc { return m.procsOf(trayComm, trayWord) }
|
||||
|
||||
// TraySetting is the application's own setting that makes its login helper start the tray. The
|
||||
// module never writes it: the application rewrites its preferences file itself.
|
||||
type TraySetting struct {
|
||||
// Autostart is the setting; Polychromatic's default is on.
|
||||
Autostart bool `json:"autostart"`
|
||||
Delay int `json:"autostart_delay_seconds"`
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
// setting reads the tray keys of the preferences file and nothing else of it.
|
||||
func (m *Machine) setting() TraySetting {
|
||||
s := TraySetting{Autostart: true, From: "Polychromatic's default (no preferences file)"}
|
||||
raw, err := readBounded(m.home(prefsFile))
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
var doc struct {
|
||||
Tray struct {
|
||||
Autostart *bool `json:"autostart"`
|
||||
Delay int `json:"autostart_delay"`
|
||||
} `json:"tray"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
s.From = "Polychromatic's default (the preferences file does not parse)"
|
||||
return s
|
||||
}
|
||||
s.From = "~/" + prefsFile
|
||||
if doc.Tray.Autostart != nil {
|
||||
s.Autostart = *doc.Tray.Autostart
|
||||
}
|
||||
s.Delay = doc.Tray.Delay
|
||||
return s
|
||||
}
|
||||
|
||||
// Backend is whether the openrazer daemon answers, and how many devices it has.
|
||||
type Backend struct {
|
||||
Answers bool `json:"answers"`
|
||||
Devices int `json:"devices"`
|
||||
Unanswered string `json:"unanswered,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Machine) backend() Backend {
|
||||
args := []string{"--user", "--auto-start=no", "--json=short", "call", "org.razer", "/org/razer", "razer.devices", "getDevices"}
|
||||
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
|
||||
if err := failed(o, "busctl", args...); err != nil {
|
||||
return Backend{Unanswered: "the openrazer daemon does not answer (asked without starting it): " + err.Error()}
|
||||
}
|
||||
var doc struct {
|
||||
Data [][]string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
|
||||
return Backend{Unanswered: "the daemon's device list is not busctl's JSON"}
|
||||
}
|
||||
return Backend{Answers: true, Devices: len(doc.Data[0])}
|
||||
}
|
||||
|
||||
// foreign says whether the package came from outside the official repositories (pacman -Qm).
|
||||
func (m *Machine) foreign() bool {
|
||||
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
|
||||
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
|
||||
}
|
||||
|
||||
// StatusAnswer is what polychromatic_status answers.
|
||||
type StatusAnswer struct {
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Foreign bool `json:"outside_official_repositories"`
|
||||
Tray []Proc `json:"tray"`
|
||||
StartedBy []string `json:"started_by"`
|
||||
Helper Autostart `json:"login_helper"`
|
||||
Setting TraySetting `json:"tray_setting"`
|
||||
Backend Backend `json:"backend"`
|
||||
}
|
||||
|
||||
// Status reads the tray, what starts it, and whether the daemon behind it answers.
|
||||
func (m *Machine) Status() (StatusAnswer, error) {
|
||||
s := StatusAnswer{Tray: m.trays(), StartedBy: []string{}, Helper: m.autostart(helperEntry),
|
||||
Setting: m.setting(), Backend: m.backend()}
|
||||
if s.Tray == nil {
|
||||
s.Tray = []Proc{}
|
||||
}
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Installed = v
|
||||
if v != "" {
|
||||
s.Foreign = m.foreign()
|
||||
}
|
||||
for _, l := range m.i3Starts(trayWord) {
|
||||
s.StartedBy = append(s.StartedBy, "window manager: "+l)
|
||||
}
|
||||
if s.Helper.Starts && s.Setting.Autostart {
|
||||
s.StartedBy = append(s.StartedBy, "the login helper ("+s.Helper.From+"), as the tray setting is on")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what polychromatic_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Ended []int `json:"ended"`
|
||||
Killed []int `json:"killed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Session Session `json:"session"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// Restart ends the tray and starts it again in the operator's session, under the account's service
|
||||
// manager.
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return RestartAnswer{}, err
|
||||
}
|
||||
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||
a.Ended, a.Killed = m.stopProcs(5*time.Second, m.trays())
|
||||
if err := m.detach(s, restartAs, trayBin); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Running = m.waitForOf(trayComm, trayWord, 4*time.Second)
|
||||
if len(a.Running) == 0 {
|
||||
return a, fmt.Errorf("the tray was started as %s but no %s process appeared within 4 s: "+
|
||||
"see `journalctl --user -u %s`", a.Unit, trayWord, a.Unit)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what polychromatic_check answers.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies what the module promises and relies on: the package (found, not installed by the
|
||||
// mesh); one start, the module's window-manager line; the tray running once; and the daemon answering.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("polychromatic is not installed. It is outside the official repositories, so the mesh does not install it",
|
||||
"install it from the AUR by hand; it brings the openrazer packages with it")
|
||||
}
|
||||
|
||||
lines := m.i3Starts(trayWord)
|
||||
for _, l := range lines {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
}
|
||||
switch {
|
||||
case len(lines) == 0:
|
||||
add("the window manager does not start the tray", "push the module: its line in the window manager's configuration is the tray's start")
|
||||
case len(lines) > 1:
|
||||
for _, l := range lines[1:] {
|
||||
add("a second start: "+l, "remove the line; the module's line is the tray's one start")
|
||||
}
|
||||
}
|
||||
helper := m.autostart(helperEntry)
|
||||
if set := m.setting(); helper.Starts && set.Autostart {
|
||||
a.Starts = append(a.Starts, "login helper: "+helper.From+" (the tray setting is on)")
|
||||
add("a second start: the package's login helper starts the tray too, as the tray setting is on ("+set.From+")",
|
||||
"in Polychromatic, untick "+settingName+". The helper keeps its other work: it resumes the devices' effects")
|
||||
}
|
||||
if own := m.autostart(trayWord + ".desktop"); own.Starts {
|
||||
a.Starts = append(a.Starts, "XDG autostart: "+own.From)
|
||||
add("a second start: "+own.From, "remove it; the module's line is the tray's one start")
|
||||
}
|
||||
|
||||
if _, err := m.session(); err == nil {
|
||||
switch running := m.trays(); {
|
||||
case len(running) == 0:
|
||||
add("no tray runs in the desktop session", "polychromatic_restart")
|
||||
case len(running) > 1:
|
||||
add(fmt.Sprintf("%d trays run", len(running)), "polychromatic_restart ends them all and starts one")
|
||||
}
|
||||
}
|
||||
if b := m.backend(); !b.Answers {
|
||||
add(b.Unanswered+": the tray has no devices to show", "assign the openrazer module; openrazer_check says why the daemon is not running")
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user