60 Commits
Author SHA1 Message Date
mesh-admin 0515db043a Merge pull request 'supabase: studio listens on every address, so its health check reaches it' (#74) from fix/studio-listens-where-its-health-check-asks into main 2026-10-05 21:20:40 +00:00
jochen 7f491fd6bc Studio listens on every address, so its health check reaches it
Next.js binds the address HOSTNAME names; docker sets HOSTNAME to the container's id, so studio
answered only on its network address while its image's health check asks localhost, and it read
unhealthy while working. HOSTNAME=:: as the upstream compose file sets it.
2026-10-05 23:20:35 +02:00
mesh-admin b4b86c1452 Merge pull request 'resolv-conf lists every mesh resolver and no public one (hq ADR 0223)' (#73) from feat/the-mesh-has-two-resolvers into main 2026-10-05 20:50:53 +00:00
jochen 737f42deb4 List every mesh resolver and no public one in resolv.conf
musl asks every nameserver at once and takes the first reply, so a public
resolver's NXDOMAIN for a mesh name beat the mesh's answer in every Alpine
container (hq ADR 0223). resolv-conf now renders /etc/resolv.conf from the
holders of mesh-dns-resolver, this machine first when it holds one; dnsmasq's
comments say the seat may have several holders.
2026-10-05 22:42:54 +02:00
mesh-admin 5c89eaf9a6 Merge pull request 'docker: trust the mesh's registry from the runtime's own module (hq ADR 0222, issue 190 — 2 of 3)' (#72) from fix/190-docker-insecure-registries into main 2026-10-05 20:39:05 +00:00
jochen 964a4fdfbc docker: trust the mesh's registry from the runtime's own module (hq issue 190)
The controller's private network writes insecure-registries into daemon.json, a file this
module owns. The runtime's module states it instead, through ${seat:mesh-artifact-store:reach}
(hq ADR 0222), so the controller can stop generating its registry-trust resources.
2026-10-05 22:17:16 +02:00
mesh-admin 20603b63e6 Merge pull request 'A machine's mesh name has no IPv6 address rather than no name (hq issue 262)' (#71) from fix/a-mesh-name-has-no-ipv6-address-rather-than-no-name into main 2026-10-05 20:07:33 +00:00
jochen 4bf5eef2fd A machine's mesh name has no IPv6 address rather than no name (hq issue 262)
The resolver answered a machine's name only by wildcard, which says there is no such name when
asked for an IPv6 address; musl reads that as final, so Alpine containers could not find a
machine at all. A host record per machine answers that the name exists and has none, as the
hosts file the per-machine resolvers read used to.
2026-10-05 22:07:28 +02:00
mesh-admin 4cac44face Merge pull request 'Retire resolved-split-dns (hq ADR 0220)' (#70) from feat/retire-resolved-split-dns into main 2026-10-05 20:03:01 +00:00
jochen 875d2a0554 Retire resolved-split-dns: ADR 0196 chose no stub, and nothing assigns it
The resolv-conf comment pointed operators at it and at NetworkManager as
alternative claimants; it now says the uplink's holder is required beside it
(hq ADR 0220).
2026-10-05 21:57:04 +02:00
mesh-admin 76707bbe0e Merge pull request 'The mesh's one resolver, what every node asks, and a node's hosts file (hq ADR 0194, 0196, 0199)' (#69) from feat/the-mesh-has-one-resolver into main 2026-10-05 18:44:15 +00:00
jochen 076455ec78 hosts in Go, with the machine's own name in its block (ADR 0199)
The tools were TypeScript; the mesh's modules are Go. The write to /etc/hosts is now staged and
moved into place rather than written over the live file.
2026-10-05 20:42:58 +02:00
jochen f20c4b749b The runtime's file is written by the runtime's module, not by what decides how a machine resolves (issue 190)
resolv-conf would have taken over dnsmasq's write into daemon.json — the same defect issue 190
names. docker, on every machine, now writes live-restore and reloads its own service. Log rotation
is left as each machine has it.
2026-10-05 20:42:58 +02:00
jschoubben 69d6b9066f The mesh's one resolver, what every node asks, and a node's hosts file (hq ADR 0194, 0196, 0199)
- dnsmasq holds mesh-dns-resolver: provides wildcard-resolution mesh-wide, forwards every declared
  zone (zones fact), listens on the private address and loopback only, reads no hosts file and no
  operator's files, and no longer writes the container runtime's dns.
- resolv-conf names the mesh's resolver by address, then 1.1.1.1, timeout 1, one attempt; it now
  holds the runtime's live-restore, which dnsmasq held and every node needs.
- resolved-split-dns routes the suffix to the mesh's resolver by address, not 127.0.0.1.
- hosts: new module holding node-hosts-file — the machine's own lines in its block of /etc/hosts,
  the operator's lines kept, changed by entries/add/remove through sudo -n.
2026-10-05 20:37:59 +02:00
mesh-admin 2cc27e2a74 Merge pull request 'build-agent serves its seat's verbs: current, kill, pause, resume (hq ADR 0219)' (#68) from feat/build-agent-serves-its-seat into main 2026-10-05 18:31:28 +00:00
jochen 0581256905 build-agent serves its seat's verbs: current, kill, pause, resume (hq ADR 0219)
The node-build-agent seat now promises these verbs, and a holder that does not
name them cannot hold it. The binary serving them is mesh-controller's
cmd/mesh-builder; merge only once a controller carrying the seat's new verbs runs,
since an older one refuses a claim naming verbs its row lacks.
2026-10-05 19:23:30 +02:00
mesh-admin 082f8de32f Merge pull request 'distribution: collect for real now that every kept archive is held (hq issue 253)' (#67) from fix/the-collector-collects-for-real into main 2026-10-05 16:44:30 +00:00
jochen 428f5b8864 distribution: collect for real now that every kept archive is held
The dry run stood until the controller held each kept archive by a manifest
(mesh-controller #53) and an apply stopped reopening the collection window
(mesh-host #23, hq issue 224). The controller's collection command now
reports 134 of 134 kept archives held. hq issue 253.
2026-10-05 18:34:37 +02:00
mesh-admin 579d21a209 Merge pull request 'gitea: a merge poll looks only at repositories that moved, one pass at a time (hq issue 250)' (#66) from fix/the-merge-poll-looks-only-at-what-moved into main 2026-10-05 16:12:13 +00:00
jochen 52b81d524c gitea: a merge poll looks only at repositories that moved, one pass at a time
With the poll the only announcer of a merge, its cost showed: every 30 s it
asked every repository for its pull requests, a pass outlasted the tick, and
passes piled up beside each other — a merge was announced four and a half
minutes late, and two passes at once could each announce it. A pass now asks
only repositories updated since a minute before the last look, and the next
pass starts when this one ends. hq issue 250.
2026-10-05 18:12:11 +02:00
mesh-admin b651137d95 Merge pull request 'systemd: port to Go, and read a system unit's journal as root (hq issue 255)' (#65) from feat/systemd-in-go into main 2026-10-05 16:10:03 +00:00
jochen c42f1ce45b systemd: port to Go, and read a system unit's journal as root
The journal verb ran journalctl as the operator account, which outside the
journal's group sees only its own entries: every system service read
'-- No entries --', and a person reached for a shell. The read now
escalates with sudo -n like the acts; ported to Go with every test. hq
issue 255.
2026-10-05 18:09:42 +02:00
mesh-admin 6a42bafb1c Merge pull request 'records: port to Go, and keep the checkout in a directory it owns (hq issue 251)' (#64) from feat/records-in-go into main 2026-10-05 15:55:58 +00:00
mesh-admin 053eba6950 Merge pull request 'gitea: announce a merge once, and read every page of its changed files (hq issues 250, 252)' (#63) from fix/a-merge-is-announced-once into main 2026-10-05 15:55:54 +00:00
jochen 45507c3d5c gitea: test that a pull request's files are read past the forge's page cap 2026-10-05 17:47:36 +02:00
jochen 63e53f4622 gitea: read every page of a pull request's changed files
The forge caps a page at fifty when asked for a hundred, so a large merge's
file list was cut short and reported whole: a module whose own files moved
was not rebuilt. Read until a page comes back short. hq issue 252.
2026-10-05 17:46:54 +02:00
mesh-admin ebacf79c91 Merge pull request 'URGENT distribution: the nightly collector dry-runs until every kept bundle is held (first run tonight 03:30)' (#62) from fix/the-collector-dry-runs-until-bundles-are-held into main 2026-10-05 15:45:00 +00:00
jochen a66a23582f distribution: collect as a dry run until every kept bundle is held by a manifest
The stock collector keeps only what a manifest names, and the mesh's bundles
and archives are bare blobs no manifest names: its first run, tonight at
03:30, would delete every one, the current ones included. It now reports
what it would delete and deletes nothing, until the controller holds each
kept archive with a manifest. hq issue 253.
2026-10-05 17:43:11 +02:00
jochen db9a5bff0c records: port to Go, and keep the checkout in a directory it owns
Every sync had failed since a container that ran as root left the checkout
root's: git refused it as dubious ownership, and records answered from a
stale copy. The Go bundle clones into repository/ under its directory, clears
the old layout where it can and names what it cannot. Drops the container-
runtime capability the move into the runtime left behind. hq issue 251.
2026-10-05 17:42:08 +02:00
jochen 85352c8d6f gitea: announce a merge once, from the poll
The merge tool announced pull.merged and so did the poll added for issue
131, so every merge made through the tool reached the controller twice.
The poll sees every path and carries the clone url; it is now the only
emitter. hq issue 250.
2026-10-05 17:39:01 +02:00
mesh-admin f74e1f0309 Merge pull request 'claude-code: say what a registration wrote here, whichever took it first' (#58) from fix/register-answers-what-changed into main 2026-10-05 13:17:39 +00:00
mesh-admin 97b1b2b36b Merge pull request 'Tray applets as modules: openrazer, polychromatic, forticlient, nm-applet' (#60) from feat/tray-applets into main 2026-10-05 13:10:50 +00:00
jochen d0d5546088 Give the remaining tray applets their modules, each with one start
openrazer (the official driver, daemon and library), polychromatic (the
AUR tray, kept as found; its i3 line moves out of the i3 module into its
own node-display-session contribution), forticlient (the AUR VPN client:
its service declared, its configuration never read) and nm-applet (the
desktop half of NetworkManager, apart from the server-side module).

The openrazer daemon fails on both workstations because the account is
not in the openrazer group; openrazer_check names it and the README
carries the one-off step, since the account's user resource is zsh's.

desktop.go learns to tell a program from another sharing its 15-character
command name, so polychromatic's tools never count or end themselves, and
a copies test holds the six carriers to one text.
2026-10-05 15:10:30 +02:00
mesh-admin 2a99b3c3e9 Merge pull request 'Remove jetbrains-toolbox: the operator retired JetBrains' (#61) from chore/remove-jetbrains into main 2026-10-05 13:09:53 +00:00
jochen ade4901d7f Remove jetbrains-toolbox: the operator retired JetBrains
The module goes, its link handler leaves xdg's default applications, and the shared desktop helper's
header names the three bundles left.
2026-10-05 15:09:14 +02:00
mesh-admin f2b5168647 Merge pull request 'Add slack and jetbrains-toolbox: one start each, the apps kept as found' (#59) from feat/slack-and-jetbrains-toolbox into main 2026-10-05 13:07:29 +00:00
jochen 75f25fca21 Add slack and jetbrains-toolbox: one start each, the apps kept as found
Slack comes from the AUR and Toolbox from JetBrains' self-updating tarball,
so neither is declared: the host installs official packages only, and a
pinned archive would fight Toolbox's own updates. Slack's start and the
operator's i3 window rules become one node-display-session contribution,
because Slack's own launch-on-login is a symlink in ~/.config/autostart.
Toolbox keeps its own autostart entry as its one start. The shared
desktop.go header now names all four bundles.
2026-10-05 15:05:55 +02:00
jochen 1fc10b0323 claude-code: say what a registration wrote here, whichever took it first
On g14 the node's own watch took a home registration before the tool did,
and the answer said 'already so' though the file had just been placed.
Compare the view before and after, and render whenever the item applies
here, as the MCP server registration already does.
2026-10-05 14:33:53 +02:00
mesh-admin 4f3e52b47a Merge pull request 'The store collects nightly again (hq ADR 0189)' (#57) from feat/the-store-collects-nightly-again into main 2026-10-05 12:33:35 +00:00
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 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 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 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 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 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 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 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 2026-10-05 09:52:32 +00: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 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
181 changed files with 21193 additions and 1676 deletions
+4
View File
@@ -1,2 +1,6 @@
node_modules/ node_modules/
dist/ dist/
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
modules/slack/cmd/slack-tools/slack-tools
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
+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,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
+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 ## Leaves as found
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state). - 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. - `blueman` and its applet: the `blueman` module's, which relies on this one for the stack and
That line is the `i3` module's to keep or drop. 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. - `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
+2 -1
View File
@@ -8,7 +8,8 @@
"claims": [ "claims": [
{ {
"name": "node-build-agent", "name": "node-build-agent",
"scope": "node" "scope": "node",
"serves": ["current", "kill", "pause", "resume"]
} }
], ],
"requires": [ "requires": [
+29 -4
View File
@@ -22,11 +22,17 @@ whenever the node's tool runtime collects the module's tools:
| file | holds | | 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-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence | | `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 | | `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 Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
this node a subscription token. Nothing else under the home is read or written. 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 ## 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 | | 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 | | 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 | | 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 ## 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 `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`. 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 ## Settings
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`: Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
@@ -69,19 +69,25 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} { for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
var a, b any var a, b any
_ = json.Unmarshal([]byte(got[file]), &a) _ = 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) _ = json.Unmarshal([]byte(want[file]), &b)
if !reflect.DeepEqual(a, 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]) 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("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{}), f.Plain) same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}, Config{}), f.Plain)
} }
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) { func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"}, 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 { var mcp struct {
MCPServers map[string]map[string]any `json:"mcpServers"` MCPServers map[string]map[string]any `json:"mcpServers"`
} }
@@ -89,7 +95,7 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil { if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
t.Fatalf("%v", mcp.MCPServers) 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") t.Fatal("rendering is not deterministic")
} }
} }
@@ -104,7 +110,7 @@ func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
}} }}
read := func(binding *Binding) map[string]any { read := func(binding *Binding) map[string]any {
var m map[string]any var m map[string]any
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil) out := Render(Facts{Console: "x"}, settings, binding, "/h", nil, Config{})
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil { if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -0,0 +1,944 @@
package main
// The agent's configuration, registered through this module at three scopes (novox/hq ADR 0216, to-be 36 §8).
//
// Every registration is one key in the module's `config` state (ADR 0201):
//
// mesh.<kind>.<name> every node running the agent
// node.<node>.<kind>.<name> one node — a list of nodes is one key each
// home.<node>.<kind>.<name> the operator account's own agent directory on one node
//
// Every instance watches the state and takes what applies to it. What it takes lands in one place per kind,
// the one place the vendor honours for it:
//
// skill, agent, command, hook, output-style the plugin `nox-mesh`, in a marketplace in the managed directory
// (at the home scope: the home's own directories)
// instructions sections of the managed instruction file (home: a rule file)
// settings the managed settings file, mesh then node, under the mesh's keys
//
// Tool servers keep their own state and file (`servers`, the managed tool-server file): the exclusive file
// would block a plugin's.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// Plugin is the plugin's name, and its marketplace's: what its items are called in a session, `nox-mesh:<name>`.
const Plugin = "nox-mesh"
// MarketplaceDir is where the marketplace is written, inside the managed directory.
const MarketplaceDir = "marketplace"
// MaxItemBytes is the most one registration may carry, its files included: well under the bus's message limit.
const MaxItemBytes = 256 << 10
// The kinds a registration may be.
const (
KindSkill = "skill"
KindAgent = "agent"
KindCommand = "command"
KindHook = "hook"
KindOutputStyle = "output-style"
KindInstructions = "instructions"
KindSettings = "settings"
)
// Kinds is every kind, in the order a list shows them.
var Kinds = []string{KindSkill, KindAgent, KindCommand, KindHook, KindOutputStyle, KindInstructions, KindSettings}
// The scopes.
const (
ScopeMesh = "mesh"
ScopeNode = "node"
ScopeHome = "home"
)
// SettingsName is the one name a settings registration has: a scope holds one set of settings.
const SettingsName = "settings"
var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
// nodeName is what a node may be called in a key.
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// meshOwnedKeys are the settings a registration may not set: the mesh's own, and those that would deny
// the mesh's console or its marketplace by another way.
var meshOwnedKeys = []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins",
"allowedMcpServers", "deniedMcpServers", "allowManagedMcpServersOnly", "strictKnownMarketplaces", "blockedMarketplaces"}
// hookEvents are the events a hook may be registered for.
var hookEvents = map[string]bool{"PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, "Notification": true,
"Stop": true, "SubagentStop": true, "SessionStart": true, "SessionEnd": true, "PreCompact": true}
// Item is one registration, as the state keeps it.
type Item struct {
Kind string `json:"kind"`
Name string `json:"name"`
Scope string `json:"scope"`
// Files are the item's files by path relative to the item: a skill's SKILL.md and whatever sits beside
// it; for the one-file kinds, `<name>.md`; a hook's scripts.
Files map[string]string `json:"files,omitempty"`
// A hook's event, matcher and command. In the command, ${HOOK_DIR} is the directory its files are in.
Event string `json:"event,omitempty"`
Matcher string `json:"matcher,omitempty"`
Command string `json:"command,omitempty"`
// Settings, for the settings kind: keys of the vendor's settings file.
Settings map[string]any `json:"settings,omitempty"`
// Where it was registered from, and when.
By string `json:"by,omitempty"`
At string `json:"at,omitempty"`
}
// Key is where an item lives in the state, for one node (ignored at the mesh scope).
func (it Item) Key(node string) string {
if it.Scope == ScopeMesh {
return ScopeMesh + "." + it.Kind + "." + it.Name
}
return it.Scope + "." + node + "." + it.Kind + "." + it.Name
}
// ParseKey reads a key back: its scope, the node it is for ("" at the mesh scope), the kind and the name.
func ParseKey(key string) (scope, node, kind, name string, ok bool) {
parts := strings.Split(key, ".")
switch {
case len(parts) == 3 && parts[0] == ScopeMesh:
return ScopeMesh, "", parts[1], parts[2], true
case len(parts) == 4 && (parts[0] == ScopeNode || parts[0] == ScopeHome):
return parts[0], parts[1], parts[2], parts[3], true
}
return "", "", "", "", false
}
func knownKind(k string) bool {
for _, x := range Kinds {
if x == k {
return true
}
}
return false
}
// Problem says why an item cannot be registered, or "".
func (it Item) Problem() string {
if !knownKind(it.Kind) {
return fmt.Sprintf("%q is not a kind: %s", it.Kind, strings.Join(Kinds, ", "))
}
if it.Scope != ScopeMesh && it.Scope != ScopeNode && it.Scope != ScopeHome {
return fmt.Sprintf("%q is not a scope: mesh, node or home", it.Scope)
}
if it.Kind == KindSettings {
if it.Scope == ScopeHome {
return "settings take the mesh and node scopes only: the home's settings file is the person's"
}
if it.Name != SettingsName {
return "a scope holds one set of settings, named " + SettingsName
}
if len(it.Settings) == 0 {
return "no settings given"
}
for _, key := range meshOwnedKeys {
if _, ok := it.Settings[key]; ok {
return fmt.Sprintf("%q is one of the mesh's own keys, or would turn off the mesh's console or plugin; a registration cannot set it", key)
}
}
} else if !itemName.MatchString(it.Name) {
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
}
if it.Kind == KindHook {
if it.Scope == ScopeHome {
return "a hook takes the mesh and node scopes only: at home it would live in the person's settings file"
}
if !hookEvents[it.Event] {
return fmt.Sprintf("%q is not a hook event the agent knows", it.Event)
}
if strings.TrimSpace(it.Command) == "" {
return "a hook needs a command"
}
}
for rel := range it.Files {
if problem := pathProblem(rel); problem != "" {
return problem
}
for other := range it.Files {
if strings.HasPrefix(other, rel+"/") {
return fmt.Sprintf("%q is a file and also the directory of %q", rel, other)
}
}
}
switch it.Kind {
case KindSkill:
if _, ok := it.Files["SKILL.md"]; !ok {
return "a skill needs a SKILL.md"
}
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
if len(it.Files) != 1 || strings.TrimSpace(it.Files[it.Name+".md"]) == "" {
return "this kind is one file, " + it.Name + ".md, with content"
}
}
if n := it.size(); n > MaxItemBytes {
return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes)
}
return ""
}
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
// segment a real name — never empty, `.` or `..`.
func pathProblem(rel string) string {
if rel == "" || path.IsAbs(rel) || strings.ContainsAny(rel, "\\\x00") {
return fmt.Sprintf("%q is not a path inside the item", rel)
}
for _, seg := range strings.Split(rel, "/") {
if seg == "" || seg == "." || seg == ".." {
return fmt.Sprintf("%q is not a path inside the item", rel)
}
}
return ""
}
func (it Item) size() int {
raw, _ := json.Marshal(it)
return len(raw)
}
// ---- the view -------------------------------------------------------------------------------------
// ConfigView is what this node takes from the `config` state: every mesh item, and the node and home items
// for this node — kept in memory from the watch and written through to the module's own file, so the
// managed directory renders without the bus.
type ConfigView struct {
p Paths
mu sync.Mutex
items map[string]Item
}
// NewConfigView is the view as last written through, or empty.
func NewConfigView(p Paths) *ConfigView {
v := &ConfigView{p: p, items: map[string]Item{}}
_ = readJSON(p.config(), &v.items)
return v
}
// Applies says whether a key is this node's to take.
func (v *ConfigView) Applies(key string) bool {
scope, node, _, _, ok := ParseKey(key)
return ok && (scope == ScopeMesh || node == v.p.Node)
}
// Take takes one change, and answers whether what applies to this node changed.
func (v *ConfigView) Take(key, op string, item *Item) bool {
if !v.Applies(key) {
return false
}
_, node, _, _, _ := ParseKey(key)
v.mu.Lock()
defer v.mu.Unlock()
// Only an item that is what its key says: a key decides which nodes take an item, the item where it
// lands, and the two must agree — a mesh key holding a home item would land in every home.
if op == "put" && item != nil && item.Problem() == "" && item.Key(node) == key {
v.items[key] = *item
} else {
delete(v.items, key)
}
return v.writeThroughLocked()
}
// Prune drops what the view holds and the state no longer does: a registration removed while this node
// was away is never handed over by the watch, which hands over what is there, not what went.
func (v *ConfigView) Prune(present []string) bool {
keep := map[string]bool{}
for _, k := range present {
keep[k] = true
}
v.mu.Lock()
defer v.mu.Unlock()
for k := range v.items {
if !keep[k] {
delete(v.items, k)
}
}
return v.writeThroughLocked()
}
// Items is what applies here, by key.
func (v *ConfigView) Items() map[string]Item {
v.mu.Lock()
defer v.mu.Unlock()
out := make(map[string]Item, len(v.items))
for k, it := range v.items {
out[k] = it
}
return out
}
// writeThroughLocked writes the view to its file, with v.mu held: the snapshot and the write are one step, so
// an older snapshot never lands after a newer one.
func (v *ConfigView) writeThroughLocked() bool {
now, _ := indented(v.items)
before, _ := os.ReadFile(v.p.config())
if string(before) == string(now) {
return false
}
_ = os.WriteFile(v.p.config(), now, 0o600)
return true
}
// Config is what applies to this node, sorted for rendering.
type Config struct {
Mesh, Node, Home []Item
}
// ConfigOf sorts the items that apply here by scope, each scope by kind and name.
func ConfigOf(items map[string]Item) Config {
var c Config
for _, it := range items {
switch it.Scope {
case ScopeMesh:
c.Mesh = append(c.Mesh, it)
case ScopeNode:
c.Node = append(c.Node, it)
case ScopeHome:
c.Home = append(c.Home, it)
}
}
for _, list := range [][]Item{c.Mesh, c.Node, c.Home} {
sort.Slice(list, func(i, j int) bool {
if list[i].Kind != list[j].Kind {
return list[i].Kind < list[j].Kind
}
return list[i].Name < list[j].Name
})
}
return c
}
// ---- rendering --------------------------------------------------------------------------------------
// PluginFile is one file of the marketplace: its content and whether it is run.
type PluginFile struct {
Content string
Executable bool
}
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
// over a mesh item of the same kind and name: the node's wins.
func Marketplace(c Config) map[string]PluginFile {
root := "plugins/" + Plugin + "/"
out := map[string]PluginFile{
".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{
"name": Plugin,
"owner": map[string]any{"name": "the mesh"},
"plugins": []any{map[string]any{"name": Plugin, "source": "./plugins/" + Plugin,
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand."}},
})},
root + ".claude-plugin/plugin.json": {Content: jsonFile(map[string]any{
"name": Plugin, "version": "1.0.0",
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand.",
"author": map[string]any{"name": "the mesh"},
})},
}
chosen := map[string]Item{}
for _, layer := range [][]Item{c.Mesh, c.Node} {
for _, it := range layer {
chosen[it.Kind+"/"+it.Name] = it
}
}
keys := make([]string, 0, len(chosen))
for k := range chosen {
keys = append(keys, k)
}
sort.Strings(keys)
hooks := map[string][]any{}
for _, k := range keys {
it := chosen[k]
switch it.Kind {
case KindSkill:
for rel, content := range it.Files {
out[root+"skills/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: isScript(rel, content)}
}
case KindAgent:
out[root+"agents/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
case KindCommand:
out[root+"commands/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
case KindOutputStyle:
out[root+"output-styles/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
case KindHook:
for rel, content := range it.Files {
out[root+"hooks/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: true}
}
command := strings.ReplaceAll(it.Command, "${HOOK_DIR}", `"${CLAUDE_PLUGIN_ROOT}/hooks/`+it.Name+`"`)
entry := map[string]any{"hooks": []any{map[string]any{"type": "command", "command": command}}}
if it.Matcher != "" {
entry["matcher"] = it.Matcher
}
hooks[it.Event] = append(hooks[it.Event], entry)
}
}
if len(hooks) > 0 {
out[root+"hooks/hooks.json"] = PluginFile{Content: jsonFile(map[string]any{"hooks": hooks})}
}
return out
}
func isScript(rel, content string) bool {
return strings.HasPrefix(content, "#!") || strings.HasSuffix(rel, ".sh")
}
// MarketplaceKeys are the two managed settings keys that name the marketplace and enable the plugin.
func MarketplaceKeys() map[string]any {
return map[string]any{
"extraKnownMarketplaces": map[string]any{Plugin: map[string]any{
"source": map[string]any{"source": "directory", "path": filepath.Join(ManagedDir, MarketplaceDir)}}},
"enabledPlugins": map[string]any{Plugin + "@" + Plugin: true},
}
}
// RegisteredSettings is the settings registered for the mesh, with those registered for this node laid over.
func RegisteredSettings(c Config) map[string]any {
out := map[string]any{}
for _, layer := range [][]Item{c.Mesh, c.Node} {
for _, it := range layer {
if it.Kind == KindSettings {
out = mergeSettings(out, it.Settings)
}
}
}
return out
}
// mergeSettings lays b over a: objects are merged key by key, lists are joined without repeats (a
// permission rule or an auto-mode rule added for a node adds to the mesh's), and anything else is b's.
func mergeSettings(a, b map[string]any) map[string]any {
out := map[string]any{}
for k, v := range a {
out[k] = v
}
for k, v := range b {
switch nb := v.(type) {
case map[string]any:
if na, ok := out[k].(map[string]any); ok {
out[k] = mergeSettings(na, nb)
continue
}
case []any:
if la, ok := out[k].([]any); ok {
joined := append([]any{}, la...)
seen := map[string]bool{}
for _, x := range la {
raw, _ := json.Marshal(x)
seen[string(raw)] = true
}
for _, x := range nb {
raw, _ := json.Marshal(x)
if !seen[string(raw)] {
seen[string(raw)] = true
joined = append(joined, x)
}
}
out[k] = joined
continue
}
}
out[k] = v
}
return out
}
// InstructionSections is what the managed instruction file adds after the mesh's own text: the sections
// registered for the mesh, then those for this node.
func InstructionSections(c Config) string {
var b strings.Builder
for _, part := range []struct {
title string
items []Item
}{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} {
var sections []Item
for _, it := range part.items {
if it.Kind == KindInstructions {
sections = append(sections, it)
}
}
if len(sections) == 0 {
continue
}
fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title)
for _, it := range sections {
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
}
}
return b.String()
}
// HomeFiles is what the home scope places in the operator account's agent directory, by path relative to
// that directory.
func HomeFiles(c Config) map[string]string {
out := map[string]string{}
for _, it := range c.Home {
switch it.Kind {
case KindSkill:
for rel, content := range it.Files {
out["skills/"+it.Name+"/"+rel] = content
}
case KindAgent:
out["agents/"+it.Name+".md"] = it.Files[it.Name+".md"]
case KindCommand:
out["commands/"+it.Name+".md"] = it.Files[it.Name+".md"]
case KindOutputStyle:
out["output-styles/"+it.Name+".md"] = it.Files[it.Name+".md"]
case KindInstructions:
out["rules/"+it.Name+".md"] = it.Files[it.Name+".md"]
}
}
return out
}
// ---- the home ---------------------------------------------------------------------------------------
// Placed is what this module placed in the home, by path relative to the agent directory, with the digest
// of what it wrote (ADR 0182: the mesh owns what it places, and only that).
type Placed map[string]string
func digest(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// deletedByHand marks, in the record, a path the mesh placed and the person then deleted: their choice,
// kept until the item is unregistered.
const deletedByHand = "deleted-by-hand"
// PlaceHome brings the home in line with what the home scope wants (ADR 0182): it writes what is wanted and
// absent, or its own; it never writes a path the person made, nor through a directory that is a symbolic
// link; it removes what it placed and is no longer wanted, unless the person changed it since; and a file it
// placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what
// it left alone, and why.
func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
dir := filepath.Join(p.Home, ".claude")
var placed Placed
if !readJSON(p.placed(), &placed) {
placed = Placed{}
}
paths := make([]string, 0, len(want))
for rel := range want {
paths = append(paths, rel)
}
sort.Strings(paths)
for _, rel := range paths {
full := filepath.Join(dir, filepath.FromSlash(rel))
content := want[rel]
if why := linkedParent(dir, rel); why != "" {
left = append(left, rel+": "+why+", left alone")
continue
}
current, err := os.ReadFile(full)
exists := err == nil
ours, wasPlaced := placed[rel]
switch {
case !exists && wasPlaced && ours == deletedByHand:
continue
case !exists && wasPlaced:
placed[rel] = deletedByHand
left = append(left, rel+": deleted by hand, left deleted until the item is unregistered")
continue
case exists && !wasPlaced:
left = append(left, rel+": the person's own, left alone")
continue
case exists && ours == deletedByHand:
left = append(left, rel+": made again by hand after the mesh's was deleted, left alone")
continue
case exists && string(current) == content:
continue
case exists && digest(string(current)) != ours:
left = append(left, rel+": changed by hand since it was placed, left alone")
continue
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
left = append(left, rel+": "+err.Error())
continue
}
mode := os.FileMode(0o644)
if isScript(rel, content) {
mode = 0o755
}
if err := os.WriteFile(full, []byte(content), mode); err != nil {
left = append(left, rel+": "+err.Error())
continue
}
placed[rel] = digest(content)
done = append(done, rel+": placed")
}
for rel, ours := range placed {
if _, wanted := want[rel]; wanted {
continue
}
full := filepath.Join(dir, filepath.FromSlash(rel))
if ours == deletedByHand {
delete(placed, rel)
continue
}
if why := linkedParent(dir, rel); why != "" {
left = append(left, rel+": no longer registered, but "+why+", left alone")
continue
}
current, err := os.ReadFile(full)
if err == nil && digest(string(current)) != ours {
left = append(left, rel+": no longer registered, but changed by hand, left alone")
delete(placed, rel)
continue
}
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
left = append(left, rel+": no longer registered, and could not be removed: "+err.Error())
continue
}
// Up to the kind's own directory, never it: `skills/<name>` goes when empty, `skills` stays.
removeEmptyParents(filepath.Join(dir, strings.SplitN(rel, "/", 2)[0]), filepath.Dir(full))
delete(placed, rel)
done = append(done, rel+": removed")
}
raw, _ := indented(placed)
if err := os.WriteFile(p.placed(), raw, 0o600); err != nil {
left = append(left, "the record of what was placed could not be saved: "+err.Error())
}
return done, left
}
// linkedParent says, when a directory between the agent directory and a file is a symbolic link, which one:
// writing through it would write wherever it points.
func linkedParent(dir, rel string) string {
parts := strings.Split(rel, "/")
at := dir
for _, seg := range parts[:len(parts)-1] {
at = filepath.Join(at, seg)
if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
return at + " is a symbolic link"
}
}
return ""
}
// removeEmptyParents removes empty directories from dir up to, not including, root.
func removeEmptyParents(root, dir string) {
for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) {
if err := os.Remove(dir); err != nil {
return
}
dir = filepath.Dir(dir)
}
}
// HomeConflict says whether registering an item at the home scope here would collide with something the
// person made: "" when it would not.
func HomeConflict(p Paths, it Item) string {
var placed Placed
_ = readJSON(p.placed(), &placed)
for rel := range HomeFiles(Config{Home: []Item{it}}) {
if _, ours := placed[rel]; ours {
continue
}
if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil {
return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel)
}
}
return ""
}
// ---- what the module did not place ------------------------------------------------------------------
// HomeItem is one item found in the home.
type HomeItem struct {
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
Placed bool `json:"placedByTheMesh"`
Notes []string `json:"notes,omitempty"`
}
var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`)
// HomeItems lists the home's skills, subagents, commands, output styles and rule files, says which the mesh
// placed, and notes those that share a name with a mesh item or call a tool server that is not loaded here.
func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
dir := filepath.Join(p.Home, ".claude")
var placed Placed
_ = readJSON(p.placed(), &placed)
inPlugin := map[string]bool{}
for _, layer := range [][]Item{c.Mesh, c.Node} {
for _, it := range layer {
inPlugin[it.Kind+"/"+it.Name] = true
}
}
var out []HomeItem
add := func(kind, name, rel, body string) {
h := HomeItem{Kind: kind, Name: name, Path: filepath.Join(dir, filepath.FromSlash(rel))}
_, h.Placed = placed[rel]
if inPlugin[kind+"/"+name] {
h.Notes = append(h.Notes, "the mesh also registers a "+kind+" of this name, offered as "+Plugin+":"+name)
}
seen := map[string]bool{}
for _, m := range mcpToolRef.FindAllStringSubmatch(body, -1) {
if server := m[1]; !seen[server] && server != meshEntry && loaded[server] == nil {
seen[server] = true
h.Notes = append(h.Notes, "calls tools of `"+server+"`, a tool server not loaded on this node")
}
}
out = append(out, h)
}
if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md"))
if err != nil {
continue // the vendor's own synced folders carry none at their top
}
add(KindSkill, e.Name(), "skills/"+e.Name()+"/SKILL.md", string(body))
}
}
for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} {
entries, err := os.ReadDir(filepath.Join(dir, sub))
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name()))
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Kind != out[j].Kind {
return out[i].Kind < out[j].Kind
}
return out[i].Name < out[j].Name
})
return out
}
// ImportFromHome reads one item of this node's home as an item to register. A rule file becomes instructions.
func ImportFromHome(p Paths, kind, name string) (Item, error) {
dir := filepath.Join(p.Home, ".claude")
it := Item{Kind: kind, Name: name, Files: map[string]string{}}
switch kind {
case KindSkill:
root := filepath.Join(dir, "skills", name)
err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, _ := filepath.Rel(root, full)
raw, err := os.ReadFile(full)
if err != nil {
return err
}
it.Files[filepath.ToSlash(rel)] = string(raw)
return nil
})
if err != nil {
return Item{}, fmt.Errorf("the skill %s in this home: %w", name, err)
}
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind]
raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md"))
if err != nil {
return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err)
}
it.Files[name+".md"] = string(raw)
default:
return Item{}, fmt.Errorf("a %s is not imported from a home", kind)
}
return it, nil
}
// ---- registering ------------------------------------------------------------------------------------
// ConfigState is the `config` state as this module reaches it through the runtime.
type ConfigState interface {
Put(key string, value any) error
Delete(key string) error
Keys() ([]string, error)
Get(key string) (json.RawMessage, bool, error)
}
// Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the
// node and home scopes one key per node — this node when none is given. Taken into this node's view at once,
// so the answer says what it did here.
func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) {
if !unregister {
it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339)
if problem := it.Problem(); problem != "" {
return map[string]any{"registered": false, "reason": problem}, nil
}
} else if !knownKind(it.Kind) {
return map[string]any{"unregistered": false, "reason": fmt.Sprintf("%q is not a kind", it.Kind)}, nil
}
if it.Scope == ScopeMesh {
nodes = []string{""}
} else if len(nodes) == 0 {
nodes = []string{p.Node}
} else if problem := nodesProblem(nodes); problem != "" {
return map[string]any{verbOf(unregister): false, "reason": problem}, nil
}
if !unregister && it.Scope == ScopeHome {
for _, n := range nodes {
if n == p.Node {
if conflict := HomeConflict(p, it); conflict != "" {
return map[string]any{"registered": false, "reason": conflict}, nil
}
}
}
}
// Compared before and after rather than read from Take: this node's own watch may take the same change
// first, and then Take here finds nothing new although this call made it.
before, _ := json.Marshal(v.Items())
var keys []string
for _, n := range nodes {
key := it.Key(n)
keys = append(keys, key)
var err error
if unregister {
err = state.Delete(key)
} else {
err = state.Put(key, it)
}
if err != nil {
return nil, err
}
op := "put"
if unregister {
op = "delete"
}
item := it
v.Take(key, op, &item)
}
after, _ := json.Marshal(v.Items())
here := string(before) != string(after)
for _, k := range keys {
here = here || v.Applies(k)
}
verb := map[bool]string{false: "registered", true: "unregistered"}[unregister]
answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys}
if !unregister {
switch {
case it.Scope == ScopeHome && it.Kind == KindCommand:
answer["offered as"] = "/" + it.Name
case it.Scope == ScopeHome:
answer["offered as"] = it.Name + ", from the home"
case it.Kind == KindSkill || it.Kind == KindAgent || it.Kind == KindOutputStyle:
answer["offered as"] = Plugin + ":" + it.Name
case it.Kind == KindCommand:
answer["offered as"] = "/" + Plugin + ":" + it.Name
}
}
if here {
// Rendered whenever it applies here, so the answer says what this node wrote, whichever of the
// watch and this call took the change.
rendered, err := RenderNow(p, write)
answer["rendered here"] = rendered
if err != nil {
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
}
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
} else {
answer["here"] = "not this node: each node it is for takes it from the bus"
}
return answer, nil
}
func verbOf(unregister bool) string {
return map[bool]string{false: "registered", true: "unregistered"}[unregister]
}
// nodesProblem says why a list of nodes cannot name keys, or "". "all" has been expanded before this.
func nodesProblem(nodes []string) string {
for _, n := range nodes {
if !nodeName.MatchString(n) {
return fmt.Sprintf("%q is not a node's name", n)
}
}
return ""
}
// ExpandNodes turns `all` into every node running the module; anything else is kept.
func ExpandNodes(nodes []string, running func() ([]string, error)) ([]string, error) {
if len(nodes) != 1 || nodes[0] != "all" {
return nodes, nil
}
all, err := running()
if err != nil {
return nil, fmt.Errorf("which nodes run claude-code: %w", err)
}
if len(all) == 0 {
return nil, fmt.Errorf("no node is known to run claude-code")
}
return all, nil
}
// List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state.
func List(state ConfigState, kind string) (map[string]any, error) {
keys, err := state.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
out := map[string]any{}
for _, key := range keys {
_, _, k, _, ok := ParseKey(key)
if !ok || (kind != "" && k != kind) {
continue
}
raw, found, err := state.Get(key)
if err != nil || !found {
continue
}
var it Item
if json.Unmarshal(raw, &it) != nil {
continue
}
files := make([]string, 0, len(it.Files))
for f := range it.Files {
files = append(files, f)
}
sort.Strings(files)
summary := map[string]any{"by": it.By, "at": it.At}
if len(files) > 0 {
summary["files"] = files
}
if it.Kind == KindHook {
summary["event"], summary["matcher"], summary["command"] = it.Event, it.Matcher, it.Command
}
if it.Kind == KindSettings {
summary["settings"] = it.Settings
}
out[key] = summary
}
return out, nil
}
// Show is one registration in full: its files' content included.
func Show(state ConfigState, key string) (any, error) {
raw, found, err := state.Get(key)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("nothing is registered at %s", key)
}
var it Item
if err := json.Unmarshal(raw, &it); err != nil {
return nil, err
}
return it, nil
}
@@ -0,0 +1,470 @@
package main
// The agent's configuration registered at three scopes (novox/hq ADR 0216): each test is one row of the
// record's "How it is checked".
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// memConfig is the `config` state as a map, shared by the nodes of a test the way the bus shares it.
type memConfig map[string]json.RawMessage
func (m memConfig) Put(key string, value any) error {
raw, err := json.Marshal(value)
m[key] = raw
return err
}
func (m memConfig) Delete(key string) error { delete(m, key); return nil }
func (m memConfig) Keys() ([]string, error) {
out := []string{}
for k := range m {
out = append(out, k)
}
return out, nil
}
func (m memConfig) Get(key string) (json.RawMessage, bool, error) {
raw, ok := m[key]
return raw, ok, nil
}
// deliver hands every key of the state to a node's view, as its watch would.
func deliver(m memConfig, v *ConfigView) {
for key, raw := range m {
var it Item
_ = json.Unmarshal(raw, &it)
v.Take(key, "put", &it)
}
}
func one(name, content string) map[string]string { return map[string]string{name + ".md": content} }
func pluginOf(t *testing.T, w map[string]string) map[string]PluginFile {
t.Helper()
var files map[string]PluginFile
if err := json.Unmarshal([]byte(w[MarketplaceDir+"/"]), &files); err != nil {
t.Fatalf("no marketplace written: %v", err)
}
return files
}
func TestEachKindLandsInItsOnePlace(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
register := func(it Item) {
t.Helper()
answer, err := Register(p, it, nil, false, state, view, writer(w))
if err != nil || answer["registered"] == false {
t.Fatalf("%s %s: %v %v", it.Kind, it.Name, answer, err)
}
}
register(Item{Kind: KindSkill, Name: "review", Scope: ScopeMesh,
Files: map[string]string{"SKILL.md": "---\nname: review\ndescription: d\n---\nbody", "scripts/run.sh": "#!/bin/sh\necho hi\n"}})
register(Item{Kind: KindAgent, Name: "reviewer", Scope: ScopeMesh, Files: one("reviewer", "---\nname: reviewer\n---\nx")})
register(Item{Kind: KindCommand, Name: "ship", Scope: ScopeMesh, Files: one("ship", "ship it")})
register(Item{Kind: KindOutputStyle, Name: "terse", Scope: ScopeMesh, Files: one("terse", "---\nname: terse\n---\nshort")})
register(Item{Kind: KindHook, Name: "guard", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
Command: "${HOOK_DIR}/guard.sh", Files: map[string]string{"guard.sh": "#!/bin/sh\nexit 0\n"}})
register(Item{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: one("conventions", "Commit in the imperative.")})
register(Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh,
Settings: map[string]any{"permissions": map[string]any{"deny": []any{"Bash(rm -rf:*)"}}}})
plugin := pluginOf(t, w)
root := "plugins/" + Plugin + "/"
for _, want := range []string{".claude-plugin/marketplace.json", root + ".claude-plugin/plugin.json",
root + "skills/review/SKILL.md", root + "skills/review/scripts/run.sh", root + "agents/reviewer.md",
root + "commands/ship.md", root + "output-styles/terse.md", root + "hooks/hooks.json", root + "hooks/guard/guard.sh"} {
if _, ok := plugin[want]; !ok {
t.Errorf("the plugin lacks %s", want)
}
}
if !plugin[root+"skills/review/scripts/run.sh"].Executable || !plugin[root+"hooks/guard/guard.sh"].Executable {
t.Error("a script is not executable")
}
var hooks struct {
Hooks map[string][]struct {
Matcher string `json:"matcher"`
Hooks []struct {
Command string `json:"command"`
} `json:"hooks"`
} `json:"hooks"`
}
_ = json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks)
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 1 || pre[0].Matcher != "Bash" ||
pre[0].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
t.Errorf("the hook's command does not name its directory, quoted: %s", plugin[root+"hooks/hooks.json"].Content)
}
if !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.") {
t.Errorf("the instruction section is not in the managed instruction file:\n%s", w["CLAUDE.md"])
}
var managed map[string]any
_ = json.Unmarshal([]byte(w["managed-settings.json"]), &managed)
if managed["permissions"] == nil || managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
t.Errorf("managed settings: %v", managed)
}
if _, inPlugin := plugin[root+"settings.json"]; inPlugin {
t.Error("settings were put in the plugin, which drops them")
}
}
func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T) {
a, wa := node(t, "laptop")
b, wb := node(t, "server")
state := memConfig{}
va, vb := NewConfigView(a), NewConfigView(b)
for _, r := range []struct {
it Item
nodes []string
}{
{Item{Kind: KindCommand, Name: "everywhere", Scope: ScopeMesh, Files: one("everywhere", "x")}, nil},
{Item{Kind: KindCommand, Name: "server-only", Scope: ScopeNode, Files: one("server-only", "x")}, []string{"server"}},
{Item{Kind: KindCommand, Name: "at-home", Scope: ScopeHome, Files: one("at-home", "x")}, []string{"laptop"}},
} {
if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil {
t.Fatal(err)
}
}
deliver(state, vb)
if _, err := RenderNow(b, writer(wb)); err != nil {
t.Fatal(err)
}
root := "plugins/" + Plugin + "/commands/"
pa, pb := pluginOf(t, wa), pluginOf(t, wb)
if _, ok := pa[root+"everywhere.md"]; !ok {
t.Error("the mesh item is missing on the laptop")
}
if _, ok := pb[root+"everywhere.md"]; !ok {
t.Error("the mesh item is missing on the server")
}
if _, ok := pa[root+"server-only.md"]; ok {
t.Error("the server's item reached the laptop")
}
if _, ok := pb[root+"server-only.md"]; !ok {
t.Error("the server's item is missing on the server")
}
if _, err := os.Stat(filepath.Join(a.Home, ".claude", "commands", "at-home.md")); err != nil {
t.Error("the home item was not placed in the laptop's home")
}
if _, err := os.Stat(filepath.Join(b.Home, ".claude", "commands", "at-home.md")); err == nil {
t.Error("the laptop's home item reached the server's home")
}
if _, ok := pa[root+"at-home.md"]; ok {
t.Error("a home item went into the plugin")
}
}
func TestTheMeshsOwnKeysCannotBeSetOrReplaced(t *testing.T) {
for _, key := range []string{"extraKnownMarketplaces", "enabledPlugins", "attribution", "apiKeyHelper"} {
it := Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{key: true}}
if it.Problem() == "" {
t.Errorf("a registration could set %s", key)
}
}
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"enabledPlugins": map[string]any{Plugin + "@" + Plugin: false}}},
nil, "/h", nil, Config{})
var managed map[string]any
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
if managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
t.Error("the operator's setting turned the mesh's plugin off")
}
}
func TestSettingsLayMeshThenNodeAndJoinTheirLists(t *testing.T) {
c := Config{
Mesh: []Item{{Kind: KindSettings, Scope: ScopeMesh, Settings: map[string]any{
"permissions": map[string]any{"deny": []any{"A"}}, "model": "mesh-model"}}},
Node: []Item{{Kind: KindSettings, Scope: ScopeNode, Settings: map[string]any{
"permissions": map[string]any{"deny": []any{"A", "B"}}, "model": "node-model"}}},
}
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"permissions": map[string]any{"allow": []any{"C"}}}},
nil, "/h", nil, c)
var managed struct {
Permissions map[string][]string `json:"permissions"`
Model string `json:"model"`
}
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
if strings.Join(managed.Permissions["deny"], ",") != "A,B" || strings.Join(managed.Permissions["allow"], ",") != "C" || managed.Model != "node-model" {
t.Fatalf("%+v", managed)
}
}
func TestTheHomeScopeOwnsOnlyWhatItPlaced(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
mine := filepath.Join(p.Home, ".claude", "agents", "mine.md")
_ = os.MkdirAll(filepath.Dir(mine), 0o755)
writeFile(t, mine, "the person's own")
answer, _ := Register(p, Item{Kind: KindAgent, Name: "mine", Scope: ScopeHome, Files: one("mine", "the mesh's")}, nil, false, state, view, writer(w))
if answer["registered"] != false {
t.Fatalf("a name the person uses was taken: %v", answer)
}
if raw, _ := os.ReadFile(mine); string(raw) != "the person's own" {
t.Fatal("the person's file was overwritten")
}
placed := filepath.Join(p.Home, ".claude", "agents", "placed.md")
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
t.Fatal(err)
}
if raw, _ := os.ReadFile(placed); string(raw) != "v1" {
t.Fatal("the home item was not placed")
}
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(placed); err == nil {
t.Fatal("unregistering did not remove what the mesh placed")
}
if _, err := os.Stat(mine); err != nil {
t.Fatal("unregistering removed the person's file")
}
// Changed by hand after it was placed: left alone when unregistered.
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome, Files: one("edited", "v1")}, nil, false, state, view, writer(w)); err != nil {
t.Fatal(err)
}
edited := filepath.Join(p.Home, ".claude", "agents", "edited.md")
writeFile(t, edited, "changed by hand")
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
t.Fatal(err)
}
if raw, _ := os.ReadFile(edited); string(raw) != "changed by hand" {
t.Fatal("a placed file changed by hand was removed")
}
}
func TestAnItemAboveTheLimitOrMisshapenIsRefused(t *testing.T) {
big := Item{Kind: KindSkill, Name: "big", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": strings.Repeat("x", MaxItemBytes+1)}}
cases := map[string]Item{
"too big": big,
"a bad name": {Kind: KindAgent, Name: "Bad Name", Scope: ScopeMesh, Files: one("Bad Name", "x")},
"a skill without one": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"other.md": "x"}},
"a path outside": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": "x", "../escape": "x"}},
"a hook at home": {Kind: KindHook, Name: "h", Scope: ScopeHome, Event: "Stop", Command: "true"},
"settings at home": {Kind: KindSettings, Name: SettingsName, Scope: ScopeHome, Settings: map[string]any{"model": "x"}},
"an unknown event": {Kind: KindHook, Name: "h", Scope: ScopeMesh, Event: "Whenever", Command: "true"},
}
for label, it := range cases {
if it.Problem() == "" {
t.Errorf("%s was accepted", label)
}
}
}
func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\nuse mcp__gone__do_it")
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
it, err := ImportFromHome(p, KindSkill, "old")
if err != nil || len(it.Files) != 2 || it.Files["scripts/a.sh"] == "" {
t.Fatalf("%+v %v", it, err)
}
items := HomeItems(p, Config{Mesh: []Item{{Kind: KindSkill, Name: "old"}}}, Servers{})
if len(items) != 1 || len(items[0].Notes) != 2 {
t.Fatalf("%+v", items)
}
}
// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced.
func TestTheManifestListsEveryToolServed(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
}
_ = json.Unmarshal(raw, &m)
listed := map[string]bool{}
for _, n := range m.Tools {
listed[n] = true
}
p, _ := node(t, "laptop")
served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p))
for _, tool := range served {
if !listed[tool.Name] {
t.Errorf("%s is served and not in the manifest", tool.Name)
}
delete(listed, tool.Name)
}
for n := range listed {
t.Errorf("%s is in the manifest and not served", n)
}
}
// The tree writer's comparison: a directory holding exactly the files given, executable bits included.
func TestATreeIsTheSameOnlyWhenEveryFileAndModeIs(t *testing.T) {
dir := t.TempDir()
files := map[string]PluginFile{"a.md": {Content: "a"}, "s/run.sh": {Content: "#!/bin/sh\n", Executable: true}}
_ = os.MkdirAll(filepath.Join(dir, "s"), 0o755)
writeFile(t, filepath.Join(dir, "a.md"), "a")
writeFile(t, filepath.Join(dir, "s", "run.sh"), "#!/bin/sh\n")
if sameTree(dir, files) {
t.Fatal("a script without its executable bit counted as the same")
}
_ = os.Chmod(filepath.Join(dir, "s", "run.sh"), 0o755)
if !sameTree(dir, files) {
t.Fatal("the same tree counted as different")
}
writeFile(t, filepath.Join(dir, "extra.md"), "x")
if sameTree(dir, files) {
t.Fatal("an extra file counted as the same")
}
}
// A registration removed while a node was away is dropped when it is back: the watch hands over only what is
// there, so the view is pruned to the keys the state still holds.
func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
for _, name := range []string{"kept", "gone"} {
if _, err := Register(p, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}, nil, false, state, view, writer(w)); err != nil {
t.Fatal(err)
}
}
delete(state, "mesh.command.gone") // removed from another node while this one was off
back := NewConfigView(p) // the node starts again from what it last wrote
if len(back.Items()) != 2 {
t.Fatalf("the view did not start from what was last written: %v", back.Items())
}
keys, _ := state.Keys()
if !back.Prune(keys) || len(back.Items()) != 1 {
t.Fatalf("after pruning: %v", back.Items())
}
}
// The review's findings, each held by a test.
func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
for label, files := range map[string]map[string]string{
"a dot": {"SKILL.md": "x", ".": "x"},
"an empty segment": {"SKILL.md": "x", "a//b": "x"},
"a parent segment": {"SKILL.md": "x", "a/../b": "x"},
"a file and directory": {"SKILL.md": "x", "a": "x", "a/b": "x"},
} {
if (Item{Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: files}).Problem() == "" {
t.Errorf("%s was accepted", label)
}
}
p, _ := node(t, "laptop")
v := NewConfigView(p)
home := Item{Kind: KindCommand, Name: "x", Scope: ScopeHome, Files: one("x", "y")}
if v.Take("mesh.command.x", "put", &home); len(v.Items()) != 0 {
t.Fatal("a mesh key holding a home item was taken")
}
for _, key := range []string{"mesh.command.x", "mesh.agent.x"} {
mesh := Item{Kind: KindCommand, Name: "x", Scope: ScopeMesh, Files: one("x", "y")}
v.Take(key, "put", &mesh)
}
if len(v.Items()) != 1 {
t.Fatalf("an item under a key of another kind was taken: %v", v.Items())
}
}
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
t.Fatalf("%v %v", nodes, err)
}
p, w := node(t, "laptop")
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
if answer["registered"] != false {
t.Fatalf("a node name with a dot was used in a key: %v", answer)
}
if (Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{"deniedMcpServers": []any{}}}).Problem() == "" {
t.Fatal("a registration could deny the mesh's console")
}
}
func TestTheOperatorsOwnPluginsAndMarketplacesAreKept(t *testing.T) {
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{
"enabledPlugins": map[string]any{"theirs@market": true},
"extraKnownMarketplaces": map[string]any{"market": map[string]any{"source": map[string]any{"source": "github", "repo": "o/r"}}},
}}, nil, "/h", nil, Config{})
var managed struct {
Enabled map[string]any `json:"enabledPlugins"`
Known map[string]any `json:"extraKnownMarketplaces"`
}
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
if managed.Enabled["theirs@market"] != true || managed.Enabled[Plugin+"@"+Plugin] != true || managed.Known["market"] == nil || managed.Known[Plugin] == nil {
t.Fatalf("%+v", managed)
}
}
func TestTheHomeLeavesThePersonsChoicesAlone(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
// An identical file the person made is not taken over, so unregistering never removes it.
_ = os.MkdirAll(filepath.Join(dir, "agents"), 0o755)
writeFile(t, filepath.Join(dir, "agents", "same.md"), "x")
if _, left := PlaceHome(p, map[string]string{"agents/same.md": "x"}); len(left) != 1 {
t.Fatalf("an identical file of the person's was taken over: %v", left)
}
PlaceHome(p, map[string]string{})
if _, err := os.Stat(filepath.Join(dir, "agents", "same.md")); err != nil {
t.Fatal("the person's file was removed")
}
// A placed file the person deleted stays deleted while it is registered.
PlaceHome(p, map[string]string{"commands/c.md": "x"})
_ = os.Remove(filepath.Join(dir, "commands", "c.md"))
PlaceHome(p, map[string]string{"commands/c.md": "x"})
if _, err := os.Stat(filepath.Join(dir, "commands", "c.md")); err == nil {
t.Fatal("a file the person deleted was placed again")
}
// The kind's own directory stays when the mesh's last item in it goes.
PlaceHome(p, map[string]string{"skills/s/SKILL.md": "x"})
PlaceHome(p, map[string]string{})
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); err == nil {
t.Fatal("the item's own directory was left")
}
if _, err := os.Stat(filepath.Join(dir, "skills")); err != nil {
t.Fatal("the kind's directory was removed")
}
// Never through a symbolic link.
elsewhere := t.TempDir()
if err := os.Symlink(elsewhere, filepath.Join(dir, "output-styles")); err != nil {
t.Skip("no symbolic links here")
}
PlaceHome(p, map[string]string{"output-styles/o.md": "x"})
if _, err := os.Stat(filepath.Join(elsewhere, "o.md")); err == nil {
t.Fatal("a file was written through a symbolic link")
}
}
func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
p, _ := node(t, "laptop")
w := map[string]string{}
failing := func(name, content string) (string, error) {
if name == MarketplaceDir+"/" {
return "", os.ErrPermission
}
w[name] = content
return name + ": written", nil
}
if _, err := RenderNow(p, failing); err == nil {
t.Fatal("the failure was not reported")
}
if w["managed-settings.json"] == "" || w["managed-mcp.json"] == "" || w["CLAUDE.md"] == "" {
t.Fatalf("the other files were not written: %v", w)
}
}
// The answer says what this node wrote even when its own watch took the change first.
func TestTheAnswerSaysWhatWasWrittenWhenTheWatchWasFirst(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
it := Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}
first := it
view.Take(it.Key("laptop"), "put", &first) // the watch, first
answer, err := Register(p, it, nil, false, state, view, writer(w))
if err != nil || answer["rendered here"] == nil || answer["here"] != nil {
t.Fatalf("%v %v", answer, err)
}
}
@@ -0,0 +1,353 @@
package main
// The tools that register the agent's configuration (novox/hq ADR 0216): for each kind a list, a register and an
// unregister; for settings, a read, a set, a clear and the permission rules; and the status and import tools
// for what the module did not place.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// kindTool is how one kind is named in its tools and described to whoever calls them.
type kindTool struct {
kind, tool, what, lands string
}
var kindTools = []kindTool{
{KindSkill, "skill", "a skill: a folder with a SKILL.md (front matter `name` and `description`) and any files beside it",
"the plugin's skills/<name>/ (home: ~/.claude/skills/<name>/)"},
{KindAgent, "agent", "a subagent: one markdown file with front matter (`name`, `description`, optionally `tools`, `model`)",
"the plugin's agents/<name>.md (home: ~/.claude/agents/<name>.md)"},
{KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)",
"the plugin's commands/<name>.md, run as /" + Plugin + ":<name> (home: ~/.claude/commands/<name>.md, run as /<name>)"},
{KindHook, "hook", "a hook: an event, an optional matcher, a command, and optionally the scripts it runs — ${HOOK_DIR} in the command is their directory",
"the plugin's hooks/hooks.json, its scripts in hooks/<name>/ (mesh and node scopes only)"},
{KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)",
"the plugin's output-styles/<name>.md (home: ~/.claude/output-styles/<name>.md)"},
{KindInstructions, "instructions", "a section of instructions every session reads",
"a section of the managed instruction file, after the mesh's own text (home: a rule file, ~/.claude/rules/<name>.md)"},
}
func boolArg(a map[string]any, k string) bool { b, _ := a[k].(bool); return b }
func strArg(a map[string]any, k string) string {
s, _ := a[k].(string)
return strings.TrimSpace(s)
}
// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list.
func targetNodes(a map[string]any) ([]string, error) {
return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe)
}
// scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for.
func scopeOf(a map[string]any) string {
if s := strArg(a, "scope"); s != "" {
return s
}
return ScopeMesh
}
// filesOf reads an item's files: `content` for a one-file kind, or `files`, a map of path to content.
func filesOf(kind, name string, a map[string]any) (map[string]string, error) {
out := map[string]string{}
if raw, ok := a["files"].(map[string]any); ok {
for rel, v := range raw {
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("files.%s is not text", rel)
}
out[rel] = s
}
}
if content, ok := a["content"].(string); ok && content != "" {
switch kind {
case KindSkill:
out["SKILL.md"] = content
case KindHook:
return nil, errors.New("a hook's scripts are given as files")
default:
out[name+".md"] = content
}
}
return out, nil
}
func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`)
nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`)
var out []stdio.Tool
for _, k := range kindTools {
k := k
input := map[string]any{
"name": str("the name: lower-case letters, digits and -"),
"scope": scopeArg,
"nodes": nodesArg,
}
switch k.kind {
case KindSkill:
input["content"] = str("the SKILL.md, when the skill is that one file")
input["files"] = map[string]any{"type": "object", "description": "the skill's files by path inside it, SKILL.md among them, e.g. {\"SKILL.md\": \"...\", \"scripts/run.sh\": \"#!/bin/sh ...\"}"}
case KindHook:
input["event"] = str("PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart, SessionEnd or PreCompact")
input["matcher"] = str("for tool events, which tools, e.g. Bash or Edit|Write; absent is every one")
input["command"] = str("the shell command; ${HOOK_DIR} is the directory of the files given, e.g. ${HOOK_DIR}/check.sh")
input["files"] = map[string]any{"type": "object", "description": "the scripts the command runs, by path, e.g. {\"check.sh\": \"#!/bin/sh ...\"}"}
default:
input["content"] = str("the file's content, front matter included")
}
out = append(out,
stdio.Tool{Name: "claude_code_" + k.tool + "_list",
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, or at home where the person already has one of that name.",
Input: input,
Run: func(a map[string]any) (any, error) {
name := strArg(a, "name")
files, err := filesOf(k.kind, name, a)
if err != nil {
return nil, err
}
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, false, state, view, writeManaged)
}},
stdio.Tool{Name: "claude_code_" + k.tool + "_unregister",
Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.",
Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, true, state, view, writeManaged)
}},
)
}
settingsScope := str(`mesh (every node; the default) or node (the nodes given, or this one)`)
out = append(out,
stdio.Tool{Name: "claude_code_settings_get",
Description: "Claude Code's managed settings on this node as written, and where each part came from: the operator's `managed_settings` setting (ADR 0213), the settings registered for the mesh and for this node, and the mesh's own keys, which always win.",
Run: func(map[string]any) (any, error) {
written := map[string]any{}
_ = readJSON(filepath.Join(ManagedDir, "managed-settings.json"), &written)
var settings Settings
_ = readJSON(p.Settings, &settings)
c := ConfigOf(view.Items())
layers := map[string]any{"managed_settings setting": settings.ManagedSettings}
for _, it := range c.Mesh {
if it.Kind == KindSettings {
layers["registered for the mesh"] = it.Settings
}
}
for _, it := range c.Node {
if it.Kind == KindSettings {
layers["registered for this node"] = it.Settings
}
}
return map[string]any{"written": written, "layers": layers,
"order": "managed_settings setting, then mesh, then node — objects merged, lists joined — then the mesh's own keys"}, nil
}},
stdio.Tool{Name: "claude_code_settings_set",
Description: "Set Claude Code settings (the vendor's settings keys: permissions, autoMode, env, model, hooks, statusLine, …) for every node or some. Merged into what that scope holds — objects key by key, lists joined — unless replace is set. The mesh's own keys (attribution, the connectors key, the key-helper, the plugin's marketplace) cannot be set. The agent refuses to loosen its own settings: this is the operator's act.",
Input: map[string]any{
"settings": map[string]any{"type": "object", "description": "settings keys, e.g. {\"permissions\": {\"deny\": [\"Bash(rm -rf:*)\"]}}"},
"replace": map[string]any{"type": "boolean", "description": "replace what the scope holds instead of merging into it"},
"scope": settingsScope, "nodes": nodesArg,
},
Run: func(a map[string]any) (any, error) {
given, _ := a["settings"].(map[string]any)
if len(given) == 0 {
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
if boolArg(a, "replace") {
return given
}
return mergeSettings(held, given)
}, state, view)
}},
stdio.Tool{Name: "claude_code_settings_clear",
Description: "Remove the Claude Code settings registered at a scope.",
Input: map[string]any{"scope": settingsScope, "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, true, state, view, writeManaged)
}},
stdio.Tool{Name: "claude_code_permission_add",
Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.",
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
list, rule := strArg(a, "list"), strArg(a, "rule")
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}})
}, state, view)
}},
stdio.Tool{Name: "claude_code_permission_remove",
Description: "Remove a permission rule added through this module, at its scope.",
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
list, rule := strArg(a, "list"), strArg(a, "rule")
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
perms, _ := held["permissions"].(map[string]any)
rules, _ := perms[list].([]any)
if len(rules) == 0 {
return held // nothing to remove: what the scope holds stays as it is
}
kept := []any{}
for _, r := range rules {
if r != rule {
kept = append(kept, r)
}
}
next := mergeSettings(map[string]any{}, held)
np := mergeSettings(map[string]any{}, perms)
np[list] = kept
next["permissions"] = np
return next
}, state, view)
}},
stdio.Tool{Name: "claude_code_config_list",
Description: "Everything registered for Claude Code on the mesh through this module — skills, subagents, commands, hooks, output styles, instruction sections, settings — by key, with who registered each and its files.",
Run: func(map[string]any) (any, error) { return List(state, "") }},
stdio.Tool{Name: "claude_code_config_show",
Description: "One registration in full, its files' content included, by its key as a list shows it (e.g. mesh.skill.review).",
Input: map[string]any{"key": str("the key")},
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
stdio.Tool{Name: "claude_code_config_status",
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles and rule files — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
Run: func(map[string]any) (any, error) {
c := ConfigOf(view.Items())
names := func(items []Item) []string {
out := []string{}
for _, it := range items {
out = append(out, it.Kind+" "+it.Name)
}
return out
}
var mcp struct {
MCPServers Servers `json:"mcpServers"`
}
_ = readJSON(filepath.Join(ManagedDir, "managed-mcp.json"), &mcp)
var placed Placed
_ = readJSON(p.placed(), &placed)
plugin := []string{}
root := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin)
_ = filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
rel, _ := filepath.Rel(root, full)
plugin = append(plugin, rel)
}
return nil
})
return map[string]any{
"applies here": map[string]any{"mesh": names(c.Mesh), "node": names(c.Node), "home": names(c.Home)},
"plugin": map[string]any{"name": Plugin, "files": plugin},
"home": HomeItems(p, c, mcp.MCPServers),
"placed": placed,
}, nil
}},
stdio.Tool{Name: "claude_code_config_import",
Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.",
Input: map[string]any{
"kind": str("skill, agent, command, output-style or instructions (a rule file)"),
"name": str("its name in the home: the skill's folder, or the file without .md"),
"scope": scopeArg, "nodes": nodesArg,
},
Run: func(a map[string]any) (any, error) {
it, err := ImportFromHome(p, strArg(a, "kind"), strArg(a, "name"))
if err != nil {
return nil, err
}
it.Scope = scopeOf(a)
if it.Scope == ScopeHome {
return nil, errors.New("it is already at home; import it to the mesh or node scope")
}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, false, state, view, writeManaged)
}},
)
return out
}
// SetSettings changes the settings registered at a scope, one key per node, by what change makes of what
// the key holds. An empty result removes the key.
func SetSettings(p Paths, scope string, nodes []string, change func(held map[string]any) map[string]any,
state ConfigState, view *ConfigView) (map[string]any, error) {
if scope != ScopeMesh && scope != ScopeNode {
return map[string]any{"set": false, "reason": "settings take the mesh and node scopes only"}, nil
}
if scope == ScopeMesh {
nodes = []string{""}
} else if len(nodes) == 0 {
nodes = []string{p.Node}
} else if problem := nodesProblem(nodes); problem != "" {
return map[string]any{"set": false, "reason": problem}, nil
}
answers := map[string]any{}
for _, n := range nodes {
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scope}
held := map[string]any{}
if raw, found, err := state.Get(it.Key(n)); err != nil {
return nil, err
} else if found {
var was Item
if json.Unmarshal(raw, &was) == nil && was.Settings != nil {
held = was.Settings
}
}
it.Settings = change(held)
target := []string(nil)
if n != "" {
target = []string{n}
}
var answer map[string]any
var err error
if len(it.Settings) == 0 {
answer, err = Register(p, it, target, true, state, view, writeManaged)
} else {
answer, err = Register(p, it, target, false, state, view, writeManaged)
}
if err != nil {
return nil, err
}
answer["settings"] = it.Settings
answers[it.Key(n)] = answer
}
return answers, nil
}
+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 // 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). // /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) { func writeManaged(name, content string) (string, error) {
if strings.HasSuffix(name, "/") {
return writeManagedTree(strings.TrimSuffix(name, "/"), content)
}
path := filepath.Join(ManagedDir, name) path := filepath.Join(ManagedDir, name)
if was, err := os.ReadFile(path); err == nil && string(was) == content { if was, err := os.ReadFile(path); err == nil && string(was) == content {
return name + ": unchanged", nil return name + ": unchanged", nil
@@ -54,6 +57,74 @@ func writeManaged(name, content string) (string, error) {
return name + ": written", nil 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. // 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) } 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) 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) Delete(key string) error { return s.s.Delete(key) }
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() } 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 // nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
// tool's question. // tool's question.
@@ -152,9 +230,9 @@ func nodesOf(v any) []string {
return out 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`) 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", {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.", 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 }}, 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 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. // 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")} servers := stateOf{stdio.State("servers")}
view := NewServerView(p) view := NewServerView(p)
go run(p, view) config := stateOf{stdio.State("config")}
if err := stdio.Serve("", tools(p, servers, view)); err != nil { configView := NewConfigView(p)
go run(p, view, configView)
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
say("%v", err) say("%v", err)
os.Exit(1) os.Exit(1)
} }
} }
// run is the module's long-running half, beside the tools (ADR 0198). // 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). // Every node's MCP servers: the whole current set first, then each change (ADR 0201).
go persist("watching the MCP servers", func() error { go persist("watching the MCP servers", func() error {
return stdio.State("servers").Watch("", func(c stdio.StateChange) error { return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
+35 -4
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) 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) 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) 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. // 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) type Ask func(address string, args any) (json.RawMessage, error)
@@ -102,9 +104,15 @@ func Registered(p Paths) Servers {
return s return s
} }
var renderMu sync.Mutex
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers // RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
// registered here. // registered here.
func RenderNow(p Paths, write WriteManaged) ([]string, error) { 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 var facts Facts
if !readJSON(p.Facts, &facts) || facts.Console == "" { if !readJSON(p.Facts, &facts) || facts.Console == "" {
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts) 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) { if readJSON(p.binding(), &b) {
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)) names := make([]string, 0, len(files))
for n := range files { for n := range files {
names = append(names, n) 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 out []string
var failed []error
for _, n := range names { for _, n := range names {
line, err := write(n, files[n]) line, err := write(n, files[n])
if err != nil { if err != nil {
return out, err failed = append(failed, err)
continue
} }
out = append(out, line) 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 ---------------------------------------------------------------------------------- // ---- the licence ----------------------------------------------------------------------------------
+22 -7
View File
@@ -96,8 +96,10 @@ func jsonFile(v any) string {
} }
// Render composes the three files. registered — what was registered through this module and applies // 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. // here — is laid over the servers the operator set in its settings; config is the rest of what was
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string { // 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{} servers := map[string]any{}
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} { for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
for name, entry := range layer { for name, entry := range layer {
@@ -109,14 +111,27 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
} }
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console} servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
managed := map[string]any{} // The operator's `managed_settings` (ADR 0213), then what was registered for the mesh, then for this node.
for key, value := range settings.ManagedSettings { managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
managed[key] = value managed = mergeSettings(managed, RegisteredSettings(config))
}
// The mesh's own keys are laid last: a setting never replaces them. // The mesh's own keys are laid last: a setting never replaces them.
managed["attribution"] = map[string]any{"commit": "", "pr": ""} managed["attribution"] = map[string]any{"commit": "", "pr": ""}
managed["allowAllClaudeAiMcps"] = true managed["allowAllClaudeAiMcps"] = true
delete(managed, "apiKeyHelper") 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" { if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath managed["apiKeyHelper"] = helperPath
} }
@@ -127,6 +142,6 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
return map[string]string{ return map[string]string{
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}), "managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
"managed-settings.json": jsonFile(managed), "managed-settings.json": jsonFile(managed),
"CLAUDE.md": instructionsText(facts.Node, role), "CLAUDE.md": instructionsText(facts.Node, role) + InstructionSections(config),
} }
} }
+30 -2
View File
@@ -13,7 +13,8 @@
}, },
"state": [ "state": [
"servers", "servers",
"holdings" "holdings",
"config"
], ],
"reads": [ "reads": [
"claude-licence-manager.bindings" "claude-licence-manager.bindings"
@@ -26,7 +27,34 @@
"claude_code_add_api_key", "claude_code_add_api_key",
"claude_code_mcp_list", "claude_code_mcp_list",
"claude_code_mcp_register", "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": [ "resources": [
{ {
+19
View File
@@ -78,6 +78,25 @@ today), remove the unused vendor drivers, once:
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while - **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
the Canon queue is used. the Canon queue is used.
## The print applet: not this module's
The desktop has `system-config-printer` (official, explicit, installed 2026-10-04). Its package ships
`/etc/xdg/autostart/print-applet.desktop`, so from the desktop's next login `dex` starts
`system-config-printer-applet`, a tray icon for print jobs and printer problems. The laptop does not
have the package.
This module does not take it:
- `cups` needs no display and declares nothing graphical. The applet requires `x11-display`, and a
GTK package in this module would put it on any machine that prints, the laptop included, where the
operator never installed it.
- The queues and the default printer are already this module's tools (`cups_printers`, `cups_queue`,
`cups_default`), which is most of what the applet's window offers.
So the applet is left as found on the desktop, started by its package's entry. If it is wanted on both
workstations, it becomes a `system-config-printer` desktop module of its own, beside `blueman` and
`nm-applet`, requiring `x11-display`. If not, removing the package on the desktop ends it.
## Leaves as found ## Leaves as found
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset), The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
+17
View File
@@ -63,6 +63,23 @@
"env": { "env": {
"REGISTRY_STORAGE_DELETE_ENABLED": "true" "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"
]
} }
] ]
} }
File diff suppressed because one or more lines are too long
+50 -51
View File
@@ -14,6 +14,8 @@ tools below are the module's own.
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) | | `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module | | `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) | | `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
| `daemon` | `live-restore` and the mesh's registry in `insecure-registries` written into `/etc/docker/daemon.json`, beside other keys | each key given back as found when the module goes; only the member this module added leaves the list |
| `runtime` | `docker.service` running, enabled at boot; reloaded, never restarted, when `daemon` changes | given back as found (ADR 0118) |
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
takes no volume, no container and no image a container uses, so it never touches a container the mesh takes no volume, no container and no image a container uses, so it never touches a container the mesh
@@ -24,62 +26,60 @@ while the machine was off happens at the next boot.
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and `container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
the module that installs the daemon cannot require it. the module that installs the daemon cannot require it.
## The runtime's own file and service (issue 190, hq ADR 0196, ADR 0222)
This module writes two keys into `/etc/docker/daemon.json` (`into: json`, ADR 0102): `live-restore`
and `insecure-registries`. `dnsmasq` used to write `live-restore`, beside `dns`; it no longer writes
either. Under ADR 0196 a container copies its machine's resolvers, so no module writes `dns`. The
controller's private network wrote `insecure-registries`; under ADR 0222 the controller writes
nothing into this file, and this module states the registry itself (the order below).
- `daemon`: `{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"]}`,
merged into the file beside the keys others write.
- `${seat:mesh-artifact-store:reach}` is where this machine reaches the mesh's artifact store
(host:port), filled in by the controller: no binding, no credential, the same address the mesh
composes into every image it built. Trusting it in the clear is ADR 0082's decision: every path to
it is inside the private network's encryption. While no machine on the network holds the store the
answer is empty, and the controller drops the empty member, so the list gets nothing.
- `insecure-registries` is a list, and the host adds to it rather than replacing it: a machine's own
trusted registries stay, and undeclaring takes out only the member this module added.
- `runtime`: `docker.service` running, enabled at boot, and **reloaded, never restarted**, when
`daemon` changes. A restart stops every container. A reload turns `live-restore` on and takes the
trusted registries, and with `live-restore` on a later restart keeps every container running.
In the apply that moves the key, the host first gives back `dnsmasq`'s resources, then applies this
module's: `live-restore` is set again in the same apply, and the daemon is reloaded once.
`dns` is removed from the file then, but the daemon reads it only at its next start, and a running
container keeps the resolvers it was created with. Each container pinned to a machine's own resolver
is restarted before that machine's `dnsmasq` goes (ADR 0194, step 4).
**The order it lands in.** The controller that fills `${seat:…:reach}` is deployed first: one that
does not know the placeholder would send it through unfilled. Then this module. Then the controller
stops generating the private network's `registry-trust` and `registry-trust-reload` and refuses a
generated resource that collides with a module's (issue 190, steps 2 and 5). In the apply that moves
the member, the host removes the private network's record first (the member leaves the list) and
then applies this module's (it is added back, recorded as this module's); the daemon is reloaded
once, for `daemon`. The address is the same one, so the runtime's trust does not change.
Still elsewhere:
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand; they are left
as they are until a size is chosen for every machine.
## What it does not declare yet, and why ## What it does not declare yet, and why
Three things this module should own are already declared by other modules on every machine. The One thing this module should own is still declared elsewhere. The controller refuses two modules
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package` on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`,
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module
make the module unassignable everywhere. The refusals were checked against the controller's own unassignable everywhere. The refusals were checked against the controller's own
check: check:
``` ```
zsh and docker both declare the name "${machine:account}" zsh and docker both declare the name "${machine:account}"
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
dnsmasq and docker both declare the unit "docker.service"
``` ```
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190) ### The operator account's membership of the `docker` group
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
service:
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
`insecure-registries`. The collision check does not see generated resources.
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
**The change proposed, in one merge:**
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
2. `docker` adds the two resources below:
```json
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
```
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
start keeps every container running.
**Why one merge, and only after this module is on every machine:**
- In one apply, the host first gives back the resources that are no longer declared, then applies
the new ones (mesh-host `apply.go`).
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
again in the same apply. The daemon is reloaded once, after both steps.
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
every container.
**Later:** the controller hands the registry to this module as a value, and the overlay stops
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
per resource.
### 2. The operator account's membership of the `docker` group
The right shape is the host's `user` shape. Its `groups` are additive: the host runs The right shape is the host's `user` shape. Its `groups` are additive: the host runs
`usermod --append` and never takes a group away. `usermod --append` and never takes a group away.
@@ -111,9 +111,8 @@ On the machine the mesh was first installed on, the foundation bundle declared `
never sees them (mesh-host `store.go`). never sees them (mesh-host `store.go`).
- So `docker.package` here is a **second record of the same package**. The apply says "already - So `docker.package` here is a **second record of the same package**. The apply says "already
installed", and neither record ever uninstalls it. installed", and neither record ever uninstalls it.
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step - `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*,
1 would add a second record of that unit. Its found state is *running*, because genesis started because genesis started it, so undeclaring this module leaves the daemon running.
it, so undeclaring this module would leave the daemon running.
## Tools ## Tools
+18
View File
@@ -50,6 +50,24 @@
"state": "running", "state": "running",
"boot": "enabled" "boot": "enabled"
}, },
{
"id": "daemon",
"type": "file",
"path": "/etc/docker/daemon.json",
"mode": "0644",
"into": "json",
"content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"]}\n"
},
{
"id": "runtime",
"type": "service",
"unit": "docker.service",
"state": "running",
"boot": "enabled",
"reload-on": [
"daemon"
]
},
{ {
"id": "prune-service", "id": "prune-service",
"type": "file", "type": "file",
-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", "name": "tools",
"kind": "bundle", "kind": "bundle",
"language": "typescript", "language": "go",
"entrypoints": [ "system": "arch",
"tools/index.js" "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"
]
}
+94
View File
@@ -0,0 +1,94 @@
# forticlient
The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the
operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned
only where a display server is held on the same machine.
**This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no
credential, no gateway, no certificate is declared, read, printed or stored by the module or its
tools. The tools report running and connected state only.
## Owns
| what | where |
|---|---|
| the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled |
Nothing else. It holds no seat, makes no contribution and writes no file.
- **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on
both workstations it is a foreign (AUR) package that repackages the vendor's build, installed
explicitly. The host installs from the official repositories only, so the module cannot declare it.
ADR 0205's pinned archive does not fit: it is a vendor binary set with a root service, a firewall
helper and an install script. It waits for the mesh's package repository (research 027 question 1,
option P2). Until then a fresh workstation installs it by hand.
- **The service is declared, and so depended on.** `forticlient.service` is the package's unit, running
and enabled on both workstations. Declared running and enabled, it is held in that state, and on a
machine without the package the host refuses it by name (*does not exist on this machine*): loud,
never a silent pass. The `asus-zephyrus-g14` module does the same with its foreign daemons. The
module never restarts it: a change to nothing of the module's would, and nothing of the module's
changes.
- **The configuration stays the operator's, and unread.** `/etc/forticlient/`, the client's database
under `/opt/forticlient/`, the account's FortiClient settings, the VPN profiles, saved credentials
and certificates are set in the client's own window. They are found (ADR 0182), and unlike any other
found file, the tools do not even read them.
## How it starts: the vendor's autostart entry, and nothing else
The tray has two processes: `fortitraylauncher`, which starts and watches `fortitray`. The package's
install script links `/etc/xdg/autostart/Fortitray.desktop` to the package's
`/opt/forticlient/Fortitray.desktop` (`Exec=/opt/forticlient/fortitraylauncher`). The session runs it
once at login through the `i3` module's `dex --autostart --environment i3`. **That entry is the tray's
one start.** The module adds no `xinitrc` slot and no `node-display-session` exec, because either would
start it a second time. The link is the vendor's, made by its install script; the mesh does not make
or remove it.
The tunnel is not the tray's: the service's processes hold it, as root. Ending the tray leaves a
connected tunnel connected.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `forticlient_status` (r) | <ul><li>the installed version, and that it is from outside the official repositories</li><li>the service: active, enabled</li><li>whether the launcher and the tray run: pid, since, and the scope or unit they run in</li><li>what starts the tray at login</li><li>connected or not, as the number of the client's tunnel interfaces that are up</li></ul> |
| `forticlient_restart` (a) | asks the tray and its launcher to end (SIGTERM), forces them after 5 s, and starts the launcher in the operator's session as a transient user unit `mesh-forticlient-tray`, so it outlives the tools runtime. The launcher starts the tray. The service and the tunnel are not touched. Refused plainly when nobody is logged in to the desktop |
| `forticlient_check` (r) | <ul><li>the package is installed</li><li>the service is running and enabled</li><li>exactly one start: the vendor's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one launcher and one tray run in a desktop session</li></ul>Being connected is never a finding: that is the operator's to decide. Each finding says what to do |
**What the tools never touch**, held by the tests (a fake machine carries a profile, a gateway, an
address, a secret and a certificate where the client keeps them, and no answer may hold any of them):
- no file under `/etc/forticlient` or the account's FortiClient settings is opened, and under
`/opt/forticlient` only the tray's autostart entry, through its link (it names the launcher and
nothing else);
- the vendor's command-line client and `fortivpn` are never run, and its logs are never read;
- a process is named by its command name only, never by its arguments;
- *connected* is whether an interface named `fctvpn…` is up, from its flags. The interface's name
(it carries an identifier) and its addresses are never answered. A tunnel of a kind that brings up
no such interface (IPsec) is not seen, and the answer says *not connected*.
## What changes when it is assigned
| | laptop | desktop |
|---|---|---|
| package | none: `forticlient-vpn` 7.4.3.5411, explicit, foreign | the same |
| service | none: running and enabled | the same |
| tray | none: dex starts it from the vendor's entry, in the login session's scope | none on disk. **No tray runs now:** that session began before `dex` was installed, and the predecessor's window manager never started it. The next login is the first that starts it |
## Migration (ADR 0182)
Nothing is required on either machine. On the desktop, log out and in once, or run
`forticlient_restart`, and the tray runs from its one start. `forticlient_check` then answers `ok`.
## Leaves as found
Everything of the client's: its configuration and database, the VPN profiles and credentials, its
logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package itself.
## Relies on
- **The package, installed by hand.** Without it the host refuses the service by name.
- **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does
not start. `forticlient_check` says so.
- A display server on the same machine (`x11-display`, ADR 0208 §3).
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,205 @@
package main
// FortiClient as the tools see it: the vendor's scheduler service, the tray and the launcher that
// starts it, the tray's XDG autostart entry, and whether a tunnel is up. This is the operator's work
// VPN, so the tools never read its profiles, its credentials, its gateways or its certificates: no file
// under /etc/forticlient or the account's FortiClient settings is opened, and under /opt/forticlient
// only the tray's autostart entry (through its link in /etc/xdg/autostart); the vendor's command-line
// client is never run, its logs are never read, a process is named by its command name only (never its
// arguments), and a tunnel is counted, never named or addressed.
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
launcherComm = "fortitraylaunch" // the kernel keeps 15 characters of fortitraylauncher
launcherWord = "fortitraylauncher"
trayComm = "fortitray"
launcherBin = "/opt/forticlient/fortitraylauncher"
entryName = "Fortitray.desktop"
restartAs = "mesh-forticlient-tray"
packageFor = "forticlient-vpn"
serviceUnit = "forticlient.service"
// tunnelPrefix starts the name of the interface the client brings up for a connected tunnel.
tunnelPrefix = "fctvpn"
)
// named keeps a process's command name and drops its arguments.
func named(ps []Proc, comm string) []Proc {
out := []Proc{}
for _, p := range ps {
p.Command = comm
out = append(out, p)
}
return out
}
// Tunnel is whether the client holds a tunnel up: how many of its tunnel interfaces exist and are up.
// Their names and addresses are never answered.
type Tunnel struct {
Connected bool `json:"connected"`
Up int `json:"tunnels_up"`
}
func (m *Machine) tunnel() Tunnel {
t := Tunnel{}
entries, _ := os.ReadDir(m.path("/sys/class/net"))
for _, e := range entries {
if !strings.HasPrefix(e.Name(), tunnelPrefix) {
continue
}
flags, err := strconv.ParseUint(strings.TrimPrefix(readTrimmed(m.path(filepath.Join("/sys/class/net", e.Name(), "flags"))), "0x"), 16, 32)
if err == nil && flags&1 == 1 { // IFF_UP
t.Up++
}
}
t.Connected = t.Up > 0
return t
}
// Service is the vendor's scheduler, which holds the tunnel.
type Service struct {
Active string `json:"active"`
Enabled string `json:"enabled"`
}
func (m *Machine) service() Service {
word := func(verb string) string {
o := m.cmd(5*time.Second, nil, "systemctl", verb, serviceUnit)
if f := strings.Fields(o.Stdout); o.Err == nil && len(f) > 0 {
return f[0]
}
return "unknown"
}
return Service{Active: word("is-active"), Enabled: word("is-enabled")}
}
// foreign says whether the package came from outside the official repositories (pacman -Qm).
func (m *Machine) foreign() bool {
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
}
// StatusAnswer is what forticlient_status answers.
type StatusAnswer struct {
Installed string `json:"installed,omitempty"`
Foreign bool `json:"outside_official_repositories"`
Service Service `json:"service"`
Launcher []Proc `json:"launcher"`
Tray []Proc `json:"tray"`
StartedBy Autostart `json:"started_by"`
Tunnel Tunnel `json:"tunnel"`
}
// Status reads the client's running state, and nothing of its configuration.
func (m *Machine) Status() (StatusAnswer, error) {
s := StatusAnswer{Service: m.service(), Launcher: named(m.procs(launcherComm), launcherWord),
Tray: named(m.procs(trayComm), trayComm), StartedBy: m.autostart(entryName), Tunnel: m.tunnel()}
v, err := m.installed(packageFor)
if err != nil {
return s, err
}
s.Installed = v
if v != "" {
s.Foreign = m.foreign()
}
return s, nil
}
// RestartAnswer is what forticlient_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
// Tunnel is after the restart: the tray is only the client's face, the service holds the tunnel.
Tunnel Tunnel `json:"tunnel"`
}
// Restart ends the tray and its launcher and starts the launcher again in the operator's session,
// under the account's service manager. The launcher starts the tray. The tunnel is the service's,
// and is not touched.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(5*time.Second, launcherComm, trayComm)
if err := m.detach(s, restartAs, launcherBin); err != nil {
return a, err
}
a.Running = named(m.waitFor(launcherComm, 4*time.Second), launcherWord)
a.Tunnel = m.tunnel()
if len(a.Running) == 0 {
return a, fmt.Errorf("the launcher was started as %s but no %s process appeared within 4 s",
a.Unit, launcherWord)
}
return a, nil
}
// CheckAnswer is what forticlient_check answers. Being connected or not is never a finding: that is
// the operator's choice, and forticlient_status says which.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises and relies on: the package (found, not installed by the
// mesh); the service running and enabled; one start for the tray (the vendor's autostart entry, which
// the session's dex runs); and the tray running once in a desktop session.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("forticlient-vpn is not installed. It is outside the official repositories, so the mesh does not install it",
"install it from the AUR by hand")
}
if s := m.service(); s.Active != "active" || s.Enabled != "enabled" {
add(fmt.Sprintf("the service %s is %s and %s", serviceUnit, s.Active, s.Enabled),
"push the module, which declares it running and enabled")
}
entry := m.autostart(entryName)
if entry.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
if entry.From != "/etc/xdg/autostart/"+entryName {
add("the account's own "+entry.From+" replaces the vendor's entry", "remove it, so the vendor's entry is the one start")
}
} else {
add("the tray does not start with the session ("+entry.Because+")",
"remove ~/.config/autostart/"+entryName+" if it hides the vendor's entry; if the vendor's is gone, reinstall forticlient-vpn, whose install links it")
}
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
}
for _, word := range []string{launcherWord, trayComm} {
for _, l := range m.i3Starts(word) {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; the vendor's autostart entry is the tray's one start")
}
}
if _, err := m.session(); err == nil {
for _, p := range []struct{ comm, word string }{{launcherComm, launcherWord}, {trayComm, trayComm}} {
switch running := m.procs(p.comm); {
case len(running) == 0:
add("no "+p.word+" runs in the desktop session", "forticlient_restart")
case len(running) > 1:
add(fmt.Sprintf("%d of %s run", len(running), p.word), "forticlient_restart ends them all and starts one")
}
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
// secrets are what the operator's VPN configuration holds on a real machine. The fake machine carries
// them where the client keeps them, and no answer may.
var secrets = []string{"vpn.example.invalid", "192.0.2.10", "s3cret-psk", "work-profile", "BEGIN CERTIFICATE", "65d16a50"}
func newClient(t *testing.T, running, connected bool) *fake {
f := newFake(t)
f.write("/opt/forticlient/Fortitray.desktop", "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
f.write("/etc/forticlient/config.db", "work-profile vpn.example.invalid 192.0.2.10 s3cret-psk\n")
f.write(testHome+"/.config/FortiClient/state.json", `{"gateway":"vpn.example.invalid","cert":"-----BEGIN CERTIFICATE-----"}`)
f.write("/sys/class/net/wlp3s0/flags", "0x1003\n")
if connected {
f.write("/sys/class/net/fctvpn65d16a50/flags", "0x1091\n")
f.write("/sys/class/net/fctvpn65d16a50/address", "192.0.2.10\n")
}
if running {
f.proc(3857, 1000, launcherComm, []string{launcherBin, "--profile=work-profile"}, "session-c1.scope")
f.proc(4509, 1000, trayComm, []string{"/opt/forticlient/fortitray", "--gateway", "vpn.example.invalid"}, "session-c1.scope")
}
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman" && args[0] == "-Q":
return Output{Stdout: "forticlient-vpn 7.4.3.5411-1\n"}
case name == "pacman" && args[0] == "-Qqm":
return Output{Stdout: "forticlient-vpn\n"}
case name == "systemctl" && args[0] == "is-active":
return Output{Stdout: "active\n"}
case name == "systemctl" && args[0] == "is-enabled":
return Output{Stdout: "enabled\n"}
}
return Output{}
}
return f
}
// carries fails the test when an answer holds anything of the VPN's configuration.
func carries(t *testing.T, answer any) {
t.Helper()
raw, _ := json.Marshal(answer)
for _, s := range secrets {
if strings.Contains(string(raw), s) {
t.Fatalf("the answer carries %q: %s", s, raw)
}
}
}
func TestStatusIsRunningAndConnectedStateAndNothingOfTheConfiguration(t *testing.T) {
f := newClient(t, true, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed != "7.4.3.5411-1" || !s.Foreign || s.Service != (Service{"active", "enabled"}) || len(s.Launcher) != 1 || len(s.Tray) != 1 ||
!s.StartedBy.Starts || !s.Tunnel.Connected || s.Tunnel.Up != 1 {
t.Fatalf("%+v", s)
}
if s.Launcher[0].Command != launcherWord || s.Tray[0].Command != trayComm {
t.Fatalf("processes by command name only: %+v %+v", s.Launcher, s.Tray)
}
carries(t, s)
for _, c := range f.calls {
for _, never := range []string{"forticlient-cli", "fortivpn", "journalctl", "sqlite", "/etc/forticlient", "/opt/forticlient/.config"} {
if strings.Contains(c, never) {
t.Fatalf("ran %q", c)
}
}
}
down := newClient(t, true, false)
down.write("/sys/class/net/fctvpn0/flags", "0x1090\n")
if s, _ := down.Status(); s.Tunnel.Connected || s.Tunnel.Up != 0 {
t.Fatalf("an interface that is down is no tunnel: %+v", s.Tunnel)
}
}
func TestCheckPassesTheVendorsOneStartAndNeverJudgesTheConnection(t *testing.T) {
f := newClient(t, true, false)
f.desktopSession()
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
t.Fatalf("%+v %v", c, err)
}
carries(t, c)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id /opt/forticlient/fortitraylauncher\n")
f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=/opt/forticlient/fortitraylauncher\nHidden=true\n")
f.proc(3858, 1000, launcherComm, []string{launcherBin}, "session-c1.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "systemctl":
return Output{Stdout: "inactive\n", Code: 3}
case "dex":
return Output{Code: 127, Err: ErrNotInstalled}
}
return Output{}
}
c, _ = f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not installed", "forticlient.service is inactive", "does not start with the session (Hidden=true)", "dex",
"a second start: ~/.config/i3/config:1", "2 of fortitraylauncher run"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
carries(t, c)
}
func TestRestartStartsTheLauncherAndLeavesTheTunnel(t *testing.T) {
f := newClient(t, true, true)
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
f.desktopSession()
f.onStart = func(argv []string) { f.proc(9100, 1000, launcherComm, argv, "app.slice/"+restartAs+".service") }
a, err := f.Restart()
if err != nil || len(a.Ended) != 2 || len(a.Running) != 1 || !a.Tunnel.Connected {
t.Fatalf("%+v %v", a, err)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
t.Fatalf("%q", f.calls)
}
for _, c := range f.calls {
if strings.Contains(c, serviceUnit) {
t.Fatalf("the restart touched the service: %q", c)
}
}
carries(t, a)
}
@@ -0,0 +1,49 @@
// The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient
// VPN client's tray in the operator's session and the vendor's service behind it, served by the node's
// runtime as the operator account. The module holds no seat, so every tool is its own. The tools
// report running and connected state only: never a profile, a credential, a gateway or a certificate.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "forticlient_status",
Description: "The VPN client: the installed version and whether it came from outside the official " +
"repositories, the vendor's service (active, enabled), whether the tray and its launcher run " +
"(pid, since, scope or unit), what starts the tray at login, and whether a tunnel is up (a " +
"count; never a name, an address, a gateway or a profile). (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "forticlient_restart",
Description: "End the tray and its launcher (asked first, then forced after 5 s) and start the " +
"launcher again in the operator's desktop session, under the account's service manager. The " +
"tunnel is the service's and is not touched. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "forticlient_check",
Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
"outside the official repositories); the service runs and is enabled; the tray has exactly one " +
"start (the vendor's XDG autostart entry, which the session's dex runs; no window-manager exec); " +
"and the tray and its launcher run once in a desktop session. Being connected is not checked. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,106 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// forticlient's shape (novox/hq ADR 0205, ADR 0207, ADR 0208): no package (the client is outside the
// official repositories and kept as found), the vendor's service declared running and enabled, the X
// display on its own machine, no start of its own (the vendor's autostart entry is the tray's one
// start), nothing of the VPN's configuration, and the Go bundle serving exactly the listed forticlient_
// tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "forticlient_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed %s and described", tool.Name, "forticlient_")
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/forticlient-tools" || b["binary"] != "forticlient-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
!reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) {
t.Fatalf("%+v", m)
}
if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil {
t.Fatalf("resources: %v", m.Resources)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
t.Fatal("no seat, no environment, and no second start")
}
for _, never := range []string{"/etc/forticlient", "/opt/forticlient", "autostart", "\"package\"", "vpn.", "gateway", "profile"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %s", never)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module forticlient
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=
+39
View File
@@ -0,0 +1,39 @@
{
"module": "forticlient",
"version": "1",
"capabilities": [
"service-manager"
],
"requires": [
"x11-display"
],
"tools": [
"forticlient_status",
"forticlient_restart",
"forticlient_check"
],
"resources": [
{
"id": "scheduler",
"type": "service",
"unit": "forticlient.service",
"state": "running",
"boot": "enabled"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/forticlient-tools",
"binary": "forticlient-tools",
"loads": [
"forticlient-tools"
]
}
]
}
}
+29 -4
View File
@@ -17,6 +17,8 @@ export interface GiteaRepo {
description?: string; description?: string;
html_url: string; html_url: string;
default_branch?: string; default_branch?: string;
/** When anything last moved in it — a push, and so a merge. */
updated_at?: string;
} }
/** An issue, with its labels flattened to names. */ /** An issue, with its labels flattened to names. */
@@ -249,10 +251,24 @@ export class GiteaClient {
* `limit` is what is asked for, and a merge that changed more says so rather than being read * `limit` is what is asked for, and a merge that changed more says so rather than being read
* page by page: what the mesh does with a partial list is treat the whole repository as changed, * page by page: what the mesh does with a partial list is treat the whole repository as changed,
* so more pages would buy nothing. */ * so more pages would buy nothing. */
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> { /** Every file a pull request changed, page by page. **The forge caps a page below what is asked**
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`); * (fifty, asked for a hundred), so one page read as the whole list dropped files silently, and a module
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== ""); * whose own files moved was not rebuilt (novox/hq issue 252). Read until a page comes back short; past
return { paths, truncated: paths.length >= limit }; * `most` files the list is cut and says so, and the mesh then rebuilds everything built from the
* repository, the safe direction. */
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; truncated: boolean }> {
const paths: string[] = [];
let pageSize = 0;
for (let page = 1; ; page++) {
const files = (await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=50&page=${page}`)) ?? [];
if (page === 1) pageSize = files.length;
for (const f of files) {
const name = String(f?.filename ?? "");
if (name !== "") paths.push(name);
}
if (files.length === 0 || files.length < pageSize) return { paths, truncated: false };
if (paths.length >= most) return { paths, truncated: true };
}
} }
async createPullRequest( async createPullRequest(
@@ -342,6 +358,7 @@ export class GiteaClient {
description: r.description || undefined, description: r.description || undefined,
html_url: r.html_url, html_url: r.html_url,
default_branch: r.default_branch, default_branch: r.default_branch,
updated_at: r.updated_at ?? undefined,
}; };
} }
@@ -584,3 +601,11 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/admin/users/${username}`, res); GiteaAdmin.fail(`/admin/users/${username}`, res);
} }
} }
/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose
* update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */
export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] {
if (!floor) return repos;
const at = Date.parse(floor);
return repos.filter((r) => !r.updated_at || !(Date.parse(r.updated_at) < at));
}
+21 -9
View File
@@ -3,18 +3,18 @@
// //
// Emits (novox/hq ADR 0041/0042): // Emits (novox/hq ADR 0041/0042):
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool) // module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
// module.gitea.pull.merged — a pull request was merged, however it was merged (web UI, API, or tool)
// //
// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh // issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
// takes that action — the natural point, and one process only. repo.created belongs here instead: // repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the // polling is the only way to catch every path — and the only emitter, so a fact is never announced
// repo list is the only way to catch every path — and keeping it out of the create-repo tool means // twice. The merge tool announced too until novox/hq issue 250, and every merge it made was heard twice.
// the fact is never announced twice from two processes.
// //
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering // The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
// the forge for an immediacy nobody asked for is not. // the forge for an immediacy nobody asked for is not.
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient } from "./client.js"; import { GiteaClient, movedSince } from "./client.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -84,8 +84,16 @@ function keepAnnounced(): void {
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since })); writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
renameSync(tmp, mergedRecord); renameSync(tmp, mergedRecord);
} }
// **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the
// repository's update time; asking every repository for its pull requests on every tick took longer than the
// tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a
// minute before the last look is skipped — the minute absorbs the forge's clock against this one.
let lastLook = "";
const MARGIN_MS = 60_000;
async function pollMerged(client: GiteaClient): Promise<void> { async function pollMerged(client: GiteaClient): Promise<void> {
const repos = await client.listAllRepos(); const began = new Date().toISOString();
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
const repos = movedSince(await client.listAllRepos(), floor);
let changed = false; let changed = false;
for (const repo of repos) { for (const repo of repos) {
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" }); const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
@@ -124,6 +132,7 @@ async function pollMerged(client: GiteaClient): Promise<void> {
if (!primedMerges) since = new Date().toISOString(); if (!primedMerges) since = new Date().toISOString();
if (!primedMerges || changed) keepAnnounced(); if (!primedMerges || changed) keepAnnounced();
primedMerges = true; primedMerges = true;
lastLook = began;
} }
if (gitea) { if (gitea) {
@@ -132,6 +141,9 @@ if (gitea) {
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line. // yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
let failing: string | null = null; let failing: string | null = null;
const tick = (fn: () => Promise<void>, everyMs: number): void => { const tick = (fn: () => Promise<void>, everyMs: number): void => {
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
// could each announce the same merge before either recorded it.
const run = (): void => const run = (): void =>
void fn() void fn()
.then(() => { .then(() => {
@@ -142,8 +154,8 @@ if (gitea) {
const why = err instanceof Error ? err.message : String(err); const why = err instanceof Error ? err.message : String(err);
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`); if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
failing = why; failing = why;
}); })
setInterval(run, everyMs); .finally(() => setTimeout(run, everyMs));
run(); run();
}; };
tick(() => pollRepos(client), 60_000); tick(() => pollRepos(client), 60_000);
+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 .*$", "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" "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"
}
] ]
} }
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
import { GiteaClient } from "../client.ts";
test("every page of a pull request's files is read, though the forge caps a page at fifty", async () => {
const total = 59;
const server = createServer((req, res) => {
const url = new URL(req.url ?? "", "http://x");
const page = Number(url.searchParams.get("page") ?? "1");
const start = (page - 1) * 50;
const files = Array.from({ length: Math.max(0, Math.min(50, total - start)) }, (_, i) => ({ filename: `modules/m${start + i}/x` }));
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(files));
});
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as any).port;
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
const got = await client.listPullFiles("novox", "mesh-catalog", 60);
server.close();
assert.equal(got.paths.length, total);
assert.equal(got.truncated, false);
assert.equal(new Set(got.paths).size, total);
});
test("a pass asks only the repositories that moved since the last look, every one before the first", async () => {
const { movedSince } = await import("../client.ts");
const repos = [
{ full_name: "a/old", name: "old", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T10:00:00Z" },
{ full_name: "a/new", name: "new", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T16:10:02Z" },
{ full_name: "a/unknown", name: "unknown", owner: "a", private: false, html_url: "" },
];
assert.deepEqual(movedSince(repos, "").map((r) => r.name), ["old", "new", "unknown"]);
assert.deepEqual(movedSince(repos, "2026-10-05T16:09:00.000Z").map((r) => r.name), ["new", "unknown"]);
assert.deepEqual(movedSince(repos, "2026-10-05T18:10:02+02:00").map((r) => r.name), ["new", "unknown"], "an offset is a moment, not a string");
});
+9 -27
View File
@@ -1,12 +1,12 @@
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client. // gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client.
// They return structured data; the mesh serves them through the sdk's tool harness. // They return structured data; the mesh serves them through the sdk's tool harness.
// //
// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0041/0042): // One tool emits an event at the natural point of the action it takes (novox/hq ADR 0041/0042):
// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on // create-issue emits issue.opened. pull.merged and repo.created are deliberately NOT emitted here: a
// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos // merge or a repository is as often made in the web UI or by a plain API call as by these tools, so the
// are far more often born from a `git push` or the web UI than from this tool, so the events // events entrypoint (index.ts) owns both by polling, which catches every path. Announcing a merge here
// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and // as well announced every merge made through this tool twice — the tool's at once, the poll's moments
// the poll double-announcing the same repo from two processes. // later (novox/hq issue 250).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
@@ -228,29 +228,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
const number = Number(args.number); const number = Number(args.number);
const method = args.method ? String(args.method) : "merge"; const method = args.method ? String(args.method) : "merge";
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
// Read the PR first, so the merged event carries a title and branches, not just a number.
const pull = await gitea.getPullRequest(owner, repo, number);
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
// Read it again: the merge commit only exists now, and it is what a build is made from. // The merge commit only exists now; answered so the caller can follow what is built from it.
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
const merged = await gitea.getPullRequest(owner, repo, number); const merged = await gitea.getPullRequest(owner, repo, number);
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
// every module built from the repository (novox/hq 04-ISSUES/131).
const changed = await gitea.listPullFiles(owner, repo, number);
await emit("pull.merged", {
owner,
repo,
number,
title: pull.title,
head: pull.head,
base: pull.base,
merge_commit_sha: merged.merge_commit_sha,
merged_at: merged.merged_at,
method,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
});
return { merged: true, number, method, deleted_branch: deleteBranch };
}, },
}, },
+331
View File
@@ -0,0 +1,331 @@
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
//
// Root is the module's concern (ADR 0175 §4): the runtime launching this binary runs as the operator's
// account, so the file is written through sudo without a prompt where the account is not root, as the
// packet filter's is.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net/netip"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
)
// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two
// together.
const HostsPath = "/etc/hosts"
// Operator is the owner of every line outside a block.
const Operator = "operator"
// Runner runs one command as root and answers what it printed, so the writes can be tested without a
// machine.
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.
func escalated(uid int, name string, args []string) (string, []string) {
if uid == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
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())
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 said != "" {
return "", fmt.Errorf("%s: %s", name, said)
}
return "", fmt.Errorf("%s failed: %v", name, err)
}
// Line is one line of the file, as a reader sees it.
type Line struct {
// Text is the line exactly as it is in the file.
Text string `json:"text"`
// Owner is whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator".
Owner string `json:"owner"`
// Address and Names are an entry's; absent for a comment or a blank line.
Address string `json:"address,omitempty"`
Names []string `json:"names,omitempty"`
}
var (
begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`)
end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`)
)
// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be.
func isAddress(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// Parse is every line of a hosts file, each marked whose it is.
func Parse(text string) []Line {
out := []Line{}
block := ""
for _, raw := range strings.Split(text, "\n") {
if block == "" {
if m := begin.FindStringSubmatch(raw); m != nil {
block = m[1]
out = append(out, Line{Text: raw, Owner: block})
continue
}
}
owner := block
if owner == "" {
owner = Operator
}
line := Line{Text: raw, Owner: owner}
entry, _, _ := strings.Cut(raw, "#")
if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) {
line.Address, line.Names = fields[0], fields[1:]
}
out = append(out, line)
if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block {
block = ""
}
}
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") {
out = out[:n-1]
}
return out
}
var label = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
// Refused input says why, so a caller is one edit from right.
func checkAddress(address string) error {
if !isAddress(address) {
return fmt.Errorf("%q is not an IPv4 or IPv6 address", address)
}
return nil
}
func checkName(name string) error {
bad := fmt.Errorf("%q is not a host name", name)
if len(name) < 1 || len(name) > 253 {
return bad
}
for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") {
if !label.MatchString(l) {
return bad
}
}
return nil
}
// WithAdded is the file with one address and its names added to the operator's lines; unchanged when
// they are already there.
func WithAdded(text, address string, names []string) (string, error) {
if err := checkAddress(address); err != nil {
return "", err
}
if len(names) == 0 {
return "", errors.New("add names at least one name for the address")
}
for _, n := range names {
if err := checkName(n); err != nil {
return "", err
}
}
have := map[string]bool{}
for _, l := range Parse(text) {
if l.Owner == Operator && l.Address == address {
for _, n := range l.Names {
have[n] = true
}
}
}
var missing []string
for _, n := range names {
if !have[n] && !slices.Contains(missing, n) {
missing = append(missing, n)
}
}
if len(missing) == 0 {
return text, nil
}
body := text
if body != "" && !strings.HasSuffix(body, "\n") {
body += "\n"
}
return body + address + "\t" + strings.Join(missing, " ") + "\n", nil
}
// WithRemoved is the file with one name, or every line of one address, taken out of the operator's
// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool
// writes is refused, naming whose it is.
func WithRemoved(text, what string) (string, int, error) {
byAddress := isAddress(what)
if !byAddress {
if err := checkName(what); err != nil {
return "", 0, err
}
}
matches := func(l Line) bool {
if byAddress {
return l.Address == what
}
return slices.Contains(l.Names, what)
}
lines := Parse(text)
removed := 0
kept := []string{}
for _, l := range lines {
if l.Owner != Operator || l.Address == "" || !matches(l) {
kept = append(kept, l.Text)
continue
}
removed++
if byAddress {
continue
}
var rest []string
for _, n := range l.Names {
if n != what {
rest = append(rest, n)
}
}
if len(rest) > 0 {
kept = append(kept, l.Address+"\t"+strings.Join(rest, " "))
}
}
if removed == 0 {
for _, l := range lines {
if l.Owner != Operator && matches(l) {
return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner)
}
}
}
return strings.Join(kept, "\n") + "\n", removed, nil
}
// HostsFile is the machine's hosts file.
type HostsFile struct {
Path string
Run Runner
}
// Entries is the file's lines, each marked whose.
type Entries struct {
Path string `json:"path"`
Lines []Line `json:"lines"`
}
func (h HostsFile) read() (string, error) {
b, err := os.ReadFile(h.Path)
return string(b), err
}
// Entries is every line of the file, each marked whose it is.
func (h HostsFile) Entries() (*Entries, error) {
text, err := h.read()
if err != nil {
return nil, err
}
return &Entries{Path: h.Path, Lines: Parse(text)}, nil
}
// Added is whether add changed the file, and the line it wrote.
type Added struct {
Added bool `json:"added"`
Line string `json:"line,omitempty"`
}
// Add adds one address and its names to the operator's lines.
func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) {
before, err := h.read()
if err != nil {
return nil, err
}
after, err := WithAdded(before, address, names)
if err != nil {
return nil, err
}
if after == before {
return &Added{Added: false}, nil
}
if err := h.write(ctx, after); err != nil {
return nil, err
}
return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil
}
// Removed is how many of the operator's lines remove touched.
type Removed struct {
Removed int `json:"removed"`
}
// Remove takes one name, or every line of one address, out of the operator's lines.
func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) {
before, err := h.read()
if err != nil {
return nil, err
}
after, removed, err := WithRemoved(before, what)
if err != nil {
return nil, err
}
if removed > 0 {
if err := h.write(ctx, after); err != nil {
return nil, err
}
}
return &Removed{Removed: removed}, nil
}
// write puts the file in place whole, so a reader never sees half of it: the content is staged in a
// private copy, installed as root beside the file — the same directory, so the same filesystem — and
// renamed over it.
func (h HostsFile) write(ctx context.Context, content string) error {
dir, err := os.MkdirTemp("", "hosts-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
staged := filepath.Join(dir, "hosts")
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
return err
}
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hosts-tools")
if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil {
return err
}
if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil {
_, _ = h.Run(ctx, "rm", "-f", beside)
return err
}
return nil
}
+286
View File
@@ -0,0 +1,286 @@
package main
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
// distribution lines, an operator's development names, the mesh's block and another tool's.
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
const file = "# Static table lookup for hostnames.\n" +
"127.0.0.1\tlocaldev.example.com\n" +
"127.0.0.1 a.example.com b.example.com\n" +
"# BEGIN mesh hosts.own\n" +
"127.0.0.1\tlocalhost\n" +
"::1\tlocalhost\n" +
"# END mesh hosts.own\n" +
"# BEGIN other-tool\n" +
"192.0.2.7\tproject.test\n" +
"# END other-tool\n"
func blocks(text string) []string {
var out []string
for _, l := range Parse(text) {
if l.Owner != Operator {
out = append(out, l.Text)
}
}
return out
}
func TestEveryLineSaysWhoseItIs(t *testing.T) {
lines := Parse(file)
if len(lines) != 10 {
t.Fatalf("%d lines: %+v", len(lines), lines)
}
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
if !reflect.DeepEqual(lines[1], want) {
t.Errorf("%+v", lines[1])
}
if lines[0].Address != "" || lines[0].Owner != Operator {
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
}
if lines[3].Owner != "mesh hosts.own" || lines[4].Owner != "mesh hosts.own" || lines[6].Owner != "mesh hosts.own" {
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
}
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
t.Errorf("%+v", lines[8])
}
if lines[5].Address != "::1" {
t.Errorf("an IPv6 entry: %+v", lines[5])
}
}
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
t.Errorf("%+v", l)
}
}
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
for _, l := range lines {
if l.Owner != "x" {
t.Errorf("%+v", l)
}
}
}
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
t.Errorf("%q", after)
}
if !reflect.DeepEqual(blocks(after), blocks(file)) {
t.Errorf("blocks changed")
}
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
t.Errorf("a name already there changed the file")
}
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
t.Errorf("%q", some)
}
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
t.Errorf("a file without a last newline: %q", ended)
}
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
t.Errorf("an empty file: %q", empty)
}
}
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
t.Errorf("%q", after)
}
}
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
for _, c := range []struct {
address string
names []string
says string
}{
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
{"192.0.2.9", []string{}, "at least one name"},
} {
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%q %q: %v", c.address, c.names, err)
}
}
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
t.Errorf("an IPv6 address and a rooted name: %v", err)
}
}
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
one, n, err := WithRemoved(file, "a.example.com")
if err != nil || n != 1 {
t.Fatalf("%d %v", n, err)
}
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
t.Errorf("%q", one)
}
if !reflect.DeepEqual(blocks(one), blocks(file)) {
t.Errorf("blocks changed")
}
all, n, err := WithRemoved(file, "127.0.0.1")
if err != nil || n != 2 {
t.Fatalf("%d %v", n, err)
}
if !strings.Contains(all, "# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n") {
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
}
if !reflect.DeepEqual(blocks(all), blocks(file)) {
t.Errorf("blocks changed")
}
}
func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
t.Errorf("%v", err)
}
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hosts.own") {
t.Errorf("%v", err)
}
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
t.Errorf("%d %v", n, err)
}
if _, _, err := WithRemoved(file, "bad name"); err == nil {
t.Errorf("a name that is neither an address nor a host name is refused")
}
}
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
t.Errorf("%s %v", p, a)
}
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
t.Errorf("%s %v", p, a)
}
}
// runPlain runs a command as given, unescalated: the test's file is the test's own.
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
return string(out), err
}
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hosts")
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
t.Fatal(err)
}
var calls [][]string
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
calls = append(calls, append([]string{name}, args...))
return runPlain(ctx, name, args...)
}}
ctx := context.Background()
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
t.Fatalf("%+v %v", added, err)
}
beside := filepath.Join(dir, ".hosts.hosts-tools")
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
t.Errorf("%v", calls)
}
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
}
got, err := h.Entries()
if err != nil {
t.Fatal(err)
}
last := got.Lines[len(got.Lines)-1]
if last.Owner != Operator || last.Address != "192.0.2.9" {
t.Errorf("add then entries shows the line as the operator's: %+v", last)
}
removed, err := h.Remove(ctx, "lab.test")
if err != nil || removed.Removed != 1 {
t.Fatalf("%+v %v", removed, err)
}
if b, _ := os.ReadFile(path); string(b) != file {
t.Errorf("add then remove gives the file back: %q", b)
}
if _, err := os.Stat(beside); !os.IsNotExist(err) {
t.Errorf("the staged copy beside the file is left: %v", err)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
t.Errorf("mode %v", info.Mode())
}
}
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Claims []struct {
Name string `json:"name"`
Serves []string `json:"serves"`
} `json:"claims"`
Resources []struct {
ID string `json:"id"`
Path string `json:"path"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
found := false
for _, r := range m.Resources {
if r.ID == "own" {
found = true
if r.Path != HostsPath {
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
}
}
}
if !found {
t.Errorf("no resource own")
}
var served []string
for _, tool := range tools(HostsFile{}) {
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
if tool.Description == "" || tool.Run == nil {
t.Errorf("%s", tool.Name)
}
}
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
}
}
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
t.Errorf("%v", got)
}
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
t.Errorf("%v", got)
}
if got := namesArg(map[string]any{}); len(got) != 0 {
t.Errorf("%v", got)
}
}
+81
View File
@@ -0,0 +1,81 @@
// hosts-tools (novox/hq ADR 0199): the hosts file'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-hosts-file seat's three verbs — the file's lines with whose each is, add an operator's line,
// remove one. They change the machine's file and nothing else; the controller holds none of it.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"context"
"fmt"
"os"
"regexp"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Seat is the role this module holds.
const Seat = "node-hosts-file"
func main() {
if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil {
fmt.Fprintf(os.Stderr, "[hosts] %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)
}
var separators = regexp.MustCompile(`[\s,]+`)
// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list,
// the list.
func namesArg(a map[string]any) []string {
var raw []string
switch v := a["names"].(type) {
case string:
raw = separators.Split(v, -1)
case []any:
for _, n := range v {
if s, ok := n.(string); ok {
raw = append(raw, separators.Split(s, -1)...)
}
}
}
names := []string{}
for _, n := range raw {
if n != "" {
names = append(names, n)
}
}
return names
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
// subject, as <node>/node-hosts-file.<verb>.
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(h HostsFile) []stdio.Tool {
ctx := context.Background()
return []stdio.Tool{
verb("entries", "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
nil, func(map[string]any) (any, error) { return h.Entries() }),
verb("add", "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")},
func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }),
verb("remove", "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
map[string]any{"name": str("a host name, or an address to remove every line of")},
func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }),
}
}
+5
View File
@@ -0,0 +1,5 @@
module hosts
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=
+41
View File
@@ -0,0 +1,41 @@
{
"module": "hosts",
"version": "1",
"claims": [
{
"name": "node-hosts-file",
"scope": "node",
"serves": [
"entries",
"add",
"remove"
]
}
],
"resources": [
{
"id": "own",
"type": "file",
"path": "/etc/hosts",
"mode": "0644",
"into": "block",
"at": "start",
"content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/hosts-tools",
"binary": "hosts-tools",
"loads": [
"hosts-tools"
]
}
]
}
}
+4 -3
View File
@@ -91,6 +91,7 @@ file because each module carries them now:
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` | | the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
| both bars | `i3status-rust`'s `60-i3status-rust.conf` | | both bars | `i3status-rust`'s `60-i3status-rust.conf` |
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login | | the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
| the peripherals' tray (`exec … polychromatic-tray-applet`) | `polychromatic`'s contribution to node-display-session |
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables, The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here, and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
@@ -98,9 +99,9 @@ because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDrop
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
key. key.
**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the **Kept until their owners exist.** A marked section holds the operator's own scripts: volume, games
operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each volume, the sessions launcher and the screenshot binding. Each leaves when the module that owns it is
leaves when the module that owns it is written. written. The peripherals' tray applet left it for the `polychromatic` module.
## What it leaves found ## What it leaves found
+3 -1
View File
@@ -127,7 +127,9 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin
for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm", for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm",
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/", "exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring // carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} { "rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring",
// the peripherals' tray: polychromatic's contribution
"polychromatic"} {
if strings.Contains(code, gone) { if strings.Contains(code, gone) {
t.Errorf("the configuration still holds %q", gone) t.Errorf("the configuration still holds %q", gone)
} }
+5 -6
View File
@@ -174,11 +174,9 @@ client.urgent #900000 #900000 #ffffff #900000 #900000
######################################### #########################################
# Each line below belongs to something other than i3, named on its line. When that module is written # Each line below belongs to something other than i3, named on its line. When that module is written
# it contributes the line to node-display-session, and the line goes from here in the same change. # 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 # The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'
# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14). # tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,
# polychromatic).
# the peripherals' tray (the operator's application)
exec --no-startup-id polychromatic-tray-applet
# the operator's scripts: volume, games volume, sessions, screenshot # the operator's scripts: volume, games volume, sessions, screenshot
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
@@ -194,7 +192,8 @@ bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/script
###### Other modules' lines #### ###### Other modules' lines ####
######################################### #########################################
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the # Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it. # clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's
# under a line naming it.
${contribution:node-display-session:config} ${contribution:node-display-session:config}
######################################### #########################################
###### Your own files #### ###### Your own files ####
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -132,5 +132,12 @@
} }
} }
] ]
} },
"contributions": [
{
"seat": "node-backup",
"kind": "backup",
"content": "path ${dir:data}\npath ${dir:config}\n"
}
]
} }
+7
View File
@@ -558,5 +558,12 @@
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)", "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" "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"
}
] ]
} }
+7
View File
@@ -79,5 +79,12 @@
"name": "mesh-vault", "name": "mesh-vault",
"scope": "mesh" "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"
}
]
} }
+13 -1
View File
@@ -58,6 +58,11 @@
"type": "directory", "type": "directory",
"mode": "0700" "mode": "0700"
}, },
{
"id": "dumps",
"type": "directory",
"mode": "0700"
},
{ {
"id": "net", "id": "net",
"type": "network", "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"
}
]
} }
+22 -1
View File
@@ -65,6 +65,13 @@
"mode": "0700", "mode": "0700",
"owner": "10001:0" "owner": "10001:0"
}, },
{
"id": "dumps",
"type": "directory",
"path": "${dir:data}/backup",
"mode": "0700",
"owner": "10001:0"
},
{ {
"id": "net", "id": "net",
"type": "network", "type": "network",
@@ -86,6 +93,13 @@
"${dir:data}:/var/opt/mssql" "${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" "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": { "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"
}
]
} }
+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,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,81 @@
// The nextcloud-client module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the
// Nextcloud desktop sync client in the operator's session, served by the node's runtime as the
// operator account. The module holds no seat, so every tool is its own.
//
// The client's account configuration is the operator's: the tools read the client's own files and
// never write them, and no answer carries the server's address, the account's ids or a credential.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "nextcloud_status",
Description: "The Nextcloud desktop client: whether it runs (pid, since, and the unit or session scope " +
"it runs in), the installed version, what starts it at login, each account by name (never the " +
"server's address or a credential) with its sync folders' local paths, remote paths, paused, and " +
"the last sync run (started, finished, items, errors and the first failing files), and the latest " +
"warnings in the client's log. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "nextcloud_log",
Description: "The last lines of the client's log (source client, the default) or of the sync runs' " +
"log (source sync, file by file), newest last. With problems, only warnings and worse. The " +
"server's address and the account's ids are replaced by <server> and <account>. (r)",
Input: map[string]any{
"lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"},
"source": map[string]any{"type": "string", "enum": []string{"client", "sync"}, "description": "client (default) or sync"},
"problems": map[string]any{"type": "boolean", "description": "only warning, critical and fatal lines of the client's log (default false)"},
},
Run: func(args map[string]any) (any, error) {
n, err := whole(args, "lines", 100, 1, 2000)
if err != nil {
return nil, err
}
source, err := text(args, "source", false)
if err != nil {
return nil, err
}
if source == "" {
source = "client"
}
problems, err := flag(args, "problems", false)
if err != nil {
return nil, err
}
return machine.Log(source, n, problems)
},
},
{
Name: "nextcloud_restart",
Description: "End the running client (asked first, then forced after 6 s) and start it again in the " +
"operator's desktop session, in the tray, under the account's service manager. Answers the pids " +
"ended and the new one. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "nextcloud_check",
Description: "Check what the module promises: the package is installed; the client has exactly one " +
"start (its own XDG autostart entry, which the session's dex runs; no window-manager exec, no " +
"enabled user unit); it runs once in a desktop session; it has an account and its folders exist " +
"and are not paused. Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// nextcloud-client's shape (novox/hq ADR 0208, ADR 0210, ADR 0182): one official package, no seat,
// the X display on its own machine, no start of its own (the client's own autostart entry is the one
// start), no file of the client's, and the Go bundle serving exactly the listed nextcloud_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []any `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestItInstallsTheClientAndNothingElse(t *testing.T) {
m, _ := readManifest(t)
if m.Module != "nextcloud-client" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
t.Fatalf("%+v", m)
}
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
t.Fatalf("resources: %v", m.Resources)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil {
t.Fatal("it holds no seat and sets no environment")
}
}
func TestItAddsNoSecondStartAndOwnsNoneOfTheClientsFiles(t *testing.T) {
m, raw := readManifest(t)
// The client writes its own XDG autostart entry, which the session's dex runs: an xinitrc slot
// or a window-manager exec would start it twice.
if m.Shell != nil || m.Contributions != nil {
t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions)
}
for _, never := range []string{"nextcloud.cfg", "autostart", "service"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %q: the client's files and start are its own", never)
}
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "nextcloud_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed nextcloud_ and described", tool.Name)
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/nextcloud-client-tools" || b["binary"] != "nextcloud-client-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -0,0 +1,667 @@
package main
// The Nextcloud desktop client as the tools see it, read from the client's own files only:
// - ~/.config/Nextcloud/nextcloud.cfg, the client's settings (Qt's INI form), which the client
// rewrites and the module never writes. Accounts and sync folders are read from it. The server's
// address, the account's user ids and every credential-like key are never answered: they are
// read only to be hidden in what the log tools answer;
// - the sync-run log of each folder (<appdata>/Nextcloud/*_sync.log), whose first line is the
// folder's local path, and whose run markers and per-file lines give the last sync's state and
// errors;
// - the client's log directory, ~/.config/Nextcloud/logs, rotated by the client every two hours
// (the newest file plain, the older ones gzipped).
import (
"bufio"
"bytes"
"compress/gzip"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// Where the client keeps things, under the operator's home.
const (
configFile = ".config/Nextcloud/nextcloud.cfg"
logDir = ".config/Nextcloud/logs"
entryName = "Nextcloud.desktop"
clientComm = "nextcloud"
clientBin = "/usr/bin/nextcloud"
restartAs = "mesh-nextcloud-client"
packageFor = "nextcloud-client"
userUnit = "com.nextcloud.desktopclient.nextcloud.service"
)
// syncLogDirs are where the client has kept its sync-run logs, newest convention first.
var syncLogDirs = []string{".local/share/Nextcloud", ".config/Nextcloud"}
// ini is a Qt INI file: section → key → value. Keys keep Qt's backslash-separated groups.
type ini map[string]map[string]string
func parseINI(raw []byte) ini {
out := ini{}
section := "General"
s := bufio.NewScanner(bytes.NewReader(raw))
s.Buffer(make([]byte, 64<<10), 4<<20) // a certificate is one long line
for s.Scan() {
l := strings.TrimSpace(s.Text())
if l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#") {
continue
}
if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") {
section = l[1 : len(l)-1]
continue
}
i := strings.IndexByte(l, '=')
if i <= 0 {
continue
}
v := strings.TrimSpace(l[i+1:])
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
v = v[1 : len(v)-1]
}
if out[section] == nil {
out[section] = map[string]string{}
}
out[section][strings.TrimSpace(l[:i])] = v
}
return out
}
// Folder is one sync folder as the client is configured.
type Folder struct {
LocalPath string `json:"-"`
Local string `json:"local_path"`
RemotePath string `json:"remote_path"`
Paused bool `json:"paused"`
VirtualFiles string `json:"virtual_files,omitempty"`
Journal string `json:"-"`
JournalFound bool `json:"journal_found"`
LastSync *Run `json:"last_sync,omitempty"`
}
// Account is one account, by name only.
type Account struct {
ID string `json:"id"`
Name string `json:"name"`
Auth string `json:"auth,omitempty"`
Folders []Folder `json:"folders"`
// hidden are the values never answered: the server's host and the account's user ids.
hosts []string
users []string
}
// Config is what the tools read from nextcloud.cfg.
type Config struct {
Found bool `json:"found"`
ClientVersion string `json:"client_version,omitempty"`
LaunchAtStartup *bool `json:"launch_on_system_startup,omitempty"`
Accounts []Account `json:"accounts"`
}
func (m *Machine) config() (Config, error) {
raw, err := readBounded(m.home(configFile))
if os.IsNotExist(err) {
return Config{Accounts: []Account{}}, nil
}
if err != nil {
return Config{}, fmt.Errorf("reading the client's settings: %w", err)
}
f := parseINI(raw)
c := Config{Found: true, ClientVersion: f["General"]["clientVersion"], Accounts: []Account{}}
if v, ok := f["General"]["launchOnSystemStartup"]; ok {
b := v == "true"
c.LaunchAtStartup = &b
}
byID := map[string]*Account{}
folders := map[string]map[string]*Folder{}
var ids []string
for k, v := range f["Accounts"] {
parts := strings.Split(k, `\`)
if len(parts) < 2 {
continue
}
id := parts[0]
if _, err := strconv.Atoi(id); err != nil {
continue
}
a := byID[id]
if a == nil {
a = &Account{ID: id, Folders: []Folder{}}
byID[id] = a
folders[id] = map[string]*Folder{}
ids = append(ids, id)
}
if len(parts) == 2 {
switch parts[1] {
case "displayName":
a.Name = v
case "authType":
a.Auth = v
case "url":
if u, err := url.Parse(v); err == nil && u.Host != "" {
a.hosts = append(a.hosts, u.Host)
if u.Hostname() != u.Host {
a.hosts = append(a.hosts, u.Hostname())
}
}
case "user", "dav_user", "webflow_user", "http_user":
if v != "" {
a.users = append(a.users, v)
}
}
continue
}
// <id>\Folders\<n>\<key>, and the other folder groups (FoldersWithPlaceholders, Multifolders).
if len(parts) == 4 && strings.HasPrefix(parts[1], "Folders") || len(parts) == 4 && parts[1] == "Multifolders" {
key := parts[1] + `\` + parts[2]
fo := folders[id][key]
if fo == nil {
fo = &Folder{}
folders[id][key] = fo
}
switch parts[3] {
case "localPath":
fo.LocalPath = v
case "targetPath":
fo.RemotePath = v
case "paused":
fo.Paused = v == "true"
case "virtualFilesMode":
fo.VirtualFiles = v
case "journalPath":
fo.Journal = v
}
}
}
sort.Strings(ids)
for _, id := range ids {
a := byID[id]
if a.Name == "" {
a.Name = "account " + id
}
var keys []string
for k := range folders[id] {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fo := *folders[id][k]
if fo.LocalPath == "" {
continue
}
fo.Local = m.tilde(fo.LocalPath)
if fo.Journal != "" {
fo.JournalFound = exists(filepath.Join(m.Root, fo.LocalPath, fo.Journal))
}
a.Folders = append(a.Folders, fo)
}
c.Accounts = append(c.Accounts, *a)
}
return c, nil
}
// redactor hides what an answer must never carry: the servers' hosts, the accounts' user ids, and
// anything shaped like a credential.
type redactor struct{ hosts, users []string }
var credential = regexp.MustCompile(`(?i)\b(authorization|bearer|basic|token|apppassword|app_password|password|passwd|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:(?:bearer|basic)\s+)?[^\s"',;&]+`)
func (c Config) redactor() redactor {
var r redactor
for _, a := range c.Accounts {
r.hosts = append(r.hosts, a.hosts...)
r.users = append(r.users, a.users...)
}
// Longest first, so a host's port form is replaced before its bare name.
sort.Slice(r.hosts, func(i, j int) bool { return len(r.hosts[i]) > len(r.hosts[j]) })
sort.Slice(r.users, func(i, j int) bool { return len(r.users[i]) > len(r.users[j]) })
return r
}
func (r redactor) clean(s string) string {
s = credential.ReplaceAllString(s, "${1}${2}<hidden>")
for _, u := range r.users {
s = strings.ReplaceAll(s, u, "<account>")
}
for _, h := range r.hosts {
s = strings.ReplaceAll(s, h, "<server>")
}
return s
}
// Run is one folder's last sync run, from its sync-run log.
type Run struct {
Started string `json:"started,omitempty"`
Finished string `json:"finished,omitempty"`
// State is "finished", "running", or "never" when the log has no run.
State string `json:"state"`
Changes int `json:"items"`
Errors int `json:"errors"`
Problems []Problem `json:"problems,omitempty"`
Log string `json:"log"`
}
// Problem is one item of a run that ended with an error.
type Problem struct {
File string `json:"file"`
Error string `json:"error"`
HTTP string `json:"http,omitempty"`
}
const mostProblems = 10
// syncLogFor finds the sync-run log whose first line is the folder's local path; the newest wins.
func (m *Machine) syncLogFor(local string) string {
want := strings.TrimSuffix(local, "/")
best, bestAt := "", time.Time{}
for _, d := range syncLogDirs {
files, _ := filepath.Glob(m.home(d, "*_sync.log"))
for _, f := range files {
h, err := os.Open(f)
if err != nil {
continue
}
first, _ := bufio.NewReader(io.LimitReader(h, 4096)).ReadString('\n')
h.Close()
if strings.TrimSuffix(strings.TrimSpace(first), "/") != want {
continue
}
if st, err := os.Stat(f); err == nil && st.ModTime().After(bestAt) {
best, bestAt = f, st.ModTime()
}
}
}
return best
}
// lastRun reads the end of a sync-run log: the last run's markers and its items.
func (m *Machine) lastRun(path string, r redactor) (*Run, error) {
lines, err := tailLines(path, 4000)
if err != nil {
return nil, err
}
run := &Run{State: "never", Log: m.tilde(strings.TrimPrefix(path, m.Root))}
start := -1
for i := len(lines) - 1; i >= 0; i-- {
if strings.HasPrefix(lines[i], "#=#=#=# Syncrun started ") {
start = i
break
}
}
if start < 0 {
return run, nil
}
run.Started = marker(lines[start], "#=#=#=# Syncrun started ")
run.State = "running"
for _, l := range lines[start+1:] {
switch {
case strings.HasPrefix(l, "#=#=#=# Syncrun finished "):
run.Finished = marker(l, "#=#=#=# Syncrun finished ")
run.State = "finished"
case strings.HasPrefix(l, "#"):
default:
f := strings.Split(l, "|")
if len(f) < 12 {
continue
}
run.Changes++
if e := strings.TrimSpace(f[10]); e != "" {
run.Errors++
if len(run.Problems) < mostProblems {
run.Problems = append(run.Problems, Problem{File: r.clean(f[2]), Error: r.clean(e), HTTP: strings.TrimSpace(f[11])})
}
}
}
}
return run, nil
}
func marker(line, prefix string) string {
f := strings.Fields(strings.TrimPrefix(line, prefix))
if len(f) == 0 {
return ""
}
return f[0]
}
// tailLines answers the last n lines of a file, plain or gzipped, read to at most MostRead.
func tailLines(path string, n int) ([]string, error) {
h, err := os.Open(path)
if err != nil {
return nil, err
}
defer h.Close()
var r io.Reader = h
if strings.HasSuffix(path, ".gz") {
z, err := gzip.NewReader(h)
if err != nil {
return nil, fmt.Errorf("%s: %w", filepath.Base(path), err)
}
defer z.Close()
r = z
} else if st, err := h.Stat(); err == nil && st.Size() > MostRead {
// A plain file is read from its end.
if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil {
return nil, err
}
}
raw, err := io.ReadAll(io.LimitReader(r, MostRead))
if err != nil {
return nil, err
}
all := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if len(all) == 1 && all[0] == "" {
all = nil
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, nil
}
// clientLogs are the client's log files, newest first.
func (m *Machine) clientLogs() []string {
entries, err := os.ReadDir(m.home(logDir))
if err != nil {
return nil
}
type file struct {
path string
at time.Time
}
var files []file
for _, e := range entries {
// The client's own log, not its permanent-delete records beside it.
if e.IsDir() || !strings.Contains(e.Name(), "_nextcloud.log") {
continue
}
if info, err := e.Info(); err == nil {
files = append(files, file{m.home(logDir, e.Name()), info.ModTime()})
}
}
sort.Slice(files, func(i, j int) bool { return files[i].at.After(files[j].at) })
out := make([]string, len(files))
for i, f := range files {
out[i] = f.path
}
return out
}
// level is a client log line's level: "[ warning category file:line ]:" → warning.
func level(line string) string {
i := strings.Index(line, "[ ")
if i < 0 {
return ""
}
f := strings.Fields(line[i+2:])
if len(f) == 0 {
return ""
}
return f[0]
}
func isProblem(line string) bool {
switch level(line) {
case "warning", "critical", "fatal":
return true
}
return false
}
// LogAnswer is what nextcloud_log answers.
type LogAnswer struct {
Source string `json:"source"`
Files []string `json:"files"`
Lines []string `json:"lines"`
Cut bool `json:"cut,omitempty"`
Note string `json:"note,omitempty"`
}
const mostAnswer = 256 << 10
// Log answers the last n lines of the client's log (or only its warnings and worse), or of the sync
// runs' log, the server's address and the account's ids hidden.
func (m *Machine) Log(source string, n int, problemsOnly bool) (LogAnswer, error) {
c, err := m.config()
if err != nil {
return LogAnswer{}, err
}
r := c.redactor()
a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}}
var files []string
switch source {
case "client":
files = m.clientLogs()
if len(files) == 0 {
a.Note = "the client keeps no log files in ~/" + logDir + ": it writes them there only while its logging is switched on"
return a, nil
}
case "sync":
for _, acc := range c.Accounts {
for _, f := range acc.Folders {
if p := m.syncLogFor(f.LocalPath); p != "" {
files = append(files, p)
}
}
}
if len(files) == 0 {
a.Note = "no sync-run log found for any configured folder"
return a, nil
}
default:
return a, fmt.Errorf("source is client or sync, not %q", source)
}
// The newest file first; older ones until n lines are found, at most four files.
var got []string
for i, f := range files {
if i == 4 || len(got) >= n {
break
}
lines, err := tailLines(f, 1<<20)
if err != nil {
return a, err
}
if problemsOnly {
var keep []string
for _, l := range lines {
if isProblem(l) {
keep = append(keep, l)
}
}
lines = keep
}
if len(lines) > n-len(got) {
lines = lines[len(lines)-(n-len(got)):]
}
got = append(lines, got...)
a.Files = append(a.Files, m.tilde(strings.TrimPrefix(f, m.Root)))
if source == "sync" {
// Each folder's log is its own story: the newest folder's only, unless asked again.
break
}
}
size := 0
for i := len(got) - 1; i >= 0; i-- {
l := r.clean(got[i])
if size+len(l) > mostAnswer {
a.Cut = true
got = got[i+1:]
break
}
size += len(l) + 1
got[i] = l
}
if got == nil {
got = []string{}
}
a.Lines = got
return a, nil
}
// Status is what nextcloud_status answers.
type Status struct {
Installed string `json:"installed,omitempty"`
Running []Proc `json:"running"`
Config Config `json:"settings"`
Problems []string `json:"recent_client_problems"`
StartedBy Autostart `json:"started_by"`
}
// Status reads the client: whether it runs, its accounts and folders with their last sync, and the
// warnings and worse at the end of its log.
func (m *Machine) Status() (Status, error) {
c, err := m.config()
if err != nil {
return Status{}, err
}
r := c.redactor()
s := Status{Running: m.procs(clientComm), Config: c, Problems: []string{}, StartedBy: m.autostart(entryName)}
if s.Running == nil {
s.Running = []Proc{}
}
if v, err := m.installed(packageFor); err == nil {
s.Installed = v
}
for ai := range s.Config.Accounts {
for fi := range s.Config.Accounts[ai].Folders {
f := &s.Config.Accounts[ai].Folders[fi]
if p := m.syncLogFor(f.LocalPath); p != "" {
if run, err := m.lastRun(p, r); err == nil {
f.LastSync = run
}
}
}
}
// The two newest files: the log rotates every two hours, and may just have.
logs := m.clientLogs()
if len(logs) > 2 {
logs = logs[:2]
}
for i := len(logs) - 1; i >= 0; i-- {
if lines, err := tailLines(logs[i], 1<<20); err == nil {
for _, l := range lines {
if isProblem(l) {
s.Problems = append(s.Problems, r.clean(l))
}
}
}
}
if len(s.Problems) > 10 {
s.Problems = s.Problems[len(s.Problems)-10:]
}
return s, nil
}
// RestartAnswer is what nextcloud_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends the running client and starts it again in the operator's session, under the account's
// service manager, as its autostart entry does (--background: to the tray, no window).
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(6*time.Second, clientComm)
if err := m.detach(s, restartAs, clientBin, "--background"); err != nil {
return a, err
}
a.Running = m.waitFor(clientComm, 4*time.Second)
if len(a.Running) == 0 {
return a, fmt.Errorf("the client was started as %s but no %s process appeared within 4 s: "+
"see `journalctl --user -u %s`", a.Unit, clientComm, a.Unit)
}
return a, nil
}
// CheckAnswer is what nextcloud_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies the module's promises: the package, one start (the client's own autostart entry,
// which the session's dex runs), the client running once in a session, and its folders present.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("the package "+packageFor+" is not installed", "push the module to the node")
}
c, err := m.config()
if err != nil {
return a, err
}
entry := m.autostart(entryName)
if entry.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
if !strings.Contains(entry.Exec, clientComm) {
add("the autostart entry runs "+entry.Exec+", not the client", "untick and tick again 'Launch on system startup' in the client's settings")
}
} else {
add("the client does not start with the session ("+entry.Because+")",
"tick 'Launch on system startup' in the client's General settings: the client writes its own entry")
}
if c.LaunchAtStartup != nil && !*c.LaunchAtStartup && entry.Starts {
add("the client's setting says not to launch at startup, but its autostart entry is there", "tick and untick the setting, or remove ~/.config/autostart/"+entryName)
}
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
}
for _, l := range m.i3Starts(clientComm) {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; the autostart entry is the client's one start")
}
if o := m.cmd(0, m.bus(), "systemctl", "--user", "is-enabled", userUnit); o.Err == nil && strings.TrimSpace(o.Stdout) == "enabled" {
a.Starts = append(a.Starts, "user unit: "+userUnit)
add("a second start: the packaged user unit "+userUnit+" is enabled", "systemctl --user disable "+userUnit)
}
running := m.procs(clientComm)
if _, err := m.session(); err == nil {
switch {
case len(running) == 0:
add("no client runs in the desktop session", "nextcloud_restart")
case len(running) > 1:
add(fmt.Sprintf("%d clients run", len(running)), "nextcloud_restart ends them all and starts one")
}
}
if !c.Found {
add("the client has no settings yet (~/"+configFile+")", "open the client and add the account: its configuration is the operator's")
} else if len(c.Accounts) == 0 {
add("the client has no account", "add the account in the client")
}
for _, acc := range c.Accounts {
for _, f := range acc.Folders {
if !exists(filepath.Join(m.Root, f.LocalPath)) {
add("the sync folder "+m.tilde(f.LocalPath)+" of "+acc.Name+" does not exist", "recreate it, or remove the folder from the client")
} else if f.Journal != "" && !f.JournalFound {
add("the sync folder "+m.tilde(f.LocalPath)+" has no sync journal yet", "it is written at the first sync")
}
if f.Paused {
add("the sync folder "+m.tilde(f.LocalPath)+" is paused", "resume it in the client")
}
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,269 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// A client's settings as the client writes them, with a certificate, a server and user ids that no
// answer may carry.
const cfg = `[General]
clientVersion=34.0.4daily
launchOnSystemStartup=true
[Accounts]
0\Folders\1\journalPath=.sync_abc.db
0\Folders\1\localPath=/home/operator/Nextcloud/
0\Folders\1\paused=false
0\Folders\1\targetPath=/
0\Folders\1\virtualFilesMode=off
0\Folders\2\localPath=/home/operator/Photos/
0\Folders\2\paused=true
0\Folders\2\targetPath=/Photos
0\General\CaCertificates="@ByteArray(-----BEGIN CERTIFICATE-----\nMIIE6zCC\n-----END CERTIFICATE-----)"
0\authType=webflow
0\dav_user=kc-1234-uid
0\displayName=The Operator
0\url=https://cloud.example.test:8443
0\user=kc-1234-uid@cloud.example.test
0\webflow_user=kc-1234-uid
version=13
`
const syncLog = `/home/operator/Nextcloud/
# timestamp | duration | file | instruction | dir | modtime | etag | size | fileId | status | errorString | http result code | other size | other modtime | X-Request-ID
#=#=#=# Syncrun started 2026-10-05T07:27:35Z
||old.md|2|2|1|e|1|i|13||0|1|1||
#=#=#=# Syncrun finished 2026-10-05T07:27:35Z (last step: 16 msec, total: 170 msec)
#=#=#=# Syncrun started 2026-10-05T09:27:40Z
#=#=#=#=# Propagation starts 2026-10-05T09:27:40Z (last step: 131 msec, total: 131 msec)
13:58:53||Notes/a.md|8|1|1|e|1|i|13||201|0|0|r|
13:58:54||Notes/b.md|8|1|1|e|1|i|13|"/Notes/b.md" is locked|423|0|0|r|
13:58:55||kc-1234-uid/c.md|8|1|1|e|1|i|13|File has changed since discovery|200|0|0|r|
#=#=#=# Syncrun finished 2026-10-05T09:27:40Z (last step: 17 msec, total: 148 msec)
`
func clientLine(level, text string) string {
return "2026-10-05 11:27:41:151 [ " + level + " nextcloud.gui.folder /src/folder.cpp:1 ]:\t" + text
}
func newClient(t *testing.T) *fake {
f := newFake(t)
f.write(testHome+"/"+configFile, cfg)
f.write(testHome+"/Nextcloud/.sync_abc.db", "")
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", syncLog)
// An older log of the same folder, in the old place: the newer one wins.
f.write(testHome+"/.config/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2023-11-17T00:00:00Z\n")
old := filepath.Join(f.Root, testHome, ".config/Nextcloud/Nextcloud_sync.log")
_ = os.Chtimes(old, time.Unix(1_700_000_000, 0), time.Unix(1_700_000_000, 0))
var gz bytes.Buffer
z := gzip.NewWriter(&gz)
_, _ = z.Write([]byte(clientLine("info", "older") + "\n" + clientLine("warning", "Network error on https://cloud.example.test:8443/x Authorization: Bearer abc.def") + "\n"))
_ = z.Close()
f.write(testHome+"/"+logDir+"/20261005_0927_nextcloud.log.0.gz", gz.String())
f.write(testHome+"/"+logDir+"/20261005_1127_permanent_delete.log.0", "not the client's log\n")
f.write(testHome+"/"+logDir+"/20261005_1127_nextcloud.log.0",
clientLine("info", "Sync finished for folder of account [kc-1234-uid@cloud.example.test]")+"\n"+clientLine("critical", "Could not read journal")+"\n")
gzPath := filepath.Join(f.Root, testHome, logDir, "20261005_0927_nextcloud.log.0.gz")
_ = os.Chtimes(gzPath, time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nName=Nextcloud\nExec=\"/usr/bin/nextcloud\" --background\nX-GNOME-Autostart-enabled=true\n")
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman":
return Output{Stdout: "nextcloud-client 2:34.0.4-1\n"}
case name == "systemctl" && len(args) > 1 && args[1] == "is-enabled":
return Output{Stdout: "disabled\n", Code: 1}
}
return Output{}
}
return f
}
func noSecrets(t *testing.T, v any) string {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
s := string(raw)
for _, never := range []string{"example.test", "kc-1234", "CERTIFICATE", "abc.def", "/home/operator"} {
if strings.Contains(s, never) {
t.Errorf("the answer carries %q: %s", never, s)
}
}
return s
}
func TestStatusNamesAccountsAndFoldersWithTheirLastSyncAndNothingSecret(t *testing.T) {
f := newClient(t)
f.proc(3865, 1000, clientComm, []string{"/usr/bin/nextcloud", "--background"}, "session-c1.scope")
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
noSecrets(t, s)
if s.Installed != "2:34.0.4-1" || len(s.Running) != 1 || s.Running[0].StartedIn != "session-c1.scope" {
t.Fatalf("%+v", s)
}
if !s.StartedBy.Starts || s.StartedBy.From != "~/.config/autostart/Nextcloud.desktop" {
t.Fatalf("%+v", s.StartedBy)
}
if s.Config.ClientVersion != "34.0.4daily" || s.Config.LaunchAtStartup == nil || !*s.Config.LaunchAtStartup {
t.Fatalf("%+v", s.Config)
}
if len(s.Config.Accounts) != 1 {
t.Fatalf("%+v", s.Config.Accounts)
}
a := s.Config.Accounts[0]
if a.Name != "The Operator" || a.Auth != "webflow" || len(a.Folders) != 2 {
t.Fatalf("%+v", a)
}
main, photos := a.Folders[0], a.Folders[1]
if main.Local != "~/Nextcloud/" || main.RemotePath != "/" || main.Paused || !main.JournalFound || main.VirtualFiles != "off" {
t.Fatalf("%+v", main)
}
run := main.LastSync
if run == nil || run.State != "finished" || run.Started != "2026-10-05T09:27:40Z" || run.Finished != "2026-10-05T09:27:40Z" ||
run.Changes != 3 || run.Errors != 2 || run.Log != "~/.local/share/Nextcloud/Nextcloud_sync.log" {
t.Fatalf("%+v", run)
}
if run.Problems[0].File != "Notes/b.md" || run.Problems[0].HTTP != "423" || run.Problems[1].File != "<account>/c.md" {
t.Fatalf("%+v", run.Problems)
}
if !photos.Paused || photos.RemotePath != "/Photos" || photos.LastSync != nil {
t.Fatalf("%+v", photos)
}
// Warnings and worse of the two newest client logs, oldest first, hidden.
if len(s.Problems) != 2 || !strings.Contains(s.Problems[0], "<server>/x Authorization: <hidden>") ||
!strings.Contains(s.Problems[1], "Could not read journal") {
t.Fatalf("%q", s.Problems)
}
}
func TestARunWithoutItsEndIsRunningAndALogWithoutRunsIsNever(t *testing.T) {
f := newClient(t)
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2026-10-05T10:00:00Z\n||x|1|1|1|e|1|i|13||200|0|0||\n")
s, _ := f.Status()
if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "running" || r.Finished != "" || r.Changes != 1 {
t.Fatalf("%+v", r)
}
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n")
s, _ = f.Status()
if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "never" {
t.Fatalf("%+v", r)
}
}
func TestNoSettingsIsNoAccountNotAnError(t *testing.T) {
f := newFake(t)
f.answer = func(string, []string) Output { return Output{Code: 1} }
s, err := f.Status()
if err != nil || s.Config.Found || len(s.Config.Accounts) != 0 || s.Installed != "" {
t.Fatalf("%+v %v", s, err)
}
}
func TestTheLogIsBoundedHiddenAndReadAcrossRotations(t *testing.T) {
f := newClient(t)
l, err := f.Log("client", 3, false)
if err != nil {
t.Fatal(err)
}
noSecrets(t, l)
if len(l.Lines) != 3 || !strings.Contains(l.Lines[0], "Authorization: <hidden>") || !strings.Contains(l.Lines[1], "[<account>]") ||
len(l.Files) != 2 || !strings.HasSuffix(l.Files[0], "_1127_nextcloud.log.0") {
t.Fatalf("%+v", l)
}
l, _ = f.Log("client", 1, false)
if len(l.Lines) != 1 || len(l.Files) != 1 || !strings.Contains(l.Lines[0], "Could not read journal") {
t.Fatalf("%+v", l)
}
l, _ = f.Log("client", 50, true)
if len(l.Lines) != 2 {
t.Fatalf("%+v", l)
}
l, _ = f.Log("sync", 2, false)
noSecrets(t, l)
if len(l.Lines) != 2 || !strings.HasPrefix(l.Lines[1], "#=#=#=# Syncrun finished") || l.Files[0] != "~/.local/share/Nextcloud/Nextcloud_sync.log" {
t.Fatalf("%+v", l)
}
if _, err := f.Log("server", 2, false); err == nil {
t.Fatal("an unknown source is refused")
}
empty := newFake(t)
if l, err := empty.Log("client", 5, false); err != nil || l.Note == "" || l.Lines == nil {
t.Fatalf("%+v %v", l, err)
}
}
func TestRestartEndsTheClientAndStartsOneInTheSession(t *testing.T) {
f := newClient(t)
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
f.desktopSession()
f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-4.scope")
f.onStart = func(argv []string) {
f.proc(9000, 1000, clientComm, argv, "app.slice/"+restartAs+".service")
}
a, err := f.Restart()
if err != nil {
t.Fatal(err)
}
if len(a.Ended) != 1 || a.Ended[0] != 3865 || len(a.Running) != 1 || a.Running[0].PID != 9000 ||
a.Running[0].StartedIn != restartAs+".service" || a.Running[0].Command != "/usr/bin/nextcloud --background" {
t.Fatalf("%+v", a)
}
f.onStart = nil
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "journalctl --user -u "+restartAs) {
t.Fatalf("a start that shows no process: %v", err)
}
}
func TestCheckPassesOneStartAndNamesEveryOther(t *testing.T) {
f := newClient(t)
f.desktopSession()
f.write(testHome+"/Photos/.keep", "")
f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope")
c, err := f.Check()
if err != nil {
t.Fatal(err)
}
// The one finding is the paused folder.
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "~/Photos/ is paused") || len(c.Starts) != 1 {
t.Fatalf("%+v", c)
}
noSecrets(t, c)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id nextcloud\n")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1, Stderr: "error: package 'nextcloud-client' was not found"}
case "systemctl":
return Output{Stdout: "enabled\n"}
case "dex":
return Output{Code: 127, Err: ErrNotInstalled}
}
return Output{}
}
f.proc(3866, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope")
f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nExec=nextcloud\nHidden=true\n")
c, _ = f.Check()
all := noSecrets(t, c)
for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1",
"packaged user unit", "2 clients run"} {
if !strings.Contains(all, want) {
t.Errorf("no finding %q in %s", want, all)
}
}
if len(c.Starts) != 2 {
t.Fatalf("%q", c.Starts)
}
}
+5
View File
@@ -0,0 +1,5 @@
module nextcloud-client
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=
+38
View File
@@ -0,0 +1,38 @@
{
"module": "nextcloud-client",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"tools": [
"nextcloud_status",
"nextcloud_log",
"nextcloud_restart",
"nextcloud_check"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "nextcloud-client"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/nextcloud-client-tools",
"binary": "nextcloud-client-tools",
"loads": [
"nextcloud-client-tools"
]
}
]
}
}
+8 -1
View File
@@ -117,5 +117,12 @@
} }
} }
] ]
} },
"contributions": [
{
"seat": "node-backup",
"kind": "backup",
"content": "path ${dir:html}\n"
}
]
} }
+78
View File
@@ -0,0 +1,78 @@
# nm-applet
NetworkManager's tray applet on the workstations, as a module (novox/hq ADR 0208). It requires
`x11-display`, so it is assigned only where a display server is held on the same machine.
## Owns
| what | where |
|---|---|
| the applet (and, as its dependencies, the connection editor and libnma) | package `network-manager-applet`, from the official repositories |
Nothing else. It holds no seat, makes no contribution and writes no file.
- **No AUR.** Both workstations run the official package (`extra`), installed explicitly.
- **NetworkManager is not this module's.** The `networkmanager` module holds the node's uplink
(`node-uplink`) with it, on servers as well as workstations. It declares the `networkmanager`
package, its one drop-in and its service. This module declares none of them (ADR 0210 §4: one
package, one module), and it does not declare the applet's own dependencies (`nm-connection-editor`,
`libnma`) either: the package brings them.
- **Why a module of its own, and not a part of `networkmanager`:** that module runs on machines with no
display, where a tray applet has nothing to draw on and the package would pull in GTK. The applet is
a desktop piece, beside `blueman` (for `bluetooth`) in the same way.
- **The connections stay the operator's.** The applet is NetworkManager's secret agent: it asks for a
network's password and can keep it in the keyring. Profiles, networks, secrets and addresses are
joined at the machine (the `networkmanager` module's README). The tools never ask for them.
- **The applet's settings are found.** It keeps its switches in dconf (`org.gnome.nm-applet`): the
notifications it shows and whether it shows itself. The module neither sets nor resets them.
## How it starts: the package's autostart entry, and nothing else
The package ships `/etc/xdg/autostart/nm-applet.desktop` (`nm-applet`, `NotShowIn=KDE;GNOME;`). The
session runs it once at login through the `i3` module's `dex --autostart --environment i3`. **That
entry is the applet's one start.** The module adds no `xinitrc` slot and no `node-display-session`
exec, because either would start it a second time.
- **Excluded:** the window manager's `exec … nm-applet`, which the `i3` module's configuration dropped.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `nm_applet_status` (r) | <ul><li>whether the applet runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and what starts it at login</li><li>its switches in `org.gnome.nm-applet`</li><li>NetworkManager's overall state: state, connectivity, Wi-Fi and networking on or off. Never a connection, a network, a secret or an address</li></ul> |
| `nm_applet_restart` (a) | asks the applet to end (SIGTERM), forces it after 5 s, and starts `nm-applet` in the operator's session as a transient user unit `mesh-nm-applet`, so it outlives the tools runtime. NetworkManager and its connections are not touched; for those few seconds no secret agent answers a password prompt. Refused plainly when nobody is logged in to the desktop |
| `nm_applet_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one applet runs in a desktop session</li><li>`NetworkManager.service` is active</li></ul>Each finding says what to do |
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment, as
`blueman` does. Every command has a timeout and capped output. Everything runs through an injected
runner and a fake root in the tests.
## What changes when it is assigned
| | laptop | desktop |
|---|---|---|
| package | none: `network-manager-applet` 1.36.0, explicit, from `extra` | the same |
| start | none: dex starts the applet from the package's entry, in the login session's scope | none on disk. **The applet running now came from the predecessor's window-manager line** (`nm-applet --sm-disable`, a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
## Migration (ADR 0182)
Nothing is required on either machine. On the desktop, log out and in once, or run
`nm_applet_restart`, and the applet runs from its one start. `nm_applet_check` then answers `ok`.
## Leaves as found
- The applet's dconf settings (`/org/gnome/nm-applet/`).
- `/etc/xdg/autostart/nm-applet.desktop`, the package's own file.
- Every connection profile and secret.
## Relies on
- **The `networkmanager` module, for NetworkManager.** There is no dependency mechanism between two
modules that hold no seat, so nothing refuses `nm-applet` without it; `nm_applet_check` reports it.
`networkmanager` holds `node-uplink`, but a module cannot depend on a seat without a resource or a
contribution that derives it (ADR 0207, ADR 0210 §3). **Assign both.**
- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat.
Assigned without `i3`, the applet is installed and does not start. `nm_applet_check` says so.
- A display server on the same machine (`x11-display`, ADR 0208 §3).
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,48 @@
// The nm-applet module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): NetworkManager's
// tray applet in the operator's session, served by the node's runtime as the operator account. The
// module holds no seat, so every tool is its own. NetworkManager itself is the networkmanager module's.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "nm_applet_status",
Description: "NetworkManager's applet: whether it runs (pid, since, and the unit or session scope it " +
"runs in), the installed version, what starts it at login, its notification switches, and " +
"NetworkManager's overall state (state, connectivity, Wi-Fi and networking on or off; never a " +
"connection, a network, a secret or an address). (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "nm_applet_restart",
Description: "End the applet (asked first, then forced after 5 s) and start it again in the " +
"operator's desktop session, under the account's service manager. NetworkManager and its " +
"connections are not touched. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "nm_applet_check",
Description: "Check what the module promises and relies on: the package is installed; the applet has " +
"exactly one start (the package's XDG autostart entry, which the session's dex runs; no " +
"window-manager exec); it runs once in a desktop session; and NetworkManager runs (the " +
"networkmanager module's). Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,104 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// nm-applet's shape (novox/hq ADR 0208, ADR 0210): the applet's one official package, no seat, the X
// display on its own machine, no start of its own (the package's autostart entry is the one start),
// nothing of the networkmanager module's (its package, its configuration, its service), and the Go
// bundle serving exactly the listed nm_applet_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "nm_applet_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed %s and described", tool.Name, "nm_applet_")
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/nm-applet-tools" || b["binary"] != "nm-applet-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestItInstallsTheAppletAndNothingOfNetworkManager(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "nm-applet" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
t.Fatalf("%+v", m)
}
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
t.Fatalf("resources: %v", m.Resources)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
t.Fatal("no seat, no environment, and no second start")
}
for _, never := range []string{"\"networkmanager\"", "nm-connection-editor", "libnma", "/etc/NetworkManager", "autostart", "service"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %s: the stack is the networkmanager module's, the start the package's", never)
}
}
}
@@ -0,0 +1,169 @@
package main
// NetworkManager's applet as the tools see it: its process, its XDG autostart entry (the package's),
// its settings in gsettings, and NetworkManager's overall state as nmcli gives it. The applet is also
// NetworkManager's secret agent, the program that asks for a network's password; the tools never ask
// NetworkManager for a connection, a secret or an address. Those are joined at the machine (the
// networkmanager module's README).
import (
"fmt"
"strings"
"time"
)
const (
appletComm = "nm-applet"
appletBin = "/usr/bin/nm-applet"
entryName = "nm-applet.desktop"
restartAs = "mesh-nm-applet"
packageFor = "network-manager-applet"
stackUnit = "NetworkManager.service"
settingsSet = "org.gnome.nm-applet"
)
// Manager is NetworkManager's overall state: no connection, network or address.
type Manager struct {
State string `json:"state"`
Connectivity string `json:"connectivity"`
Wifi string `json:"wifi"`
Networking string `json:"networking"`
}
func (m *Machine) manager() (*Manager, error) {
args := []string{"-t", "-f", "STATE,CONNECTIVITY,WIFI,NETWORKING", "general"}
o := m.cmd(5*time.Second, nil, "nmcli", args...)
if err := failed(o, "nmcli", args...); err != nil {
return nil, err
}
f := strings.Split(strings.TrimSpace(o.Stdout), ":")
if len(f) != 4 {
return nil, fmt.Errorf("nmcli's general state is not four fields: %q", tail(o.Stdout, 200))
}
return &Manager{State: f[0], Connectivity: f[1], Wifi: f[2], Networking: f[3]}, nil
}
// settings are the applet's switches in gsettings: the notifications it shows and whether it shows
// itself.
func (m *Machine) settings() map[string]string {
out := map[string]string{}
o := m.cmd(5*time.Second, m.bus(), "gsettings", "list-recursively", settingsSet)
if o.Err != nil || o.Code != 0 {
return out
}
for _, l := range strings.Split(o.Stdout, "\n") {
f := strings.Fields(l)
if len(f) == 3 && f[0] == settingsSet && f[1] != "stamp" {
out[f[1]] = f[2]
}
}
return out
}
// StatusAnswer is what nm_applet_status answers.
type StatusAnswer struct {
Installed string `json:"installed,omitempty"`
Applet []Proc `json:"applet"`
StartedBy Autostart `json:"started_by"`
Settings map[string]string `json:"settings"`
Manager *Manager `json:"network_manager,omitempty"`
Unanswered string `json:"unanswered,omitempty"`
}
// Status reads the applet and NetworkManager's overall state.
func (m *Machine) Status() (StatusAnswer, error) {
s := StatusAnswer{Applet: m.procs(appletComm), StartedBy: m.autostart(entryName), Settings: m.settings()}
if s.Applet == nil {
s.Applet = []Proc{}
}
v, err := m.installed(packageFor)
if err != nil {
return s, err
}
s.Installed = v
if s.Manager, err = m.manager(); err != nil {
s.Unanswered = err.Error()
}
return s, nil
}
// RestartAnswer is what nm_applet_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends the applet and starts it again in the operator's session, under the account's service
// manager. NetworkManager and its connections are not touched: the applet is only their face.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(5*time.Second, appletComm)
if err := m.detach(s, restartAs, appletBin); err != nil {
return a, err
}
a.Running = m.waitFor(appletComm, 4*time.Second)
if len(a.Running) == 0 {
return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+
"see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit)
}
return a, nil
}
// CheckAnswer is what nm_applet_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises and relies on: the package; one start (the package's
// autostart entry, which the session's dex runs); the applet running once in a session; and
// NetworkManager, which is the networkmanager module's, running.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("the package "+packageFor+" is not installed", "push the module to the node")
}
entry := m.autostart(entryName)
if entry.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
if entry.From != "/etc/xdg/autostart/"+entryName {
add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start")
}
} else {
add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry")
}
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
}
for _, l := range m.i3Starts(appletComm) {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start")
}
if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" {
add("NetworkManager ("+stackUnit+") is not running: the applet has nothing to show",
"assign the networkmanager module, which holds the node's uplink with it")
}
if _, err := m.session(); err == nil {
switch running := m.procs(appletComm); {
case len(running) == 0:
add("no applet runs in the desktop session", "nm_applet_restart")
case len(running) > 1:
add(fmt.Sprintf("%d applets run", len(running)), "nm_applet_restart ends them all and starts one")
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,107 @@
package main
import (
"strings"
"testing"
)
func newApplet(t *testing.T, running bool) *fake {
f := newFake(t)
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nName=Network\nExec=nm-applet\nNotShowIn=KDE;GNOME;\n")
if running {
f.proc(3860, 1000, appletComm, []string{"nm-applet"}, "session-c1.scope")
}
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman":
return Output{Stdout: "network-manager-applet 1.36.0-2\n"}
case name == "nmcli":
return Output{Stdout: "connected:full:enabled:enabled\n"}
case name == "gsettings":
return Output{Stdout: "org.gnome.nm-applet disable-connected-notifications false\norg.gnome.nm-applet stamp 0\n" +
"org.gnome.nm-applet show-applet true\n"}
case name == "systemctl":
return Output{Stdout: "active\n"}
}
return Output{}
}
return f
}
func TestStatusReadsTheAppletAndOnlyTheManagersOverallState(t *testing.T) {
f := newApplet(t, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed != "1.36.0-2" || len(s.Applet) != 1 || !s.StartedBy.Starts || s.Manager == nil ||
*s.Manager != (Manager{"connected", "full", "enabled", "enabled"}) || len(s.Settings) != 2 || s.Settings["show-applet"] != "true" {
t.Fatalf("%+v", s)
}
for _, c := range f.calls {
for _, never := range []string{"connection", "device", "secrets", "--show-secrets", " ip"} {
if strings.HasPrefix(c, "nmcli") && strings.Contains(c, never) {
t.Fatalf("asked NetworkManager for more than its overall state: %q", c)
}
}
}
f.answer = func(name string, args []string) Output {
if name == "nmcli" {
return Output{Code: 8, Stderr: "Error: NetworkManager is not running."}
}
return Output{Stdout: "x 1\n"}
}
if s, _ := f.Status(); s.Manager != nil || !strings.Contains(s.Unanswered, "not running") {
t.Fatalf("%+v", s)
}
}
func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) {
f := newApplet(t, true)
f.desktopSession()
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
t.Fatalf("%+v %v", c, err)
}
f.write(testHome+"/.config/i3/config", "exec --no-startup-id nm-applet --sm-disable\n")
f.proc(3861, 1000, appletComm, []string{"nm-applet"}, "session-c1.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "systemctl":
return Output{Stdout: "inactive\n", Code: 3}
}
return Output{}
}
c, _ = f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not installed", "a second start: ~/.config/i3/config:1", "NetworkManager.service", "2 applets run"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
}
func TestRestartEndsTheAppletAndStartsItUnderTheServiceManager(t *testing.T) {
f := newApplet(t, true)
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
f.desktopSession()
f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") }
a, err := f.Restart()
if err != nil || len(a.Ended) != 1 || len(a.Running) != 1 || a.Running[0].Command != appletBin {
t.Fatalf("%+v %v", a, err)
}
for _, c := range f.calls {
if strings.Contains(c, stackUnit) {
t.Fatalf("the restart touched NetworkManager: %q", c)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module nm-applet
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": "nm-applet",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"tools": [
"nm_applet_status",
"nm_applet_restart",
"nm_applet_check"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "network-manager-applet"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/nm-applet-tools",
"binary": "nm-applet-tools",
"loads": [
"nm-applet-tools"
]
}
]
}
}
+106
View File
@@ -0,0 +1,106 @@
# openrazer
The Razer peripherals' kernel driver and the account's daemon that drives it, on the workstations, as
a module (novox/hq ADR 0208: one module per piece of software). The tray that shows the devices is
the `polychromatic` module's. This module needs no display: the daemon speaks to the driver and to its
clients on the session bus.
## Owns
| what | where |
|---|---|
| the kernel driver's source, built by DKMS for each installed kernel (`razerkbd`, `razermouse`, `razerkraken`, `razeraccessory`) | package `openrazer-driver-dkms` |
| the daemon (`org.razer` on the session bus) and its user unit | package `openrazer-daemon` |
| the client library every front end speaks to the daemon through | package `python-openrazer` |
All three from the official repositories (`extra`). On both workstations they are installed today as
dependencies of the AUR tray, and become the mesh's here.
**Why two modules and not one `razer`:** the driver and the daemon are one project, in the official
repositories, and any front end uses them. The tray is another project, outside the official
repositories. That is the line between `bluetooth` and `blueman` too. A machine can hold the stack
without the tray, for a front end of the operator's own or for the daemon's persistence of the
devices' lighting and DPI.
Not this module's:
- **The kernel headers DKMS builds against** (`linux-headers`). They are the kernel's, and every DKMS
driver on a machine needs them (the laptop also builds `nvidia`, the desktop `vboxhost` and `xone`).
No module declares them yet. `openrazer_check` says when the driver is not built for the running
kernel.
- **`dkms` itself**, which the driver package depends on.
- **The account's membership of the `openrazer` group** (below).
## The group: a step for the operator, once
The driver's udev rules give each device's files to the group `openrazer`, which the driver package
creates (sysusers). The daemon refuses to start for an account outside that group: *User is not a
member of the openrazer group*.
**On both workstations the account is not in it today, so the daemon has failed at every start**
since openrazer moved from `plugdev` to its own group. The tray runs, and shows no devices. The
account is still in `plugdev`, which openrazer no longer uses. Another device's rules may (the laptop
has a Logitech receiver rule that does), so it stays.
The module cannot declare the membership. The host's `user` resource takes groups, additively, but the
`zsh` module already declares the operator's account as its `user` resource. A second module declaring
the same account is refused at composition, as two owners of one name. Until the mesh can add a group
to the account from a second module, this is the operator's step (below), and `openrazer_check` holds
it.
## How it starts: D-Bus activation, and nothing else
The package installs `org.razer` as a D-Bus service whose `SystemdService` is the user unit
`openrazer-daemon.service`. The first client that asks for `org.razer` starts the daemon through that
unit: at login, the tray's helper (`polychromatic-helper --autostart`). **That activation is the
daemon's one start.** One unit, so it is never two daemons.
- The unit is **not enabled** on either workstation, and the module does not enable it. Enabling it
would start the same unit at login a moment earlier, with nothing gained.
- Asked by a tool, the bus is always called with `--auto-start=no`, so asking never starts it.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `openrazer_status` (r) | <ul><li>the three packages' versions</li><li>the driver: the running kernel, DKMS's state for it, the modules loaded, the devices bound (USB id, driver, interfaces)</li><li>the group: whether the account is in it, and whether the account's running service manager has it</li><li>the daemon: its unit's active and enabled states, its process, its version, and, when its unit failed, the reason it gave</li><li>each device the daemon sees: name, type, firmware, battery and charging where the device has them. Never its serial</li></ul> |
| `openrazer_restart` (a) | restarts `openrazer-daemon.service` in the account's service manager and answers the devices it then sees. When it fails, the answer carries the daemon's own reason |
| `openrazer_check` (r) | <ul><li>the packages are installed</li><li>the driver is built for the running kernel, and loaded</li><li>a device is bound</li><li>the account is in `openrazer`, and its service manager has the group (a group added after login needs a new login)</li><li>one start: the activation file is present, no window-manager exec</li><li>one daemon runs, from its unit, and answers</li><li>it sees every device the driver holds</li></ul>Each finding says what to do |
Every command has a timeout and capped output. Everything runs through an injected runner and a fake
root in the tests.
## What changes when it is assigned
| | laptop | desktop |
|---|---|---|
| packages | none: the three are installed, 3.12.4, as dependencies of the tray | the same |
| driver | none: built for the running kernel, `razermouse` loaded, a Basilisk V3 Pro bound | the same, a Basilisk V2 bound |
| daemon | none: the unit stays disabled; it failed at login (not in the group) | the same |
## Migration (ADR 0182)
On each workstation, once:
1. `sudo gpasswd -a $USER openrazer`
2. Log out of every session, or reboot. The account's service manager takes its groups when it
starts, and the daemon runs under it.
3. `openrazer_check` answers `ok`, and the tray shows the devices.
`plugdev` stays. Nothing else is required.
## Leaves as found
- The daemon's settings and persistence under `~/.config/openrazer/`, and its log under
`~/.local/share/openrazer/`.
- `plugdev` and every other group of the account.
- The kernel headers and DKMS.
## Relies on
- **The account in `openrazer`**, by hand (above).
- **A client to start the daemon.** At login that is the `polychromatic` module's tray helper.
Without a client nothing asks, and nothing needs it to run.
- **The kernel headers of every installed kernel**, for DKMS.
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}

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