Author SHA1 Message Date
jschoubben 1a03caf8a5 The store collects nightly again (hq ADR 0189)
Withdrawn 2026-10-04 to unblock novox: while-stopped named the module-local
id and the host refuses a declaration naming a container it does not have,
whole. mesh-controller#259 fixed the namespacing and it has been live since,
so the window composes as distribution.store and the host will take it.

Unchanged from before: plain garbage-collect at 03:30 with the server held
still. The store is at 40G and nothing reclaims it until this runs.
2026-10-05 14:33:14 +02:00
mesh-admin 54ba97f901 Merge pull request 'claude-code: register the agent's configuration at three scopes, served as the nox-mesh plugin (hq ADR 0216)' (#52) from feat/nox-mesh-plugin into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 12:30:10 +00:00
jochen 92f78db970 claude-code: act on the review of the nox-mesh plugin
Refuse paths with empty, dot or parent segments and a file that is also a
directory; take an item only when its key says what it is; write the view
under its lock; expand nodes all and check node names; merge the plugin's
entries into the operator's own; render every file past one that fails;
in the home, never take over the person's file, never write through a
symbolic link, keep a deleted file deleted, keep the kind's directory; and
refuse settings that would deny the console or the marketplace.
2026-10-05 14:29:53 +02:00
jochen 1d8f1ceff8 claude-code: drop what was removed while away, render one at a time, refuse an empty settings set
Review of the nox-mesh plugin: a watch hands over what is there, not what
went, so the view is pruned to the state's keys before it starts; the
watches and the tools render one at a time, as the home's record is read
and written whole; the register answer names how an item is offered.
2026-10-05 14:25:10 +02:00
jschoubben 83bd2afe66 Merge pull request 'restic: restore a single kept file, not only a directory' (#56) from fix/restore-a-file into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 12:22:17 +00:00
jschoubben 46e3a5a6b7 restic: restore a single kept file, not only a directory
The first restore of an arr app refused its settings file with "not a directory": restic restores
a snapshot's subfolder, not a file. A file is restored with its full path into a scratch directory
beside the target, moved into place, and the scratch removed.
2026-10-05 14:05:52 +02:00
jschoubben d67e786bf9 Merge pull request 'restic: declare sqlite, the tool SQLite stores are copied with' (#55) from feat/arr-backups into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 11:49:35 +00:00
jschoubben 7786507d45 restic: declare sqlite, the tool SQLite stores are copied with
The arr apps keep SQLite databases; a consistent copy of a live one is sqlite3's .backup. Declared
once, by the holder that runs the copies, rather than by each app.
2026-10-05 13:39:55 +02:00
jschoubben a70227a40a Merge pull request 'restic: ask as root whether a directory is there' (#54) from fix/backup-sees-as-root into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 10:38:25 +00:00
jschoubben 08c7a79f79 restic: ask as root whether a directory is there
The first run on the control node called postgres's dumps missing: the holder looked as the
runtime's account, which cannot see inside a store's 0700 data directory. Every other act already
runs as root; the existence checks do too now.
2026-10-05 12:31:07 +02:00
jschoubben d9120c6848 Merge pull request 'fail2ban: its tools in Go' (#53) from feat/fail2ban-in-go into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 10:24:17 +00:00
jschoubben 96ee0d7ad6 Merge pull request 'Back up every store: the restic module and the stores' contributions (hq ADR 0214, to-be 43)' (#49) from feat/node-backup into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 10:13:48 +00:00
jschoubben d43de93e49 fail2ban: its tools in Go
Go is the default for module code. One binary, fail2ban-tools, serving the node-intrusion-prevention
seat's four verbs and fail2ban_settings over the SDK, with the same parsing and the same tests; read
back against the control node's live daemon.
2026-10-05 12:01:25 +02:00
jschoubben 3a88bca21a restic: the holder in Go
Go is the default for new module code; the holder is one binary like the licence manager, serving
the seat's verbs over the SDK and running the nights beside them. Same behaviour and tests.
2026-10-05 11:58:18 +02:00
jochen dd1035a27d claude-code: register the agent's configuration at three scopes, served as the nox-mesh plugin
Skills, subagents, commands and hooks had no machine-wide place, so they were
copied into homes by hand and drifted. Each is now registered once through the
module's tools, kept in its config state, and written per node: the plugin in
the managed directory, settings and instructions in the managed files, or the
account's own directory, touching only what the module placed. hq ADR 0216.
2026-10-05 11:57:35 +02:00
mesh-admin 37bb53ab95 Merge pull request 'xdg: the account's folders and the machine's default applications' (#50) from feat/xdg-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:53:30 +00:00
mesh-admin 45f27eb300 Merge pull request 'dbus: hold node-message-bus, and never restart the bus live (hq ADR 0215)' (#51) from feat/dbus-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:52:32 +00:00
jochen 7b5e1d3362 dbus: hold node-message-bus, and never restart the bus live (hq ADR 0215)
A live restart of the system bus during an upgrade hung every login on a
workstation until a reboot. The module owns the bus's packages, declares
the bus running with no restart or reload trigger, publishes only curated
events (health, services, denials; never traffic) and serves tools to look
at both buses.
2026-10-05 11:50:52 +02:00
jochen 785d32407b xdg: own the account's folders and the machine's default applications
The workstations' XDG conventions had no owner: xdg-user-dirs-update rewrote
the folders file at every login, both machines' default-application lists
named an editor neither has, and the laptop kept two dead links of the
retired predecessor. The module adopts the operator's folders (three as
settings, as the machines differ), disables the update so it cannot undo the
mesh's file, and writes the machine's own mimeapps list, the last one read,
so the person's choices in their own list always win. Autostart is listed,
not owned: each entry is its application's module's.
2026-10-05 11:50:34 +02:00
mesh-admin 170b3296e9 Merge pull request 'nextcloud-client and blueman: the two tray apps as modules, each with one start' (#48) from feat/nextcloud-client-and-blueman into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:48:43 +00:00
jschoubben 32cd92baeb Back up every store: the restic module holds node-backup, the stores contribute their dumps
ADR 0214 / to-be 43. restic keeps one repository per machine and takes a nightly snapshot per
module — 14 daily, 8 weekly, 6 monthly — and restores beside the live data, never over it. postgres,
mssql and mongodb contribute a consistent dump; minio, influxdb, the vault, mailu, gitea and
nextcloud the directories that hold their data.
2026-10-05 11:47:59 +02:00
jochen 9582208984 Add nextcloud-client and blueman: the two tray apps get an owner and tools, each with one start
Both are official packages already on both workstations, started once by dex from an XDG
autostart entry (the client's own, the package's). The modules declare the package, add no
second start, own none of the apps' files, and give the mesh status, log, restart and check.
2026-10-05 11:47:38 +02:00
mesh-admin 4224ac7252 Merge pull request 'i3: other modules' lines are contributions to node-display-session (hq ADR 0212)' (#47) from feat/i3-fragments-as-contributions into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 08:11:45 +00:00
jochen 1d4de00603 i3: other modules' lines are contributions to node-display-session (hq ADR 0212)
rofi, clipmenu, feh, i3status-rust and the laptop's model module wrote files into i3's config.d,
naming no dependency on the window manager. They now contribute their lines; i3 places them under a
line naming each module, and config.d is the operator's alone. The catalogue-wide test composes the
contributions as the controller does and checks the whole with i3 -C.
2026-10-05 10:11:27 +02:00
mesh-admin 7047198146 Merge pull request 'power: a lock problem is no longer said once the lock is held' (#46) from fix/power-clears-a-solved-problem into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 08:03:44 +00:00
jochen 815a55a9fd power: a lock problem is no longer said once the lock is held 2026-10-05 10:03:30 +02:00
mesh-admin a3c8086d52 Merge pull request 'Apply a binding that differs, and never repeat a generation a node passed (hq issue 243)' (#45) from fix/a-reset-generation-silences-no-node into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 08:01:12 +00:00
jochen b91b4c427d Apply a binding that differs, and never repeat a generation a node passed
A licence store rebuilt after issue 241 counted generations from one again,
and nodes that apply only a higher number discarded the login move and the
rotations unseen. Nodes now apply any binding other than the one applied;
the manager moves its sequence past every generation a node reports. hq
issue 243.
2026-10-05 09:59:19 +02:00
jschoubben 316576f1da Merge pull request 'A withdrawn consumer keeps its data, in every provider that holds some (hq issue 241)' (#44) from fix/a-withdrawn-consumer-keeps-its-data into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 23:45:16 +00:00
jschoubben 1fb7ca3d72 A withdrawn consumer keeps its data, in every provider that holds some (hq issue 241)
mssql disables the login, mongodb takes the user's roles, minio revokes the key and keeps the bucket,
mailu disables the mailbox, gitea prohibits the login instead of purging the user and their
repositories, umami keeps the website. Each provider's create already enables what this locks.
2026-10-05 00:34:40 +02:00
jschoubben 190d711a2a postgres: a withdrawn consumer keeps its database; retiring renames, never drops (hq issue 241) 2026-10-05 00:33:12 +02:00
mesh-admin 9275a9e295 Merge pull request 'power: polkit is the module's package' (#289) from fix/power-needs-polkit into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 15:53:42 +00:00
jochen d26eb1d9a6 power: polkit is the module's package; without it logind refused the watcher its delay lock on a server 2026-10-04 17:53:31 +02:00
mesh-admin 6c73848ba1 Merge pull request 'power: the watcher checks its lock is logind's, and takes it again when it is not' (#288) from fix/power-watcher-verifies-its-lock into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 15:49:31 +00:00
jochen 94d0caa09c power: the watcher checks its lock is logind's, and takes it again when it is not
On one server the watcher reported a lock that logind did not list. A descriptor that is not an
inhibitor reference is never wrapped (0 would be the bundle's stdin, its channel to the runtime), and
every poll checks the lock is still held, taking it again and saying why when it is not.
2026-10-04 17:49:19 +02:00
mesh-admin 5f8869b165 Merge pull request 'power: the supply rule matches only the charger' (#287) from fix/power-supply-rule-only-the-charger into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 15:44:46 +00:00
jochen 91f69d1dd3 power: the supply rule matches only the charger; the battery's level changes started the unit every second 2026-10-04 17:44:30 +02:00
mesh-admin 1ace62b969 Merge pull request 'claude-code: let the operator set the agent's managed settings' (#286) from feat/claude-code-agent-settings into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-04 15:34:08 +00:00
jochen 8067e91409 Let the operator set the agent's managed settings through claude-code
managed-settings.json carried only the mesh's fixed keys, so permissions and
auto-mode rules could only be set by hand per machine, outside the mesh.
A managed_settings setting is laid under the mesh's keys, which still win.
2026-10-04 17:31:20 +02:00
142 changed files with 13695 additions and 619 deletions
+7
View File
@@ -376,3 +376,10 @@ module's contribution to `node-power`'s `after-wake` moment, so the module depen
module. `logind.conf.d/power.conf` is the power module's. This laptop's values (suspend on the power
key and on the lid in every case) are that module's settings for this machine. The NVIDIA driver's
sleep drop-ins stay here: they must run inside the sleep transaction, which a contribution cannot.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
+50 -14
View File
@@ -28,8 +28,48 @@ type KeysReport struct {
// TriggerDir is triggerhappy's directory of trigger files; the module owns one file in it.
const TriggerDir = "/etc/triggerhappy/triggers.d"
// I3Fragments are the module's own files in i3's include directory, relative to the account's home.
var I3Fragments = []string{".config/i3/config.d/10-asus.conf", ".config/i3/config.d/20-g14.conf"}
// I3Config is the window manager's configuration, relative to the account's home. This module's lines
// are its contribution to node-display-session (novox/hq ADR 0212), placed there by the i3 module
// under a line that names this module.
const I3Config = ".config/i3/config"
// ModuleName is the line the controller writes before this module's contributed lines.
const ModuleName = "asus-zephyrus-g14"
// ownSection is this module's contributed lines in a placed file: from the line naming it to the next
// line naming another module, a section header, or the file's end.
func ownSection(text string) []string {
var out []string
in := false
for _, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if t == "# "+ModuleName {
in = true
continue
}
if !in {
continue
}
if strings.HasPrefix(t, "####") || (strings.HasPrefix(t, "# ") && isModuleLine(strings.TrimPrefix(t, "# "))) {
break
}
out = append(out, line)
}
return out
}
// isModuleLine is a comment that is one catalogue module's name: lower case, digits and dashes.
func isModuleLine(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
// physicalKeys names the key behind an evdev code, as far as it is known on this model. The media
// codes are the M4 key and Fn+F4/F5 together; which code is which key was not recorded.
@@ -91,26 +131,22 @@ func (m *Machine) Keys() KeysReport {
if home == "" {
r.NotRead = append(r.NotRead, "the module's i3 lines (MESH_OPERATOR_HOME is not set)")
}
for _, rel := range I3Fragments {
if home == "" {
break
if home != "" {
lines := ownSection(m.read(filepath.Join(home, I3Config)))
if len(lines) == 0 {
r.Warnings = append(r.Warnings, "~/"+I3Config+" holds no lines of this module's: is the i3 module assigned and pushed?")
}
p := filepath.Join(home, rel)
text := m.read(p)
if text == "" {
r.Warnings = append(r.Warnings, "~/"+rel+" is missing or empty")
continue
}
for _, line := range strings.Split(text, "\n") {
from := "~/" + I3Config + " (" + ModuleName + "'s contribution)"
for _, line := range lines {
line = strings.TrimSpace(line)
if g := i3Bind.FindStringSubmatch(line); g != nil {
when := "pressed"
if strings.Contains(g[1], "--release") {
when = "released"
}
r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: "~/" + rel})
r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: from})
} else if g := i3Exec.FindStringSubmatch(line); g != nil {
r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: "~/" + rel})
r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: from})
}
}
}
@@ -33,8 +33,15 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
put(t, f.root, TriggerDir+"/mesh.conf", "# asus-zephyrus-g14\n"+m.triggers())
home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home)
put(t, f.root, home+"/"+I3Fragments[0], content["i3-vendor-keys"])
put(t, f.root, home+"/"+I3Fragments[1], content["i3-model"])
// The i3 module's file as the controller composes it: other modules' lines around this one's.
var own string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" {
own += c.Content
}
}
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
r := f.machine().Keys()
got := map[string]Key{}
+10 -16
View File
@@ -175,22 +175,6 @@
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
},
{
"id": "i3-vendor-keys",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/10-asus.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"id": "i3-model",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/20-g14.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
}
],
"build": {
@@ -218,6 +202,16 @@
"seat": "node-hotkeys",
"kind": "trigger",
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
}
],
"shell": [
+82
View File
@@ -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).
+97
View File
@@ -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,574 @@
package main
// desktop.go is the same file in the nextcloud-client and blueman bundles: 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
}
// 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 pids []int
for _, c := range comms {
for _, p := range m.procs(c) {
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 {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procs(comm); 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,202 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client and blueman bundles.
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 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))
}
}
+48
View File
@@ -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)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module blueman
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+37
View File
@@ -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"
]
}
]
}
}
+2 -2
View File
@@ -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.
+35 -4
View File
@@ -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 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`:
@@ -58,6 +83,12 @@ Per node or for the whole mesh, through `mesh-controller.settings module=claude-
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
module's own and cannot be set. Put a person's own servers here, or they stop loading.
- `managed_settings` — keys of the agent's managed settings, in the vendor's `settings.json` shape:
`permissions` (allow, ask, deny), `autoMode` (environment, allow, soft_deny), `env`, hooks and so on.
Managed settings outrank every other scope, so a rule here holds in every session on the node. The
mesh's own keys (`attribution`, `allowAllClaudeAiMcps`, `apiKeyHelper`) are laid last and cannot be
set. A setting layer is replaced whole: setting `managed_settings` without `role` or `mcp_servers`
clears those in that layer.
## On a machine that carried the predecessor
@@ -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,11 +95,45 @@ 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")
}
}
func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
settings := Settings{ManagedSettings: map[string]any{
"autoMode": map[string]any{"allow": []any{"merging an approved pull request"}},
"permissions": map[string]any{"deny": []any{"Bash(rm -rf /)"}},
"attribution": map[string]any{"commit": "made by a machine"},
"allowAllClaudeAiMcps": false,
"apiKeyHelper": "/somewhere/else",
}}
read := func(binding *Binding) map[string]any {
var m map[string]any
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)
}
return m
}
m := read(nil)
if allow := m["autoMode"].(map[string]any)["allow"].([]any); len(allow) != 1 || allow[0] != "merging an approved pull request" {
t.Errorf("the operator's auto mode was not carried: %v", m["autoMode"])
}
if m["permissions"] == nil {
t.Errorf("the operator's permissions were not carried: %v", m)
}
if !reflect.DeepEqual(m["attribution"], map[string]any{"commit": "", "pr": ""}) || m["allowAllClaudeAiMcps"] != true {
t.Errorf("a setting replaced the mesh's own keys: %v", m)
}
if _, ok := m["apiKeyHelper"]; ok {
t.Errorf("a setting named a key-helper the licence did not: %v", m)
}
if helper := read(&Binding{Licence: "api", Kind: "api-key"})["apiKeyHelper"]; helper != "/h" {
t.Errorf("an API-key licence's key-helper was replaced: %v", helper)
}
}
// ---- the credentials file -----------------------------------------------------------------------------
func i64(v int64) *int64 { return &v }
@@ -399,3 +439,31 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
t.Fatalf("the key crossed in the clear: %v", sent)
}
}
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
t.Fatal(err)
}
// The rotation that came with it: a later token, as a refresh hands one over.
later := func(address string, args any) (json.RawMessage, error) {
asked = append(asked, address)
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
if err != nil {
t.Fatal(err)
}
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
}
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
later, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
}
}
@@ -0,0 +1,937 @@
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
}
}
}
}
var keys []string
changed := false
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
changed = v.Take(key, op, &item) || changed
}
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 changed {
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 if v.Applies(keys[0]) || len(keys) > 1 {
answer["here"] = "already so"
} 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,457 @@
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)
}
}
@@ -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
}
+122 -5
View File
@@ -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 {
+43 -7
View File
@@ -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 ----------------------------------------------------------------------------------
@@ -255,14 +286,19 @@ func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
}
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
// which lives hours, and says so.
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
// lives hours, and says so.
//
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
// a node that waited for a number above its own ignored every binding it was sent, its login and the
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
if b == nil {
return "this node's binding was released; it keeps its last token until it expires", nil
}
var applied Binding
if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence {
return "", nil
}
out, err := Pull(p, ask, write)
+33 -7
View File
@@ -10,9 +10,11 @@ package main
// servers the operator declared or registered through this module. Exclusive by
// the vendor's rule — a server not listed here does not load (operator's choice,
// 2026-10-03).
// managed-settings.json the mesh's keys only: the repositories' attribution convention, the claude.ai
// connectors kept beside the managed servers, and — for an API-key licence only —
// the key-helper. A person's preferences are theirs.
// managed-settings.json the keys the operator set in this module's `managed_settings` (the agent's
// permissions and auto mode, say), under the mesh's own keys, which always win:
// the repositories' attribution convention, the claude.ai connectors kept beside
// the managed servers, and — for an API-key licence only — the key-helper. A
// person's preferences are theirs, in their own settings.
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
import (
@@ -38,6 +40,8 @@ type Facts struct {
type Settings struct {
Role string `json:"role"`
MCPServers map[string]map[string]any `json:"mcp_servers"`
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
ManagedSettings map[string]any `json:"managed_settings"`
}
// Binding is the licence this node holds, as it was last applied.
@@ -92,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 {
@@ -105,7 +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{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
// 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
}
@@ -116,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),
}
}
+31 -3
View File
@@ -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": [
{
@@ -69,7 +97,7 @@
"mode": "0600",
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {}\n}\n"
}
],
"build": {
@@ -49,6 +49,8 @@ type Holdings struct {
Fingerprint string `json:"fingerprint"`
} `json:"refresh"`
ChangedAt string `json:"changedAt"`
// Generation is the binding generation the node last applied (novox/hq issue 243).
Generation int64 `json:"generation"`
}
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
@@ -189,6 +191,17 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
// exchanged. Answers what was adopted.
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
// counts from one again, while every node still holds the number it last applied. The nodes no longer
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
// skipped. So the count is moved past every number reported, before anything here binds.
var highest int64
for _, r := range reports {
highest = max(highest, r.Generation)
}
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
return nil, err
}
byAccount, err := m.CandidatesIn(ctx, reports)
if err != nil {
return nil, err
@@ -423,3 +423,18 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
}
}
}
// A store rebuilt after a loss counts generations from one again (novox/hq issue 243): the first look at the
// reports moves the count past every generation a node says it applied, so no binding repeats one.
func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
laptop := mm.login("laptop", "rt-a", true, t0)
laptop.Generation = 16
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
t.Fatal(err)
}
if g := mm.state["laptop"].Generation; g <= 16 {
t.Fatalf("the laptop applied generation 16 and was given %d", g)
}
}
@@ -225,6 +225,16 @@ func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
return err == nil && tag.RowsAffected() == 1, err
}
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
if generation <= 0 {
return nil
}
// setval with is_called, so the next nextval is generation+1; only ever forward.
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
where not is_called or last_value < $1`, generation)
return err
}
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
returning consumer, licence, generation`, licence)
@@ -70,6 +70,8 @@ type Store interface {
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
Advance(ctx context.Context, licence string) ([]Binding, error)
// GenerationsAbove makes every generation given from now on greater than this one.
GenerationsAbove(ctx context.Context, generation int64) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
@@ -200,6 +202,13 @@ func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
return ok, nil
}
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.generation = max(m.generation, generation)
return nil
}
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
+7
View File
@@ -80,3 +80,10 @@ So clipmenud runs with the module's own `xsel` first on its `PATH`
(`/usr/local/lib/mesh-clipmenu/xsel`). Before a read it asks the selection for its `TARGETS` and goes
ahead only when the selection offers text. It uses `xclip` for that question, which the `xclip`
module installs on every workstation.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -22,7 +22,9 @@ type manifest struct {
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
Contributions []contribution `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
@@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
}
}
}
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// i3Lines is what the module contributes to the window manager.
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
}
}
@@ -53,8 +53,8 @@ func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) {
if _, set := m.Environment.Variables["CM_LAUNCHER"]; set {
t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command")
}
m.sameAsSource(t, "i3-bindings", "files/i3/50-clipmenu.conf")
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") {
m.i3LinesAreSource(t, "files/i3/50-clipmenu.conf")
if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") {
t.Fatalf("%s", c)
}
}
+8 -9
View File
@@ -48,14 +48,6 @@
"package": "rofi-greenclip",
"absent": true
},
{
"id": "i3-bindings",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/50-clipmenu.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
},
{
"id": "text-only",
"type": "file",
@@ -78,5 +70,12 @@
]
}
]
}
},
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
}
]
}
+111
View File
@@ -0,0 +1,111 @@
# dbus
A machine's message bus (novox/hq ADR 0215). This module holds `node-message-bus` on every machine,
servers included, because every machine runs a D-Bus system bus. It owns the bus implementation's
packages and declares its system service running. It publishes what matters about the bus on the
mesh's bus, and serves tools to look at the system bus and at the operator's session bus.
## What it owns
| | |
|---|---|
| package `dbus-broker` | the bus every machine runs |
| package `dbus-broker-units` | `dbus.service` as an alias of `dbus-broker.service`, for the system and for each user |
| package `dbus` | the bus's configuration (`system.conf`, `session.conf`), `dbus.socket`, which starts the bus at boot, and libdbus |
| service `dbus-broker.service` | declared running, with no boot state and no restart or reload trigger (below) |
All four machines were found with dbus-broker 37 behind `dbus.service` and dbus 1.16.2, from the
distribution. One server also has an old `dbus-units` package, an empty package that depends on
`dbus-broker-units`. It is not declared: nothing needs it, and removing it is a choice for its
operator.
## The bus is never restarted live
On 2026-10-04 a full upgrade on a workstation restarted the system bus while the upgrade was still
running. From then on every login hung, sshd answered nothing and the machine's host stopped
reporting, until someone rebooted it at its keyboard. Every program that speaks on the bus (the
service manager, logind, the network manager, the keyring, the power module's sleep lock) holds a
connection to it. A restart takes all of them away at once, and not all of them come back.
So the module never restarts or reloads the bus, for any change. Its service has no `restart-on` and
no `reload-on`, and the host never restarts a service that has neither. A new bus takes effect at
the next boot. `dbus_check` says when the running bus is older than an installed package, which is
the sign that a reboot is due. The distribution's own upgrade can still restart the bus. This rule
covers what the mesh does, not what pacman does.
**Why `state: running` and no `boot`.** `dbus.service` is an alias (`systemctl is-enabled` says
`alias`), so it cannot be declared enabled: the host would run `systemctl enable` and read back
something other than `enabled`. The real unit, `dbus-broker.service`, reads `disabled` on three
machines and `enabled` on one. It needs no enabling. At boot `dbus.socket`, which the `dbus` package
links into `sockets.target`, pulls in `dbus.service`, and `dbus-broker-units` ships that name as a
link to `dbus-broker.service`. On the three machines where it reads `disabled`, enabling it would
only write a second alias link into `/etc`, and a module's apply would change something on a running
machine for no gain. The service is declared so that the mesh knows the bus is the module's and says
so when it is not running. A host finding it stopped would start it, which can only help a machine
whose bus is down. ADR 0215 §1 says "running and enabled". On these machines the package's own
socket link is what makes it start at boot.
**A policy change** a module needs in the future uses the bus's own reload (`dbus-broker.service` is
`Type=notify-reload`), which keeps every connection. No module needs one today (below).
## Events
The watcher runs beside the tools in the same process, on its own connection to the system bus as the
operator's account. It reads only the bus driver's answers, the driver's `NameOwnerChanged` signal and
the bus unit's journal.
| event | when | carries |
|---|---|---|
| `bus.stalled` | the bus does not answer the driver's `GetId` within 3 s, or cannot be reached | the reason |
| `bus.recovered` | a stalled bus answers again | since when it was stalled, and for how long |
| `bus.restarted` | after reconnecting, the bus's id or the driver's pid has changed within the same boot (after a boot both change, and `power` says `booted`) | the old and new id and pid, the unit |
| `service.appeared` | a well-known name appears and is still there 10 s later | the name, the owner's pid, process and unit, whether it is activatable |
| `service.left` | a well-known name is gone and still gone 10 s later | the name and what held it |
| `policy.denied` | the bus logged a policy denial, at most once per 5 minutes | how many since the last one, and up to 5 distinct examples: the refused message's type, sender, destination, path, interface and member |
Unique names (`:1.42`) come and go with every client and are never published. A service restarted
within the 10 s, or one that came and went, publishes nothing. Activatable services that exit when
idle, such as hostnamed, do appear and leave, and their events say `activatable: true`.
**Why traffic never leaves the machine.** The bus carries secrets from the keyring, notification text
and the clipboard. The watcher never becomes a monitor, so it never sees another peer's messages. Its
events are built from names, pids, units and the header fields the broker logs with a denial. The log
line itself is not passed on. The tests hold every event's body to those fields.
Events the mesh's bus does not take wait in order and go out when it answers again, as `power`'s do.
At most 1000 events wait. When there are more, the oldest are dropped and counted.
The watcher's ping is `GetId`, not `org.freedesktop.DBus.Peer.Ping`. The system policy refuses the
peer ping to an account that is not root and logs a denial each time, which the watcher would then
publish.
## Tools
| tool | |
|---|---|
| `dbus_names` | `bus`: system or session. Every well-known name with its owner's pid, process, user and unit, the activatable names that are not running, and the number of connections |
| `dbus_introspect` | `bus`, `service`, `path`. The object's interfaces with their methods, properties (type and access, never values) and signals, and its children. Uses `--auto-start=no`, so looking never starts a service |
| `dbus_monitor` | `bus`, `seconds` (at most 15), optional `match` rule and `names`. The headers of what passed (type, sender, destination, path, interface, member, error name), at most 500. Never a body: each line is decoded into the header alone. On the system bus only root may monitor, so it runs through `sudo -n` |
| `dbus_check` | the packages are installed. `dbus.service` is `dbus-broker.service` and active. The running bus is not older than the installed `dbus-broker` or `dbus` (otherwise a reboot is due). Every activatable service file's `SystemdService` exists. Policy denials in the last hour. The watcher is connected, not stalled, and has nothing stuck |
| `dbus_health` | the round trip of a ping on the watcher's connection, the connection count (from `Debug.Stats` through `sudo -n`, else the unique names), and the bus's unit, pid, start and uptime |
Every command is bounded at 20 s and its output read up to 1 MiB. Lists stop at 500 entries.
`Debug.Stats` is asked only as root: asked as the account it is refused and logged as a denial.
The session bus is the runtime's `DBUS_SESSION_BUS_ADDRESS`, else `$XDG_RUNTIME_DIR/bus`, else
`/run/user/<uid>/bus`. On a machine where the account has no session, the tools say so.
The check notes, without failing, systemd's own services whose `dbus-org.*` alias is missing because
they are not enabled, such as resolved, networkd and homed on the workstations. Activating them fails
by design.
## Assignment
On every machine, as the first holder of `node-message-bus`. The module needs nothing set: no
settings, no secrets, no ports.
## What comes later
ADR 0215 §4: the seat receives nothing yet. Packages ship their own D-Bus policy and service files,
and no module writes one of its own. When one does, that file will be a contribution to this seat,
and this module will list the kind and reload the bus's policy for it, without a restart.
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"context"
"github.com/godbus/dbus/v5"
)
const (
busName = "org.freedesktop.DBus"
busPath = "/org/freedesktop/DBus"
)
// systemBus is the machine's system bus as the watcher uses it, over its own private connection: the
// bus driver's answers and its NameOwnerChanged signal, never another peer's messages.
type systemBus struct {
conn *dbus.Conn
out chan NameChange
}
// DialSystemBus connects to the system bus as this account and listens for names changing owner.
func DialSystemBus() (Bus, error) {
conn, err := dbus.SystemBusPrivate()
if err != nil {
return nil, err
}
if err := conn.Auth(nil); err != nil {
conn.Close()
return nil, err
}
if err := conn.Hello(); err != nil {
conn.Close()
return nil, err
}
if err := conn.AddMatchSignal(dbus.WithMatchSender(busName), dbus.WithMatchInterface(busName),
dbus.WithMatchMember("NameOwnerChanged")); err != nil {
conn.Close()
return nil, err
}
raw := make(chan *dbus.Signal, 256)
conn.Signal(raw)
b := &systemBus{conn: conn, out: make(chan NameChange, 256)}
go func() {
// The signal channel closes when the connection is lost; so does this one, which is how the
// watcher learns the bus went away.
defer close(b.out)
for {
select {
case s, open := <-raw:
if !open {
return
}
if s.Name != busName+".NameOwnerChanged" || len(s.Body) != 3 {
continue
}
name, _ := s.Body[0].(string)
old, _ := s.Body[1].(string)
nw, _ := s.Body[2].(string)
b.out <- NameChange{Name: name, Old: old, New: nw}
case <-conn.Context().Done():
return
}
}
}()
return b, nil
}
func (b *systemBus) driver() dbus.BusObject { return b.conn.Object(busName, busPath) }
// Ping asks the bus driver its id. Not org.freedesktop.DBus.Peer.Ping: dbus-broker's system policy
// refuses that to an account that is not root, and logs the refusal as a denial.
func (b *systemBus) Ping(ctx context.Context) error {
var id string
return b.driver().CallWithContext(ctx, busName+".GetId", 0).Store(&id)
}
func (b *systemBus) ID(ctx context.Context) (string, error) {
var id string
err := b.driver().CallWithContext(ctx, busName+".GetId", 0).Store(&id)
return id, err
}
func (b *systemBus) PID(ctx context.Context, name string) (uint32, error) {
var pid uint32
err := b.driver().CallWithContext(ctx, busName+".GetConnectionUnixProcessID", 0, name).Store(&pid)
return pid, err
}
func (b *systemBus) Names(ctx context.Context) ([]string, error) {
var names []string
err := b.driver().CallWithContext(ctx, busName+".ListNames", 0).Store(&names)
return names, err
}
func (b *systemBus) Activatable(ctx context.Context) ([]string, error) {
var names []string
err := b.driver().CallWithContext(ctx, busName+".ListActivatableNames", 0).Store(&names)
return names, err
}
func (b *systemBus) Changes() <-chan NameChange { return b.out }
func (b *systemBus) Close() { b.conn.Close() }
+290
View File
@@ -0,0 +1,290 @@
package main
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// Packages are the bus implementation's packages, as the manifest declares them: dbus-broker (the
// bus), its units (the dbus.service alias), and the reference package, which ships the bus's
// configuration, the socket that starts it at boot, and libdbus.
var Packages = []string{"dbus", "dbus-broker", "dbus-broker-units"}
// RunningPackages are the packages whose files the running bus loaded when it started: a newer one
// installed since takes effect only at the next boot (novox/hq ADR 0215 §2).
var RunningPackages = []string{"dbus-broker", "dbus"}
// SystemUnit is the bus's real unit; dbus.service is its alias.
const SystemUnit = "dbus-broker.service"
// ServiceDirs are where activatable system services are described.
var ServiceDirs = []string{"/usr/share/dbus-1/system-services", "/usr/local/share/dbus-1/system-services",
"/usr/lib/dbus-1/system-services"}
// Package is one installed package as the package manager's local database records it.
type Package struct {
Name string
Version string
Installed time.Time
}
// ParseDesc reads one package's desc file in the local database.
func ParseDesc(desc string) Package {
var p Package
lines := strings.Split(desc, "\n")
for i := 0; i+1 < len(lines); i++ {
v := strings.TrimSpace(lines[i+1])
switch strings.TrimSpace(lines[i]) {
case "%NAME%":
p.Name = v
case "%VERSION%":
p.Version = v
case "%INSTALLDATE%":
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
p.Installed = time.Unix(n, 0)
}
}
}
return p
}
// InstalledPackage reads a package from the local database, without running the package manager.
func (m *Machine) InstalledPackage(name string) (Package, bool) {
for _, d := range m.glob("/var/lib/pacman/local/" + name + "-*/desc") {
if p := ParseDesc(m.read(d) + "\n"); p.Name == name {
return p, true
}
}
return Package{}, false
}
// ParseShow reads `systemctl show` blocks: one map per unit, in the order asked.
func ParseShow(out string) []map[string]string {
var blocks []map[string]string
cur := map[string]string{}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
if len(cur) > 0 {
blocks = append(blocks, cur)
cur = map[string]string{}
}
continue
}
if k, v, ok := strings.Cut(line, "="); ok {
cur[k] = v
}
}
if len(cur) > 0 {
blocks = append(blocks, cur)
}
return blocks
}
// unixStamp reads systemd's "@<seconds>" timestamp.
func unixStamp(s string) (time.Time, bool) {
n, err := strconv.ParseInt(strings.TrimPrefix(s, "@"), 10, 64)
if err != nil || n == 0 {
return time.Time{}, false
}
return time.Unix(n, 0), true
}
// BusUnit is the system bus's unit as the service manager has it.
type BusUnit struct {
Unit string `json:"unit"`
Active string `json:"active"`
PID uint32 `json:"pid,omitempty"`
Since time.Time `json:"-"`
Started string `json:"started,omitempty"`
}
// SystemBusUnit asks the service manager about the system bus through its alias, so the answer is
// the implementation's unit whichever it is.
func (m *Machine) SystemBusUnit(ctx context.Context) (BusUnit, error) {
out, err := m.Run(ctx, "systemctl", "show", "dbus.service", "-p", "Id,ActiveState,MainPID,ActiveEnterTimestamp",
"--timestamp=unix")
if err != nil {
return BusUnit{}, err
}
b := ParseShow(out)
if len(b) == 0 {
return BusUnit{}, fmt.Errorf("systemctl show answered nothing for dbus.service")
}
u := BusUnit{Unit: b[0]["Id"], Active: b[0]["ActiveState"], PID: parsePID(b[0]["MainPID"])}
if t, ok := unixStamp(b[0]["ActiveEnterTimestamp"]); ok {
u.Since, u.Started = t, t.UTC().Format(time.RFC3339)
}
return u, nil
}
// ServiceFile is one activatable system service's description.
type ServiceFile struct {
File string
Name string
Unit string
}
// ParseServiceFile reads the Name and SystemdService of a D-Bus service file.
func ParseServiceFile(file, content string) ServiceFile {
s := ServiceFile{File: file}
for _, line := range strings.Split(content, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "Name":
s.Name = strings.TrimSpace(v)
case "SystemdService":
s.Unit = strings.TrimSpace(v)
}
}
return s
}
// Check is one thing the module expects of the machine.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// RebootDue says, per package the running bus loaded, whether a newer one was installed after the bus
// started: the bus is never restarted live, so that package waits for a boot.
func RebootDue(started time.Time, pkgs []Package) (bool, string) {
var newer []string
for _, p := range pkgs {
if !p.Installed.IsZero() && p.Installed.After(started) {
newer = append(newer, fmt.Sprintf("%s %s installed %s", p.Name, p.Version, p.Installed.UTC().Format(time.RFC3339)))
}
}
if len(newer) == 0 {
return false, "the running bus started " + started.UTC().Format(time.RFC3339) + ", after every package it loaded was installed"
}
return true, "the running bus started " + started.UTC().Format(time.RFC3339) + " and is older than " +
strings.Join(newer, ", ") + ": a reboot is due (the bus is never restarted live, novox/hq ADR 0215)"
}
// Check says what this module expects and whether the machine meets it.
func (m *Machine) Check(ctx context.Context, w *Watcher) map[string]any {
var checks []Check
var notes []string
add := func(name string, ok bool, format string, args ...any) {
checks = append(checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, args...)})
}
var running []Package
for _, name := range Packages {
p, ok := m.InstalledPackage(name)
add("package "+name, ok, "installed: %v %s", ok, p.Version)
for _, r := range RunningPackages {
if ok && r == name {
running = append(running, p)
}
}
}
unit, err := m.SystemBusUnit(ctx)
if err != nil {
add("system bus", false, "%v", err)
} else {
add("system bus", unit.Unit == SystemUnit && unit.Active == "active",
"dbus.service is %s, %s, pid %d, since %s (want %s active)", orWord(unit.Unit, "unknown"),
orWord(unit.Active, "unknown"), unit.PID, orWord(unit.Started, "unknown"), SystemUnit)
if !unit.Since.IsZero() {
due, detail := RebootDue(unit.Since, running)
add("running bus is the installed one", !due, "%s", detail)
}
}
var files []ServiceFile
for _, dir := range ServiceDirs {
for _, f := range m.glob(dir + "/*.service") {
if s := ParseServiceFile(f, m.read(f)); s.Unit != "" {
files = append(files, s)
}
}
}
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
if len(files) > 0 {
args := []string{"show", "-p", "Id,LoadState"}
for _, f := range files {
args = append(args, f.Unit)
}
out, err := m.Run(ctx, "systemctl", args...)
blocks := ParseShow(out)
switch {
case err != nil:
add("activatable services", false, "systemctl show: %v", err)
case len(blocks) != len(files):
add("activatable services", false, "systemctl show answered %d units for %d service files", len(blocks), len(files))
default:
var broken, disabled []string
for i, f := range files {
if blocks[i]["LoadState"] != "not-found" {
continue
}
// systemd's own bus services are reached through a dbus-org.* alias that exists only
// while the service is enabled: a disabled one is a choice, not a fault.
if strings.HasPrefix(f.Unit, "dbus-org.") {
disabled = append(disabled, f.Name+" → "+f.Unit)
} else {
broken = append(broken, f.Name+" → "+f.Unit+" ("+f.File+")")
}
}
add("activatable services", len(broken) == 0, "%d service files; whose unit does not exist: %s",
len(files), orWord(strings.Join(broken, ", "), "none"))
if len(disabled) > 0 {
notes = append(notes, "activation fails for "+strings.Join(disabled, ", ")+
": the service is not enabled, so its dbus-org alias does not exist")
}
}
}
denials, _, err := m.Denials(ctx, "", m.Now().Add(-time.Hour))
if err != nil {
add("policy denials in the last hour", false, "reading the journal: %v", err)
} else {
seen := map[string]bool{}
var ex []string
for _, d := range denials {
k := strings.TrimSpace(d.Type + " " + d.Interface + "." + d.Member + " to " + d.Destination)
if !seen[k] && len(ex) < DenialExamples {
seen[k] = true
ex = append(ex, k)
}
}
add("policy denials in the last hour", len(denials) == 0, "%d: %s", len(denials), orWord(strings.Join(ex, "; "), "none"))
}
if a, err := m.SessionAddress(); err == nil {
notes = append(notes, "this account's session bus: "+a)
} else {
notes = append(notes, err.Error())
}
if w != nil {
s := w.Snapshot()
add("watcher", s.Connected && !s.Stalled, "connected %v, stalled %v%s, last ping %.1f ms, %d well-known names",
s.Connected, s.Stalled, orNote(s.StallReason), s.LastPingMS, s.Services)
add("events reach the mesh's bus", s.Pending == 0 && s.Problem == "", "%d event(s) waiting, %d dropped%s",
s.Pending, s.Dropped, orNote(s.Problem))
}
failing := 0
for _, c := range checks {
if !c.OK {
failing++
}
}
return map[string]any{"checks": checks, "failing": failing, "notes": notes}
}
func orNote(s string) string {
if s == "" {
return ""
}
return " (" + s + ")"
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"encoding/json"
"strings"
"time"
)
// CountConnections reads the bus driver's Debug.Stats answer (`busctl call … GetStats --json=short`):
// dbus-broker lists one accounting entry per peer, the reference daemon says ActiveConnections.
func CountConnections(out string) (int, bool) {
var reply struct {
Data []map[string]struct {
Data json.RawMessage `json:"data"`
} `json:"data"`
}
if json.Unmarshal([]byte(strings.TrimSpace(out)), &reply) != nil || len(reply.Data) != 1 {
return 0, false
}
if v, ok := reply.Data[0]["org.bus1.DBus.Debug.Stats.PeerAccounting"]; ok {
var peers []json.RawMessage
if json.Unmarshal(v.Data, &peers) == nil {
return len(peers), true
}
}
if v, ok := reply.Data[0]["ActiveConnections"]; ok {
var n int
if json.Unmarshal(v.Data, &n) == nil {
return n, true
}
}
return 0, false
}
// CountUniqueNames counts the connections among ListNames' answer (`busctl call … ListNames`).
func CountUniqueNames(out string) (int, bool) {
var reply struct {
Data [][]string `json:"data"`
}
if json.Unmarshal([]byte(strings.TrimSpace(out)), &reply) != nil || len(reply.Data) != 1 {
return 0, false
}
n := 0
for _, name := range reply.Data[0] {
if strings.HasPrefix(name, ":") {
n++
}
}
return n, true
}
// Health is the system bus's health now: a ping through the watcher's connection, how many
// connections the bus has, and how long it has run.
func (m *Machine) Health(ctx context.Context, w *Watcher) map[string]any {
h := map[string]any{"bus": "system"}
if w != nil {
took, err := w.PingNow()
if err != nil {
h["ping"] = "no answer: " + err.Error()
} else {
h["ping_ms"] = float64(took.Microseconds()) / 1000
}
h["watcher"] = w.Snapshot()
}
// Debug.Stats is root's on the system bus; asked as the account it is refused and logged as a
// denial, which the watcher would then publish. So it is asked through sudo -n or not at all.
call := []string{"busctl", "--system", "--json=short", "--no-pager", "call", busName, busPath}
if out, err := m.privileged(ctx, call[0], append(call[1:], busName+".Debug.Stats", "GetStats")...); err == nil {
if n, ok := CountConnections(out); ok {
h["connections"], h["connections_from"] = n, "the bus's Debug.Stats"
}
}
if _, ok := h["connections"]; !ok {
if out, err := m.Run(ctx, call[0], append(call[1:], busName, "ListNames")...); err == nil {
if n, ok := CountUniqueNames(out); ok {
h["connections"], h["connections_from"] = n, "the unique names on the bus (Debug.Stats needs sudo -n)"
}
}
}
if u, err := m.SystemBusUnit(ctx); err != nil {
h["unit"] = err.Error()
} else {
h["unit"], h["active"], h["pid"] = u.Unit, u.Active, u.PID
if !u.Since.IsZero() {
h["started"] = u.Started
h["uptime"] = m.Now().Sub(u.Since).Round(time.Second).String()
}
}
return h
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"regexp"
"strings"
"github.com/godbus/dbus/v5/introspect"
)
// A bus name and an object path as the specification allows them; anything else is refused before
// it reaches busctl, so no argument is ever taken for an option.
var (
busNameRE = regexp.MustCompile(`^(:[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+|[A-Za-z_-][A-Za-z0-9_-]*(\.[A-Za-z_-][A-Za-z0-9_-]*)+)$`)
objectPathRE = regexp.MustCompile(`^/([A-Za-z0-9_]+(/[A-Za-z0-9_]+)*)?$`)
)
// Member is one method or signal, with its arguments as "name type".
type Member struct {
Name string `json:"name"`
In []string `json:"in,omitempty"`
Out []string `json:"out,omitempty"`
Args []string `json:"args,omitempty"`
}
// Property is one property's name, type and access, never its value: a value can be anything a
// service holds, and reading it is a call of its own.
type Property struct {
Name string `json:"name"`
Type string `json:"type"`
Access string `json:"access"`
}
// Interface is one interface of an object.
type Interface struct {
Name string `json:"name"`
Methods []Member `json:"methods,omitempty"`
Properties []Property `json:"properties,omitempty"`
Signals []Member `json:"signals,omitempty"`
}
// Object is dbus_introspect's answer.
type Object struct {
Bus string `json:"bus"`
Service string `json:"service"`
Path string `json:"path"`
Interfaces []Interface `json:"interfaces"`
Children []string `json:"children,omitempty"`
}
// ParseIntrospection reads `busctl call … Introspect --json=short` ({"type":"s","data":["<xml>"]}).
func ParseIntrospection(out string) (introspect.Node, error) {
var reply struct {
Data []string `json:"data"`
}
var node introspect.Node
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &reply); err != nil || len(reply.Data) != 1 {
return node, fmt.Errorf("the introspection answer is not a single string")
}
if err := xml.Unmarshal([]byte(reply.Data[0]), &node); err != nil {
return node, fmt.Errorf("the introspection document does not parse: %w", err)
}
return node, nil
}
// Shape turns an introspection document into the tool's answer.
func Shape(bus, service, path string, node introspect.Node) Object {
o := Object{Bus: bus, Service: service, Path: path, Interfaces: []Interface{}}
for _, i := range node.Interfaces {
iface := Interface{Name: i.Name}
for _, m := range i.Methods {
mem := Member{Name: m.Name}
for _, a := range m.Args {
s := strings.TrimSpace(a.Name + " " + a.Type)
if a.Direction == "out" {
mem.Out = append(mem.Out, s)
} else {
mem.In = append(mem.In, s)
}
}
iface.Methods = append(iface.Methods, mem)
}
for _, p := range i.Properties {
iface.Properties = append(iface.Properties, Property{Name: p.Name, Type: p.Type, Access: p.Access})
}
for _, s := range i.Signals {
mem := Member{Name: s.Name}
for _, a := range s.Args {
mem.Args = append(mem.Args, strings.TrimSpace(a.Name+" "+a.Type))
}
iface.Signals = append(iface.Signals, mem)
}
o.Interfaces = append(o.Interfaces, iface)
}
for _, c := range node.Children {
if len(o.Children) >= AnswerCap {
break
}
o.Children = append(o.Children, c.Name)
}
return o
}
// Introspect asks one object what it offers, as this account, without starting a service that is not
// running (--auto-start=no): looking must not change what runs.
func (m *Machine) Introspect(ctx context.Context, bus, service, path string) (Object, error) {
if !busNameRE.MatchString(service) {
return Object{}, fmt.Errorf("%q is not a bus name", service)
}
if path == "" {
path = "/"
}
if !objectPathRE.MatchString(path) {
return Object{}, fmt.Errorf("%q is not an object path", path)
}
args, err := m.busArgs(bus)
if err != nil {
return Object{}, err
}
out, err := m.Run(ctx, "busctl", append(args, "--json=short", "--no-pager", "--auto-start=no", "call",
service, path, "org.freedesktop.DBus.Introspectable", "Introspect")...)
if err != nil {
return Object{}, err
}
node, err := ParseIntrospection(out)
if err != nil {
return Object{}, err
}
return Shape(orWord(bus, "system"), service, path, node), nil
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
)
// BusUnits are the system bus's units as the journal knows them: dbus-broker's own name, and the
// alias every implementation answers to.
var BusUnits = []string{"dbus-broker.service", "dbus.service"}
// JournalLines is the most journal entries one read takes.
const JournalLines = 2000
// Denial is one policy denial as the bus logged it: the header of the message it refused, never its
// body, which the bus does not log either.
type Denial struct {
At string `json:"at"`
Action string `json:"action,omitempty"`
Type string `json:"type,omitempty"`
Sender string `json:"sender,omitempty"`
Destination string `json:"destination,omitempty"`
Path string `json:"path,omitempty"`
Interface string `json:"interface,omitempty"`
Member string `json:"member,omitempty"`
Policy string `json:"policy,omitempty"`
}
// key is what makes two denials the same example: who was refused what, ignoring the sender's unique
// name, which differs at every connection.
func (d Denial) key() string {
return d.Action + "|" + d.Type + "|" + d.Destination + "|" + d.Interface + "|" + d.Member
}
// journalEntry is the part of a journal entry the module reads. MESSAGE is read only to recognise a
// denial; it is never answered or published.
type journalEntry struct {
Cursor string `json:"__CURSOR"`
Realtime string `json:"__REALTIME_TIMESTAMP"`
Message any `json:"MESSAGE"`
Action string `json:"DBUS_BROKER_TRANSMIT_ACTION"`
Type string `json:"DBUS_BROKER_MESSAGE_TYPE"`
Sender string `json:"DBUS_BROKER_SENDER_UNIQUE_NAME"`
Destination string `json:"DBUS_BROKER_MESSAGE_DESTINATION"`
Path string `json:"DBUS_BROKER_MESSAGE_PATH"`
Interface string `json:"DBUS_BROKER_MESSAGE_INTERFACE"`
Member string `json:"DBUS_BROKER_MESSAGE_MEMBER"`
Policy string `json:"DBUS_BROKER_POLICY_TYPE"`
}
// IsDenial is whether a bus's log line is a policy denial: dbus-broker's "A security policy denied",
// or the reference daemon's "Rejected send message".
func IsDenial(message string) bool {
return strings.Contains(message, "security policy denied") || strings.Contains(message, "Rejected send message") ||
strings.Contains(message, "Rejected receive message")
}
// ParseDenials reads journalctl's JSON lines and answers the denials among them and the last cursor.
func ParseDenials(out string) ([]Denial, string) {
var got []Denial
cursor := ""
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || line[0] != '{' {
continue
}
var e journalEntry
if json.Unmarshal([]byte(line), &e) != nil {
continue
}
if e.Cursor != "" {
cursor = e.Cursor
}
msg, _ := e.Message.(string) // a binary MESSAGE comes as an array of bytes and is no denial
if !IsDenial(msg) {
continue
}
at := ""
if us, err := strconv.ParseInt(e.Realtime, 10, 64); err == nil {
at = time.UnixMicro(us).UTC().Format(time.RFC3339)
}
got = append(got, Denial{At: at, Action: e.Action, Type: e.Type, Sender: e.Sender,
Destination: e.Destination, Path: e.Path, Interface: e.Interface, Member: e.Member, Policy: e.Policy})
}
return got, cursor
}
func journalArgs() []string {
args := []string{"--no-pager", "-o", "json", "-n", strconv.Itoa(JournalLines)}
for _, u := range BusUnits {
args = append(args, "-u", u)
}
return args
}
// Denials is the watcher's Journal on this machine: journalctl as the operator's account, which
// reads the system journal through its group.
func (m *Machine) Denials(ctx context.Context, after string, since time.Time) ([]Denial, string, error) {
args := journalArgs()
if after != "" {
args = append(args, "--after-cursor", after)
} else {
args = append(args, "--since", "@"+strconv.FormatInt(since.Unix(), 10))
}
out, err := m.Run(ctx, "journalctl", args...)
if err != nil {
return nil, "", err
}
d, cursor := ParseDenials(out)
return d, cursor, nil
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// Run on a real machine with MESH_LIVE=1: every tool reads this machine's buses, and the watcher's
// connection answers. Nothing is changed.
func TestLiveTools(t *testing.T) {
if os.Getenv("MESH_LIVE") == "" {
t.Skip("set MESH_LIVE=1 on a machine with a system bus")
}
ctx := context.Background()
m := Here()
b, err := DialSystemBus()
if err != nil {
t.Fatal(err)
}
defer b.Close()
if err := b.Ping(ctx); err != nil {
t.Fatal(err)
}
id, _ := b.ID(ctx)
pid, _ := b.PID(ctx, busName)
t.Logf("bus %s, driver pid %d in %s", id, pid, m.UnitOf(pid))
show := func(name string, v any, err error) {
raw, _ := json.Marshal(v)
if len(raw) > 1500 {
raw = append(raw[:1500], "…"...)
}
t.Logf("%s: %v\n%s", name, err, raw)
}
n, err := m.Names(ctx, "system")
show("names system", n, err)
n, err = m.Names(ctx, "session")
show("names session", n, err)
o, err := m.Introspect(ctx, "system", "org.freedesktop.login1", "/org/freedesktop/login1")
show("introspect", o, err)
w, err := m.Monitor(ctx, "system", 2, "", nil)
show("monitor", w, err)
show("health", m.Health(ctx, nil), nil)
show("check", m.Check(ctx, nil), nil)
}
// Run with MESH_LIVE=1: the watcher connects, pings and baselines the names, and says nothing.
func TestLiveWatcher(t *testing.T) {
if os.Getenv("MESH_LIVE") == "" {
t.Skip("set MESH_LIVE=1 on a machine with a system bus")
}
m := Here()
var said []string
w := NewWatcher(m, func(e string, b any) error { said = append(said, e); return nil }, DialSystemBus, m.Denials)
w.state = filepath.Join(t.TempDir(), "bus")
ctx, cancel := context.WithTimeout(context.Background(), PingEvery+2*time.Second)
defer cancel()
w.Run(ctx)
s := w.Snapshot()
t.Logf("%+v said %v", s, said)
if s.BusID == "" || s.LastPingAt == "" || s.Stalled || s.Services == 0 {
t.Fatalf("%+v", s)
}
}
+247
View File
@@ -0,0 +1,247 @@
package main
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// CommandTimeout bounds every command a tool runs: a bus that hangs must cost a tool call twenty
// seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// ReadCap is the most of one command's output the module reads. An introspection document of the
// service manager is about 100 KiB; nothing the module asks is near a mebibyte.
const ReadCap = 1024 * 1024
// AnswerCap is the most entries a tool answers in one list (names, messages, denials).
const AnswerCap = 500
// Runner runs one command and answers its standard output. Injected, so every tool is tested against
// recorded answers rather than this machine's bus.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Streamer runs one command for at most the context's time and hands each line of its output to
// line, which says whether it wants more. The end of the time is the normal end, not an error.
// Injected, so dbus_monitor is tested without a bus.
type Streamer func(ctx context.Context, line func(string) bool, name string, args ...string) error
// ExecRunner runs a command, bounded by CommandTimeout. A failure carries what it said on stderr.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
out := capped(stdout.String(), ReadCap)
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, capped(said, 2048))
}
return out, nil
}
// ExecStreamer runs a command until the context ends, line by line. A line longer than ReadCap is
// skipped, never held: a monitored message's line carries its body, which the module never keeps.
func ExecStreamer(ctx context.Context, line func(string) bool, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
// A terminate, which sudo passes on to what it runs; a kill would leave a root monitor behind.
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 2 * time.Second
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
r := bufio.NewReaderSize(out, 64*1024)
var cur []byte
tooLong := false
for {
chunk, isPrefix, err := r.ReadLine()
if err != nil {
break
}
if !tooLong {
cur = append(cur, chunk...)
if len(cur) > ReadCap {
cur, tooLong = cur[:0], true
}
}
if isPrefix {
continue
}
if !tooLong && !line(string(cur)) {
break
}
cur, tooLong = cur[:0], false
}
_ = cmd.Process.Signal(syscall.SIGTERM)
werr := cmd.Wait()
if ctx.Err() != nil {
return nil // the time ran out: the normal end of a bounded watch
}
if werr != nil && stderr.Len() > 0 {
return fmt.Errorf("%s: %w: %s", name, werr, capped(strings.TrimSpace(stderr.String()), 2048))
}
return nil
}
func capped(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "\n… (cut)"
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree),
// a way to run commands, a way to stream one, and the environment the runtime gave it.
type Machine struct {
Root string
Run Runner
Stream Streamer
Env func(string) string
UID int
Now func() time.Time
}
// Here is the machine this process runs on.
func Here() *Machine {
return &Machine{Root: "/", Run: ExecRunner, Stream: ExecStreamer, Env: os.Getenv, UID: os.Getuid(), Now: time.Now}
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (m *Machine) exists(p string) bool {
_, err := os.Stat(m.path(p))
return err == nil
}
func (m *Machine) glob(pattern string) []string {
got, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(got))
for _, g := range got {
rel, err := filepath.Rel(m.Root, g)
if err != nil {
continue
}
out = append(out, "/"+rel)
}
return out
}
// privileged runs a command as root without asking for a password (sudo -n), as the other modules'
// tools do: the runtime runs as the operator's account, and watching the system bus is root's.
func (m *Machine) privileged(ctx context.Context, name string, args ...string) (string, error) {
return m.Run(ctx, "sudo", append([]string{"-n", name}, args...)...)
}
// Buses are the two buses a tool can be pointed at.
var Buses = []string{"system", "session"}
// ErrNoSession is the answer on a machine where this account has no session bus: the servers.
var ErrNoSession = errors.New("no session bus for this account on this machine")
// SessionAddress is the account's session bus: the runtime's DBUS_SESSION_BUS_ADDRESS, else the
// user manager's socket under XDG_RUNTIME_DIR or /run/user/<uid>. Only a socket that exists counts.
func (m *Machine) SessionAddress() (string, error) {
if a := m.Env("DBUS_SESSION_BUS_ADDRESS"); strings.HasPrefix(a, "unix:path=") {
p := strings.TrimPrefix(a, "unix:path=")
if i := strings.IndexByte(p, ','); i >= 0 {
p = p[:i]
}
if m.exists(p) {
return a, nil
}
} else if a != "" {
return a, nil // an abstract or other address: taken as given
}
dir := m.Env("XDG_RUNTIME_DIR")
if dir == "" {
dir = "/run/user/" + strconv.Itoa(m.UID)
}
if p := dir + "/bus"; m.exists(p) {
return "unix:path=" + p, nil
}
return "", fmt.Errorf("%w (no socket at %s/bus)", ErrNoSession, dir)
}
// busArgs are busctl's words for one bus.
func (m *Machine) busArgs(bus string) ([]string, error) {
switch bus {
case "", "system":
return []string{"--system"}, nil
case "session":
a, err := m.SessionAddress()
if err != nil {
return nil, err
}
return []string{"--address=" + a}, nil
}
return nil, fmt.Errorf("bus is system or session, not %q", bus)
}
// UnitOf is the systemd unit a process runs in, from its cgroup: readable for any process.
func (m *Machine) UnitOf(pid uint32) string {
if pid == 0 {
return ""
}
return UnitFromCgroup(m.read(fmt.Sprintf("/proc/%d/cgroup", pid)))
}
// ProcessName is a process's command name.
func (m *Machine) ProcessName(pid uint32) string {
if pid == 0 {
return ""
}
return m.read(fmt.Sprintf("/proc/%d/comm", pid))
}
// UnitFromCgroup is the innermost service, socket or scope in a cgroup v2 path.
func UnitFromCgroup(cgroup string) string {
for _, line := range strings.Split(cgroup, "\n") {
if !strings.HasPrefix(line, "0::") {
continue
}
parts := strings.Split(strings.TrimPrefix(line, "0::"), "/")
for i := len(parts) - 1; i >= 0; i-- {
p := parts[i]
if strings.HasSuffix(p, ".service") || strings.HasSuffix(p, ".scope") {
return p
}
}
}
return ""
}
// BootID is this boot's id: a bus that changed across a boot did not restart, the machine did.
func (m *Machine) BootID() string { return m.read("/proc/sys/kernel/random/boot_id") }
+26
View File
@@ -0,0 +1,26 @@
// The dbus module's Go bundle (novox/hq ADR 0215): the holder of node-message-bus. One process the
// node's runtime launches as the operator's account, serving the module's tools over MCP on stdio and
// running its watcher beside them, which publishes what matters about the system bus, never its
// traffic.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
m := Here()
w := NewWatcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) },
DialSystemBus, m.Denials)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
if err := stdio.Serve("", Tools(m, w)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,159 @@
package main
// The dbus module's shape (novox/hq ADR 0215): it holds node-message-bus, owns the bus's packages,
// declares the bus running and never restarts or reloads it, and publishes only its curated events.
import (
"encoding/json"
"os"
"reflect"
"regexp"
"sort"
"strings"
"testing"
)
type manifestShape struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Claims []map[string]any `json:"claims"`
Emits []string `json:"emits"`
Consumes []string `json:"consumes"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func manifest(t *testing.T) (manifestShape, string) {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifestShape
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m, string(raw)
}
func TestItHoldsTheMessageBusSeatWithOnlyWhatItNeeds(t *testing.T) {
m, _ := manifest(t)
if m.Module != "dbus" || len(m.Claims) != 1 || m.Claims[0]["name"] != "node-message-bus" || m.Claims[0]["scope"] != "node" {
t.Fatalf("claims: %+v", m.Claims)
}
if _, serves := m.Claims[0]["serves"]; serves {
t.Error("the seat receives nothing yet (ADR 0215 §4) and serves no verbs")
}
if !reflect.DeepEqual(m.Capabilities, []string{"package-manager", "service-manager"}) {
t.Fatalf("capabilities: %v", m.Capabilities)
}
}
func TestItOwnsTheBusImplementationsPackages(t *testing.T) {
m, _ := manifest(t)
var pkgs []string
for _, r := range m.Resources {
if r["type"] == "package" {
pkgs = append(pkgs, r["package"].(string))
if r["absent"] == true {
t.Errorf("%v is declared absent", r["package"])
}
}
}
sort.Strings(pkgs)
if !reflect.DeepEqual(pkgs, Packages) {
t.Fatalf("packages %v, the check reads %v", pkgs, Packages)
}
}
// TestTheBusIsNeverRestartedOrReloaded is ADR 0215 §2: the bus is declared running, with no trigger
// that would restart or reload it, and no boot state: dbus.service is an alias and dbus-broker.service
// is started at boot through dbus.socket, so enabling would write an alias link the machines do not
// have today.
func TestTheBusIsNeverRestartedOrReloaded(t *testing.T) {
m, raw := manifest(t)
if strings.Contains(raw, "restart-on") || strings.Contains(raw, "reload-on") {
t.Fatal("the manifest carries a restart or reload trigger")
}
var services []map[string]any
for _, r := range m.Resources {
if r["type"] == "service" {
services = append(services, r)
}
}
if len(services) != 1 {
t.Fatalf("services: %v", services)
}
s := services[0]
if s["unit"] != SystemUnit || s["state"] != "running" {
t.Fatalf("%v", s)
}
if _, ok := s["boot"]; ok {
t.Fatal("a boot state on the bus would have the host enable it")
}
for k := range s {
if k != "id" && k != "type" && k != "unit" && k != "state" {
t.Errorf("the bus's service carries %q", k)
}
}
}
func TestItEmitsTheCuratedEventsAndConsumesNothing(t *testing.T) {
m, _ := manifest(t)
if !reflect.DeepEqual(m.Emits, Emits) {
t.Fatalf("emits %v, the watcher publishes %v", m.Emits, Emits)
}
if len(m.Consumes) != 0 {
t.Fatalf("consumes %v", m.Consumes)
}
}
func TestToolsAreTheManifests(t *testing.T) {
m, _ := manifest(t)
served := map[string]bool{}
for _, tool := range Tools(testMachine(t, nil), nil) {
if served[tool.Name] {
t.Errorf("%s is served twice", tool.Name)
}
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "dbus_") {
t.Errorf("%s is not named dbus_<verb>", tool.Name)
}
}
for _, want := range m.Tools {
if !served[want] {
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
}
delete(served, want)
}
for extra := range served {
t.Errorf("the bundle serves %s, which the manifest does not list", extra)
}
}
func TestTheBundleIsTheBuildersShape(t *testing.T) {
m, _ := manifest(t)
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
a := m.Build.Artifacts[0]
for k, v := range map[string]string{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/dbus-tools", "binary": "dbus-tools"} {
if a[k] != v {
t.Errorf("%s is %v, want %s", k, a[k], v)
}
}
}
// TestNothingInstallationSpecific holds the manifest to the catalogue's rule: no node names, domains
// or home paths.
func TestNothingInstallationSpecific(t *testing.T) {
_, raw := manifest(t)
for _, re := range []*regexp.Regexp{regexp.MustCompile(`/home/`), regexp.MustCompile(`\.(be|internal|com)\b`)} {
if loc := re.FindString(raw); loc != "" {
t.Errorf("the manifest carries %q", loc)
}
}
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// MaxMonitorSeconds bounds a watch of the bus: long enough to catch an exchange, short enough that
// nobody leaves a monitor running.
const MaxMonitorSeconds = 15
// MonitorLimit is the most messages busctl reads in one watch before it stops by itself.
const MonitorLimit = 20000
// Header is what dbus_monitor answers of one message: who sent what to whom. The body is never read
// into it: the struct has no field for it, so a payload is dropped as each line is decoded.
type Header struct {
Time string `json:"time,omitempty"`
Type string `json:"type"`
Sender string `json:"sender,omitempty"`
Destination string `json:"destination,omitempty"`
Path string `json:"path,omitempty"`
Interface string `json:"interface,omitempty"`
Member string `json:"member,omitempty"`
ErrorName string `json:"error_name,omitempty"`
}
type monitorLine struct {
Type string `json:"type"`
Sender string `json:"sender"`
Destination string `json:"destination"`
Path string `json:"path"`
Interface string `json:"interface"`
Member string `json:"member"`
ErrorName string `json:"error_name"`
Realtime int64 `json:"timestamp-realtime"`
}
// ParseHeader reads one line of `busctl monitor --json=short` into its header alone.
func ParseHeader(line string) (Header, bool) {
var l monitorLine
if json.Unmarshal([]byte(line), &l) != nil || l.Type == "" {
return Header{}, false
}
h := Header{Type: l.Type, Sender: l.Sender, Destination: l.Destination, Path: l.Path,
Interface: l.Interface, Member: l.Member, ErrorName: l.ErrorName}
if l.Realtime > 0 {
h.Time = time.UnixMicro(l.Realtime).UTC().Format("15:04:05.000000")
}
return h, true
}
// Watch is dbus_monitor's answer.
type Watch struct {
Bus string `json:"bus"`
Seconds int `json:"seconds"`
Match string `json:"match,omitempty"`
Names []string `json:"names,omitempty"`
Total int `json:"total"`
Messages []Header `json:"messages"`
Cut bool `json:"cut,omitempty"`
Note string `json:"note"`
}
// Monitor watches one bus for a few seconds and answers the headers of what passed. The system bus is
// watched as root (sudo -n): only root may become a monitor there. The session bus is the account's
// own. The bodies, which carry secrets, notification text and the clipboard, are never kept.
func (m *Machine) Monitor(ctx context.Context, bus string, seconds int, match string, names []string) (Watch, error) {
if seconds < 1 || seconds > MaxMonitorSeconds {
return Watch{}, fmt.Errorf("seconds is 1 to %d", MaxMonitorSeconds)
}
if len(match) > 512 || strings.ContainsAny(match, "\n\x00") {
return Watch{}, fmt.Errorf("the match rule is not one line of at most 512 characters")
}
for _, n := range names {
if !busNameRE.MatchString(n) {
return Watch{}, fmt.Errorf("%q is not a bus name", n)
}
}
args, err := m.busArgs(bus)
if err != nil {
return Watch{}, err
}
cmd := append([]string{"timeout", strconv.Itoa(seconds) + "s", "busctl"}, args...)
cmd = append(cmd, "--json=short", "--no-pager", "--limit-messages="+strconv.Itoa(MonitorLimit), "monitor")
if match != "" {
cmd = append(cmd, "--match="+match)
}
cmd = append(cmd, names...)
if orWord(bus, "system") == "system" {
cmd = append([]string{"sudo", "-n"}, cmd...)
}
w := Watch{Bus: orWord(bus, "system"), Seconds: seconds, Match: match, Names: names, Messages: []Header{},
Note: "headers only; message bodies are never read into the answer"}
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second+5*time.Second)
defer cancel()
err = m.Stream(ctx, func(line string) bool {
h, ok := ParseHeader(line)
if !ok {
return true
}
w.Total++
if len(w.Messages) < AnswerCap {
w.Messages = append(w.Messages, h)
} else {
w.Cut = true
}
return true
}, cmd[0], cmd[1:]...)
return w, err
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
)
// busctlName is one line of `busctl list --json`.
type busctlName struct {
Name string `json:"name"`
PID *uint32 `json:"pid"`
Process *string `json:"process"`
User *string `json:"user"`
Connection string `json:"connection"`
Unit *string `json:"unit"`
}
// Name is one well-known name on a bus, with what holds it.
type Name struct {
Name string `json:"name"`
PID uint32 `json:"pid,omitempty"`
Process string `json:"process,omitempty"`
User string `json:"user,omitempty"`
Unit string `json:"unit,omitempty"`
Connection string `json:"connection,omitempty"`
}
// Names is dbus_names's answer.
type Names struct {
Bus string `json:"bus"`
Running []Name `json:"running"`
ActivatableNotRunning []string `json:"activatable_not_running"`
Connections int `json:"connections"`
Cut bool `json:"cut,omitempty"`
}
// ParseNames reads `busctl list --json=short`: the well-known names running, the activatable names
// that are not, and how many connections (unique names) the bus has.
func ParseNames(bus, out string) (Names, error) {
var raw []busctlName
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &raw); err != nil {
return Names{}, fmt.Errorf("busctl list answered what is not its JSON: %w", err)
}
n := Names{Bus: bus, Running: []Name{}, ActivatableNotRunning: []string{}}
for _, r := range raw {
switch {
case r.Name == busName: // the bus itself, which busctl attributes to the service manager
case strings.HasPrefix(r.Name, ":"):
n.Connections++
case r.Connection == "(activatable)" || r.PID == nil:
n.ActivatableNotRunning = append(n.ActivatableNotRunning, r.Name)
default:
n.Running = append(n.Running, Name{Name: r.Name, PID: deref(r.PID), Process: derefS(r.Process),
User: derefS(r.User), Unit: derefS(r.Unit), Connection: r.Connection})
}
}
sort.Slice(n.Running, func(i, j int) bool { return n.Running[i].Name < n.Running[j].Name })
sort.Strings(n.ActivatableNotRunning)
if len(n.Running) > AnswerCap {
n.Running, n.Cut = n.Running[:AnswerCap], true
}
if len(n.ActivatableNotRunning) > AnswerCap {
n.ActivatableNotRunning, n.Cut = n.ActivatableNotRunning[:AnswerCap], true
}
return n, nil
}
// Names lists one bus's names as this account sees them.
func (m *Machine) Names(ctx context.Context, bus string) (Names, error) {
args, err := m.busArgs(bus)
if err != nil {
return Names{}, err
}
out, err := m.Run(ctx, "busctl", append(args, "--json=short", "--no-pager", "list")...)
if err != nil {
return Names{}, err
}
return ParseNames(orWord(bus, "system"), out)
}
func deref(p *uint32) uint32 {
if p == nil {
return 0
}
return *p
}
func derefS(p *string) string {
if p == nil {
return ""
}
return *p
}
func orWord(s, word string) string {
if strings.TrimSpace(s) == "" {
return word
}
return s
}
+304
View File
@@ -0,0 +1,304 @@
package main
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
)
// Recorded answers, trimmed, from a workstation of 2026-10-05.
const busctlList = `[{"name":":1.0","pid":456,"process":"systemd-timesyn","user":"systemd-timesync","connection":":1.0","unit":"systemd-timesyncd.service","session":null,"description":null},` +
`{"name":"fi.w1.wpa_supplicant1","pid":1442,"process":"wpa_supplicant","user":"root","connection":":1.23","unit":"wpa_supplicant.service","session":null,"description":null},` +
`{"name":"org.blueman.Mechanism","pid":null,"process":null,"user":null,"connection":"(activatable)","unit":null,"session":null,"description":null},` +
`{"name":":1.11","pid":975,"process":"polkitd","user":"polkitd","connection":":1.11","unit":"polkit.service","session":null,"description":null},` +
`{"name":"org.freedesktop.DBus","pid":807,"process":"dbus-broker-lau","user":"root","connection":"org.freedesktop.DBus","unit":"dbus-broker.service","session":null,"description":null}]`
func TestNamesAreSplitIntoRunningActivatableAndConnections(t *testing.T) {
n, err := ParseNames("system", busctlList)
if err != nil {
t.Fatal(err)
}
if n.Connections != 2 || len(n.Running) != 1 || n.Running[0].Name != "fi.w1.wpa_supplicant1" ||
n.Running[0].Unit != "wpa_supplicant.service" || n.Running[0].PID != 1442 {
t.Fatalf("%+v", n)
}
if !reflect.DeepEqual(n.ActivatableNotRunning, []string{"org.blueman.Mechanism"}) {
t.Fatalf("%v", n.ActivatableNotRunning)
}
}
const introspection = `{"type":"s","data":["<!DOCTYPE node PUBLIC \"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN\" \"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd\">\n<node>\n <interface name=\"org.freedesktop.login1.Manager\">\n <property name=\"IdleHint\" type=\"b\" access=\"read\"></property>\n <method name=\"Inhibit\">\n <arg type=\"s\" name=\"what\" direction=\"in\"/>\n <arg type=\"h\" name=\"pipe_fd\" direction=\"out\"/>\n </method>\n <signal name=\"PrepareForSleep\">\n <arg type=\"b\" name=\"start\"/>\n </signal>\n </interface>\n <node name=\"session\"/>\n <node name=\"seat\"/>\n</node>\n"]}`
func TestAnIntrospectionIsShapedWithoutValues(t *testing.T) {
node, err := ParseIntrospection(introspection)
if err != nil {
t.Fatal(err)
}
o := Shape("system", "org.freedesktop.login1", "/org/freedesktop/login1", node)
if len(o.Interfaces) != 1 || !reflect.DeepEqual(o.Children, []string{"session", "seat"}) {
t.Fatalf("%+v", o)
}
i := o.Interfaces[0]
if !reflect.DeepEqual(i.Methods[0], Member{Name: "Inhibit", In: []string{"what s"}, Out: []string{"pipe_fd h"}}) ||
!reflect.DeepEqual(i.Properties[0], Property{Name: "IdleHint", Type: "b", Access: "read"}) ||
!reflect.DeepEqual(i.Signals[0], Member{Name: "PrepareForSleep", Args: []string{"start b"}}) {
t.Fatalf("%+v", i)
}
}
func TestIntrospectRefusesWhatIsNotANameOrAPathAndNeverStartsAService(t *testing.T) {
var asked []string
m := testMachine(t, nil)
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
asked = append([]string{name}, args...)
return introspection, nil
}
if _, err := m.Introspect(context.Background(), "system", "--address=x", "/"); err == nil {
t.Fatal("an option was taken for a bus name")
}
if _, err := m.Introspect(context.Background(), "system", "org.freedesktop.login1", "relative"); err == nil {
t.Fatal("a relative path was taken")
}
if _, err := m.Introspect(context.Background(), "system", "org.freedesktop.login1", ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(asked, " "), "--auto-start=no") || asked[len(asked)-3] != "/" {
t.Fatalf("%v", asked)
}
}
const monitorLines = `{"type":"method_call","endian":"l","flags":0,"version":1,"cookie":186058,"timestamp-realtime":1791193058632202,"sender":":1.797","destination":"org.freedesktop.UPower","path":"/org/freedesktop/UPower","interface":"org.freedesktop.UPower","member":"GetDisplayDevice","payload":{"type":"","data":[]}}
{"type":"signal","endian":"l","flags":1,"version":1,"cookie":7,"timestamp-realtime":1791193058632725,"sender":":1.40","path":"/org/freedesktop/Notifications","interface":"org.freedesktop.Notifications","member":"Notify","payload":{"type":"susssasa{sv}i","data":["app",0,"","the secret notification text","the clipboard's password",[],{},5000]}}
not json at all
{"type":"error","endian":"l","flags":1,"version":1,"cookie":9,"sender":":1.9","destination":":1.797","error_name":"org.freedesktop.DBus.Error.AccessDenied","payload":{"type":"s","data":["the reason, with a token"]}}`
func TestAMonitoredMessageIsItsHeaderOnly(t *testing.T) {
var asked []string
m := testMachine(t, nil)
m.Stream = func(ctx context.Context, line func(string) bool, name string, args ...string) error {
asked = append([]string{name}, args...)
if _, ok := ctx.Deadline(); !ok {
t.Error("the watch has no deadline")
}
for _, l := range strings.Split(monitorLines, "\n") {
if !line(l) {
break
}
}
return nil
}
w, err := m.Monitor(context.Background(), "system", 3, "type='signal'", []string{"org.freedesktop.Notifications"})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(w)
for _, secret := range []string{"secret notification", "password", "token", "payload", "data"} {
if strings.Contains(string(raw), secret) {
t.Errorf("the answer carries %q: %s", secret, raw)
}
}
if w.Total != 3 || w.Messages[1].Member != "Notify" || w.Messages[2].ErrorName != "org.freedesktop.DBus.Error.AccessDenied" {
t.Fatalf("%+v", w)
}
cmd := strings.Join(asked, " ")
if !strings.HasPrefix(cmd, "sudo -n timeout 3s busctl --system") || !strings.Contains(cmd, "--match=type='signal'") ||
!strings.HasSuffix(cmd, "org.freedesktop.Notifications") {
t.Fatalf("%s", cmd)
}
}
func TestAMonitorIsBoundedAndTheSessionBusIsTheAccounts(t *testing.T) {
m := testMachine(t, map[string]string{"/run/user/1000/bus": ""})
var asked string
m.Stream = func(_ context.Context, _ func(string) bool, name string, args ...string) error {
asked = name + " " + strings.Join(args, " ")
return nil
}
for _, s := range []int{0, MaxMonitorSeconds + 1} {
if _, err := m.Monitor(context.Background(), "system", s, "", nil); err == nil {
t.Errorf("%d seconds were accepted", s)
}
}
if _, err := m.Monitor(context.Background(), "session", 2, "", []string{"-x"}); err == nil {
t.Error("an option was taken for a name")
}
if _, err := m.Monitor(context.Background(), "session", 2, "", nil); err != nil {
t.Fatal(err)
}
if strings.HasPrefix(asked, "sudo") || !strings.Contains(asked, "--address=unix:path=/run/user/1000/bus") {
t.Fatalf("%s", asked)
}
}
func TestASessionBusIsFoundOrSaidMissing(t *testing.T) {
m := testMachine(t, nil)
if _, err := m.SessionAddress(); !errors.Is(err, ErrNoSession) {
t.Fatalf("a server without a session said %v", err)
}
m = testMachine(t, map[string]string{"/run/user/1000/bus": ""})
if a, err := m.SessionAddress(); err != nil || a != "unix:path=/run/user/1000/bus" {
t.Fatalf("%q %v", a, err)
}
m.Env = func(k string) string {
if k == "DBUS_SESSION_BUS_ADDRESS" {
return "unix:path=/run/user/1000/bus,guid=abc"
}
return ""
}
if a, _ := m.SessionAddress(); a != "unix:path=/run/user/1000/bus,guid=abc" {
t.Fatalf("%q", a)
}
}
const journalLines = `{"__CURSOR":"s=1;i=1","__REALTIME_TIMESTAMP":"1791192960341045","MESSAGE":"Ready","_PID":"807"}
{"__CURSOR":"s=1;i=2","__REALTIME_TIMESTAMP":"1791192960341045","MESSAGE":"A security policy denied :1.1199 to send method call /org/freedesktop/DBus:org.freedesktop.DBus.Debug.Stats.GetStats to org.freedesktop.DBus.","DBUS_BROKER_TRANSMIT_ACTION":"send","DBUS_BROKER_MESSAGE_TYPE":"method_call","DBUS_BROKER_SENDER_UNIQUE_NAME":":1.1199","DBUS_BROKER_MESSAGE_DESTINATION":"org.freedesktop.DBus","DBUS_BROKER_MESSAGE_PATH":"/org/freedesktop/DBus","DBUS_BROKER_MESSAGE_INTERFACE":"org.freedesktop.DBus.Debug.Stats","DBUS_BROKER_MESSAGE_MEMBER":"GetStats","DBUS_BROKER_POLICY_TYPE":"internal"}
{"__CURSOR":"s=1;i=3","MESSAGE":[65,66]}`
func TestDenialsAreReadFromTheJournalByTheirFields(t *testing.T) {
d, cursor := ParseDenials(journalLines)
if cursor != "s=1;i=3" || len(d) != 1 {
t.Fatalf("%q %+v", cursor, d)
}
want := Denial{At: "2026-10-05T09:36:00Z", Action: "send", Type: "method_call", Sender: ":1.1199",
Destination: "org.freedesktop.DBus", Path: "/org/freedesktop/DBus", Interface: "org.freedesktop.DBus.Debug.Stats",
Member: "GetStats", Policy: "internal"}
if d[0] != want {
t.Fatalf("%+v", d[0])
}
}
func TestTheJournalIsReadFromTheCursorOn(t *testing.T) {
m := testMachine(t, nil)
var asked []string
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
asked = append([]string{name}, args...)
return journalLines, nil
}
_, next, _ := m.Denials(context.Background(), "", time.Unix(100, 0))
if !strings.Contains(strings.Join(asked, " "), "--since @100") {
t.Fatalf("%v", asked)
}
m.Denials(context.Background(), next, time.Time{})
if !strings.Contains(strings.Join(asked, " "), "--after-cursor s=1;i=3") || !strings.Contains(strings.Join(asked, " "), "-u dbus-broker.service") {
t.Fatalf("%v", asked)
}
}
func TestAUnitIsReadFromACgroup(t *testing.T) {
for in, want := range map[string]string{
"0::/system.slice/bluetooth.service\n": "bluetooth.service",
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/dunst.service": "dunst.service",
"0::/user.slice/user-1000.slice/session-2.scope": "session-2.scope",
"0::/init.scope": "init.scope",
"": "",
} {
if got := UnitFromCgroup(in); got != want {
t.Errorf("%q: %q, want %q", in, got, want)
}
}
}
func TestShowBlocksAreReadInOrder(t *testing.T) {
b := ParseShow("Id=dbus-org.freedesktop.resolve1.service\nLoadState=not-found\n\nId=systemd-hostnamed.service\nLoadState=loaded\n")
if len(b) != 2 || b[0]["LoadState"] != "not-found" || b[1]["Id"] != "systemd-hostnamed.service" {
t.Fatalf("%v", b)
}
}
func TestARunningBusOlderThanItsPackageIsSaid(t *testing.T) {
p := ParseDesc("%NAME%\ndbus-broker\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1772097880\n")
if p.Name != "dbus-broker" || p.Version != "37-3" || p.Installed.Unix() != 1772097880 {
t.Fatalf("%+v", p)
}
if due, _ := RebootDue(time.Unix(1791123478, 0), []Package{p}); due {
t.Fatal("a bus started after its package was said to be older")
}
due, detail := RebootDue(time.Unix(1772000000, 0), []Package{p})
if !due || !strings.Contains(detail, "reboot is due") || !strings.Contains(detail, "dbus-broker 37-3") {
t.Fatalf("%v %s", due, detail)
}
}
func TestConnectionsAreCountedFromStatsOrNames(t *testing.T) {
stats := `{"type":"a{sv}","data":[{"org.bus1.DBus.Debug.Stats.PeerAccounting":{"type":"a(sa{sv}a{su})","data":[[":1.0",{},{}],[":1.1",{},{}],[":1.7",{},{}]]}}]}`
if n, ok := CountConnections(stats); !ok || n != 3 {
t.Fatalf("%d %v", n, ok)
}
if n, ok := CountConnections(`{"type":"a{sv}","data":[{"ActiveConnections":{"type":"u","data":12}}]}`); !ok || n != 12 {
t.Fatalf("%d %v", n, ok)
}
if n, ok := CountUniqueNames(`{"type":"as","data":[["org.freedesktop.DBus",":1.0","org.bluez",":1.5"]]}`); !ok || n != 2 {
t.Fatalf("%d %v", n, ok)
}
}
func TestStatsAreNeverAskedAsTheAccount(t *testing.T) {
m := testMachine(t, nil)
var calls []string
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
calls = append(calls, line)
if name == "sudo" {
return "", errors.New("sudo: a password is required")
}
if strings.Contains(line, "ListNames") {
return `{"type":"as","data":[[":1.0",":1.1"]]}`, nil
}
return "Id=dbus-broker.service\nActiveState=active\nMainPID=807\nActiveEnterTimestamp=@1791123478\n", nil
}
h := m.Health(context.Background(), nil)
for _, c := range calls {
if strings.Contains(c, "GetStats") && !strings.HasPrefix(c, "sudo -n ") {
t.Fatalf("Debug.Stats asked as the account, which the bus logs as a denial: %s", c)
}
}
if h["connections"] != 2 || h["unit"] != "dbus-broker.service" {
t.Fatalf("%v", h)
}
}
func TestTheCheckReadsPackagesUnitsServiceFilesAndDenials(t *testing.T) {
m := testMachine(t, map[string]string{
"/var/lib/pacman/local/dbus-1.16.2-1/desc": "%NAME%\ndbus\n\n%VERSION%\n1.16.2-1\n\n%INSTALLDATE%\n1741340000\n",
"/var/lib/pacman/local/dbus-broker-37-3/desc": "%NAME%\ndbus-broker\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1800000000\n",
"/var/lib/pacman/local/dbus-broker-units-37-3/desc": "%NAME%\ndbus-broker-units\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1772097880\n",
"/usr/share/dbus-1/system-services/org.bluez.service": "[D-BUS Service]\nName=org.bluez\nExec=/bin/false\nUser=root\nSystemdService=dbus-org.bluez.service\n",
"/usr/share/dbus-1/system-services/org.example.service": "[D-BUS Service]\nName=org.example\nSystemdService=example.service\n",
"/usr/share/dbus-1/system-services/org.freedesktop.systemd1.service": "[D-BUS Service]\nName=org.freedesktop.systemd1\nExec=/bin/false\n",
})
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
line := strings.Join(args, " ")
switch {
case name == "journalctl":
return journalLines, nil
case strings.Contains(line, "Id,LoadState"):
return "Id=dbus-org.bluez.service\nLoadState=not-found\n\nId=example.service\nLoadState=not-found\n", nil
default:
return "Id=dbus-broker.service\nActiveState=active\nMainPID=807\nActiveEnterTimestamp=@1791123478\n", nil
}
}
got := m.Check(context.Background(), nil)
byName := map[string]Check{}
for _, c := range got["checks"].([]Check) {
byName[c.Name] = c
}
if !byName["package dbus-broker-units"].OK || !byName["system bus"].OK {
t.Fatalf("%+v", got)
}
if c := byName["running bus is the installed one"]; c.OK || !strings.Contains(c.Detail, "dbus-broker 37-3") {
t.Fatalf("an upgraded broker was not said: %+v", c)
}
if c := byName["activatable services"]; c.OK || !strings.Contains(c.Detail, "org.example → example.service") || strings.Contains(c.Detail, "bluez") {
t.Fatalf("%+v", c)
}
if c := byName["policy denials in the last hour"]; c.OK || !strings.Contains(c.Detail, "GetStats") {
t.Fatalf("%+v", c)
}
if !strings.Contains(strings.Join(got["notes"].([]string), " "), "org.bluez → dbus-org.bluez.service") {
t.Fatalf("%v", got["notes"])
}
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
)
var busArg = map[string]any{"type": "string", "enum": Buses,
"description": "system (the default) or session (this account's login session bus, where one exists)"}
func busOf(args map[string]any) string {
b, _ := args["bus"].(string)
return b
}
// Tools are the module's tools over MCP (novox/hq ADR 0215): the names on either bus, what an object
// offers, a bounded watch of message headers, the module's check, and the bus's health.
func Tools(m *Machine, w *Watcher) []stdio.Tool {
return []stdio.Tool{
{
Name: "dbus_names",
Description: "Every well-known name on the system or session bus: its owner's pid, process, user and " +
"systemd unit, the activatable names that are not running, and how many connections the bus has. " +
"Read as the operator's account.",
Input: map[string]any{"type": "object", "properties": map[string]any{"bus": busArg}},
Run: func(args map[string]any) (any, error) {
return m.Names(context.Background(), busOf(args))
},
},
{
Name: "dbus_introspect",
Description: "What one object on a bus offers: its interfaces with their methods (arguments in and out), " +
"properties (type and access, never their values) and signals, and its child objects. A service " +
"that is not running is not started for it.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"bus": busArg,
"service": map[string]any{"type": "string", "description": "the bus name, e.g. org.freedesktop.login1"},
"path": map[string]any{"type": "string", "description": "the object path (default /)"},
},
"required": []string{"service"},
},
Run: func(args map[string]any) (any, error) {
service, _ := args["service"].(string)
path, _ := args["path"].(string)
return m.Introspect(context.Background(), busOf(args), service, path)
},
},
{
Name: "dbus_monitor",
Description: fmt.Sprintf("Watch a bus for a few seconds (at most %d) and answer the headers of the "+
"messages that passed: type, sender, destination, path, interface, member. Never a message's body, "+
"which carries secrets, notification text and the clipboard. Optional match rule and names to "+
"narrow it. The system bus is watched as root through sudo -n.", MaxMonitorSeconds),
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"bus": busArg,
"seconds": map[string]any{"type": "integer", "description": fmt.Sprintf("how long (default 5, at most %d)", MaxMonitorSeconds)},
"match": map[string]any{"type": "string", "description": "a D-Bus match rule, e.g. type='signal',interface='org.freedesktop.login1.Manager'"},
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "only messages to or from these bus names"},
},
},
Run: func(args map[string]any) (any, error) {
s := 5
if v, ok := args["seconds"].(float64); ok {
s = int(v)
}
match, _ := args["match"].(string)
var names []string
if list, ok := args["names"].([]any); ok {
for _, n := range list {
if str, ok := n.(string); ok {
names = append(names, str)
}
}
}
return m.Monitor(context.Background(), busOf(args), s, match, names)
},
},
{
Name: "dbus_check",
Description: "Whether this machine's message bus is as the mesh expects: the bus's packages installed, " +
"dbus-broker running behind dbus.service, whether the running bus is older than its installed " +
"package (then a reboot is due: the bus is never restarted live), activatable services whose " +
"unit does not exist, policy denials in the last hour, and the watcher's state.",
Run: func(map[string]any) (any, error) { return m.Check(context.Background(), w), nil },
},
{
Name: "dbus_health",
Description: "The system bus's health now: a ping's round trip, how many connections it has, its unit, " +
"pid and uptime, and what the watcher last saw.",
Run: func(map[string]any) (any, error) { return m.Health(context.Background(), w), nil },
},
}
}
+619
View File
@@ -0,0 +1,619 @@
package main
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// The watcher publishes what matters about the machine's system bus as this module's events
// (novox/hq ADR 0215 §3): its health, a well-known service appearing or leaving, and a policy
// denial. It reads only the bus driver's own answers and signals and the bus unit's journal. It never
// becomes a monitor and never sees another peer's messages, so traffic cannot leave the machine
// through it: the event bodies are built from names, pids, units and the denial's header fields.
// Event types, as the module's manifest declares them in `emits`.
const (
BusStalled = "bus.stalled"
BusRecovered = "bus.recovered"
BusRestarted = "bus.restarted"
ServiceAppeared = "service.appeared"
ServiceLeft = "service.left"
PolicyDenied = "policy.denied"
)
// Emits is every event the watcher publishes, in the manifest's order.
var Emits = []string{BusStalled, BusRecovered, BusRestarted, ServiceAppeared, ServiceLeft, PolicyDenied}
const (
// PingEvery is how often the bus is pinged.
PingEvery = 10 * time.Second
// StallAfter is how long a ping may take before the bus counts as stalled.
StallAfter = 3 * time.Second
// Debounce is how long a name must stay as it is before its change is said: a service restarted
// within it, or one that flaps, says nothing.
Debounce = 10 * time.Second
// DenialsEvery is how often the journal is read for policy denials.
DenialsEvery = 30 * time.Second
// DenialEventEvery is the least time between two policy.denied events; denials in between are
// counted into the next one.
DenialEventEvery = 5 * time.Minute
// MaxQueue is the most events kept while the mesh's bus does not take them; the oldest go first.
MaxQueue = 1000
// DenialExamples is the most distinct denials one policy.denied names.
DenialExamples = 5
)
// NameChange is the bus driver's NameOwnerChanged: a name, its old owner and its new one.
type NameChange struct{ Name, Old, New string }
// Bus is the system bus as the watcher uses it, behind an interface so it is tested without one.
type Bus interface {
Ping(ctx context.Context) error
ID(ctx context.Context) (string, error)
PID(ctx context.Context, name string) (uint32, error)
Names(ctx context.Context) ([]string, error)
Activatable(ctx context.Context) ([]string, error)
// Changes closes when the connection is lost.
Changes() <-chan NameChange
Close()
}
// Emitter publishes one event and returns once the mesh's bus has it.
type Emitter func(eventType string, body any) error
// Journal answers the policy denials in the bus unit's journal after a cursor (or, with none, since
// a time), and the cursor to continue from.
type Journal func(ctx context.Context, after string, since time.Time) ([]Denial, string, error)
type queued struct {
Type string
Body map[string]any
}
type owner struct {
PID uint32
Process string
Unit string
}
// Watcher is the long-running half of the module.
type Watcher struct {
m *Machine
emit Emitter
dial func() (Bus, error)
journal Journal
now func() time.Time
state string // where the last seen bus identity is kept, across restarts of the runtime
retry time.Duration
mu sync.Mutex
bus Bus
queue []queued
dropped int
issue string
connected bool
stalled bool
stallSince time.Time
stallReason string
lastPing time.Duration
lastPingAt time.Time
busID string
brokerPID uint32
baselined bool
current map[string]bool
published map[string]owner
dirty map[string]time.Time
activatable map[string]bool
cursor string
denials int
denialSince time.Time
examples []Denial
lastDenial time.Time
kick chan struct{}
}
// NewWatcher is a watcher for this machine, emitting through emit, reaching the bus through dial and
// the journal through journal.
func NewWatcher(m *Machine, emit Emitter, dial func() (Bus, error), journal Journal) *Watcher {
home, _ := os.UserHomeDir()
now := time.Now
if m != nil && m.Now != nil {
now = m.Now
}
return &Watcher{m: m, emit: emit, dial: dial, journal: journal, now: now,
state: filepath.Join(home, ".local", "state", "mesh-dbus", "bus"), retry: 5 * time.Second,
current: map[string]bool{}, published: map[string]owner{}, dirty: map[string]time.Time{},
activatable: map[string]bool{}, kick: make(chan struct{}, 1)}
}
// Snapshot is what dbus_check and dbus_health show of the watcher.
type Snapshot struct {
Connected bool `json:"connected"`
Stalled bool `json:"stalled"`
StalledSince string `json:"stalled_since,omitempty"`
StallReason string `json:"stall_reason,omitempty"`
LastPingMS float64 `json:"last_ping_ms"`
LastPingAt string `json:"last_ping_at,omitempty"`
BusID string `json:"bus_id,omitempty"`
BrokerPID uint32 `json:"broker_pid,omitempty"`
Services int `json:"well_known_names"`
Pending int `json:"pending_events"`
Dropped int `json:"dropped_events,omitempty"`
Problem string `json:"problem,omitempty"`
}
func (w *Watcher) Snapshot() Snapshot {
w.mu.Lock()
defer w.mu.Unlock()
s := Snapshot{Connected: w.connected, Stalled: w.stalled, StallReason: w.stallReason,
LastPingMS: float64(w.lastPing.Microseconds()) / 1000, BusID: w.busID, BrokerPID: w.brokerPID,
Services: len(w.published), Pending: len(w.queue), Dropped: w.dropped, Problem: w.issue}
if w.stalled {
s.StalledSince = w.stallSince.UTC().Format(time.RFC3339)
}
if !w.lastPingAt.IsZero() {
s.LastPingAt = w.lastPingAt.UTC().Format(time.RFC3339)
}
return s
}
func (w *Watcher) problem(s string) {
w.mu.Lock()
w.issue = s
w.mu.Unlock()
}
// enqueue adds an event in order, stamped with when it happened. A full queue lets the oldest go and
// counts it: a mesh bus gone for a day must not grow the process without bound.
func (w *Watcher) enqueue(eventType string, body map[string]any) {
if body == nil {
body = map[string]any{}
}
body["at"] = w.now().UTC().Format(time.RFC3339)
w.mu.Lock()
w.queue = append(w.queue, queued{eventType, body})
if len(w.queue) > MaxQueue {
w.dropped += len(w.queue) - MaxQueue
w.queue = w.queue[len(w.queue)-MaxQueue:]
}
w.mu.Unlock()
select {
case w.kick <- struct{}{}:
default:
}
}
// flush publishes what waits, in order, and stops at the first the mesh's bus does not take.
func (w *Watcher) flush() {
for {
w.mu.Lock()
if len(w.queue) == 0 {
w.mu.Unlock()
return
}
next := w.queue[0]
w.mu.Unlock()
if err := w.emit(next.Type, next.Body); err != nil {
w.problem("the mesh's bus did not take " + next.Type + ": " + err.Error())
return
}
w.mu.Lock()
if len(w.queue) > 0 {
w.queue = w.queue[1:]
}
if len(w.queue) == 0 && strings.HasPrefix(w.issue, "the mesh's bus") {
w.issue = ""
}
w.mu.Unlock()
}
}
// flusher publishes on its own, so an emit waiting on the runtime never delays a ping.
func (w *Watcher) flusher(ctx context.Context) {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-w.kick:
case <-tick.C:
}
w.flush()
}
}
// markStalled says bus.stalled once, until the bus answers again.
func (w *Watcher) markStalled(reason string) {
w.mu.Lock()
if w.stalled {
w.stallReason = reason
w.mu.Unlock()
return
}
w.stalled, w.stallSince, w.stallReason = true, w.now(), reason
w.mu.Unlock()
w.enqueue(BusStalled, map[string]any{"reason": reason})
}
// answered says bus.recovered when a stalled bus answers again.
func (w *Watcher) answered() {
w.mu.Lock()
if !w.stalled {
w.mu.Unlock()
return
}
since := w.stallSince
w.stalled, w.stallReason = false, ""
w.mu.Unlock()
w.enqueue(BusRecovered, map[string]any{"stalled_since": since.UTC().Format(time.RFC3339),
"stalled_seconds": int(w.now().Sub(since).Seconds())})
}
// ping asks the bus driver to answer within StallAfter.
func (w *Watcher) ping(b Bus) {
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
start := time.Now()
err := b.Ping(ctx)
took := time.Since(start)
if err != nil {
if ctx.Err() != nil {
w.markStalled("the bus did not answer a ping within " + StallAfter.String())
} else {
w.markStalled("the bus answered a ping with an error: " + err.Error())
}
return
}
w.mu.Lock()
w.lastPing, w.lastPingAt = took, w.now()
w.mu.Unlock()
w.answered()
}
// PingNow pings the bus on the watcher's connection, for dbus_health.
func (w *Watcher) PingNow() (time.Duration, error) {
w.mu.Lock()
b := w.bus
w.mu.Unlock()
if b == nil {
return 0, errNotConnected
}
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
start := time.Now()
err := b.Ping(ctx)
return time.Since(start), err
}
type watcherError string
func (e watcherError) Error() string { return string(e) }
const errNotConnected = watcherError("the watcher is not connected to the system bus")
// identity notes the bus's id and the bus driver's pid, and says bus.restarted when either changed
// within one boot: after a boot both change, and that is the machine's news, not the bus's.
func (w *Watcher) identity(id string, pid uint32) {
boot := w.m.BootID()
w.mu.Lock()
prevID, prevPID := w.busID, w.brokerPID
w.busID, w.brokerPID = id, pid
w.mu.Unlock()
prevBoot := boot
if prevID == "" {
if b, err := os.ReadFile(w.state); err == nil {
f := strings.Fields(string(b))
if len(f) == 3 {
prevBoot, prevID = f[0], f[1]
prevPID = parsePID(f[2])
}
}
}
if prevID != "" && prevBoot == boot && (prevID != id || prevPID != pid) {
w.enqueue(BusRestarted, map[string]any{"previous_bus_id": prevID, "bus_id": id,
"previous_pid": prevPID, "pid": pid, "unit": w.m.UnitOf(pid)})
}
if err := os.MkdirAll(filepath.Dir(w.state), 0o755); err == nil {
_ = os.WriteFile(w.state, []byte(boot+" "+id+" "+itoa(pid)+"\n"), 0o644)
}
}
// IsWellKnown is whether a name is a service's name rather than a connection's: unique names (":1.42")
// come and go with every client and are never said, nor is the bus driver's own.
func IsWellKnown(name string) bool {
return name != "" && !strings.HasPrefix(name, ":") && name != busName
}
// connected baselines the names after a (re)connect. The first time it says nothing; after a lost
// connection the difference with what was said is debounced like any other change, so a service that
// did not come back with a restarted bus is said to have left.
func (w *Watcher) connectedTo(b Bus) {
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
names, err := b.Names(ctx)
if err != nil {
w.problem("listing the bus's names: " + err.Error())
return
}
act, _ := b.Activatable(ctx)
now := w.now()
w.mu.Lock()
defer w.mu.Unlock()
w.activatable = map[string]bool{}
for _, n := range act {
w.activatable[n] = true
}
cur := map[string]bool{}
for _, n := range names {
if IsWellKnown(n) {
cur[n] = true
}
}
if !w.baselined {
w.baselined = true
w.current = cur
for n := range cur {
w.published[n] = owner{}
w.dirty[n] = time.Time{} // resolved silently at the next settle
}
return
}
for n := range cur {
if _, said := w.published[n]; !said {
w.dirty[n] = now
}
}
for n := range w.published {
if !cur[n] {
w.dirty[n] = now
}
}
w.current = cur
}
// observe takes one NameOwnerChanged. Only well-known names count.
func (w *Watcher) observe(c NameChange) {
if !IsWellKnown(c.Name) {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if c.New != "" {
w.current[c.Name] = true
} else {
delete(w.current, c.Name)
}
w.dirty[c.Name] = w.now()
}
// settle says what changed and stayed changed for Debounce. A name's owner is resolved when it is
// said, so the event names the process and the unit that holds it.
func (w *Watcher) settle(b Bus) {
now := w.now()
w.mu.Lock()
var due []string
for n, at := range w.dirty {
if now.Sub(at) >= Debounce {
due = append(due, n)
}
}
sort.Strings(due)
w.mu.Unlock()
for _, n := range due {
w.mu.Lock()
present := w.current[n]
was, said := w.published[n]
silent := w.dirty[n].IsZero()
delete(w.dirty, n)
activatable := w.activatable[n]
w.mu.Unlock()
switch {
case present && (!said || silent):
o := w.resolve(b, n)
w.mu.Lock()
w.published[n] = o
w.mu.Unlock()
if !silent {
w.enqueue(ServiceAppeared, o.body(n, activatable))
}
case !present && said:
w.mu.Lock()
delete(w.published, n)
w.mu.Unlock()
w.enqueue(ServiceLeft, was.body(n, activatable))
}
}
}
func (w *Watcher) resolve(b Bus, name string) owner {
if b == nil {
return owner{}
}
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
pid, err := b.PID(ctx, name)
if err != nil {
return owner{}
}
return owner{PID: pid, Process: w.m.ProcessName(pid), Unit: w.m.UnitOf(pid)}
}
func (o owner) body(name string, activatable bool) map[string]any {
body := map[string]any{"name": name, "activatable": activatable}
if o.PID != 0 {
body["pid"] = o.PID
}
if o.Process != "" {
body["process"] = o.Process
}
if o.Unit != "" {
body["unit"] = o.Unit
}
return body
}
// readDenials takes the denials logged since the last read, and says policy.denied at most once per
// DenialEventEvery, with the count and a few distinct examples: a client denied in a loop must not
// flood the mesh's bus.
func (w *Watcher) readDenials(ctx context.Context, start time.Time) {
if w.journal == nil {
return
}
w.mu.Lock()
cursor := w.cursor
w.mu.Unlock()
got, next, err := w.journal(ctx, cursor, start)
if err != nil {
w.problem("reading the bus's journal: " + err.Error())
return
}
now := w.now()
w.mu.Lock()
if next != "" {
w.cursor = next
}
if strings.HasPrefix(w.issue, "reading the bus's journal") {
w.issue = ""
}
for _, d := range got {
if w.denials == 0 {
w.denialSince = now
}
w.denials++
if len(w.examples) < DenialExamples && !containsDenial(w.examples, d) {
w.examples = append(w.examples, d)
}
}
due := w.denials > 0 && (w.lastDenial.IsZero() || now.Sub(w.lastDenial) >= DenialEventEvery)
var body map[string]any
if due {
body = map[string]any{"count": w.denials, "since": w.denialSince.UTC().Format(time.RFC3339),
"examples": w.examples}
w.denials, w.examples, w.lastDenial = 0, nil, now
}
w.mu.Unlock()
if due {
w.enqueue(PolicyDenied, body)
}
}
// Run watches until ctx ends. Without the system bus it says the bus stalled, and tries again every
// few seconds; a lost connection is followed at once by a new one.
func (w *Watcher) Run(ctx context.Context) {
go w.flusher(ctx)
start := w.now()
tick := time.NewTicker(PingEvery)
defer tick.Stop()
denials := time.NewTicker(DenialsEvery)
defer denials.Stop()
retry := time.NewTimer(0)
defer retry.Stop()
var changes <-chan NameChange
for {
select {
case <-ctx.Done():
w.mu.Lock()
b := w.bus
w.bus, w.connected = nil, false
w.mu.Unlock()
if b != nil {
b.Close()
}
w.flush()
return
case <-retry.C:
b, err := w.dial()
if err != nil {
w.markStalled("the system bus is not reachable: " + err.Error())
retry.Reset(w.retry)
continue
}
idCtx, cancel := context.WithTimeout(ctx, StallAfter)
id, idErr := b.ID(idCtx)
pid, _ := b.PID(idCtx, busName)
cancel()
if idErr != nil {
b.Close()
w.markStalled("the system bus did not say its id: " + idErr.Error())
retry.Reset(w.retry)
continue
}
w.mu.Lock()
w.bus, w.connected = b, true
w.mu.Unlock()
changes = b.Changes()
w.identity(id, pid)
w.connectedTo(b)
w.answered()
w.settle(b)
case c, open := <-changes:
if !open {
w.mu.Lock()
b := w.bus
w.bus, w.connected = nil, false
w.mu.Unlock()
if b != nil {
b.Close()
}
changes = nil
retry.Reset(time.Second)
continue
}
w.observe(c)
case <-tick.C:
w.mu.Lock()
b := w.bus
w.mu.Unlock()
if b != nil {
w.ping(b)
w.settle(b)
}
case <-denials.C:
w.readDenials(ctx, start)
}
}
}
func containsDenial(list []Denial, d Denial) bool {
for _, x := range list {
if x.key() == d.key() {
return true
}
}
return false
}
func parsePID(s string) uint32 {
var n uint32
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + uint32(c-'0')
}
return n
}
func itoa(n uint32) string {
if n == 0 {
return "0"
}
var b [10]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
+446
View File
@@ -0,0 +1,446 @@
package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// fakeBus is a system bus in memory: names with their owners' pids, a ping that can hang, and the
// NameOwnerChanged stream.
type fakeBus struct {
mu sync.Mutex
id string
pid uint32
names map[string]uint32
hang bool
changes chan NameChange
}
func newFakeBus(id string, pid uint32, names map[string]uint32) *fakeBus {
return &fakeBus{id: id, pid: pid, names: names, changes: make(chan NameChange, 64)}
}
func (f *fakeBus) Ping(ctx context.Context) error {
f.mu.Lock()
hang := f.hang
f.mu.Unlock()
if hang {
<-ctx.Done()
return ctx.Err()
}
return nil
}
func (f *fakeBus) ID(context.Context) (string, error) { return f.id, nil }
func (f *fakeBus) PID(_ context.Context, name string) (uint32, error) {
if name == busName {
return f.pid, nil
}
f.mu.Lock()
defer f.mu.Unlock()
if p, ok := f.names[name]; ok {
return p, nil
}
return 0, errors.New("no such name")
}
func (f *fakeBus) Names(context.Context) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []string{busName, ":1.0", ":1.1"}
for n := range f.names {
out = append(out, n)
}
return out, nil
}
func (f *fakeBus) Activatable(context.Context) ([]string, error) {
return []string{"org.freedesktop.hostname1"}, nil
}
func (f *fakeBus) Changes() <-chan NameChange { return f.changes }
func (f *fakeBus) Close() {}
// meshBus records what was published, and can refuse.
type meshBus struct {
mu sync.Mutex
down bool
types []string
bodies []map[string]any
}
func (b *meshBus) emit(t string, body any) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.down {
return errors.New("no bus")
}
b.types = append(b.types, t)
m, _ := body.(map[string]any)
b.bodies = append(b.bodies, m)
return nil
}
func (b *meshBus) seen() []string {
b.mu.Lock()
defer b.mu.Unlock()
return append([]string(nil), b.types...)
}
// clock is a time the test moves.
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func (c *clock) advance(d time.Duration) { c.t = c.t.Add(d) }
func testMachine(t *testing.T, files map[string]string) *Machine {
t.Helper()
root := t.TempDir()
for p, c := range files {
full := filepath.Join(root, p)
os.MkdirAll(filepath.Dir(full), 0o755)
os.WriteFile(full, []byte(c), 0o644)
}
return &Machine{Root: root, Env: func(string) string { return "" }, UID: 1000, Now: time.Now}
}
func testWatcher(t *testing.T, b *meshBus, c *clock) *Watcher {
m := testMachine(t, map[string]string{
"/proc/sys/kernel/random/boot_id": "boot-1\n",
"/proc/700/comm": "systemd-logind\n",
"/proc/700/cgroup": "0::/system.slice/systemd-logind.service\n",
"/proc/900/comm": "bluetoothd\n",
"/proc/900/cgroup": "0::/system.slice/bluetooth.service\n",
})
w := NewWatcher(m, b.emit, nil, nil)
w.now = c.now
w.state = filepath.Join(t.TempDir(), "bus")
return w
}
func start() *clock { return &clock{t: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)} }
func TestTheFirstConnectionSaysNothingAboutNames(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.identity("id-1", 500)
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("the baseline was announced: %v", got)
}
if w.Snapshot().Services != 1 {
t.Fatalf("%+v", w.Snapshot())
}
}
func TestAServiceAppearingIsSaidOnceItStaysWithItsUnit(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
fb.names["org.bluez"] = 900
w.observe(NameChange{Name: "org.bluez", New: ":1.9"})
c.advance(Debounce / 2)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("said before the debounce: %v", got)
}
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceAppeared}) {
t.Fatalf("%v", got)
}
body := mb.bodies[0]
if body["name"] != "org.bluez" || body["unit"] != "bluetooth.service" || body["process"] != "bluetoothd" || body["pid"] != uint32(900) {
t.Fatalf("%v", body)
}
}
func TestAFlapIsDebouncedAway(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
// logind restarted: left and back within the debounce, and a newcomer that came and went.
w.observe(NameChange{Name: "org.freedesktop.login1", Old: ":1.5"})
c.advance(time.Second)
w.observe(NameChange{Name: "org.freedesktop.login1", New: ":1.80"})
w.observe(NameChange{Name: "org.example.Brief", New: ":1.81"})
w.observe(NameChange{Name: "org.example.Brief", Old: ":1.81"})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("a flap was said: %v", got)
}
}
func TestAServiceLeavingIsSaidWithTheUnitItHad(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
delete(fb.names, "org.freedesktop.login1")
w.observe(NameChange{Name: "org.freedesktop.login1", Old: ":1.5"})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceLeft}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["unit"] != "systemd-logind.service" {
t.Fatalf("%v", mb.bodies[0])
}
}
func TestUniqueNamesAndTheDriverAreNeverSaid(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
w.observe(NameChange{Name: ":1.42", New: ":1.42"})
w.observe(NameChange{Name: ":1.42", Old: ":1.42"})
w.observe(NameChange{Name: busName, New: busName})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("%v", got)
}
for _, n := range []string{":1.1", busName, ""} {
if IsWellKnown(n) {
t.Errorf("%q counted as a service", n)
}
}
}
func TestAPingThatHangsIsAStallAndAnAnswerARecovery(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, nil)
fb.hang = true
begun := time.Now()
w.ping(fb)
w.ping(fb)
if time.Since(begun) > 2*StallAfter+time.Second {
t.Fatal("a ping waited longer than its bound")
}
c.advance(42 * time.Second)
fb.hang = false
w.ping(fb)
w.ping(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusStalled, BusRecovered}) {
t.Fatalf("%v", got)
}
if mb.bodies[1]["stalled_seconds"] != 42 || w.Snapshot().Stalled {
t.Fatalf("%v %+v", mb.bodies[1], w.Snapshot())
}
}
func TestARestartWithinABootIsSaidAndABootIsNot(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
w.identity("id-1", 500)
w.identity("id-1", 500) // a reconnect to the same bus
w.identity("id-2", 501) // the bus came back as another
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusRestarted}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["previous_bus_id"] != "id-1" || mb.bodies[0]["pid"] != uint32(501) {
t.Fatalf("%v", mb.bodies[0])
}
// The runtime restarts: the bus it remembers is the one still running, so nothing is said.
again := NewWatcher(w.m, mb.emit, nil, nil)
again.state, again.now = w.state, c.now
again.identity("id-2", 501)
// After a boot both change, and that is not the bus's restart.
os.WriteFile(filepath.Join(w.m.Root, "/proc/sys/kernel/random/boot_id"), []byte("boot-2\n"), 0o644)
third := NewWatcher(w.m, mb.emit, nil, nil)
third.state, third.now = w.state, c.now
third.identity("id-3", 400)
again.flush()
third.flush()
if got := mb.seen(); len(got) != 1 {
t.Fatalf("a runtime restart or a boot was taken for the bus's restart: %v", got)
}
}
func TestAServiceThatDidNotComeBackAfterAReconnectHasLeft(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700, "org.bluez": 900})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
back := newFakeBus("id-2", 501, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(back)
c.advance(Debounce)
w.settle(back)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceLeft}) || mb.bodies[0]["name"] != "org.bluez" {
t.Fatalf("%v %v", got, mb.bodies)
}
}
func TestEventsWaitInOrderWhileTheMeshBusIsGone(t *testing.T) {
mb, c := &meshBus{down: true}, start()
w := testWatcher(t, mb, c)
w.markStalled("test")
c.advance(time.Minute)
w.answered()
w.flush()
if s := w.Snapshot(); s.Pending != 2 || s.Problem == "" {
t.Fatalf("what the mesh's bus did not take is not kept and said: %+v", s)
}
mb.mu.Lock()
mb.down = false
mb.mu.Unlock()
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusStalled, BusRecovered}) {
t.Fatalf("%v", got)
}
if s := w.Snapshot(); s.Pending != 0 || s.Problem != "" {
t.Fatalf("%+v", s)
}
}
func TestAFullQueueLetsTheOldestGo(t *testing.T) {
mb, c := &meshBus{down: true}, start()
w := testWatcher(t, mb, c)
for i := 0; i < MaxQueue+5; i++ {
w.enqueue(ServiceAppeared, map[string]any{"i": i})
}
s := w.Snapshot()
if s.Pending != MaxQueue || s.Dropped != 5 || w.queue[0].Body["i"] != 5 {
t.Fatalf("%+v first %v", s, w.queue[0].Body)
}
}
func TestDenialsAreSaidAtMostOncePerWindowWithACount(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
batches := [][]Denial{
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"}},
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"},
{Type: "method_call", Interface: "org.example.B", Member: "Other", Destination: "org.example"}},
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"}},
}
n := 0
w.journal = func(ctx context.Context, after string, since time.Time) ([]Denial, string, error) {
if n > 0 && after != "c"+string(rune('0'+n-1)) {
t.Errorf("read %d did not continue from the cursor: %q", n, after)
}
d := batches[n]
n++
return d, "c" + string(rune('0'+n-1)), nil
}
w.readDenials(context.Background(), c.t)
c.advance(DenialsEvery)
w.readDenials(context.Background(), c.t)
c.advance(DenialEventEvery)
w.readDenials(context.Background(), c.t)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{PolicyDenied, PolicyDenied}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["count"] != 1 || mb.bodies[1]["count"] != 3 {
t.Fatalf("%v", mb.bodies)
}
if ex := mb.bodies[1]["examples"].([]Denial); len(ex) != 2 {
t.Fatalf("the same denial is one example: %v", ex)
}
}
// TestNoEventCarriesTraffic holds every event body to names, pids, units, times, counts and the
// header fields of a denial: nothing in the watcher can carry a message's body.
func TestNoEventCarriesTraffic(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
fb.names["org.bluez"] = 900
w.observe(NameChange{Name: "org.bluez", New: ":1.9"})
c.advance(Debounce)
w.settle(fb)
w.markStalled("x")
w.answered()
w.identity("a", 1)
w.identity("b", 2)
w.journal = func(context.Context, string, time.Time) ([]Denial, string, error) {
d, cur := ParseDenials(`{"__CURSOR":"c","MESSAGE":"A security policy denied :1.9 to send method call /p:i.m to d.","DBUS_BROKER_MESSAGE_MEMBER":"m","SECRET_BODY":"hunter2"}`)
return d, cur, nil
}
w.readDenials(context.Background(), c.t)
w.flush()
allowed := map[string]bool{"at": true, "name": true, "pid": true, "process": true, "unit": true, "activatable": true,
"reason": true, "stalled_since": true, "stalled_seconds": true, "previous_bus_id": true, "bus_id": true,
"previous_pid": true, "count": true, "since": true, "examples": true}
if len(mb.types) != 5 {
t.Fatalf("%v", mb.types)
}
for i, b := range mb.bodies {
for k := range b {
if !allowed[k] {
t.Errorf("%s carries %q", mb.types[i], k)
}
}
raw, _ := json.Marshal(b)
if strings.Contains(string(raw), "hunter2") || strings.Contains(string(raw), "security policy") {
t.Errorf("%s carries what the bus logged verbatim: %s", mb.types[i], raw)
}
}
}
func TestRunWithoutABusSaysItStalledAndReconnects(t *testing.T) {
mb := &meshBus{}
m := testMachine(t, map[string]string{"/proc/sys/kernel/random/boot_id": "b\n"})
fb := newFakeBus("id-1", 500, map[string]uint32{})
var dials int
w := NewWatcher(m, mb.emit, func() (Bus, error) {
dials++
if dials == 1 {
return nil, errors.New("no socket")
}
return fb, nil
}, nil)
w.state = filepath.Join(t.TempDir(), "bus")
w.retry = 20 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
done := make(chan struct{})
go func() { w.Run(ctx); close(done) }()
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) && !w.Snapshot().Connected {
time.Sleep(50 * time.Millisecond)
}
if !w.Snapshot().Connected {
t.Fatal("the watcher did not reconnect")
}
close(fb.changes) // the bus goes away
for time.Now().Before(deadline) && w.Snapshot().Connected {
time.Sleep(10 * time.Millisecond)
}
cancel()
<-done
got := mb.seen()
if len(got) < 2 || got[0] != BusStalled || got[1] != BusRecovered {
t.Fatalf("%v", got)
}
}
+8
View File
@@ -0,0 +1,8 @@
module dbus
go 1.22
require (
git.novox.be/novox/mesh-sdk/go v0.1.7
github.com/godbus/dbus/v5 v5.1.0
)
+4
View File
@@ -0,0 +1,4 @@
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=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+67
View File
@@ -0,0 +1,67 @@
{
"module": "dbus",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"claims": [
{
"name": "node-message-bus",
"scope": "node"
}
],
"emits": [
"bus.stalled",
"bus.recovered",
"bus.restarted",
"service.appeared",
"service.left",
"policy.denied"
],
"tools": [
"dbus_names",
"dbus_introspect",
"dbus_monitor",
"dbus_check",
"dbus_health"
],
"resources": [
{
"id": "dbus",
"type": "package",
"package": "dbus"
},
{
"id": "broker",
"type": "package",
"package": "dbus-broker"
},
{
"id": "broker-units",
"type": "package",
"package": "dbus-broker-units"
},
{
"id": "system-bus",
"type": "service",
"unit": "dbus-broker.service",
"state": "running"
}
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/dbus-tools",
"binary": "dbus-tools",
"loads": [
"dbus-tools"
]
}
]
}
}
+17
View File
@@ -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"
]
}
]
}
-236
View File
@@ -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")) }},
}
}
+5
View File
@@ -0,0 +1,5 @@
module fail2ban
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+6 -3
View File
@@ -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"
]
}
]
-18
View File
@@ -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'"
}
}
-114
View File
@@ -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);
});
-62
View File
@@ -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));
-15
View File
@@ -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"
]
}
+7
View File
@@ -45,3 +45,10 @@ setting once issue 168 closes, not a file the next push would overwrite.
- `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads
them as unknown.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -22,7 +22,9 @@ type manifest struct {
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
Contributions []contribution `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
@@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
}
}
}
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// i3Lines is what the module contributes to the window manager.
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
}
}
+3 -3
View File
@@ -63,11 +63,11 @@ func TestFehbgIsOwnedAndTheSessionStartRunsItOnce(t *testing.T) {
func TestTheKeyThatRestoresTheWallpaperIsAnI3DropIn(t *testing.T) {
m := readManifest(t)
m.sameAsSource(t, "i3-bindings", "files/i3/50-feh.conf")
if p := m.resource(t, "i3-bindings")["path"]; p != "${machine:account-home}/.config/i3/config.d/50-feh.conf" {
m.i3LinesAreSource(t, "files/i3/50-feh.conf")
if p := m.i3Lines(); p == "" {
t.Fatalf("path: %v", p)
}
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") {
if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") {
t.Fatalf("%s", c)
}
}
+8 -9
View File
@@ -38,14 +38,6 @@
"owner": "${machine:account}",
"mode": "0755",
"content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n"
},
{
"id": "i3-bindings",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/50-feh.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
}
],
"build": {
@@ -67,5 +59,12 @@
]
}
]
}
},
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
}
]
}
+10
View File
@@ -564,6 +564,16 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member);
}
/** Withdraw a user and keep everything they own: login prohibited, which ensureUser undoes. */
async prohibitLogin(username: string): Promise<void> {
const res = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
method: "PATCH",
body: JSON.stringify({ login_name: username, prohibit_login: true }),
});
if (res.status === 200 || res.status === 404) return;
GiteaAdmin.fail(`/admin/users/${username}`, res);
}
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
* an error, so a re-run of remove is safe. */
async deleteUser(username: string): Promise<void> {
+7
View File
@@ -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"
}
]
}
+2 -1
View File
@@ -54,7 +54,8 @@ runProvisioner("npm-package-registry", {
},
async remove(p: { as: string }): Promise<void> {
await gitea.deleteUser(p.as);
// Login prohibited, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): deleting purges every repository the user owns.
await gitea.prohibitLogin(p.as);
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
+8
View File
@@ -150,3 +150,11 @@ about to end, or accept it: a reload keeps every window and workspace.
written the files. So at its start the watcher compares the files on disk with what the running i3
loaded (`GET_CONFIG`), and reloads when they differ. The push that assigns `i3`, or changes its
configuration, is therefore reloaded although it also restarted the watcher.
## Other modules' lines are contributions (changed 2026-10-05, novox/hq ADR 0212)
`node-display-session` receives `config` contributions, and this module places them with
`${contribution:node-display-session:config}` near the end of its configuration, each module's
under a line naming it. `~/.config/i3/config.d/` is now the operator's alone: the include at the very
end reads it after the mesh's lines. The catalogue-wide test composes every module's contribution as
the controller does and checks the result with `i3 -C`.
+39 -14
View File
@@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
@@ -138,7 +139,12 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin
t.Fatal("the lock key goes through logind, which the lock screen's module relies on")
}
if i3, err := exec.LookPath("i3"); err == nil {
out, err := exec.Command(i3, "-C", "-c", filepath.Join("..", "..", "config", "config")).CombinedOutput()
// As a node with no contribution and no file of the operator's would hold it.
alone := strings.Replace(strings.Replace(c, "${contribution:node-display-session:config}", "", 1),
"\ninclude ~/.config/i3/config.d/*.conf", "\n", 1)
path := filepath.Join(t.TempDir(), "config")
os.WriteFile(path, []byte(alone), 0o644)
out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput()
if err != nil || len(ParseCheck(string(out))) > 0 {
t.Fatalf("i3 -C: %v %s", err, out)
}
@@ -169,37 +175,56 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) {
}
}
// Every module of the catalogue that drops a file into i3's config.d is loaded with the main file, as
// i3 would load them on a machine with all of them assigned: no two bind one key, and every line parses.
func TestTheMainFileAndEveryModulesDropInLoadTogether(t *testing.T) {
// Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in
// the main file as the controller places them — in module order, each module's under a line naming it
// — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and
// every line parses.
func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) {
i3, err := exec.LookPath("i3")
if err != nil {
t.Skip("no i3 here to check with")
}
dir := t.TempDir()
drop := filepath.Join(dir, "config.d")
os.MkdirAll(drop, 0o755)
manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json"))
sort.Strings(manifests)
var placed strings.Builder
var found []string
for _, p := range manifests {
raw, _ := os.ReadFile(p)
var m struct {
Resources []map[string]any `json:"resources"`
Module string `json:"module"`
Contributions []struct {
Seat, Kind, Content string
} `json:"contributions"`
}
json.Unmarshal(raw, &m)
for _, r := range m.Resources {
path, _ := r["path"].(string)
if r["type"] == "file" && strings.Contains(path, "/.config/i3/config.d/") {
os.WriteFile(filepath.Join(drop, filepath.Base(path)), []byte(r["content"].(string)), 0o644)
found = append(found, filepath.Base(path))
named := false
for _, c := range m.Contributions {
if c.Seat != "node-display-session" || c.Kind != "config" {
continue
}
if !named {
placed.WriteString("# " + m.Module + "\n")
named = true
found = append(found, m.Module)
}
placed.WriteString(c.Content)
if !strings.HasSuffix(c.Content, "\n") {
placed.WriteString("\n")
}
}
}
if len(found) < 5 {
t.Fatalf("expected the launcher, clipboard, wallpaper, bars and the laptop to contribute; found %v", found)
}
main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config"))
text := strings.Replace(string(main), "include ~/.config/i3/config.d/*.conf", "include "+drop+"/*.conf", 1)
text := strings.Replace(string(main), "${contribution:node-display-session:config}", placed.String(), 1)
// The operator's own files are not the catalogue's: the include line itself goes (a contribution may
// name it in a comment, so only the line that is exactly it).
text = strings.Replace(text, "\ninclude ~/.config/i3/config.d/*.conf", "\n", 1)
os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644)
out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput()
if err != nil || len(ParseCheck(string(out))) > 0 {
t.Fatalf("with the drop-ins %v: %v\n%s", found, err, out)
t.Fatalf("with the contributions of %v: %v\n%s", found, err, out)
}
}
+14 -7
View File
@@ -1,9 +1,10 @@
# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;
# change the module instead. i3's user guide is the reference.
#
# Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named
# <NN>-<module>.conf and read in name order by the include at the end, where every variable set here
# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours.
# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR
# 0212): the mesh places their lines near the end, each module's under a line naming it, where every
# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read
# after them, by the include at the very end, and is yours.
#
# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the
# result with i3 -C; it never reloads into a configuration with errors.
@@ -172,9 +173,9 @@ client.urgent #900000 #900000 #ffffff #900000 #900000
###### Until their modules carry them ##
#########################################
# Each line below belongs to something other than i3, named on its line. When that module is written
# it contributes the line as its own drop-in in config.d, and the line goes from here in the same
# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs
# (rofi, clipmenu, feh, i3status-rust, gnome-keyring).
# 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
@@ -190,6 +191,12 @@ bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.s
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
#########################################
###### Other modules' drop-ins ####
###### 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.
${contribution:node-display-session:config}
#########################################
###### Your own files ####
#########################################
include ~/.config/i3/config.d/*.conf
File diff suppressed because one or more lines are too long
+7
View File
@@ -84,3 +84,10 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod
runs per session and ends with the session, as a session-start line already does.
- `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared
once per node.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -22,7 +22,9 @@ type manifest struct {
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
Contributions []contribution `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
@@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
}
}
}
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// i3Lines is what the module contributes to the window manager.
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
}
}
@@ -31,7 +31,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
m := readManifest(t)
for id, src := range map[string]string{
"top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml",
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "i3-bars": "files/i3/60-i3status-rust.conf",
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog",
} {
m.sameAsSource(t, id, src)
}
@@ -47,13 +47,10 @@ func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testin
}
}
func TestTheBarsAreAnI3DropInAndTheWatchdogRunsOncePerSession(t *testing.T) {
func TestTheBarsAreAContributionToTheWindowManagerAndTheWatchdogRunsOncePerSession(t *testing.T) {
m := readManifest(t)
bars := m.resource(t, "i3-bars")
if bars["path"] != "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf" {
t.Fatalf("%v", bars["path"])
}
c := bars["content"].(string)
m.i3LinesAreSource(t, "files/i3/60-i3status-rust.conf")
c := m.i3Lines()
if strings.Count(c, "bar {") != 2 || !strings.Contains(c, "status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml") ||
!strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") {
t.Fatalf("%s", c)
+8 -9
View File
@@ -85,14 +85,6 @@
"owner": "${machine:account}",
"mode": "0755",
"content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n"
},
{
"id": "i3-bars",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n"
}
],
"build": {
@@ -109,5 +101,12 @@
]
}
]
}
},
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n"
}
]
}
+8 -1
View File
@@ -132,5 +132,12 @@
}
}
]
}
},
"contributions": [
{
"seat": "node-backup",
"kind": "backup",
"content": "path ${dir:data}\npath ${dir:config}\n"
}
]
}
+5
View File
@@ -140,6 +140,11 @@ export class MailuClient {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true });
}
/** Withdraw a mailbox and keep its mail: disabled, which applyProvisioned undoes. */
async disableUser(email: string): Promise<void> {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { enabled: false });
}
async deleteUser(email: string): Promise<void> {
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
}
+7
View File
@@ -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"
}
]
}
+3 -1
View File
@@ -75,7 +75,9 @@ runProvisioner("smtp", {
// exists, and left otherwise — a mailbox holding mail is the one thing a background loop
// must not guess about (this module's own events file says the same). Withdrawal of a
// named-account consumer is an operator action until the harness carries values here.
await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
// Disabled, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): a mailbox holding mail is the one thing a background loop must
// not destroy. applyProvisioned enables it again when the consumer returns.
await mailu.disableUser(`${p.as}@${domain()}`).catch(() => {});
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
+7
View File
@@ -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"
}
]
}
+8 -1
View File
@@ -151,5 +151,12 @@
}
}
]
}
},
"contributions": [
{
"seat": "node-backup",
"kind": "backup",
"content": "path ${dir:data}\n"
}
]
}
+3 -8
View File
@@ -47,15 +47,10 @@ runProvisioner("s3-bucket", {
async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> {
const bucket = bucketNamed(p.derived);
// Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if
// empty; a bucket that still holds objects is left for an operator rather than erroring on every
// reconcile tick — access is already gone, and silently deleting a consumer's data would be worse.
// Revoking the key is what cuts the consumer's access, and the bucket is kept, empty or not
// (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). A bucket is removed by a person, never by this loop.
try { await minio.removeAccessKey(p.as); } catch { /* already gone */ }
try {
await minio.removeBucket(bucket);
} catch (err) {
console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`);
}
console.error(`[minio] ${p.as} withdrawn: access key revoked, bucket ${bucket} kept`);
await announce("bucket.removed", { bucket, accessKey: p.as });
},
+12
View File
@@ -125,6 +125,18 @@ export class MongoClient {
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
* user is removed first so a re-grant of the same login starts clean. */
/** Withdraw a consumer and keep its database: the user keeps its name and loses every role. */
async lockUser(database: string, user: string): Promise<void> {
await this.admin(async (client) => {
const target = client.db(database);
try {
await target.command({ updateUser: user, roles: [] });
} catch (err) {
if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
}
});
}
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
await this.admin(async (client) => {
const target = client.db(database);
+13 -1
View File
@@ -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"
}
]
}
+3 -2
View File
@@ -41,8 +41,9 @@ runProvisioner("mongodb-database", {
},
async remove(p: { as: string }): Promise<void> {
await mongo.dropDatabaseAndUser(p.as, p.as);
await announce("database.deprovisioned", { database: p.as });
// Locked, never dropped (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). create gives the roles back.
await mongo.lockUser(p.as, p.as);
await announce("database.deprovisioned", { database: p.as, kept: "true" });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
+8
View File
@@ -256,6 +256,14 @@ export class MssqlClient {
}
/** Drop a database and its login, idempotently, after evicting live connections. */
/** Withdraw a consumer and keep its database: its login is disabled, which create undoes. */
async disableLogin(login: string): Promise<void> {
const logins = await this.query(
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
);
if (logins.length > 0) await this.exec(`ALTER LOGIN ${ident(login)} DISABLE`);
}
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
const dbs = await this.query(
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
+22 -1
View File
@@ -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"
}
]
}
+3 -2
View File
@@ -41,8 +41,9 @@ runProvisioner("mssql-database", {
},
async remove(p: { as: string }): Promise<void> {
await mssql.dropDatabaseAndLogin(p.as, p.as);
await announce("database.deprovisioned", { database: p.as });
// Disabled, never dropped (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). create enables the login again.
await mssql.disableLogin(p.as);
await announce("database.deprovisioned", { database: p.as, kept: "true" });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
+106
View File
@@ -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,574 @@
package main
// desktop.go is the same file in the nextcloud-client and blueman bundles: 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
}
// 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 pids []int
for _, c := range comms {
for _, p := range m.procs(c) {
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 {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procs(comm); 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,202 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client and blueman bundles.
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 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)
}
}

Some files were not shown because too many files have changed in this diff Show More