Author SHA1 Message Date
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
113 changed files with 11375 additions and 1220 deletions
+4
View File
@@ -1,2 +1,6 @@
node_modules/
dist/
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
modules/slack/cmd/slack-tools/slack-tools
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
@@ -0,0 +1,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")
}
}
+36 -9
View File
@@ -1,7 +1,8 @@
package main
// desktop.go is the same file in the nextcloud-client and blueman bundles: a tray application of the
// operator's graphical session, seen from the node's tool runtime (novox/hq ADR 0208).
// 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
@@ -251,6 +252,22 @@ func (m *Machine) procs(comm string) []Proc {
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") {
@@ -411,12 +428,19 @@ func (m *Machine) detach(s Session, unit string, argv ...string) error {
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var pids []int
var ps []Proc
for _, c := range comms {
for _, p := range m.procs(c) {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
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 {
@@ -451,10 +475,13 @@ func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []in
}
// 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 {
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.procs(comm); len(p) > 0 || waited >= d {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
@@ -1,7 +1,7 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client and blueman bundles.
// 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"
@@ -113,6 +113,23 @@ func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
}
}
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") {
+2 -1
View File
@@ -8,7 +8,8 @@
"claims": [
{
"name": "node-build-agent",
"scope": "node"
"scope": "node",
"serves": ["current", "kill", "pause", "resume"]
}
],
"requires": [
+12 -5
View File
@@ -800,8 +800,10 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
}
}
}
// 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
changed := false
for _, n := range nodes {
key := it.Key(n)
keys = append(keys, key)
@@ -819,7 +821,12 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
op = "delete"
}
item := it
changed = v.Take(key, op, &item) || changed
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}
@@ -835,15 +842,15 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
answer["offered as"] = "/" + Plugin + ":" + it.Name
}
}
if changed {
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 if v.Applies(keys[0]) || len(keys) > 1 {
answer["here"] = "already so"
} else {
answer["here"] = "not this node: each node it is for takes it from the bus"
}
@@ -455,3 +455,16 @@ func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
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)
}
}
+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
the Canon queue is used.
## The print applet: not this module's
The desktop has `system-config-printer` (official, explicit, installed 2026-10-04). Its package ships
`/etc/xdg/autostart/print-applet.desktop`, so from the desktop's next login `dex` starts
`system-config-printer-applet`, a tray icon for print jobs and printer problems. The laptop does not
have the package.
This module does not take it:
- `cups` needs no display and declares nothing graphical. The applet requires `x11-display`, and a
GTK package in this module would put it on any machine that prints, the laptop included, where the
operator never installed it.
- The queues and the default printer are already this module's tools (`cups_printers`, `cups_queue`,
`cups_default`), which is most of what the applet's window offers.
So the applet is left as found on the desktop, started by its package's entry. If it is wanted on both
workstations, it becomes a `system-config-printer` desktop module of its own, beside `blueman` and
`nm-applet`, requiring `x11-display`. If not, removing the package on the desktop ends it.
## Leaves as found
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
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) |
| `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) |
| `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
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
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
Three things this module should own are already declared by other modules on every machine. The
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
make the module unassignable everywhere. The refusals were checked against the controller's own
One thing this module should own is still declared elsewhere. The controller refuses two modules
on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`,
mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module
unassignable everywhere. The refusals were checked against the controller's own
check:
```
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)
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 operator account's membership of the `docker` group
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
`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`).
- So `docker.package` here is a **second record of the same package**. The apply says "already
installed", and neither record ever uninstalls it.
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
1 would add a second record of that unit. Its found state is *running*, because genesis started
it, so undeclaring this module would leave the daemon running.
- `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*,
because genesis started it, so undeclaring this module leaves the daemon running.
## Tools
+18
View File
@@ -50,6 +50,24 @@
"state": "running",
"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",
"type": "file",
+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;
html_url: 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. */
@@ -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
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
* so more pages would buy nothing. */
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
return { paths, truncated: paths.length >= limit };
/** Every file a pull request changed, page by page. **The forge caps a page below what is asked**
* (fifty, asked for a hundred), so one page read as the whole list dropped files silently, and a module
* whose own files moved was not rebuilt (novox/hq issue 252). Read until a page comes back short; past
* `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(
@@ -342,6 +358,7 @@ export class GiteaClient {
description: r.description || undefined,
html_url: r.html_url,
default_branch: r.default_branch,
updated_at: r.updated_at ?? undefined,
};
}
@@ -584,3 +601,11 @@ export class GiteaAdmin {
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):
// 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
// takes that action — the natural point, and one process only. repo.created belongs here instead:
// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the
// repo list is the only way to catch every path — and keeping it out of the create-repo tool means
// the fact is never announced twice from two processes.
// issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
// repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
// polling is the only way to catch every path — and the only emitter, so a fact is never announced
// twice. The merge tool announced too until novox/hq issue 250, and every merge it made was heard twice.
//
// 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.
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
// 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 }));
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> {
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;
for (const repo of repos) {
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 || changed) keepAnnounced();
primedMerges = true;
lastLook = began;
}
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.
let failing: string | null = null;
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 =>
void fn()
.then(() => {
@@ -142,8 +154,8 @@ if (gitea) {
const why = err instanceof Error ? err.message : String(err);
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
failing = why;
});
setInterval(run, everyMs);
})
.finally(() => setTimeout(run, everyMs));
run();
};
tick(() => pollRepos(client), 60_000);
+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.
// 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):
// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on
// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos
// are far more often born from a `git push` or the web UI than from this tool, so the events
// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and
// the poll double-announcing the same repo from two processes.
// One tool emits an event at the natural point of the action it takes (novox/hq ADR 0041/0042):
// create-issue emits issue.opened. pull.merged and repo.created are deliberately NOT emitted here: a
// merge or a repository is as often made in the web UI or by a plain API call as by these tools, so the
// events entrypoint (index.ts) owns both by polling, which catches every path. Announcing a merge here
// as well announced every merge made through this tool twice — the tool's at once, the poll's moments
// later (novox/hq issue 250).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { emit } from "@novox/mesh-sdk/events";
@@ -228,29 +228,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
const number = Number(args.number);
const method = args.method ? String(args.method) : "merge";
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);
// 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);
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
// 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 };
return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
},
},
+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` |
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
| the peripherals' tray (`exec … polychromatic-tray-applet`) | `polychromatic`'s contribution to node-display-session |
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
@@ -98,9 +99,9 @@ because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDrop
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
key.
**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the
operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each
leaves when the module that owns it is written.
**Kept until their owners exist.** A marked section holds the operator's own scripts: volume, games
volume, the sessions launcher and the screenshot binding. Each leaves when the module that owns it is
written. The peripherals' tray applet left it for the `polychromatic` module.
## What it leaves found
+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",
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} {
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring",
// the peripherals' tray: polychromatic's contribution
"polychromatic"} {
if strings.Contains(code, gone) {
t.Errorf("the configuration still holds %q", gone)
}
+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
# it contributes the line to node-display-session, and the line goes from here in the same change.
# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute
# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14).
# the peripherals' tray (the operator's application)
exec --no-startup-id polychromatic-tray-applet
# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'
# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,
# polychromatic).
# the operator's scripts: volume, games volume, sessions, screenshot
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
@@ -194,7 +192,8 @@ bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/script
###### Other modules' lines ####
#########################################
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.
# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's
# under a line naming it.
${contribution:node-display-session:config}
#########################################
###### Your own files ####
File diff suppressed because one or more lines are too long
@@ -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")
}
}
@@ -1,7 +1,8 @@
package main
// desktop.go is the same file in the nextcloud-client and blueman bundles: a tray application of the
// operator's graphical session, seen from the node's tool runtime (novox/hq ADR 0208).
// 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
@@ -251,6 +252,22 @@ func (m *Machine) procs(comm string) []Proc {
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") {
@@ -411,12 +428,19 @@ func (m *Machine) detach(s Session, unit string, argv ...string) error {
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var pids []int
var ps []Proc
for _, c := range comms {
for _, p := range m.procs(c) {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
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 {
@@ -451,10 +475,13 @@ func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []in
}
// 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 {
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.procs(comm); len(p) > 0 || waited >= d {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
@@ -1,7 +1,7 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client and blueman bundles.
// 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"
@@ -113,6 +113,23 @@ func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
}
}
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") {
+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))
}
}
@@ -0,0 +1,50 @@
// The openrazer module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Razer
// peripherals' kernel driver and the account's daemon that speaks to it, served by the node's runtime
// as the operator account. The module holds no seat, so every tool is its own. The tray that drives
// the daemon is the polychromatic module's.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "openrazer_status",
Description: "The Razer stack: the installed versions, the driver (built by DKMS for the running " +
"kernel or not, its kernel modules loaded, the devices bound to it), the account's openrazer " +
"group (in the group file, and in its running service manager), the daemon (its unit's state, " +
"its process, its version) and each device the daemon sees: name, type, firmware, battery. " +
"Asks the daemon without starting it. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "openrazer_restart",
Description: "Restart the daemon through its unit in the account's service manager (the unit D-Bus " +
"activation starts), and answer its state and the devices it then sees. Needs the account " +
"logged in. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "openrazer_check",
Description: "Check what the module promises and relies on: the packages are installed; the driver " +
"is built for the running kernel and loaded; the account is in the openrazer group, and its " +
"service manager has it; the daemon runs once, from its unit, and sees the devices the driver " +
"has. Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,112 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// openrazer's shape (novox/hq ADR 0207, ADR 0208, ADR 0210): the three official packages of the
// driver, the daemon and its client library, no seat, no display (the daemon needs none), no start of
// its own (D-Bus activation through the package's unit is the one start), nothing of the tray's (the
// polychromatic module's), and the Go bundle serving exactly the listed openrazer_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "openrazer_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed %s and described", tool.Name, "openrazer_")
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/openrazer-tools" || b["binary"] != "openrazer-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestItInstallsTheStackAndStartsNothing(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "openrazer" || m.Requires != nil || !reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
t.Fatalf("%+v", m)
}
var pkgs []string
for _, r := range m.Resources {
if r["type"] != "package" {
t.Errorf("only packages: %v", r)
}
pkgs = append(pkgs, r["package"].(string))
}
if !reflect.DeepEqual(pkgs, packages) {
t.Fatalf("%v", pkgs)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
t.Fatal("it holds no seat, sets no environment and adds no start")
}
// The tray is polychromatic's, from outside the official repositories; the kernel headers DKMS
// builds against are the kernel's; the account's groups are its user resource's (zsh's).
for _, never := range []string{"polychromatic", "linux-headers", "\"dkms\"", "\"user\"", "groups"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %s", never)
}
}
}
@@ -0,0 +1,499 @@
package main
// openrazer as the tools see it: the kernel driver (built by DKMS, loaded, holding devices), the
// account's membership of the group the driver gives its devices to, and the daemon in the account's
// service manager, asked over the session bus. Every bus call is made with --auto-start=no: the daemon
// is D-Bus activatable, and a question must never start it.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const (
dkmsName = "openrazer-driver"
group = "openrazer"
daemonComm = "openrazer-daemo" // the kernel keeps 15 characters of openrazer-daemon
daemonUnit = "openrazer-daemon.service"
busName = "org.razer"
busRoot = "/org/razer"
activation = "/usr/share/dbus-1/services/org.razer.service"
// mostDevices bounds the questions one answer asks the daemon.
mostDevices = 16
)
// packages are the module's, from the official repositories: the driver's DKMS source, the daemon,
// and the Python library every front end (the tray among them) speaks to the daemon through.
var packages = []string{"openrazer-driver-dkms", "openrazer-daemon", "python-openrazer"}
// kernelModules are what the DKMS package builds (its dkms.conf).
var kernelModules = []string{"razerkbd", "razermouse", "razerkraken", "razeraccessory"}
// hidID is a HID device's name under its driver: bus:vendor:product.instance.
// stamp is the daemon's own time at the front of its log lines, dropped so repeats are seen as one.
var stamp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s*\|\s*`)
var hidID = regexp.MustCompile(`^[0-9A-Fa-f]{4}:([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4})\.[0-9A-Fa-f]{4}$`)
// Bound is one device the driver holds: its USB id, the driver module and how many of its HID
// interfaces are bound.
type Bound struct {
USB string `json:"usb"`
Driver string `json:"driver"`
Interfaces int `json:"interfaces"`
}
// Driver is the kernel side.
type Driver struct {
Kernel string `json:"running_kernel"`
// Built is DKMS's word for the driver and the running kernel: "installed" when it is built and
// installed for it, "" when DKMS has nothing for it.
Built string `json:"built,omitempty"`
Loaded []string `json:"loaded"`
Bound []Bound `json:"bound"`
}
// Group is the account's membership of the group the driver's udev rules give the devices to.
type Group struct {
Name string `json:"name"`
Exists bool `json:"exists"`
Account bool `json:"account_in_group"`
// Manager is whether the account's running service manager has the group. It took its groups when
// it started, at the account's first login, and the daemon runs under it.
Manager *bool `json:"service_manager_has_it,omitempty"`
}
// Device is one device as the daemon sees it. Its serial is not answered.
type Device struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Firmware string `json:"firmware,omitempty"`
Battery *float64 `json:"battery_percent,omitempty"`
Charging *bool `json:"charging,omitempty"`
}
// Daemon is the account's daemon.
type Daemon struct {
Active string `json:"unit_active"`
Enabled string `json:"unit_enabled"`
Running []Proc `json:"running"`
// StartedBy is what starts it: D-Bus activation of org.razer, through its unit.
StartedBy string `json:"started_by"`
Version string `json:"version,omitempty"`
Unanswered string `json:"unanswered,omitempty"`
LastWords []string `json:"last_words,omitempty"`
}
// StatusAnswer is what openrazer_status answers.
type StatusAnswer struct {
Installed map[string]string `json:"installed"`
Driver Driver `json:"driver"`
Group Group `json:"group"`
Daemon Daemon `json:"daemon"`
Devices []Device `json:"devices"`
Notes []string `json:"notes,omitempty"`
}
// busCall asks the daemon one question and answers busctl's data.
func (m *Machine) busCall(path, iface, method string) (json.RawMessage, error) {
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, path, iface, method}
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
if err := failed(o, "busctl", args...); err != nil {
return nil, err
}
var doc struct {
Data []json.RawMessage `json:"data"`
}
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
return nil, fmt.Errorf("the daemon's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
}
return doc.Data[0], nil
}
func (m *Machine) busString(path, iface, method string) (string, error) {
raw, err := m.busCall(path, iface, method)
if err != nil {
return "", err
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", fmt.Errorf("the daemon's %s is not a string: %w", method, err)
}
return s, nil
}
// devices asks the daemon for the devices it holds, and each one's name, type, firmware and battery.
func (m *Machine) devices() ([]Device, string, error) {
version, err := m.busString(busRoot, "razer.daemon", "version")
if err != nil {
return nil, "", err
}
raw, err := m.busCall(busRoot, "razer.devices", "getDevices")
if err != nil {
return nil, version, err
}
var serials []string
if err := json.Unmarshal(raw, &serials); err != nil {
return nil, version, fmt.Errorf("the daemon's device list is not a list of names: %w", err)
}
if len(serials) > mostDevices {
serials = serials[:mostDevices]
}
out := []Device{}
for _, serial := range serials {
path := busRoot + "/device/" + serial
d := Device{}
d.Name, _ = m.busString(path, "razer.device.misc", "getDeviceName")
d.Type, _ = m.busString(path, "razer.device.misc", "getDeviceType")
d.Firmware, _ = m.busString(path, "razer.device.misc", "getFirmware")
if raw, err := m.busCall(path, "razer.device.power", "getBattery"); err == nil {
var pct float64
if json.Unmarshal(raw, &pct) == nil {
d.Battery = &pct
}
}
if raw, err := m.busCall(path, "razer.device.power", "isCharging"); err == nil {
var on bool
if json.Unmarshal(raw, &on) == nil {
d.Charging = &on
}
}
if d.Name == "" {
d.Name = "(unnamed)"
}
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, version, nil
}
// driver reads the kernel side: DKMS's state for the running kernel, the loaded modules, and the
// devices bound to them.
func (m *Machine) driver() Driver {
d := Driver{Kernel: readTrimmed(m.path("/proc/sys/kernel/osrelease")), Loaded: []string{}, Bound: []Bound{}}
if d.Kernel != "" {
o := m.cmd(0, nil, "dkms", "status", dkmsName, "-k", d.Kernel)
for _, l := range strings.Split(o.Stdout, "\n") {
if strings.HasPrefix(l, dkmsName+"/") && strings.Contains(l, d.Kernel) {
if i := strings.LastIndex(l, ":"); i > 0 {
d.Built = strings.TrimSpace(l[i+1:])
}
}
}
}
for _, k := range kernelModules {
if exists(m.path(filepath.Join("/sys/module", k))) {
d.Loaded = append(d.Loaded, k)
}
}
drivers, _ := os.ReadDir(m.path("/sys/bus/hid/drivers"))
seen := map[string]int{}
for _, drv := range drivers {
if !strings.HasPrefix(drv.Name(), "razer") {
continue
}
entries, _ := os.ReadDir(m.path(filepath.Join("/sys/bus/hid/drivers", drv.Name())))
for _, e := range entries {
if f := hidID.FindStringSubmatch(e.Name()); f != nil {
usb := strings.ToLower(f[1] + ":" + f[2])
if i, ok := seen[drv.Name()+" "+usb]; ok {
d.Bound[i].Interfaces++
continue
}
seen[drv.Name()+" "+usb] = len(d.Bound)
d.Bound = append(d.Bound, Bound{USB: usb, Driver: drv.Name(), Interfaces: 1})
}
}
}
return d
}
// account is the operator account's name, from the user database by uid.
func (m *Machine) account() string {
raw, _ := readBounded(m.path("/etc/passwd"))
for _, l := range strings.Split(string(raw), "\n") {
f := strings.Split(l, ":")
if len(f) > 2 && f[2] == strconv.Itoa(m.UID) {
return f[0]
}
}
return ""
}
// groupOf reads one group's gid and members from the group file.
func (m *Machine) groupOf(name string) (gid int, members []string, found bool) {
raw, _ := readBounded(m.path("/etc/group"))
for _, l := range strings.Split(string(raw), "\n") {
f := strings.Split(l, ":")
if len(f) == 4 && f[0] == name {
gid, _ = strconv.Atoi(f[2])
for _, who := range strings.Split(f[3], ",") {
if who = strings.TrimSpace(who); who != "" {
members = append(members, who)
}
}
return gid, members, true
}
}
return 0, nil, false
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// managerGroups are the supplementary groups of the account's own service manager; ok is false when
// it does not run.
func (m *Machine) managerGroups() (groups []int, ok bool) {
for _, p := range m.procs("systemd") {
dir := m.path(filepath.Join("/proc", strconv.Itoa(p.PID)))
if !strings.Contains(readTrimmed(filepath.Join(dir, "cgroup")), fmt.Sprintf("user@%d.service", m.UID)) {
continue
}
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 0 && f[0] == "Groups:" {
for _, g := range f[1:] {
if n, err := strconv.Atoi(g); err == nil {
groups = append(groups, n)
}
}
}
}
return groups, true
}
return nil, false
}
func (m *Machine) group() Group {
g := Group{Name: group}
gid, members, found := m.groupOf(group)
if !found {
return g
}
g.Exists = true
g.Account = contains(members, m.account())
if in, ok := m.managerGroups(); ok {
has := false
for _, n := range in {
has = has || n == gid
}
g.Manager = &has
}
return g
}
// unit answers the daemon's unit's active and enabled states in the account's service manager.
func (m *Machine) unit() (active, enabled string) {
word := func(verb string) string {
o := m.cmd(5*time.Second, m.bus(), "systemctl", "--user", verb, daemonUnit)
if o.Err != nil {
return "unknown"
}
if w := strings.TrimSpace(o.Stdout); w != "" {
return strings.Fields(w)[0]
}
return "unknown"
}
return word("is-active"), word("is-enabled")
}
// lastWords are the daemon's last lines in the account's journal, for a unit that failed: its critical
// and error lines when it wrote any (the reason, not the traceback of its exit), else the last lines.
func (m *Machine) lastWords() []string {
o := m.cmd(5*time.Second, m.bus(), "journalctl", "--user", "-u", daemonUnit, "-n", "20", "-o", "cat", "--no-pager")
var all, said []string
seen := map[string]bool{}
for _, l := range strings.Split(o.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" {
continue
}
l = tail(stamp.ReplaceAllString(l, ""), 300)
all = append(all, l)
if (strings.Contains(l, "CRITICAL") || strings.Contains(l, "ERROR")) && !seen[l] {
seen[l] = true
said = append(said, l)
}
}
if len(said) > 0 {
return said
}
return lastOf(all, 4)
}
func (m *Machine) daemon() (Daemon, []Device) {
d := Daemon{Running: m.procs(daemonComm), StartedBy: "D-Bus activation of " + busName + " through " + daemonUnit}
if d.Running == nil {
d.Running = []Proc{}
}
if !exists(m.path(activation)) {
d.StartedBy = "nothing: the package's activation file " + activation + " is missing"
}
d.Active, d.Enabled = m.unit()
if d.Active == "failed" {
d.LastWords = m.lastWords()
}
devices := []Device{}
if len(d.Running) == 0 {
d.Unanswered = "the daemon is not running (asked without starting it)"
return d, devices
}
got, version, err := m.devices()
d.Version = version
if err != nil {
d.Unanswered = err.Error()
return d, devices
}
return d, got
}
// Status reads the whole stack.
func (m *Machine) Status() (StatusAnswer, error) {
s := StatusAnswer{Installed: map[string]string{}, Driver: m.driver(), Group: m.group()}
for _, p := range packages {
v, err := m.installed(p)
if err != nil {
return s, err
}
s.Installed[p] = v
}
s.Daemon, s.Devices = m.daemon()
if _, members, ok := m.groupOf("plugdev"); ok && contains(members, m.account()) {
s.Notes = append(s.Notes, "the account is in plugdev, which this openrazer does not use: its udev rules "+
"give the devices to the openrazer group. Other devices' rules may use plugdev; the module leaves it")
}
return s, nil
}
// RestartAnswer is what openrazer_restart answers.
type RestartAnswer struct {
Active string `json:"unit_active"`
Running []Proc `json:"running"`
Version string `json:"version,omitempty"`
Devices []Device `json:"devices"`
}
// Restart restarts the daemon's unit in the account's service manager and waits for it to answer.
func (m *Machine) Restart() (RestartAnswer, error) {
a := RestartAnswer{Devices: []Device{}}
args := []string{"--user", "restart", daemonUnit}
if err := failed(m.cmd(15*time.Second, m.bus(), "systemctl", args...), "systemctl", args...); err != nil {
words := m.lastWords()
return a, fmt.Errorf("the daemon did not start: %v. Its last words: %s. See openrazer_check", err, strings.Join(words, " | "))
}
a.Active, _ = m.unit()
a.Running = m.waitFor(daemonComm, 4*time.Second)
devices, version, err := m.devices()
a.Version = version
if err != nil {
return a, fmt.Errorf("the daemon started but does not answer: %v", err)
}
a.Devices = devices
return a, nil
}
// CheckAnswer is what openrazer_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies the stack from the packages to the devices the daemon sees.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
for _, p := range packages {
v, err := m.installed(p)
if err != nil {
return a, err
}
if v == "" {
add("the package "+p+" is not installed", "push the module to the node")
}
}
d := m.driver()
switch {
case d.Kernel == "":
add("the running kernel's release is unreadable", "")
case d.Built != "installed":
add("the driver is not built for the running kernel "+d.Kernel+" (DKMS: "+orNone(d.Built)+")",
"install the headers of the running kernel (linux-headers), then `sudo dkms autoinstall`; or reboot into the kernel it was built for")
}
if len(d.Bound) == 0 {
add("no Razer device is bound to the driver", "plug the device in; if it is plugged in, openrazer_status shows which driver modules are loaded")
}
if len(d.Loaded) == 0 && d.Built == "installed" {
add("none of the driver's modules is loaded", "plug the device in, which loads its module, or `sudo modprobe razermouse` (razerkbd, razerkraken, razeraccessory)")
}
g := m.group()
switch {
case !g.Exists:
add("the group openrazer does not exist", "it comes with openrazer-driver-dkms (sysusers): reinstall it, or `sudo systemd-sysusers`")
case !g.Account:
add("the account is not in the openrazer group: the daemon refuses to start, and the devices' files are the group's",
"`sudo gpasswd -a $USER openrazer`, then log out of every session (or reboot)")
case g.Manager != nil && !*g.Manager:
add("the account is in the openrazer group, but its running service manager started before it was",
"log out of every session (or reboot), so the service manager starts again with the group")
}
if exists(m.path(activation)) {
a.Starts = append(a.Starts, "D-Bus activation: "+busName+" through "+daemonUnit)
} else {
add("the package's activation file "+activation+" is missing: nothing starts the daemon", "reinstall openrazer-daemon")
}
for _, l := range m.i3Starts("openrazer-daemon") {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; D-Bus activation starts the daemon when a client asks")
}
daemon, devices := m.daemon()
switch {
case len(daemon.Running) == 0 && daemon.Active == "failed":
add("the daemon failed at its last start: "+strings.Join(lastOf(daemon.LastWords, 2), " | "), "fix what it says, then openrazer_restart")
case len(daemon.Running) == 0:
add("the daemon is not running", "openrazer_restart; at login the tray's helper asks for it, which starts it")
case len(daemon.Running) > 1:
add(fmt.Sprintf("%d daemons run", len(daemon.Running)), "openrazer_restart ends them and starts one, from its unit")
default:
if in := daemon.Running[0].StartedIn; in != daemonUnit {
add("the daemon runs outside its unit (in "+in+"): started by hand or by a second start", "openrazer_restart")
}
}
if daemon.Unanswered != "" && len(daemon.Running) > 0 {
add("the daemon does not answer: "+daemon.Unanswered, "openrazer_restart")
}
if len(daemon.Running) > 0 && daemon.Unanswered == "" && len(devices) < len(d.Bound) {
add(fmt.Sprintf("the driver holds %d device(s) and the daemon sees %d", len(d.Bound), len(devices)), "openrazer_restart")
}
a.OK = len(a.Findings) == 0
return a, nil
}
func orNone(s string) string {
if s == "" {
return "nothing for it"
}
return s
}
func lastOf(lines []string, n int) []string {
if len(lines) <= n {
return lines
}
return lines[len(lines)-n:]
}
@@ -0,0 +1,185 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
const serial = "PM2148H00000001"
// newStack is a machine with the stack as it should be: built and loaded, a mouse bound on three
// interfaces, the account in the group (and its service manager with it), the daemon running from its
// unit and answering. member and running take parts of it away.
func newStack(t *testing.T, member, running bool) *fake {
f := newFake(t)
f.write("/proc/sys/kernel/osrelease", "7.2.8-arch1-2\n")
f.write("/sys/module/razermouse/refcnt", "0\n")
for _, i := range []string{"0004", "0005", "0006"} {
f.write("/sys/bus/hid/drivers/razermouse/0003:1532:00AB."+i+"/device_type", "")
}
f.write("/sys/bus/hid/drivers/razermouse/bind", "")
f.write("/etc/passwd", "root:x:0:0::/root:/bin/sh\noperator:x:1000:1000::/home/operator:/bin/zsh\n")
members := "operator"
if !member {
members = ""
}
f.write("/etc/group", "plugdev:x:970:operator\nopenrazer:x:958:"+members+"\n")
f.write(activation, "[D-BUS Service]\nName=org.razer\nSystemdService=openrazer-daemon.service\n")
f.proc(3596, 1000, "systemd", []string{"/usr/lib/systemd/systemd", "--user"}, "user@1000.service/init.scope")
groups := "970 958"
if !member {
groups = "970"
}
f.write("/proc/3596/status", "Name:\tsystemd\nUid:\t1000\t1000\t1000\t1000\nGroups:\t"+groups+"\n")
if running {
f.proc(4269, 1000, daemonComm, []string{"openrazer-daemon", "-F"}, "user@1000.service/app.slice/"+daemonUnit)
}
f.answer = func(name string, args []string) Output {
call := name + " " + strings.Join(args, " ")
switch {
case name == "pacman":
return Output{Stdout: args[1] + " 3.12.4-1\n"}
case name == "dkms":
return Output{Stdout: "openrazer-driver/3.12.4, 7.2.8-arch1-2, x86_64: installed\n"}
case name == "systemctl" && strings.Contains(call, "is-active"):
if running {
return Output{Stdout: "active\n"}
}
return Output{Stdout: "failed\n", Code: 3}
case name == "systemctl" && strings.Contains(call, "is-enabled"):
return Output{Stdout: "disabled\n", Code: 1}
case name == "journalctl":
return Output{Stdout: "Starting daemon.\n2026-10-04 16:26:21 | razer | CRITICAL | User is not a member of the openrazer group\n" +
"Traceback (most recent call last):\n2026-10-04 16:26:22 | razer | CRITICAL | User is not a member of the openrazer group\nSystemExit: 0\n"}
case name == "busctl" && !running:
return Output{Code: 1, Stderr: "Call failed: The name is not activatable"}
case strings.HasSuffix(call, " version"):
return Output{Stdout: `{"type":"s","data":["3.12.4"]}`}
case strings.HasSuffix(call, " getDevices"):
return Output{Stdout: `{"type":"as","data":[["` + serial + `"]]}`}
case strings.HasSuffix(call, " getDeviceName"):
return Output{Stdout: `{"type":"s","data":["Razer Basilisk V3 Pro"]}`}
case strings.HasSuffix(call, " getDeviceType"):
return Output{Stdout: `{"type":"s","data":["mouse"]}`}
case strings.HasSuffix(call, " getFirmware"):
return Output{Stdout: `{"type":"s","data":["v1.04"]}`}
case strings.HasSuffix(call, " getBattery"):
return Output{Stdout: `{"type":"d","data":[85.0]}`}
case strings.HasSuffix(call, " isCharging"):
return Output{Code: 1, Stderr: "Unknown method"}
}
return Output{}
}
return f
}
func TestStatusReadsTheDriverTheGroupAndTheDaemonWithoutStartingIt(t *testing.T) {
f := newStack(t, true, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed["openrazer-daemon"] != "3.12.4-1" || s.Driver.Built != "installed" || strings.Join(s.Driver.Loaded, ",") != "razermouse" ||
len(s.Driver.Bound) != 1 || s.Driver.Bound[0] != (Bound{USB: "1532:00ab", Driver: "razermouse", Interfaces: 3}) {
t.Fatalf("%+v", s)
}
if !s.Group.Exists || !s.Group.Account || s.Group.Manager == nil || !*s.Group.Manager {
t.Fatalf("%+v", s.Group)
}
if s.Daemon.Version != "3.12.4" || len(s.Daemon.Running) != 1 || len(s.Devices) != 1 {
t.Fatalf("%+v %+v", s.Daemon, s.Devices)
}
d := s.Devices[0]
if d.Name != "Razer Basilisk V3 Pro" || d.Type != "mouse" || d.Battery == nil || *d.Battery != 85 || d.Charging != nil {
t.Fatalf("%+v", d)
}
if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "plugdev") {
t.Fatalf("%v", s.Notes)
}
for _, c := range f.calls {
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
t.Fatalf("a bus call that could start the daemon: %s", c)
}
}
raw, _ := json.Marshal(s)
if strings.Contains(string(raw), serial) {
t.Fatal("the answer carries a device's serial")
}
stopped := newStack(t, false, false)
s, _ = stopped.Status()
if s.Group.Account || s.Group.Manager == nil || *s.Group.Manager || s.Daemon.Active != "failed" || stopped.called("busctl") {
t.Fatalf("%+v %q", s, stopped.calls)
}
if len(s.Daemon.LastWords) != 1 || s.Daemon.LastWords[0] != "razer | CRITICAL | User is not a member of the openrazer group" {
t.Fatalf("the reason, once, not the traceback: %q", s.Daemon.LastWords)
}
}
func TestCheckPassesTheWholeStackAndNamesWhatIsMissing(t *testing.T) {
f := newStack(t, true, true)
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 || !strings.HasPrefix(c.Starts[0], "D-Bus activation") {
t.Fatalf("%+v %v", c, err)
}
broken := newStack(t, false, false)
broken.write("/proc/sys/kernel/osrelease", "7.3.0-arch1-1\n")
broken.write(testHome+"/.config/i3/config", "exec --no-startup-id openrazer-daemon\n")
c, _ = broken.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What+" => "+x.Do)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not built for the running kernel 7.3.0-arch1-1", "not in the openrazer group", "gpasswd -a $USER openrazer",
"a second start: ~/.config/i3/config:1", "failed at its last start: razer | CRITICAL | User is not a member"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
}
func TestAGroupAddedAfterLoginIsNamedAsTheServiceManagers(t *testing.T) {
f := newStack(t, true, true)
f.write("/proc/3596/status", "Name:\tsystemd\nUid:\t1000\t1000\t1000\t1000\nGroups:\t970\n")
c, _ := f.Check()
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "service manager started before") {
t.Fatalf("%+v", c)
}
}
func TestADaemonOutsideItsUnitOrSeeingFewerDevicesIsAFinding(t *testing.T) {
f := newStack(t, true, true)
f.write("/proc/4269/cgroup", "0::/user.slice/user-1000.slice/session-c1.scope\n")
f.write("/sys/bus/hid/drivers/razerkbd/0003:1532:0099.0001/x", "")
c, _ := f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What)
}
got := strings.Join(all, "\n")
if !strings.Contains(got, "outside its unit (in session-c1.scope)") || !strings.Contains(got, "holds 2 device(s) and the daemon sees 1") {
t.Fatalf("%s", got)
}
}
func TestRestartGoesThroughTheUnitAndSaysWhyItFailed(t *testing.T) {
f := newStack(t, true, true)
a, err := f.Restart()
if err != nil || a.Version != "3.12.4" || len(a.Devices) != 1 || !f.called("systemctl --user restart "+daemonUnit) {
t.Fatalf("%+v %v %q", a, err, f.calls)
}
failing := newStack(t, false, false)
inner := failing.answer
failing.answer = func(name string, args []string) Output {
if name == "systemctl" && len(args) > 1 && args[1] == "restart" {
return Output{Code: 1, Stderr: "Job for openrazer-daemon.service failed"}
}
return inner(name, args)
}
if _, err := failing.Restart(); err == nil || !strings.Contains(err.Error(), "not a member of the openrazer group") {
t.Fatalf("%v", err)
}
}
+5
View File
@@ -0,0 +1,5 @@
module openrazer
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=
+44
View File
@@ -0,0 +1,44 @@
{
"module": "openrazer",
"version": "1",
"capabilities": [
"package-manager"
],
"tools": [
"openrazer_status",
"openrazer_restart",
"openrazer_check"
],
"resources": [
{
"id": "driver",
"type": "package",
"package": "openrazer-driver-dkms"
},
{
"id": "daemon",
"type": "package",
"package": "openrazer-daemon"
},
{
"id": "library",
"type": "package",
"package": "python-openrazer"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/openrazer-tools",
"binary": "openrazer-tools",
"loads": [
"openrazer-tools"
]
}
]
}
}
+96
View File
@@ -0,0 +1,96 @@
# polychromatic
The Razer peripherals' tray on the workstations, as a module (novox/hq ADR 0208). It requires
`x11-display`, so it is assigned only where a display server is held on the same machine. The driver
and the daemon it drives are the `openrazer` module's.
## Owns
| what | where |
|---|---|
| the tray's one start | a contribution to `node-display-session` (ADR 0212): `exec --no-startup-id polychromatic-tray-applet`, placed by the `i3` module among the other modules' lines |
Nothing else. It declares no package, holds no seat and writes no file.
- **The application is kept as found.** `polychromatic` (0.9.8) is not in the official repositories:
on both workstations it is a foreign (AUR) package, installed explicitly. The host installs from the
official repositories only, so the module cannot declare it. ADR 0205's pinned archive does not fit
either: it is a Qt application with a helper, a tray and a controller, not a set of plain files. Like
`snapd` and the laptop's `triggerhappy`, it waits for the mesh's package repository (research 027
question 1, option P2). Until then a fresh workstation installs it by hand, and
`polychromatic_check` says when it is missing.
- **Its dependencies are the `openrazer` module's** (`python-openrazer`, the daemon, the driver). The
tray brings them when installed by hand; the module does not declare them twice.
- **The application's settings stay the operator's.** `~/.config/polychromatic/` (preferences,
presets, effects, device states) is the application's own, rewritten by it. The tools read only the
tray's two keys of `preferences.json`.
## How it starts: the module's window-manager line, and nothing else
The tray had **two starts** on both workstations:
1. the window-manager line `exec --no-startup-id polychromatic-tray-applet`, in the `i3` module's
section *Until their modules carry them*;
2. the package's XDG autostart entry `polychromatic-autostart.desktop`, which `dex` runs at login. It
runs `polychromatic-helper --autostart`, which waits for the daemon, resumes each device's
software effect, and **starts the tray too while the application's setting *Start the tray applet
when I log on* is ticked**. It is ticked on both.
The tray takes a lock file at its start and stops an earlier instance, so two starts end as one tray,
in a race. On the laptop both ran at the login of 2026-10-04 16:26, and one tray was left; which
start it came from is not recorded anywhere. On the desktop only the window-manager line ran (that
session began before `dex` was installed).
**This module takes over the window-manager line as its contribution**, and the `i3` module drops it
in the same change. That line is the tray's one start:
- it starts the tray with the session, once (`exec`, so a reload of i3 starts nothing);
- it is the mesh's, so it is in the composed configuration only where the module is assigned.
**The helper stays**, for its other work: the effects it resumes at login. The package's entry is not
the module's to hide, and hiding it would need a file in the account's autostart directory. So the
operator unticks the tray setting once (migration, below), and `polychromatic_check` names the
helper's tray start as a second start for as long as the setting is ticked. The module never writes
the setting: the application rewrites its preferences file itself.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `polychromatic_status` (r) | <ul><li>whether the tray runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and that it is from outside the official repositories</li><li>what starts it: the window-manager lines, and the helper while the setting is on</li><li>the helper's entry, and the tray setting with its delay</li><li>whether the openrazer daemon answers, and how many devices it has (asked with `--auto-start=no`: never starts it)</li></ul> |
| `polychromatic_restart` (a) | asks the tray to end (SIGTERM), forces it after 5 s, and starts `polychromatic-tray-applet` in the operator's session as a transient user unit `mesh-polychromatic-tray`, so it outlives the tools runtime. Refused plainly when nobody is logged in to the desktop |
| `polychromatic_check` (r) | <ul><li>the package is installed</li><li>exactly one start: one window-manager line; the helper does not start the tray too; no autostart entry of the account for it</li><li>one tray runs in a desktop session</li><li>the openrazer daemon answers</li></ul>Each finding says what to do |
The kernel keeps 15 characters of a command name, and `polychromatic-tray-applet` and this bundle's
`polychromatic-tools` share them. The tools match the tray by its full program name, so they never
count or end themselves.
## What changes when it is assigned
| | laptop | desktop |
|---|---|---|
| package | none: `polychromatic` 0.9.8, explicit, foreign | the same |
| `~/.config/i3/config` | the tray's line moves from the `i3` module's section to this module's contribution: the same line, and i3's reload starts nothing | the same |
| tray | none: one runs, left by the two starts' race | none: one runs, from the predecessor's window-manager line (a child of i3, since the session of 2026-10-04 16:00, before `dex` ran) |
## Migration (ADR 0182)
On each workstation, once: in Polychromatic, **untick Preferences → Tray → *Start the tray applet
when I log on***. From the next login the module's line is the tray's one start, and
`polychromatic_check` answers `ok` once the daemon answers too (the `openrazer` module's migration).
## Leaves as found
- `~/.config/polychromatic/`: preferences, presets, custom effects, device states.
- `/etc/xdg/autostart/polychromatic-autostart.desktop`, the package's.
- The package itself, installed by hand.
## Relies on
- **The `openrazer` module** for the daemon and the driver. The tray without them shows no devices.
`polychromatic_check` reports it.
- **The `i3` module** to place the contribution (ADR 0212 §4: the contribution depends on
`node-display-session`), and to run `dex` for the helper.
- A display server on the same machine (`x11-display`, ADR 0208 §3).
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,51 @@
// The polychromatic module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Razer
// peripherals' tray in the operator's session, served by the node's runtime as the operator account.
// The module holds no seat, so every tool is its own. The driver and the daemon the tray drives are
// the openrazer module's tools.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "polychromatic_status",
Description: "The Razer peripherals' tray: whether it runs (pid, since, and the unit or session " +
"scope it runs in), the installed version and whether it came from outside the official " +
"repositories, what starts it at login, the package's login helper and the application's own " +
"tray setting, and whether the openrazer daemon behind it answers, with how many devices. " +
"Never starts the tray or the daemon. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "polychromatic_restart",
Description: "End the tray (asked first, then forced after 5 s) and start it again in the operator's " +
"desktop session, under the account's service manager. Answers the pids ended and the new one. " +
"Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "polychromatic_check",
Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
"outside the official repositories); the tray has exactly one start (the module's line in the " +
"window manager's configuration; the package's login helper must not start it too); it runs " +
"once in a desktop session; and the openrazer daemon answers. Answers ok and each finding with " +
"what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,128 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
// polychromatic's shape (novox/hq ADR 0205, ADR 0208, ADR 0212): no package (the tray is outside the
// official repositories and kept as found), the X display on its own machine, one contribution to the
// window manager's configuration that is the tray's one start, nothing of the openrazer module's, and
// the Go bundle serving exactly the listed polychromatic_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "polychromatic_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed %s and described", tool.Name, "polychromatic_")
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/polychromatic-tools" || b["binary"] != "polychromatic-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestItDeclaresNoPackageAndContributesTheTraysOneStart(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "polychromatic" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("%+v", m)
}
if m.Resources != nil || m.Capabilities != nil || m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil {
t.Fatal("no package (outside the official repositories), no seat, no environment, no xinitrc start")
}
if len(m.Contributions) != 1 || m.Contributions[0].Seat != "node-display-session" || m.Contributions[0].Kind != "config" {
t.Fatalf("%+v", m.Contributions)
}
var code []string
for _, l := range strings.Split(m.Contributions[0].Content, "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
code = append(code, l)
}
}
if !reflect.DeepEqual(code, []string{"exec --no-startup-id " + trayWord}) {
t.Fatalf("one line, the tray's start with exec (a reload must not start it again): %q", code)
}
for _, never := range []string{"openrazer", "preferences.json", "autostart\""} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %s", never)
}
}
}
// The window manager's own check parses the line, as the i3 module's catalogue-wide test does with
// every contribution together.
func TestTheContributionParsesInI3(t *testing.T) {
i3, err := exec.LookPath("i3")
if err != nil {
t.Skip("no i3 here to check with")
}
m, _ := readManifest(t)
path := filepath.Join(t.TempDir(), "config")
os.WriteFile(path, []byte("set $mod Mod4\n"+m.Contributions[0].Content), 0o644)
if out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput(); err != nil || strings.Contains(string(out), "ERROR") {
t.Fatalf("i3 -C: %v %s", err, out)
}
}
@@ -0,0 +1,213 @@
package main
// Polychromatic's tray as the tools see it: its processes, its one start (the module's line in the
// window manager's configuration), the package's login helper and the one setting of the application's
// that makes the helper start the tray too, and whether the daemon it drives answers. The daemon is
// the openrazer module's; it is asked with --auto-start=no, so a question never starts it.
import (
"encoding/json"
"fmt"
"strings"
"time"
)
const (
trayComm = "polychromatic-t" // the kernel keeps 15 characters of polychromatic-tray-applet
trayWord = "polychromatic-tray-applet"
trayBin = "/usr/bin/polychromatic-tray-applet"
helperEntry = "polychromatic-autostart.desktop"
restartAs = "mesh-polychromatic-tray"
packageFor = "polychromatic"
prefsFile = ".config/polychromatic/preferences.json"
settingName = "Preferences → Tray → \"Start the tray applet when I log on\""
)
// trays are the tray's processes. Its cut command name is this bundle's own too, so the program is
// matched by its full name.
func (m *Machine) trays() []Proc { return m.procsOf(trayComm, trayWord) }
// TraySetting is the application's own setting that makes its login helper start the tray. The
// module never writes it: the application rewrites its preferences file itself.
type TraySetting struct {
// Autostart is the setting; Polychromatic's default is on.
Autostart bool `json:"autostart"`
Delay int `json:"autostart_delay_seconds"`
From string `json:"from"`
}
// setting reads the tray keys of the preferences file and nothing else of it.
func (m *Machine) setting() TraySetting {
s := TraySetting{Autostart: true, From: "Polychromatic's default (no preferences file)"}
raw, err := readBounded(m.home(prefsFile))
if err != nil {
return s
}
var doc struct {
Tray struct {
Autostart *bool `json:"autostart"`
Delay int `json:"autostart_delay"`
} `json:"tray"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
s.From = "Polychromatic's default (the preferences file does not parse)"
return s
}
s.From = "~/" + prefsFile
if doc.Tray.Autostart != nil {
s.Autostart = *doc.Tray.Autostart
}
s.Delay = doc.Tray.Delay
return s
}
// Backend is whether the openrazer daemon answers, and how many devices it has.
type Backend struct {
Answers bool `json:"answers"`
Devices int `json:"devices"`
Unanswered string `json:"unanswered,omitempty"`
}
func (m *Machine) backend() Backend {
args := []string{"--user", "--auto-start=no", "--json=short", "call", "org.razer", "/org/razer", "razer.devices", "getDevices"}
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
if err := failed(o, "busctl", args...); err != nil {
return Backend{Unanswered: "the openrazer daemon does not answer (asked without starting it): " + err.Error()}
}
var doc struct {
Data [][]string `json:"data"`
}
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
return Backend{Unanswered: "the daemon's device list is not busctl's JSON"}
}
return Backend{Answers: true, Devices: len(doc.Data[0])}
}
// foreign says whether the package came from outside the official repositories (pacman -Qm).
func (m *Machine) foreign() bool {
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
}
// StatusAnswer is what polychromatic_status answers.
type StatusAnswer struct {
Installed string `json:"installed,omitempty"`
Foreign bool `json:"outside_official_repositories"`
Tray []Proc `json:"tray"`
StartedBy []string `json:"started_by"`
Helper Autostart `json:"login_helper"`
Setting TraySetting `json:"tray_setting"`
Backend Backend `json:"backend"`
}
// Status reads the tray, what starts it, and whether the daemon behind it answers.
func (m *Machine) Status() (StatusAnswer, error) {
s := StatusAnswer{Tray: m.trays(), StartedBy: []string{}, Helper: m.autostart(helperEntry),
Setting: m.setting(), Backend: m.backend()}
if s.Tray == nil {
s.Tray = []Proc{}
}
v, err := m.installed(packageFor)
if err != nil {
return s, err
}
s.Installed = v
if v != "" {
s.Foreign = m.foreign()
}
for _, l := range m.i3Starts(trayWord) {
s.StartedBy = append(s.StartedBy, "window manager: "+l)
}
if s.Helper.Starts && s.Setting.Autostart {
s.StartedBy = append(s.StartedBy, "the login helper ("+s.Helper.From+"), as the tray setting is on")
}
return s, nil
}
// RestartAnswer is what polychromatic_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends the tray and starts it again in the operator's session, under the account's service
// manager.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stopProcs(5*time.Second, m.trays())
if err := m.detach(s, restartAs, trayBin); err != nil {
return a, err
}
a.Running = m.waitForOf(trayComm, trayWord, 4*time.Second)
if len(a.Running) == 0 {
return a, fmt.Errorf("the tray was started as %s but no %s process appeared within 4 s: "+
"see `journalctl --user -u %s`", a.Unit, trayWord, a.Unit)
}
return a, nil
}
// CheckAnswer is what polychromatic_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises and relies on: the package (found, not installed by the
// mesh); one start, the module's window-manager line; the tray running once; and the daemon answering.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("polychromatic is not installed. It is outside the official repositories, so the mesh does not install it",
"install it from the AUR by hand; it brings the openrazer packages with it")
}
lines := m.i3Starts(trayWord)
for _, l := range lines {
a.Starts = append(a.Starts, "window manager: "+l)
}
switch {
case len(lines) == 0:
add("the window manager does not start the tray", "push the module: its line in the window manager's configuration is the tray's start")
case len(lines) > 1:
for _, l := range lines[1:] {
add("a second start: "+l, "remove the line; the module's line is the tray's one start")
}
}
helper := m.autostart(helperEntry)
if set := m.setting(); helper.Starts && set.Autostart {
a.Starts = append(a.Starts, "login helper: "+helper.From+" (the tray setting is on)")
add("a second start: the package's login helper starts the tray too, as the tray setting is on ("+set.From+")",
"in Polychromatic, untick "+settingName+". The helper keeps its other work: it resumes the devices' effects")
}
if own := m.autostart(trayWord + ".desktop"); own.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+own.From)
add("a second start: "+own.From, "remove it; the module's line is the tray's one start")
}
if _, err := m.session(); err == nil {
switch running := m.trays(); {
case len(running) == 0:
add("no tray runs in the desktop session", "polychromatic_restart")
case len(running) > 1:
add(fmt.Sprintf("%d trays run", len(running)), "polychromatic_restart ends them all and starts one")
}
}
if b := m.backend(); !b.Answers {
add(b.Unanswered+": the tray has no devices to show", "assign the openrazer module; openrazer_check says why the daemon is not running")
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,122 @@
package main
import (
"strings"
"testing"
)
// newTray is a machine with the tray as the module wants it: the module's line in i3's configuration,
// the package's helper entry with the tray setting off, one tray running, and the daemon answering.
func newTray(t *testing.T, running bool) *fake {
f := newFake(t)
f.write("/etc/xdg/autostart/"+helperEntry, "[Desktop Entry]\nExec=polychromatic-helper --autostart\n")
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# polychromatic\n"+
"exec --no-startup-id polychromatic-tray-applet\n")
f.write(testHome+"/"+prefsFile, `{"config_version":8,"tray":{"autostart":false,"autostart_delay":0,"mode":0},"editor":{}}`)
if running {
f.proc(4122, 1000, trayComm, []string{"polychromatic-tray-applet"}, "session-c1.scope")
// the tools themselves: the same cut command name
f.proc(4200, 1000, trayComm, []string{"/usr/lib/mesh/polychromatic-tools"}, "system.slice/mesh-runtime.service")
}
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman" && args[0] == "-Q":
return Output{Stdout: "polychromatic 0.9.8-1\n"}
case name == "pacman" && args[0] == "-Qqm":
return Output{Stdout: "polychromatic\n"}
case name == "busctl":
return Output{Stdout: `{"type":"as","data":[["PM2148H00000001"]]}`}
}
return Output{}
}
return f
}
func TestStatusSaysWhatStartsTheTrayAndAsksTheDaemonWithoutStartingIt(t *testing.T) {
f := newTray(t, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed != "0.9.8-1" || !s.Foreign || len(s.Tray) != 1 || !s.Backend.Answers || s.Backend.Devices != 1 ||
len(s.StartedBy) != 1 || !strings.HasPrefix(s.StartedBy[0], "window manager: ~/.config/i3/config:3") || s.Setting.Autostart {
t.Fatalf("%+v", s)
}
for _, c := range f.calls {
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
t.Fatalf("a bus call that could start the daemon: %s", c)
}
}
}
func TestTheTraySettingIsReadAndDefaultsOn(t *testing.T) {
f := newTray(t, false)
if s := f.setting(); s.Autostart || s.From != "~/"+prefsFile {
t.Fatalf("%+v", s)
}
f.write(testHome+"/"+prefsFile, `{"tray":{"autostart_delay":3}}`)
if s := f.setting(); !s.Autostart || s.Delay != 3 {
t.Fatalf("%+v", s)
}
f.write(testHome+"/"+prefsFile, `not json`)
if s := f.setting(); !s.Autostart || !strings.Contains(s.From, "does not parse") {
t.Fatalf("%+v", s)
}
}
func TestCheckPassesTheModulesOneStartAndNamesTheHelpersAsASecond(t *testing.T) {
f := newTray(t, true)
f.desktopSession()
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 {
t.Fatalf("%+v %v", c, err)
}
f.write(testHome+"/"+prefsFile, `{"tray":{"autostart":true}}`)
f.write(testHome+"/.config/i3/config.d/90-x.conf", "exec_always --no-startup-id polychromatic-tray-applet\n")
f.proc(4123, 1000, trayComm, []string{"polychromatic-tray-applet"}, "session-c1.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "busctl":
return Output{Code: 1, Stderr: "Call failed: Unit openrazer-daemon.service failed"}
}
return Output{}
}
c, _ = f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What+" => "+x.Do)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not installed", "a second start: ~/.config/i3/config.d/90-x.conf:1", "the package's login helper starts the tray too",
"Start the tray applet when I log on", "2 trays run", "openrazer daemon does not answer"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
none := newTray(t, false)
none.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n")
c, _ = none.Check()
if c.OK || !strings.Contains(c.Findings[0].What, "does not start the tray") {
t.Fatalf("%+v", c)
}
}
func TestRestartEndsTheTrayAndStartsItUnderTheServiceManager(t *testing.T) {
f := newTray(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, trayComm, argv, "app.slice/"+restartAs+".service") }
a, err := f.Restart()
if err != nil || len(a.Ended) != 1 || len(a.Running) != 1 || a.Running[0].PID != 9100 {
t.Fatalf("%+v %v", a, err)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
t.Fatalf("%q", f.calls)
}
}
+5
View File
@@ -0,0 +1,5 @@
module polychromatic
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=
+34
View File
@@ -0,0 +1,34 @@
{
"module": "polychromatic",
"version": "1",
"requires": [
"x11-display"
],
"tools": [
"polychromatic_status",
"polychromatic_restart",
"polychromatic_check"
],
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh:\n# replaced at every push. This line is the tray's one start, at the session's start (exec, not\n# exec_always, so a reload starts nothing). The package's own login helper starts it too while the\n# application's setting \"Start the tray applet when I log on\" is ticked; polychromatic_check names\n# that as a second start.\nexec --no-startup-id polychromatic-tray-applet\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/polychromatic-tools",
"binary": "polychromatic-tools",
"loads": [
"polychromatic-tools"
]
}
]
}
}
+9
View File
@@ -39,3 +39,12 @@ says so in its log.
Search the mesh, through the console, for a phrase that appears only in one design document here, and
get it back. `records_search {"query": "…"}` is that search; its test does the same against a
repository it makes.
## Where the checkout lives
A Go bundle the node's runtime launches as the operator account (`cmd/records`). It clones into
`repository/` inside the directory the mesh gives it — a directory it makes, and so owns. An earlier
layout cloned into the given directory itself, from a container running as root, and left files the
operator account cannot change; git then refused every sync as "dubious ownership" (hq issue 251). What
that layout left is removed where it is the module's, and named in `records_status` (`leftBehind`, with
the one command that deletes it) where it is not.
+133
View File
@@ -0,0 +1,133 @@
// records: the mesh's record — decisions, designs and issues — read where it is written (novox/hq ADR 0025,
// ADR 0153). A Go bundle the node's runtime launches; it keeps a checkout of one repository current on every
// merge the forge announces and on a timer, and answers five questions about it. stdout is the MCP channel;
// what this module says, it says on stderr.
package main
import (
"encoding/json"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func readJSON(path string, into any) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(raw, into)
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func strArg(a map[string]any, k string) string { s, _ := a[k].(string); return s }
func tools(r *Records) []stdio.Tool {
return []stdio.Tool{
{Name: "records_search",
Description: "Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " +
"Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.",
Input: map[string]any{
"query": str("the phrase, matched case-insensitively as written"),
"limit": map[string]any{"type": "number", "description": "at most this many places (default 50)"},
},
Run: func(a map[string]any) (any, error) {
limit := 0
if v, ok := a["limit"].(float64); ok {
limit = int(v)
}
return r.Search(strArg(a, "query"), limit)
}},
{Name: "records_read",
Description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.",
Input: map[string]any{"path": str("the document's path, e.g. 02-DECISIONS/0025-....md")},
Run: func(a map[string]any) (any, error) { return r.Read(strArg(a, "path")) }},
{Name: "records_list",
Description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
Input: map[string]any{"folder": str("a folder inside the repository (optional)")},
Run: func(a map[string]any) (any, error) { return r.List(strArg(a, "folder")) }},
{Name: "records_status",
Description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
Run: func(map[string]any) (any, error) { return r.Standing(), nil }},
{Name: "records_sync",
Description: "Bring the checkout up to date now, and say where it stands.",
Run: func(map[string]any) (any, error) {
r.Sync()
return r.Standing(), nil
}},
}
}
// keep keeps the checkout current: at start, on every merge the forge announces into this repository, and on
// an unhurried timer for the merges it did not hear about — a restart during a merge, a repository the forge
// does not emit for. The record changes when thinking changes, not by the minute.
func keep(r *Records) {
r.Sync()
s := r.Standing()
commit := s.Commit
if len(commit) > 8 {
commit = commit[:8]
}
line := fmt.Sprintf("[records] %s at %s, %d document(s)", s.Repository, commit, s.Documents)
if s.LastError != "" {
line += " — " + s.LastError
}
fmt.Fprintln(os.Stderr, line)
if s.LeftBehind != "" {
fmt.Fprintln(os.Stderr, "[records] "+s.LeftBehind)
}
go func() {
for range time.Tick(10 * time.Minute) {
r.Sync()
}
}()
// A merge on the forge into the repository this reads: pull now. The event names the repository by
// owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
subscribe := func() error {
return stdio.Subscribe("gitea.pull.merged", func(e stdio.Envelope) error {
var body struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
_ = json.Unmarshal(e.Body, &body)
if body.Owner+"/"+body.Repo != r.Repository {
return nil
}
fmt.Fprintf(os.Stderr, "[records] %s merged; syncing\n", r.Repository)
r.Sync()
return nil
})
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := subscribe()
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "[records] hearing merges not yet (%v); asking again in %s\n", err, wait)
time.Sleep(wait)
}
}
func main() {
r, err := FromEnv(os.Getenv)
if err != nil {
// Without a repository to read there is nothing to answer: no tools rather than five that fail.
fmt.Fprintf(os.Stderr, "[records] no tools — %v\n", err)
if err := stdio.Serve("", nil); err != nil {
os.Exit(1)
}
return
}
go keep(r)
if err := stdio.Serve("", tools(r)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+417
View File
@@ -0,0 +1,417 @@
package main
// The record, read where it is written (novox/hq ADR 0025, ADR 0153).
//
// A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own forge
// and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit every
// answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
// transformed, indexed or summarised on the way, so there is nothing that can drift from the source except
// by lagging behind it, and the lag is a number in every answer.
//
// **The clone is in a directory this module made** (novox/hq issue 251). The module is given a directory;
// it clones into `repository/` inside it, a directory it creates and so owns. The TypeScript module cloned
// into the given directory itself, and a sync that once ran as another account left every file there that
// account's: git refused the checkout as "dubious ownership" from then on, and the operator account the
// module runs as could change none of it. What the old layout left is removed where it is this module's,
// and named in the standing where it is not.
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"syscall"
"time"
)
// MostHits is the most places a search answers; a phrase found more often is a phrase to narrow.
const MostHits = 50
// MostBytes is how much of a document is answered; a longer one is answered in part, and says so.
const MostBytes = 200_000
// Hit is one place a phrase was found.
type Hit struct {
// Path is the document, relative to the repository's root.
Path string `json:"path"`
// Line is the line it was found on, from 1.
Line int `json:"line"`
// Heading is the nearest heading above it, so a hit reads as where in the document it is.
Heading string `json:"heading"`
// Text is the line itself, trimmed.
Text string `json:"text"`
}
// Standing is where the checkout stands.
type Standing struct {
Repository string `json:"repository"`
Origin string `json:"origin"`
// Commit is the commit the checkout is at, or empty before the first clone.
Commit string `json:"commit"`
// Committed is when that commit was made, as the repository says.
Committed string `json:"committed"`
// Fetched is when this reader last brought the checkout up to date.
Fetched string `json:"fetched"`
// Documents is how many markdown documents the checkout holds.
Documents int `json:"documents"`
// LastError is why the last sync failed, if it did; the checkout stands where it was.
LastError string `json:"lastError,omitempty"`
// LeftBehind names what an earlier layout left in the module's directory that this account cannot
// remove: another account's files, harmless, and the operator's to delete.
LeftBehind string `json:"leftBehind,omitempty"`
}
// Records reads one repository's checkout.
type Records struct {
// Base is the directory the mesh gives the module; the checkout is Base/repository.
Base string
// Origin is the forge's address, `scheme://host:port`, from the git provision's binding.
Origin string
// Repository is the repository's path on it, `owner/name`, from this module's settings.
Repository string
// URL overrides the clone URL; a test points it at a local repository.
URL string
mu sync.Mutex
syncing chan struct{}
fetched string
lastError string
leftBehind string
}
// Dir is where the checkout is.
func (r *Records) Dir() string { return filepath.Join(r.Base, "repository") }
// CloneURL is the forge and the repository. Public repositories only; a credential would be a secret this
// module has not asked for.
func (r *Records) CloneURL() string {
if r.URL != "" {
return r.URL
}
return strings.TrimRight(r.Origin, "/") + "/" + r.Repository + ".git"
}
func git(args ...string) (string, error) {
cmd := exec.Command("git", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
// Sync brings the checkout up to date, cloning it if it does not exist. One at a time: a call while one
// runs waits for that one rather than racing it. It never fails: a failed sync is recorded in the standing
// and the checkout stands where it was, which is still an answer.
func (r *Records) Sync() {
r.mu.Lock()
if r.syncing != nil {
wait := r.syncing
r.mu.Unlock()
<-wait
return
}
done := make(chan struct{})
r.syncing = done
r.mu.Unlock()
err := r.doSync()
r.mu.Lock()
if err != nil {
r.lastError = err.Error()
fmt.Fprintf(os.Stderr, "[records] could not sync %s: %v\n", r.CloneURL(), err)
} else {
r.lastError = ""
r.fetched = time.Now().UTC().Format(time.RFC3339)
}
r.syncing = nil
r.mu.Unlock()
close(done)
}
func (r *Records) doSync() error {
r.clearOldLayout()
dir := r.Dir()
if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
_ = os.RemoveAll(dir) // a clone that never finished
if err := os.MkdirAll(r.Base, 0o700); err != nil {
return err
}
_, err := git("clone", "--quiet", "--depth", "50", r.CloneURL(), dir)
return err
}
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has, rather than
// merging into something a hand may have touched.
if _, err := git("-C", dir, "fetch", "--quiet", "--depth", "50", "origin"); err != nil {
return err
}
_, err := git("-C", dir, "reset", "--quiet", "--hard", "origin/HEAD")
return err
}
// clearOldLayout removes what the TypeScript module's layout left in Base — a checkout in Base itself —
// where it is this account's, and remembers what it is not.
func (r *Records) clearOldLayout() {
entries, err := os.ReadDir(r.Base)
if err != nil {
return
}
foreign := 0
for _, e := range entries {
if e.Name() == "repository" {
continue
}
full := filepath.Join(r.Base, e.Name())
if err := os.RemoveAll(full); err != nil {
foreign++
}
}
r.mu.Lock()
defer r.mu.Unlock()
r.leftBehind = ""
if foreign > 0 {
owner := "another account"
if info, err := os.Stat(filepath.Join(r.Base, ".git")); err == nil {
if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid == 0 {
owner = "root"
}
}
r.leftBehind = fmt.Sprintf("%d entr(ies) of an earlier checkout in %s belong to %s and cannot be removed by this module; "+
"they are not read — delete them once: sudo find %s -mindepth 1 -maxdepth 1 ! -name repository -exec rm -rf {} +",
foreign, r.Base, owner, r.Base)
}
}
// Standing says where the checkout stands.
func (r *Records) Standing() Standing {
s := Standing{Repository: r.Repository, Origin: r.Origin}
if _, err := os.Stat(filepath.Join(r.Dir(), ".git")); err == nil {
s.Commit, _ = git("-C", r.Dir(), "rev-parse", "HEAD")
s.Committed, _ = git("-C", r.Dir(), "log", "-1", "--format=%cI")
}
if s.Commit != "" {
s.Documents = len(r.documents())
}
r.mu.Lock()
s.Fetched, s.LastError, s.LeftBehind = r.fetched, r.lastError, r.leftBehind
r.mu.Unlock()
return s
}
// documents is every markdown document, relative to the root, in a stable order.
func (r *Records) documents() []string {
var out []string
root := r.Dir()
_ = filepath.WalkDir(root, func(full string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules") {
return filepath.SkipDir
}
if d.Type().IsRegular() && strings.HasSuffix(d.Name(), ".md") {
rel, _ := filepath.Rel(root, full)
out = append(out, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(out)
return out
}
var (
emphasis = regexp.MustCompile("[*_`]")
spaces = regexp.MustCompile(`\s+`)
heading = regexp.MustCompile(`^#{1,6}\s`)
hashes = regexp.MustCompile(`^#+\s*`)
)
// flat is a line as a phrase is matched against it: no emphasis marks, one space, lower case.
func flat(line string) string {
return strings.TrimSpace(spaces.ReplaceAllString(strings.ToLower(emphasis.ReplaceAllString(line, "")), " "))
}
// SearchAnswer is what a search answers.
type SearchAnswer struct {
Hits []Hit `json:"hits"`
More bool `json:"more"`
Commit string `json:"commit"`
}
// Search finds where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
// design record is found by its own words. Bounded, and says when it was.
//
// **The record is wrapped prose, and a phrase does not know where the line ends.** A line is matched
// together with the one after it, joined by a space, and emphasis marks are ignored — `**reachable**` is
// the word reachable. A hit names the line it starts on.
func (r *Records) Search(query string, limit int) (SearchAnswer, error) {
needle := flat(query)
if needle == "" {
return SearchAnswer{}, errors.New("search for a phrase; an empty one matches every line of every document")
}
if limit <= 0 || limit > MostHits {
limit = MostHits
}
answer := SearchAnswer{Hits: []Hit{}}
for _, p := range r.documents() {
raw, err := os.ReadFile(filepath.Join(r.Dir(), filepath.FromSlash(p)))
if err != nil {
continue
}
lines := strings.Split(string(raw), "\n")
flats := make([]string, len(lines))
for i, l := range lines {
flats[i] = flat(l)
}
head := ""
for i, line := range lines {
if heading.MatchString(line) {
head = strings.TrimSpace(hashes.ReplaceAllString(line, ""))
}
next := ""
if i+1 < len(flats) {
next = flats[i+1]
}
// On this line, or across the break into the next — but not a phrase that begins on the next
// line alone, which is that line's hit.
onThis := strings.Contains(flats[i], needle)
across := !onThis && next != "" && strings.Contains(flats[i]+" "+next, needle) && !strings.Contains(next, needle)
if !onThis && !across {
continue
}
if len(answer.Hits) >= limit {
answer.More = true
break
}
text := strings.TrimSpace(line)
if across {
text += " " + strings.TrimSpace(lines[i+1])
}
answer.Hits = append(answer.Hits, Hit{Path: p, Line: i + 1, Heading: head, Text: text})
}
if answer.More {
break
}
}
answer.Commit = r.Standing().Commit
return answer, nil
}
// inside cleans a path given relative to the repository, refusing one that leaves it.
func inside(p string) (string, bool) {
clean := path.Clean(strings.ReplaceAll(p, "\\", "/"))
if clean == "." {
return "", true
}
if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
return "", false
}
return clean, true
}
// Document is one document, as read answers it.
type Document struct {
Path string `json:"path"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
Commit string `json:"commit"`
}
// Read is one document, whole, or its first part with a note when it is very long. The path stays inside
// the checkout: `..` and absolute paths are refused, not resolved.
func (r *Records) Read(p string) (Document, error) {
clean, ok := inside(p)
if !ok || clean == "" {
return Document{}, fmt.Errorf("%q is not a path inside the repository", p)
}
raw, err := os.ReadFile(filepath.Join(r.Dir(), filepath.FromSlash(clean)))
if err != nil {
return Document{}, fmt.Errorf("the repository holds no %s — `records_list` says what it holds", clean)
}
d := Document{Path: clean, Content: string(raw), Commit: r.Standing().Commit}
if len(d.Content) > MostBytes {
d.Content = d.Content[:MostBytes] + "\n\n[… truncated; the document is longer than this answer carries]"
d.Truncated = true
}
return d, nil
}
// Folder is what a folder holds, one level.
type Folder struct {
Folder string `json:"folder"`
Folders []string `json:"folders"`
Documents []string `json:"documents"`
}
// List says what a folder holds: its sub-folders and its documents.
func (r *Records) List(folder string) (Folder, error) {
clean, ok := inside(folder)
if !ok {
return Folder{}, fmt.Errorf("%q is not a folder inside the repository", folder)
}
entries, err := os.ReadDir(filepath.Join(r.Dir(), filepath.FromSlash(clean)))
if err != nil {
if clean == "" {
clean = "/"
}
return Folder{}, fmt.Errorf("the repository holds no folder %s", clean)
}
out := Folder{Folder: clean, Folders: []string{}, Documents: []string{}}
for _, e := range entries {
switch {
case e.IsDir() && e.Name() != ".git" && e.Name() != "node_modules":
out.Folders = append(out.Folders, e.Name())
case e.Type().IsRegular() && strings.HasSuffix(e.Name(), ".md"):
out.Documents = append(out.Documents, e.Name())
}
}
return out, nil
}
var repositoryName = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
// FromEnv is the reader as the mesh configures it: the directory it was given, the forge it was bound to,
// and the repository its settings name. It refuses to guess any of the three (novox/hq ADR 0112).
func FromEnv(env func(string) string) (*Records, error) {
dir := env("MESH_RECORDS_DIR")
if dir == "" {
return nil, errors.New("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in")
}
originFile := env("MESH_RECORDS_ORIGIN_FILE")
if originFile == "" {
return nil, errors.New("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding")
}
raw, err := os.ReadFile(originFile)
if err != nil {
return nil, err
}
origin := strings.TrimSpace(string(raw))
if origin == "" {
return nil, fmt.Errorf("%s is empty: the git provision has not been bound yet", originFile)
}
configFile := env("MESH_RECORDS_CONFIG_FILE")
if configFile == "" {
return nil, errors.New("MESH_RECORDS_CONFIG_FILE is unset")
}
var config struct {
Repository any `json:"repository"`
}
if err := readJSON(configFile, &config); err != nil {
return nil, fmt.Errorf("%s is not JSON: %w", configFile, err)
}
repository, _ := config.Repository.(string)
repository = strings.TrimSpace(repository)
if !repositoryName.MatchString(repository) {
return nil, errors.New("this module reads the repository its settings name, and none is set: " +
"`settings set records <file>` with {\"repository\": \"<owner>/<name>\"} — a module names no mesh (novox/hq ADR 0112)")
}
return &Records{Base: dir, Origin: origin, Repository: repository}, nil
}
+147
View File
@@ -0,0 +1,147 @@
package main
// The reader against a real repository: a checkout, a phrase found where it is written, a document read
// whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears only in
// one design document, and get it back. Ported with the TypeScript module's tests, and the layout's own.
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
func run(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %s", args, out)
}
}
func write(t *testing.T, path, content string) {
t.Helper()
_ = os.MkdirAll(filepath.Dir(path), 0o755)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func aRepository(t *testing.T) string {
dir := t.TempDir()
run(t, dir, "init", "--quiet", "--initial-branch=main")
run(t, dir, "config", "user.email", "t@example.invalid")
run(t, dir, "config", "user.name", "t")
write(t, filepath.Join(dir, "README.md"), "# A repository\n\nWhat this is.\n")
write(t, filepath.Join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n")
write(t, filepath.Join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n\n"+
"A phrase that is wrapped at the\ncolumn where every document wraps, and **reachable is not the same as\nsurfacing** under emphasis.\n")
run(t, dir, "add", "-A")
run(t, dir, "commit", "--quiet", "-m", "first")
return dir
}
func TestAPhraseInOneDesignDocumentComesBackFromWhereItIsWritten(t *testing.T) {
origin := aRepository(t)
r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin}
r.Sync()
found, err := r.Search("never look alike", 0)
if err != nil || len(found.Hits) != 1 || found.Hits[0].Path != "03-DESIGN/07-knowledge.md" || found.Hits[0].Heading != "The stores" {
t.Fatalf("%+v %v", found, err)
}
if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(found.Commit) {
t.Fatalf("the answer names no commit: %q", found.Commit)
}
// Wrapped prose: a phrase across the break is found once, at the line it starts on; emphasis is not
// part of the words; a phrase on one line is not also counted from the line before it.
for phrase, line := range map[string]int{"wrapped at the column where": 7, "reachable is not the same as surfacing": 8} {
got, _ := r.Search(phrase, 0)
if len(got.Hits) != 1 || got.Hits[0].Line != line {
t.Errorf("%q: %+v", phrase, got.Hits)
}
}
if got, _ := r.Search("under emphasis", 0); len(got.Hits) != 1 {
t.Errorf("a phrase on one line was counted %d times", len(got.Hits))
}
doc, err := r.Read("02-DECISIONS/0001-a-decision.md")
if err != nil || !strings.Contains(doc.Content, "We decided the thing") || doc.Truncated {
t.Fatalf("%+v %v", doc, err)
}
root, _ := r.List("")
if strings.Join(root.Folders, ",") != "02-DECISIONS,03-DESIGN" || strings.Join(root.Documents, ",") != "README.md" {
t.Fatalf("%+v", root)
}
if s := r.Standing(); s.Documents != 3 || s.LastError != "" {
t.Fatalf("%+v", s)
}
// A merge on the origin, pulled: the checkout follows and names the new commit.
write(t, filepath.Join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n")
run(t, origin, "add", "-A")
run(t, origin, "commit", "--quiet", "-m", "second")
r.Sync()
after, _ := r.Search("nobody wrote before", 0)
if len(after.Hits) != 1 || after.Commit == found.Commit {
t.Fatalf("%+v", after)
}
}
func TestAPathOutsideTheRepositoryAndAnEmptySearchAreRefused(t *testing.T) {
r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq"}
for _, p := range []string{"../etc/passwd", "/etc/passwd", "a/../../b"} {
if _, err := r.Read(p); err == nil || !strings.Contains(err.Error(), "not a path inside") {
t.Errorf("%s: %v", p, err)
}
}
if _, err := r.Search(" ", 0); err == nil || !strings.Contains(err.Error(), "empty one") {
t.Errorf("an empty search: %v", err)
}
if r.CloneURL() != "http://forge.invalid:3000/novox/hq.git" {
t.Error(r.CloneURL())
}
}
func TestAFailedSyncLeavesTheCheckoutStandingAndSaysWhy(t *testing.T) {
r := &Records{Base: t.TempDir(), Origin: "http://127.0.0.1:1", Repository: "novox/hq"}
r.Sync()
if s := r.Standing(); s.Commit != "" || s.LastError == "" {
t.Fatalf("%+v", s)
}
}
// The old layout — a checkout in the given directory itself — is cleared where it is this module's, and
// the checkout is made in its own directory (novox/hq issue 251).
func TestTheOldLayoutIsClearedAndTheCheckoutLivesInItsOwnDirectory(t *testing.T) {
origin := aRepository(t)
base := t.TempDir()
write(t, filepath.Join(base, "README.md"), "an old checkout's file")
_ = os.MkdirAll(filepath.Join(base, ".git"), 0o755)
r := &Records{Base: base, Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin}
r.Sync()
if _, err := os.Stat(filepath.Join(base, "README.md")); err == nil {
t.Error("the old layout's file was left")
}
if _, err := os.Stat(filepath.Join(base, "repository", ".git")); err != nil {
t.Fatal("the checkout is not in its own directory")
}
if s := r.Standing(); s.LeftBehind != "" || s.Documents != 3 {
t.Fatalf("%+v", s)
}
}
func TestTheReaderRefusesToGuess(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "origin"), "http://forge.invalid:3000\n")
write(t, filepath.Join(dir, "config.json"), "{}\n")
env := map[string]string{"MESH_RECORDS_DIR": dir, "MESH_RECORDS_ORIGIN_FILE": filepath.Join(dir, "origin"),
"MESH_RECORDS_CONFIG_FILE": filepath.Join(dir, "config.json")}
if _, err := FromEnv(func(k string) string { return env[k] }); err == nil || !strings.Contains(err.Error(), "none is set") {
t.Fatalf("a module with no repository set guessed one: %v", err)
}
write(t, filepath.Join(dir, "config.json"), `{"repository": "novox/hq"}`)
r, err := FromEnv(func(k string) string { return env[k] })
if err != nil || r.Repository != "novox/hq" || r.Origin != "http://forge.invalid:3000" {
t.Fatalf("%+v %v", r, err)
}
}
+5
View File
@@ -0,0 +1,5 @@
module records
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=
-34
View File
@@ -1,34 +0,0 @@
// records' consumer: keep the checkout current (novox/hq ADR 0153).
//
// Synced when the runtime binds the broker, on every merge the forge announces, and on a timer for
// the merges it did not hear about — a restart during a merge, a repository the forge does not emit
// for. The timer is unhurried: the record changes when thinking changes, not by the minute.
import { on } from "@novox/mesh-sdk/events";
import { recordsFromEnv, type Records } from "./records.js";
let records: Records | null = null;
try {
records = await recordsFromEnv();
} catch (err) {
console.log(`[records] not reading — ${err instanceof Error ? err.message : String(err)}`);
}
if (records) {
const reader = records;
void reader.sync().then(async () => {
const s = await reader.standing();
console.log(`[records] ${s.repository} at ${s.commit.slice(0, 8) || "(no commit)"}, ${s.documents} document(s)${s.lastError ? ` — ${s.lastError}` : ""}`);
});
setInterval(() => void reader.sync(), 10 * 60 * 1000).unref();
// A merge on the forge into the repository this reads: pull now. The event names the repository
// by owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
await on<{ owner?: string; repo?: string; base?: string }>("gitea.pull.merged", async (event) => {
const merged = `${event.body.owner ?? ""}/${event.body.repo ?? ""}`;
if (merged !== reader.repository) return;
console.log(`[records] ${merged} merged; syncing`);
await reader.sync();
});
}
export { records };
+5 -10
View File
@@ -2,9 +2,6 @@
"module": "records",
"version": "1",
"slug": "records",
"capabilities": [
"container-runtime"
],
"requires": [
"git"
],
@@ -60,14 +57,12 @@
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js"
],
"language": "go",
"system": "arch",
"from": "cmd/records",
"binary": "records",
"loads": [
"index.js",
"tools/index.js"
"records"
],
"env": {
"MESH_RECORDS_CONFIG_FILE": "${dir:mesh-state}/config.json",
-18
View File
@@ -1,18 +0,0 @@
{
"name": "@novox/module-records",
"version": "0.1.0",
"description": "records — reads a repository of decisions, designs and issues where it is written, and answers questions about it (novox/hq ADR 0025, ADR 0153).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc records.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-277
View File
@@ -1,277 +0,0 @@
/**
* The record, read where it is written (novox/hq ADR 0025, ADR 0153).
*
* A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own
* forge and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit
* every answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
* transformed, indexed or summarised on the way, so there is nothing that can drift from the source
* except by lagging behind it, and the lag is a number in every answer.
*
* What it answers: a search for a phrase, a document by path, what a folder holds, and where the
* checkout stands. The reasoning is in the documents; this only finds them.
*/
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import { join, normalize, relative, sep } from "node:path";
import { promisify } from "node:util";
const run = promisify(execFile);
/** One place a phrase was found. */
export interface Hit {
/** The document, relative to the repository's root. */
path: string;
/** The line it was found on, from 1. */
line: number;
/** The nearest heading above it, so a hit reads as where in the document it is. */
heading: string;
/** The line itself, trimmed. */
text: string;
}
export interface Standing {
repository: string;
origin: string;
/** The commit the checkout is at, or empty before the first clone. */
commit: string;
/** When that commit was made, as the repository says. */
committed: string;
/** When this reader last brought the checkout up to date. */
fetched: string;
/** How many markdown documents the checkout holds. */
documents: number;
/** Why the last sync failed, if it did; the checkout stands where it was. */
lastError?: string;
}
/** Search answers at most this many places; a phrase found more often is a phrase to narrow. */
export const MOST_HITS = 50;
/** A document longer than this is answered in part, and says so. */
export const MOST_BYTES = 200_000;
export class Records {
/** Where the checkout lives; the mesh gives the module the directory. */
readonly dir: string;
/** The forge's address, `scheme://host:port`, from the git provision's binding. */
readonly origin: string;
/** The repository's path on it, `owner/name`, from this module's settings. */
readonly repository: string;
private syncing: Promise<void> | undefined;
private fetched = "";
private lastError: string | undefined;
constructor(dir: string, origin: string, repository: string) {
this.dir = dir;
this.origin = origin;
this.repository = repository;
}
/** The clone URL: the forge, the repository. Public repositories only; a credential would be a
* secret this module has not asked for. */
get url(): string {
return `${this.origin.replace(/\/$/, "")}/${this.repository}.git`;
}
/** Bring the checkout up to date, cloning it if it does not exist. One at a time: a second call
* while one runs joins it rather than racing it. Never throws — a failed sync is recorded in
* `standing()` and the checkout stands where it was, which is still an answer. */
sync(): Promise<void> {
if (!this.syncing) {
this.syncing = this.doSync().finally(() => {
this.syncing = undefined;
});
}
return this.syncing;
}
private async doSync(): Promise<void> {
try {
const cloned = await exists(join(this.dir, ".git"));
if (!cloned) {
await fs.mkdir(this.dir, { recursive: true });
await run("git", ["clone", "--quiet", "--depth", "50", this.url, this.dir]);
} else {
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has,
// rather than merging into something a hand may have touched.
await run("git", ["-C", this.dir, "fetch", "--quiet", "--depth", "50", "origin"]);
await run("git", ["-C", this.dir, "reset", "--quiet", "--hard", "origin/HEAD"]);
}
this.fetched = new Date().toISOString();
this.lastError = undefined;
} catch (e) {
this.lastError = e instanceof Error ? e.message : String(e);
console.error(`[records] could not sync ${this.url}: ${this.lastError}`);
}
}
async standing(): Promise<Standing> {
let commit = "";
let committed = "";
if (await exists(join(this.dir, ".git"))) {
try {
commit = (await run("git", ["-C", this.dir, "rev-parse", "HEAD"])).stdout.trim();
committed = (await run("git", ["-C", this.dir, "log", "-1", "--format=%cI"])).stdout.trim();
} catch {
// A checkout without a commit yet: said as empty rather than thrown.
}
}
const documents = commit ? (await this.documents()).length : 0;
return {
repository: this.repository,
origin: this.origin,
commit,
committed,
fetched: this.fetched,
documents,
...(this.lastError ? { lastError: this.lastError } : {}),
};
}
/** Every markdown document, relative to the root, in a stable order. */
async documents(): Promise<string[]> {
const out: string[] = [];
const walk = async (at: string): Promise<void> => {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(at, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.name === ".git" || e.name === "node_modules") continue;
const full = join(at, e.name);
if (e.isDirectory()) await walk(full);
else if (e.isFile() && e.name.endsWith(".md")) out.push(relative(this.dir, full).split(sep).join("/"));
}
};
await walk(this.dir);
return out.sort();
}
/**
* Where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
* design record is found by its own words and a reader deciding which words matter would be a
* second opinion about somebody else's document. Bounded, and says when it was.
*
* **The record is wrapped prose, and a phrase does not know where the line ends.** Every document
* here wraps at a hundred columns, so a phrase of six words is as likely to straddle a line break
* as not; matched line by line, the first live search for a sentence of ADR 0025 found nothing.
* So a line is matched together with the one after it, joined by a space, and emphasis marks
* are ignored — `**reachable**` is the word reachable. A hit still names the line it starts on.
*/
async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> {
const needle = plain(query).toLowerCase().replace(/\s+/g, " ").trim();
if (!needle) throw new Error("search for a phrase; an empty one matches every line of every document");
const cap = Math.max(1, Math.min(limit, MOST_HITS));
const hits: Hit[] = [];
let more = false;
for (const path of await this.documents()) {
const text = await fs.readFile(join(this.dir, path), "utf8");
let heading = "";
const lines = text.split("\n");
const flat = lines.map((l) => plain(l).toLowerCase().replace(/\s+/g, " ").trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim();
const here = flat[i]!;
const next = i + 1 < flat.length ? flat[i + 1]! : "";
// On this line, or across the break into the next — but not a phrase that begins on the
// next line alone, which is that line's hit.
const onThis = here.includes(needle);
const acrossTheBreak = !onThis && next !== "" && `${here} ${next}`.includes(needle) && !next.includes(needle);
if (onThis || acrossTheBreak) {
if (hits.length >= cap) {
more = true;
break;
}
hits.push({ path, line: i + 1, heading, text: acrossTheBreak ? `${line.trim()} ${lines[i + 1]!.trim()}` : line.trim() });
}
}
if (more) break;
}
const commit = (await this.standing()).commit;
return { hits, more, commit };
}
/** One document, whole, or its first part with a note when it is very long. The path is kept
* inside the checkout: `..` and absolute paths are refused, not resolved. */
async read(path: string): Promise<{ path: string; content: string; truncated: boolean; commit: string }> {
const clean = normalize(path).split(sep).join("/");
if (!clean || clean.startsWith("..") || clean.startsWith("/") || clean.includes("/../")) {
throw new Error(`"${path}" is not a path inside the repository`);
}
let content: string;
try {
content = await fs.readFile(join(this.dir, clean), "utf8");
} catch {
throw new Error(`the repository holds no ${clean} — \`records_list\` says what it holds`);
}
const truncated = content.length > MOST_BYTES;
return {
path: clean,
content: truncated ? content.slice(0, MOST_BYTES) + "\n\n[… truncated; the document is longer than this answer carries]" : content,
truncated,
commit: (await this.standing()).commit,
};
}
/** What a folder holds: its sub-folders and its documents, one level. */
async list(folder = ""): Promise<{ folder: string; folders: string[]; documents: string[] }> {
const clean = normalize(folder || ".").split(sep).join("/").replace(/^\.\/?/, "");
if (clean.startsWith("..") || clean.startsWith("/")) {
throw new Error(`"${folder}" is not a folder inside the repository`);
}
const at = clean ? join(this.dir, clean) : this.dir;
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(at, { withFileTypes: true });
} catch {
throw new Error(`the repository holds no folder ${clean || "/"}`);
}
const folders = entries.filter((e) => e.isDirectory() && e.name !== ".git" && e.name !== "node_modules").map((e) => e.name).sort();
const documents = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
return { folder: clean, folders, documents };
}
}
/** The reader as the mesh configures it: the directory it was given, the forge it was bound to, and
* the repository its settings name. Refuses to guess any of the three (novox/hq ADR 0112). */
export async function recordsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<Records> {
const dir = env.MESH_RECORDS_DIR;
if (!dir) throw new Error("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in");
const originFile = env.MESH_RECORDS_ORIGIN_FILE;
if (!originFile) throw new Error("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding");
const origin = (await fs.readFile(originFile, "utf8")).trim();
if (!origin) throw new Error(`${originFile} is empty: the git provision has not been bound yet`);
const configFile = env.MESH_RECORDS_CONFIG_FILE;
if (!configFile) throw new Error("MESH_RECORDS_CONFIG_FILE is unset");
let config: { repository?: unknown } = {};
try {
config = JSON.parse(await fs.readFile(configFile, "utf8")) as { repository?: unknown };
} catch (e) {
throw new Error(`${configFile} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
}
const repository = typeof config.repository === "string" ? config.repository.trim() : "";
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error(
"this module reads the repository its settings name, and none is set: " +
'`settings set records <file>` with {"repository": "<owner>/<name>"} — a module names no mesh (novox/hq ADR 0112)',
);
}
return new Records(dir, origin, repository);
}
/** A line without its markdown emphasis, so a phrase matches the words and not the marks. */
function plain(line: string): string {
return line.replace(/[*_`]/g, "");
}
async function exists(path: string): Promise<boolean> {
try {
await fs.stat(path);
return true;
} catch {
return false;
}
}
-87
View File
@@ -1,87 +0,0 @@
/**
* The reader against a real repository: a checkout, a phrase found where it is written, a document
* read whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears
* only in one design document, and get it back.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Records } from "../records.ts";
function aRepository(): string {
const dir = mkdtempSync("/tmp/records-origin-");
const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args], { stdio: "pipe" });
git("init", "--quiet", "--initial-branch=main");
git("config", "user.email", "t@example.invalid");
git("config", "user.name", "t");
mkdirSync(join(dir, "02-DECISIONS"));
mkdirSync(join(dir, "03-DESIGN"));
writeFileSync(join(dir, "README.md"), "# A repository\n\nWhat this is.\n");
writeFileSync(join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n");
writeFileSync(join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n\nA phrase that is wrapped at the\ncolumn where every document wraps, and **reachable is not the same as\nsurfacing** under emphasis.\n");
git("add", "-A");
git("commit", "--quiet", "-m", "first");
return dir;
}
test("a phrase that appears in one design document comes back from where it is written", async () => {
const origin = aRepository();
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "file://" + origin.replace(/\/[^/]+$/, ""), origin.split("/").pop()!);
// A file:// origin has no `.git` suffix; point the clone at the directory itself.
Object.defineProperty(records, "url", { get: () => origin });
await records.sync();
const found = await records.search("never look alike");
assert.equal(found.hits.length, 1);
assert.equal(found.hits[0]!.path, "03-DESIGN/07-knowledge.md");
assert.equal(found.hits[0]!.heading, "The stores");
assert.match(found.commit, /^[0-9a-f]{40}$/, "the answer names the commit it was read at");
// Wrapped prose: a phrase across the line break is found, once, at the line it starts on; and
// emphasis marks are not part of the words.
const wrapped = await records.search("wrapped at the column where");
assert.deepEqual(wrapped.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 7]]);
const emphasised = await records.search("reachable is not the same as surfacing");
assert.deepEqual(emphasised.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 8]]);
const alsoOnOneLine = await records.search("under emphasis");
assert.equal(alsoOnOneLine.hits.length, 1, "a phrase on one line is not also counted from the line before it");
const doc = await records.read("02-DECISIONS/0001-a-decision.md");
assert.match(doc.content, /We decided the thing/);
assert.equal(doc.truncated, false);
const root = await records.list();
assert.deepEqual(root.folders, ["02-DECISIONS", "03-DESIGN"]);
assert.deepEqual(root.documents, ["README.md"]);
const standing = await records.standing();
assert.equal(standing.documents, 3);
assert.equal(standing.lastError, undefined);
// A merge on the origin, pulled: the checkout follows the source and names the new commit.
writeFileSync(join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n");
execFileSync("git", ["-C", origin, "add", "-A"], { stdio: "pipe" });
execFileSync("git", ["-C", origin, "commit", "--quiet", "-m", "second"], { stdio: "pipe" });
await records.sync();
const after = await records.search("nobody wrote before");
assert.equal(after.hits.length, 1);
assert.notEqual(after.commit, found.commit);
});
test("a path outside the repository is refused, and an empty search is too", async () => {
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://forge.invalid:3000", "novox/hq");
await assert.rejects(() => records.read("../etc/passwd"), /not a path inside/);
await assert.rejects(() => records.read("/etc/passwd"), /not a path inside/);
await assert.rejects(() => records.search(" "), /empty one/);
assert.equal(records.url, "http://forge.invalid:3000/novox/hq.git");
});
test("a sync that fails leaves the checkout standing and says why", async () => {
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://127.0.0.1:1", "novox/hq");
await records.sync();
const standing = await records.standing();
assert.equal(standing.commit, "");
assert.ok(standing.lastError, "a failed sync is said, not swallowed");
});
-62
View File
@@ -1,62 +0,0 @@
// records' tools — how the record is asked (novox/hq ADR 0025, ADR 0153).
//
// Five questions, each answered from the checkout at the commit it names: where does a phrase
// appear, what does one document say, what does a folder hold, where does the checkout stand, and
// bring it up to date now. The reasoning stays in the documents; the tools only find them.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { recordsFromEnv, type Records } from "../records.js";
export function getRecordsTools(records: Records): ToolDefinition[] {
return [
{
name: "records_search",
description:
"Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " +
"Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.",
input: {
query: { type: "string", description: "the phrase, matched case-insensitively as written" },
limit: { type: "number", description: "at most this many places (default 50)" },
},
run: async (args) => records.search(String(args.query ?? ""), args.limit ? Number(args.limit) : undefined),
},
{
name: "records_read",
description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.",
input: { path: { type: "string", description: "the document's path, e.g. 02-DECISIONS/0025-....md" } },
run: async (args) => records.read(String(args.path ?? "")),
},
{
name: "records_list",
description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
input: { folder: { type: "string", description: "a folder inside the repository (optional)" } },
run: async (args) => records.list(args.folder ? String(args.folder) : ""),
},
{
name: "records_status",
description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
input: {},
run: async () => records.standing(),
},
{
name: "records_sync",
description: "Bring the checkout up to date now, and say where it stands.",
input: {},
run: async () => {
await records.sync();
return records.standing();
},
},
];
}
// The reader is made once, at load, from the environment the runtime resolves; the contributor is
// synchronous and is called at every collection. Without a repository to read there is nothing to
// answer, and the module exposes no tools rather than five that fail — the sdk's contract: a
// contributor returning [] is normal.
let reader: Records | null = null;
try {
reader = await recordsFromEnv();
} catch (err) {
console.log(`[records] no tools — ${err instanceof Error ? err.message : String(err)}`);
}
registerModuleTools("records", () => (reader ? getRecordsTools(reader) : []));
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["records.ts", "index.ts", "tools/index.ts"]
}
+1 -1
View File
@@ -17,7 +17,7 @@
"type": "file",
"path": "/etc/resolv.conf",
"mode": "0644",
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"
"content": "# Managed by the mesh.\n#\n# The machine's network manager is told to leave this file alone by the module\n# holding its uplink, which the mesh requires beside this one (novox/hq ADR 0117,\n# 0220): without it, the first change of network would rewrite the file.\n#\n# The mesh's one resolver first (novox/hq ADR 0194, 0196), by address — a machine\n# cannot resolve the name of the thing it resolves names with. It answers the\n# mesh's names itself and forwards every other name. A public resolver second,\n# asked only when the first does not answer at all — its machine or the tunnel\n# down, a captive portal holding the tunnel back — so public names keep\n# resolving then. An answer from the first, \"no such name\" included, is final,\n# so a mesh name is never asked of the public one while the mesh's answers. One\n# second and one attempt, so the wait before the fallback is short. Containers\n# copy these two lines from their machine.\nnameserver ${bound:wildcard-resolution:address}\nnameserver 1.1.1.1\noptions timeout:1 attempts:1 edns0\n"
}
]
}
-39
View File
@@ -1,39 +0,0 @@
{
"module": "resolved-split-dns",
"version": "1",
"slug": "splitdns",
"requires": [
"wildcard-resolution"
],
"claims": [
{
"name": "node-resolver-config",
"scope": "node"
}
],
"resources": [
{
"id": "drop-in",
"type": "directory",
"path": "/etc/systemd/resolved.conf.d",
"mode": "0755"
},
{
"id": "route",
"type": "file",
"path": "/etc/systemd/resolved.conf.d/mesh.conf",
"mode": "0644",
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"
},
{
"id": "resolved",
"type": "service",
"unit": "systemd-resolved.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"route"
]
}
]
}
+162
View File
@@ -0,0 +1,162 @@
# slack
The Slack desktop app on the workstations, as a module (novox/hq ADR 0208). One Electron process is
both Slack's window and its tray icon. The module requires `x11-display`, so it is assigned only where
a display server is held on the same machine.
## Owns
| what | where |
|---|---|
| Slack's one start at login, and its window rules | a `config` contribution to `node-display-session` (ADR 0212), which the seat's holder (`i3`) places in its configuration |
Nothing else. It holds no seat and writes no file.
- **No package.** Both workstations run `slack-desktop` 4.51.191-1, installed explicitly, from the
AUR: `pacman` counts it as foreign (no sync repository has it, and its packager is "Unknown
Packager"). The host installs packages from the official repositories only, so the module does not
declare it, and **installing and upgrading Slack stays the operator's** (an AUR helper, by hand).
`slack_status` says whether it is outside the official repositories, and `slack_check` says when it
is missing.
- **Why not a pinned archive (ADR 0205):** Slack is a binary release of about 330 MB under Slack's own
licence. ADR 0205 vendors free software the distribution lacks; redistributing Slack's binary from
the mesh's store is not the module's to do. ADR 0205 does not apply, and the package stays as found.
- **Slack's own files are found** (ADR 0182): `~/.config/Slack/` holds the sessions (cookies, local
storage, the encryption key in `Local State`), the settings (`storage/root-state.json`), the caches
and the logs. The module never declares or writes any of it. The tools read only the settings'
switches and the logs, and never the session files.
## How it starts: the module's line in i3's configuration, and nothing else
One process has one starter (the rule `picom` states for the desktop modules). Slack's starter is
**this module's contribution**: `exec --no-startup-id /usr/bin/slack --gtk-version=3 -s`, which `i3`
runs once when the session starts. Those are the arguments of the package's own desktop entry. `-s`
starts Slack hidden, to the tray.
**Why not an XDG autostart entry**, as `nextcloud-client` and `blueman` start:
- Slack's own setting *Launch app on login* is the existence of `~/.config/autostart/slack.desktop`.
Slack reads the file's presence back into the setting at every start. Ticking the setting makes the
file **a symbolic link** to `/usr/share/applications/slack.desktop`, and unticking it deletes the
file. This is in Slack 4.51's own code, not a guess.
- If the module owned that path as a file, two writers would hold it: the mesh writing it, and Slack
deleting it whenever the setting is unticked.
- If the entry were left to Slack, the start would be a link that Slack makes in the operator's home,
which this mesh's rules do not want there.
- A contribution is a start the mesh owns, beside the window rules it belongs with, in the window
manager that runs it. Under sway, `sway` holds the same seat with the same grammar.
**Excluded, and named by `slack_check` as a second start:**
- any `~/.config/autostart/slack.desktop`: the predecessor's file, or Slack's link if *Launch app on
login* is ticked again (untick it; Slack removes the link);
- an `exec … slack` of the operator's in `~/.config/i3/config.d/`;
- `/etc/xdg/autostart/slack.desktop`, which the package does not ship.
**Not a start:** systemd's XDG autostart generator makes a unit `app-slack@autostart.service` from the
entry while the entry exists. Only a desktop that starts `xdg-desktop-autostart.target` runs it, and
i3 does not. The unit goes when the entry goes.
**Slack's cgroup does not say who started it.** Slack moves its main process into a scope of its own
(`app-slack-<pid>.scope`) whoever starts it. `slack_status` therefore names the starts from the
configuration, not from the cgroup.
## The window rules
The operator's `~/.config/i3/config.d/50-slack.conf` (2026-08) became this contribution as it was:
- Slack's chat window goes to workspace 3 (`$ws3`, i3's variable, in scope where the contributions are
placed);
- it is tiled, not floating;
- it has a 2-pixel border.
The criteria are those of the operator's file. They were verified live then, and the window tree of
both workstations on 2026-10-05 still agrees:
- Slack owns four X windows. The only one i3 manages has the class `slack` in lower case.
- The three of class `Slack` (the packaged entry's `StartupWMClass`) are unmanaged helpers and the tray
icon. A rule on `class="Slack"` therefore matches nothing.
- `window_role="browser-window"` keeps a call or screen-share window out of the rules.
**Once the module is assigned, the operator deletes `50-slack.conf`** (below). i3 accepts the same
`for_window` twice, so nothing breaks while both are there. But the rules belong in one place, and
`slack_check` names the file until it is gone.
## Tools
They are served by the node's runtime as the operator account (ADR 0175), and are read-only except
`restart`. **No answer carries a token, a cookie, a message, or the name of a person, channel or
workspace.** Accounts and workspaces are counted, never named.
| tool | does |
|---|---|
| `slack_status` (r) | <ul><li>whether Slack runs: the main process's pid, since and scope, and how many helper processes it has</li><li>the installed version, and whether it is outside the official repositories</li><li>where its output goes (fd 1 and 2): the journal, `/dev/null`, a pipe someone reads, or a pipe nobody reads</li><li>whether its icon sits in a tray, and whose (from the X window tree)</li><li>who owns the session bus's notification name (dunst)</li><li>the switches: launch on login, hide on start, run from the tray, the notification method, hardware acceleration; and the Electron version</li><li>how many accounts and workspaces it is signed in to since its last start, counted from the reports Slack logs</li><li>what starts it at login</li></ul> |
| `slack_log` (r) | the last `lines` (default 100, at most 2000) of Slack's main-process log (`source: browser`, across its rotations) or of the web app's console (`source: webapp`). `problems: true` keeps `error` and `warn` entries. Tokens (`xox…`), the `d` cookie, anything shaped like a credential, notification and message text, and the names of people, channels and workspaces become `<hidden>`. Answers are capped at 256 KiB |
| `slack_restart` (a) | ends Slack (SIGTERM, forced after 8 s) and starts `/usr/bin/slack --gtk-version=3 -s` in the operator's session. The start is a transient user unit `mesh-slack`, so it outlives the tools runtime, and its output goes to the journal. Answers the pids. Refused plainly when nobody is logged in to the desktop |
| `slack_check` (r) | <ul><li>Slack is installed (if not: from the AUR, by the operator)</li><li>exactly one start: the module's line is in i3's configuration, with no autostart entry and no other exec</li><li>the window rules are placed, and no file of the operator's repeats them</li><li>one Slack runs in the desktop session</li><li>**its output is read**: a pipe nobody reads is the session's dead output (below)</li><li>its icon is in a tray (closing the window otherwise leaves it unreachable)</li><li>a notifier owns `org.freedesktop.Notifications`</li></ul>Each finding says what to do |
**The output check (EPIPE).** A session started before the `i3` module's login entry sent the
session's output to the journal (`systemd-cat -t x-session`) gives every program it starts a stdout
pipe whose reader is gone. An Electron app's write there fails with EPIPE, and an unhandled one is the
"write EPIPE" dialog. Slack's own logger works around it: it silences its console output on EPIPE. Any
other write still fails. So `slack_check` finds the pipe by its reader: it looks for a process of the
account holding the pipe open for reading (`/proc/<pid>/fdinfo`). It names it when there is none.
`slack_restart` cures it, and every later login does too.
**Where the tools read:**
- the processes, and the fd links and fdinfo of Slack's main process, in `/proc` (the account's own
only);
- the settings' switches, from `~/.config/Slack/storage/root-state.json`, and the Electron version from
`local-settings.json`;
- the logs in `~/.config/Slack/logs/default/`. The accounts are counted from the
`STORE_USER_WORKSPACES` entries since the last `INITIALIZE`;
- the window tree from `xwininfo -root -tree`, with the session's `DISPLAY`. The session is found from
the window manager's own environment, as the other desktop modules find it;
- the notifier from `busctl --user list`.
Every command has a timeout and capped output. Everything runs through an injected runner, a fake root
and a fake link reader in the tests.
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| package | none: `slack-desktop` 4.51.191-1, AUR, installed explicitly | the same |
| start | i3's configuration gains the module's start. **Until the predecessor's `~/.config/autostart/slack.desktop` is deleted, the next login starts Slack twice.** The second start hands over to the first and exits, because Slack is single-instance. Running now: started by dex from that entry at the login of 2026-10-04 16:26 | the same entry, the same until deleted. Running now: since 2026-10-04 17:05, **with its stdout on a pipe nobody reads** (that session predates the `i3` module's login entry), so `slack_check` names it until `slack_restart` or the next login |
| window rules | i3's configuration gains them. The operator's `50-slack.conf` repeats them until deleted | the same |
| settings | *Launch app on login* reads on (the entry exists), start hidden, run from the tray, default notifications (to dunst); 1 account, 1 workspace | the same switches |
| tray, notifications | icon in i3bar's tray; dunst owns the notification name | the same |
## Migration (ADR 0182), on each workstation, once the module is assigned and pushed
1. **Delete `~/.config/autostart/slack.desktop`.** It is the predecessor's file (its comment names the
retired desktop module), the second start. At its next start Slack reads *Launch app on login* as
off. Leave the setting off: ticking it makes the entry again, and `slack_check` names it.
2. **Delete `~/.config/i3/config.d/50-slack.conf`.** It is the operator's own file, and its rules are
now the module's.
3. **shanks only:** run `slack_restart`, or log out and in, so that Slack's output is read.
`slack_check` then answers `ok`. Deleting both files before the module is assigned would leave Slack
without a start and without its rules until it is.
## Leaves as found
- `~/.config/Slack/`: the sessions, the settings, the caches, the logs, more than a dozen
`.org.chromium.Chromium.*` leftovers and `StaleCookies-*` files from past upgrades. They are Slack's
to keep and the operator's to delete.
- The package and its desktop entry, `/usr/share/applications/slack.desktop`.
- The `x-scheme-handler/slack` default, which is the `xdg` module's.
## Relies on
- **`i3` (the holder of `node-display-session`), which places the contribution.** The contribution is a
dependency on that seat (ADR 0210 §3). Assigning `slack` where no module holds it is refused.
- **`dunst` (the holder of `node-notifier`)** for Slack's notifications, which Electron sends to
`org.freedesktop.Notifications`. Nothing in the mesh declares this dependency, because the module
makes no contribution to that seat. `slack_check` reports a missing notifier instead.
- **A tray in the bar** (i3bar's `tray_output`). Without one, Slack runs with no icon, and closing its
window leaves it unreachable. `slack_check` reports it.
- **`xwininfo` (`xorg-xwininfo`)** for the tray question. No module declares it, and this one does not
install it for a status line. Without it, `slack_status` answers the tray as unknown.
- 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,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")
}
}
+601
View File
@@ -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))
}
}
+84
View File
@@ -0,0 +1,84 @@
// The slack module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Slack desktop app
// 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.
//
// Slack's sessions are the operator's: the tools never read its cookies, local storage or the key in
// Local State, and no answer carries a token, a cookie, a message, or the name of a person, channel
// or workspace. Accounts and workspaces are counted, never named.
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: "slack_status",
Description: "Slack: whether it runs (the main process's pid, since, and the scope it runs in; how " +
"many helper processes), the installed version and whether it is outside the official " +
"repositories, where its output goes, whether its icon sits in a tray, who shows its " +
"notifications, its launch and tray switches and notification method, how many accounts and " +
"workspaces it is signed in to (counted, never named), and what starts it at login. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "slack_log",
Description: "The last lines of Slack's own log: source browser (the main process, the default) or " +
"webapp (the web app's console). With problems, only error and warn entries. Tokens, cookies, " +
"message and notification text, and the names of people, channels and workspaces are " +
"replaced by <hidden>. (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{"browser", "webapp"}, "description": "browser (default) or webapp"},
"problems": map[string]any{"type": "boolean", "description": "only error and warn entries (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 = "browser"
}
problems, err := flag(args, "problems", false)
if err != nil {
return nil, err
}
return machine.Log(source, n, problems)
},
},
{
Name: "slack_restart",
Description: "End Slack (asked first, then forced after 8 s) and start it again to the tray in the " +
"operator's desktop session, under the account's service manager, its output in the journal. " +
"Answers the pids ended and the new main process. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "slack_check",
Description: "Check what the module promises: Slack is installed; it has exactly one start (the " +
"module's line in i3's configuration; no XDG autostart entry, no other exec); the window rules " +
"are the module's, not repeated in a file of the operator's; one Slack runs in the session with " +
"its output read (a pipe nobody reads makes its writes fail with EPIPE); its icon is in a tray; " +
"a notifier owns the session bus's notification name. Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,156 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// slack's shape (novox/hq ADR 0208, ADR 0210, ADR 0212, ADR 0182): no package (Slack comes from the
// AUR, and the host installs from the official repositories only), no seat, no file; the X display on
// its own machine; one contribution to node-display-session that is both Slack's one start and its
// window rules; and the Go bundle serving exactly the listed slack_ 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 TestItInstallsNothingAndOwnsNoFile(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "slack" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("%+v", m)
}
// slack-desktop is the AUR's: a package resource would ask the host for what it cannot install.
if m.Resources != nil || m.Capabilities != nil {
t.Fatalf("no package, no file: %v %v", m.Resources, m.Capabilities)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil {
t.Fatal("it holds no seat, sets no environment and adds no session code")
}
// Slack's own setting writes ~/.config/autostart/slack.desktop (a link): never the module's.
for _, never := range []string{".config/autostart", ".config/Slack", "root-state", "Cookies"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestTheOneContributionIsTheStartAndTheWindowRules(t *testing.T) {
m, _ := readManifest(t)
if len(m.Contributions) != 1 {
t.Fatalf("%+v", m.Contributions)
}
c := m.Contributions[0]
if c.Seat != "node-display-session" || c.Kind != "config" || !strings.HasSuffix(c.Content, "\n") {
t.Fatalf("%+v", c)
}
var execs, rules []string
for _, l := range strings.Split(c.Content, "\n") {
l = strings.TrimSpace(l)
switch {
case strings.HasPrefix(l, "exec"):
execs = append(execs, l)
case strings.HasPrefix(l, "for_window"):
rules = append(rules, l)
case l == "" || strings.HasPrefix(l, "#"):
default:
t.Errorf("a line that is neither the start nor a rule: %q", l)
}
}
want := "exec --no-startup-id " + slackBin + " " + strings.Join(startArgs, " ")
if len(execs) != 1 || execs[0] != want {
t.Fatalf("one start, the package entry's arguments, as slack_restart starts it: %q", execs)
}
// The operator's rules of 2026-08 (50-slack.conf), verified then against Slack's windows.
wantRules := []string{
`for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3`,
`for_window [class="(?i)^slack$" window_role="browser-window"] floating disable`,
`for_window [class="(?i)^slack$" window_role="browser-window"] border pixel 2`,
}
if !reflect.DeepEqual(rules, wantRules) {
t.Fatalf("%q", rules)
}
for _, r := range rules {
if !strings.HasPrefix(r, ruleMark) {
t.Errorf("slack_check recognises the module's rules by %q: %s", ruleMark, r)
}
}
// The placed lines start Slack once: the fake i3 configuration below holds exactly this content.
f := newFake(t)
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n# slack\n"+c.Content)
if s := f.starts(); len(s) != 1 || !s[0].Mine {
t.Fatalf("%+v", s)
}
if n, _ := f.windowRules(); n != 3 {
t.Fatalf("%d rules", n)
}
}
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, "slack_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed slack_ 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/slack-tools" || b["binary"] != "slack-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)
}
}
}
+678
View File
@@ -0,0 +1,678 @@
package main
// Slack as the tools see it. Slack is one Electron process tree: the main process (comm slack, no
// --type=) is both the window and the tray icon, and its helpers (--type=zygote, gpu-process,
// renderer, utility) are its children. The tools read only:
// - the account's processes, and of Slack's main process where its output goes (fd 1 and 2);
// - Slack's settings file, ~/.config/Slack/storage/root-state.json, of which only switches are
// answered (launch on login, start hidden, run from the tray, notification method, hardware
// acceleration), and local-settings.json for the Electron version;
// - Slack's own log, ~/.config/Slack/logs/default/browser*.log, from which the signed-in accounts and
// workspaces are counted (counted only: no name, no id) and which slack_log answers masked;
// - the X window tree (xwininfo), for the tray icon; the session bus, for who shows notifications.
//
// Never read: Cookies, Local Storage, IndexedDB, the os_crypt key in Local State. Those are the
// sessions' credentials, and no tool needs them.
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// Where Slack keeps things, under the operator's home, and how it is started.
const (
slackComm = "slack"
slackBin = "/usr/bin/slack"
packageFor = "slack-desktop"
restartAs = "mesh-slack"
entryName = "slack.desktop"
stateFile = ".config/Slack/storage/root-state.json"
localFile = ".config/Slack/local-settings.json"
logDir = ".config/Slack/logs/default"
i3Main = ".config/i3/config"
i3DropIns = ".config/i3/config.d"
notifyName = "org.freedesktop.Notifications"
)
// startArgs are the package's own desktop entry's arguments: GTK 3, and -s, started to the tray.
var startArgs = []string{"--gtk-version=3", "-s"}
// ruleMark is how the module's window rules are recognised wherever they are found.
const ruleMark = `for_window [class="(?i)^slack$"`
// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link.
var readLink = os.Readlink
// isLink says whether a path is a symbolic link itself; tests replace it for the same reason.
var isLink = func(p string) bool {
fi, err := os.Lstat(p)
return err == nil && fi.Mode()&os.ModeSymlink != 0
}
// mainProcs are Slack's main processes: comm slack without --type=.
func (m *Machine) mainProcs() []Proc {
var out []Proc
for _, p := range m.procs(slackComm) {
if !strings.Contains(p.Command, "--type=") {
out = append(out, p)
}
}
return out
}
// Stream is where a process's standard output or error goes.
type Stream struct {
FD int `json:"fd"`
Goes string `json:"goes"`
// Dead is a pipe no process of the account reads: a write there fails with EPIPE.
Dead bool `json:"dead,omitempty"`
}
// streams says where fd 1 and 2 of a process go.
func (m *Machine) streams(pid int) []Stream {
var out []Stream
for _, fd := range []int{1, 2} {
target, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%d", pid, fd)))
s := Stream{FD: fd}
switch {
case err != nil:
s.Goes = "unreadable"
case target == "/dev/null":
s.Goes = "discarded (/dev/null)"
case strings.HasPrefix(target, "socket:"):
s.Goes = "a socket (the journal, when the session's output is sent there)"
case strings.HasPrefix(target, "pipe:"):
if r := m.pipeReaders(target, pid); len(r) > 0 {
s.Goes = "a pipe read by " + strings.Join(r, ", ")
} else {
s.Goes, s.Dead = "a pipe nobody reads", true
}
default:
s.Goes = m.tilde(target)
}
out = append(out, s)
}
return out
}
// pipeReaders are the account's processes holding the pipe open for reading (O_RDONLY in fdinfo).
func (m *Machine) pipeReaders(pipe string, except int) []string {
entries, _ := os.ReadDir(m.path("/proc"))
seen := map[string]bool{}
var out []string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == except {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
fds, _ := os.ReadDir(filepath.Join(dir, "fd"))
for _, fd := range fds {
if t, err := readLink(filepath.Join(dir, "fd", fd.Name())); err != nil || t != pipe {
continue
}
if readOnly(readTrimmed(filepath.Join(dir, "fdinfo", fd.Name()))) {
name := fmt.Sprintf("%s (pid %d)", readTrimmed(filepath.Join(dir, "comm")), pid)
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
}
}
sort.Strings(out)
return out
}
// readOnly reads an fdinfo's flags (octal): the access mode is its low two bits, 0 for reading.
func readOnly(fdinfo string) bool {
for _, l := range strings.Split(fdinfo, "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "flags:" {
n, err := strconv.ParseInt(f[1], 8, 64)
return err == nil && n&3 == 0
}
}
return false
}
// Settings are the switches of Slack's settings the tools answer. Every other key stays unread.
type Settings struct {
Found bool `json:"found"`
LaunchOnLogin *bool `json:"launch_on_login,omitempty"`
HideOnStartup *bool `json:"hide_on_startup,omitempty"`
RunFromTray *bool `json:"run_from_tray,omitempty"`
NotificationMethod string `json:"notification_method,omitempty"`
HardwareAccel *bool `json:"hardware_acceleration,omitempty"`
Electron string `json:"electron,omitempty"`
}
func (m *Machine) settings() Settings {
var s Settings
raw, err := readBounded(m.home(stateFile))
if err == nil {
var doc struct {
Settings struct {
LaunchOnStartup *bool `json:"launchOnStartup"`
HideOnStartup *bool `json:"hideOnStartup"`
RunFromTray *bool `json:"runFromTray"`
NotificationMethod *string `json:"notificationMethod"`
UseHwAcceleration *bool `json:"useHwAcceleration"`
} `json:"settings"`
}
if json.Unmarshal(raw, &doc) == nil {
s.Found = true
s.LaunchOnLogin, s.HideOnStartup, s.RunFromTray = doc.Settings.LaunchOnStartup, doc.Settings.HideOnStartup, doc.Settings.RunFromTray
s.HardwareAccel = doc.Settings.UseHwAcceleration
s.NotificationMethod = "Slack's default"
if doc.Settings.NotificationMethod != nil && *doc.Settings.NotificationMethod != "" {
s.NotificationMethod = *doc.Settings.NotificationMethod
}
}
}
if raw, err := readBounded(m.home(localFile)); err == nil {
var doc struct {
Electron string `json:"lastElectronVersionLaunched"`
}
if json.Unmarshal(raw, &doc) == nil {
s.Electron = doc.Electron
}
}
return s
}
// Workspaces is how many accounts Slack is signed in to, and how many workspaces they open: counted
// from the last report Slack logged of each account, since its last start. Never a name or an id.
type Workspaces struct {
Accounts int `json:"accounts"`
Workspaces int `json:"workspaces"`
AsOf string `json:"as_of,omitempty"`
Note string `json:"note,omitempty"`
}
var logStamp = regexp.MustCompile(`^\[(\d\d/\d\d/\d\d, \d\d:\d\d:\d\d:\d+)\] (\w+): `)
// browserLogs are Slack's main-process logs, oldest first (browser2.log, browser1.log, browser.log).
func (m *Machine) browserLogs() []string {
files, _ := filepath.Glob(m.home(logDir, "browser*.log"))
sort.Slice(files, func(i, j int) bool {
a, _ := os.Stat(files[i])
b, _ := os.Stat(files[j])
if a == nil || b == nil {
return files[i] < files[j]
}
return a.ModTime().Before(b.ModTime())
})
if len(files) > 3 {
files = files[len(files)-3:]
}
return files
}
func (m *Machine) workspaces() Workspaces {
var lines []string
for _, f := range m.browserLogs() {
l, err := tailLines(f, 1<<20)
if err == nil {
lines = append(lines, l...)
}
}
start := 0
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], "] info: Store: INITIALIZE") {
start = i
break
}
}
accounts := map[string][]string{}
var asOf string
for i := start; i < len(lines); i++ {
if !strings.Contains(lines[i], "Store: STORE_USER_WORKSPACES") {
continue
}
var block strings.Builder
j := i + 1
for ; j < len(lines) && !logStamp.MatchString(lines[j]); j++ {
block.WriteString(lines[j])
}
var report struct {
User string `json:"userTeamId"`
Workspaces []string `json:"workspaceIds"`
}
if json.Unmarshal([]byte(block.String()), &report) == nil && report.User != "" {
accounts[report.User] = report.Workspaces
if mm := logStamp.FindStringSubmatch(lines[i]); mm != nil {
asOf = mm[1]
}
}
i = j - 1
}
w := Workspaces{Accounts: len(accounts), AsOf: asOf}
all := map[string]bool{}
for _, ws := range accounts {
for _, id := range ws {
all[id] = true
}
}
w.Workspaces = len(all)
if len(accounts) == 0 {
w.Note = "Slack has logged no account since its last start: signed out, or not started yet"
}
return w
}
// Tray is whether Slack's icon sits in a tray: a window of class "Slack" reparented into another
// program's window (i3bar's tray), found in the X window tree.
type Tray struct {
Present bool `json:"present"`
In string `json:"in,omitempty"`
Unknown string `json:"unknown,omitempty"`
}
var treeLine = regexp.MustCompile(`^(\s*)0x[0-9a-f]+ (?:"[^"]*"|\(has no name\)): \("([^"]*)" "([^"]*)"\)`)
// trayIn reads `xwininfo -root -tree`: each window is indented under its parent.
func trayIn(tree string) (bool, string) {
type frame struct {
indent int
class string
}
var stack []frame
for _, l := range strings.Split(tree, "\n") {
trimmed := strings.TrimLeft(l, " ")
if !strings.HasPrefix(trimmed, "0x") {
continue
}
indent := len(l) - len(trimmed)
for len(stack) > 0 && stack[len(stack)-1].indent >= indent {
stack = stack[:len(stack)-1]
}
mm := treeLine.FindStringSubmatch(l)
class := ""
if mm != nil {
class = mm[3]
if mm[2] == "slack" && class == "Slack" {
for k := len(stack) - 1; k >= 0; k-- {
if c := stack[k].class; c != "" && !strings.EqualFold(c, "slack") {
return true, c
}
}
}
}
stack = append(stack, frame{indent, class})
}
return false, ""
}
func (m *Machine) tray(s Session) Tray {
o := m.cmd(5*time.Second, s.Env(), "xwininfo", "-root", "-tree")
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
if o.Err == ErrNotInstalled {
return Tray{Unknown: "xwininfo (package xorg-xwininfo) is not installed, so the window tree cannot be read"}
}
return Tray{Unknown: err.Error()}
}
ok, in := trayIn(o.Stdout)
return Tray{Present: ok, In: in}
}
// Notifier is who shows Slack's notifications: the owner of the session bus's notification name.
type Notifier struct {
Owner string `json:"owner,omitempty"`
PID int `json:"pid,omitempty"`
// Activatable is a notifier that is not running but that the bus starts on the first notification.
Activatable bool `json:"activatable,omitempty"`
Unknown string `json:"unknown,omitempty"`
}
func (m *Machine) notifier() Notifier {
o := m.cmd(5*time.Second, m.bus(), "busctl", "--user", "list", "--no-legend", "--no-pager")
if err := failed(o, "busctl", "--user", "list"); err != nil {
return Notifier{Unknown: err.Error()}
}
for _, l := range strings.Split(o.Stdout, "\n") {
f := strings.Fields(l)
if len(f) < 3 || f[0] != notifyName {
continue
}
if pid, err := strconv.Atoi(f[1]); err == nil {
return Notifier{Owner: f[2], PID: pid}
}
return Notifier{Activatable: true}
}
return Notifier{}
}
// Status is what slack_status answers.
type Status struct {
Installed string `json:"installed,omitempty"`
Foreign bool `json:"outside_the_official_repositories,omitempty"`
Running []Proc `json:"running"`
Helpers int `json:"helper_processes"`
Output []Stream `json:"output,omitempty"`
Tray *Tray `json:"tray,omitempty"`
Notifier Notifier `json:"notifications_to"`
Settings Settings `json:"settings"`
Workspaces Workspaces `json:"signed_in"`
Starts []string `json:"starts"`
}
// foreign says whether pacman counts the package as foreign: installed, but in no sync repository.
func (m *Machine) foreign(pkg string) bool {
o := m.cmd(0, nil, "pacman", "-Qmq", pkg)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == pkg
}
func (m *Machine) Status() (Status, error) {
s := Status{Running: m.mainProcs(), Settings: m.settings(), Workspaces: m.workspaces(), Starts: []string{}}
if s.Running == nil {
s.Running = []Proc{}
}
s.Helpers = len(m.procs(slackComm)) - len(s.Running)
if v, err := m.installed(packageFor); err == nil && v != "" {
s.Installed, s.Foreign = v, m.foreign(packageFor)
}
if len(s.Running) > 0 {
s.Output = m.streams(s.Running[0].PID)
if sess, err := m.session(); err == nil {
t := m.tray(sess)
s.Tray = &t
}
}
s.Notifier = m.notifier()
for _, st := range m.starts() {
s.Starts = append(s.Starts, st.What)
}
return s, nil
}
// Start is one thing that starts Slack at login; Mine is the module's own.
type Start struct {
What string
Mine bool
Do string
}
// starts are every start of Slack at login the tools can see: window-manager exec lines (the module's
// in i3's main file, any other in the operator's drop-ins) and XDG autostart entries.
func (m *Machine) starts() []Start {
var out []Start
main := m.tilde(filepath.Join(m.Home, i3Main)) + ":"
for _, l := range m.i3Starts(slackComm) {
if strings.HasPrefix(l, main) {
out = append(out, Start{What: "window manager (this module's line): " + l, Mine: true})
} else {
out = append(out, Start{What: "window manager: " + l, Do: "remove the line: the module's line in i3's configuration is Slack's one start"})
}
}
if a := m.autostart(entryName); a.Starts {
st := Start{What: "XDG autostart: " + a.From + " (" + a.Exec + ")"}
user := m.home(".config", "autostart", entryName)
if isLink(user) {
st.Do = "untick 'Launch app on login' in Slack's preferences: Slack made this link for that setting and removes it when unticked"
} else if strings.HasPrefix(a.From, "~") {
st.Do = "delete " + a.From + " (the predecessor's): Slack then reads 'Launch app on login' as off, and the module's line is the one start"
} else {
st.Do = "hide it with an entry of the account (Hidden=true): the module's line is Slack's one start"
}
out = append(out, st)
}
return out
}
// LogAnswer is what slack_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
// The masks. A token or a cookie is hidden wherever it appears; a notification's or a message's text,
// and the names of people, channels and workspaces, are hidden by their key.
var (
slackToken = regexp.MustCompile(`xox[a-z]-[A-Za-z0-9-]+|xapp-[A-Za-z0-9-]+`)
credential = regexp.MustCompile(`(?i)\b(authorization|bearer|token|password|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:bearer\s+)?[^\s"',;&]+`)
cookieD = regexp.MustCompile(`\bd=[A-Za-z0-9%/+=._-]{20,}`)
privateKey = regexp.MustCompile(`"(title|subtitle|content|body|text|authorName|userName|channelName|workspaceName|teamName|name|email|realName|displayName)": "(?:[^"\\]|\\.)*"`)
)
func mask(s string) string {
s = slackToken.ReplaceAllString(s, "<hidden>")
s = cookieD.ReplaceAllString(s, "d=<hidden>")
s = credential.ReplaceAllString(s, "${1}${2}<hidden>")
return privateKey.ReplaceAllString(s, `"${1}": "<hidden>"`)
}
// Log answers the last n lines of Slack's main-process log (source browser) or of its web app's
// console log (source webapp), masked; problems keeps the error and warning entries.
func (m *Machine) Log(source string, n int, problems bool) (LogAnswer, error) {
a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}}
var files []string
switch source {
case "browser":
files = m.browserLogs()
case "webapp":
files, _ = filepath.Glob(m.home(logDir, "webapp-console*.log"))
sort.Slice(files, func(i, j int) bool {
a, _ := os.Stat(files[i])
b, _ := os.Stat(files[j])
return a != nil && b != nil && a.ModTime().Before(b.ModTime())
})
default:
return a, fmt.Errorf("source is browser or webapp, not %q", source)
}
if len(files) == 0 {
a.Note = "Slack keeps no such log in ~/" + logDir
return a, nil
}
var got []string
for i := len(files) - 1; i >= 0 && len(got) < n && len(a.Files) < 3; i-- {
lines, err := tailLines(files[i], 1<<20)
if err != nil {
return a, err
}
if problems {
var keep []string
for _, l := range lines {
if mm := logStamp.FindStringSubmatch(l); mm != nil && (mm[2] == "error" || mm[2] == "warn") {
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(files[i], m.Root)))
}
size := 0
for i := len(got) - 1; i >= 0; i-- {
l := mask(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
}
// tailLines answers the last n lines of a file, read from its end 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()
if st, err := h.Stat(); err == nil && st.Size() > MostRead {
if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil {
return nil, err
}
}
var all []string
s := bufio.NewScanner(io.LimitReader(h, MostRead))
s.Buffer(make([]byte, 64<<10), 4<<20)
for s.Scan() {
all = append(all, string(bytes.TrimRight(s.Bytes(), "\r")))
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, s.Err()
}
// RestartAnswer is what slack_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 Slack (the main process and its helpers) and starts it again to the tray, in the
// operator's session, under the account's service manager: its output then goes to the journal.
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(8*time.Second, slackComm)
if err := m.detach(s, restartAs, append([]string{slackBin}, startArgs...)...); err != nil {
return a, err
}
m.waitFor(slackComm, 6*time.Second)
a.Running = m.mainProcs()
if a.Running == nil {
a.Running = []Proc{}
}
if len(a.Running) == 0 {
return a, fmt.Errorf("Slack was started as %s but no main process appeared within 6 s: "+
"see `journalctl --user -u %s`", a.Unit, a.Unit)
}
return a, nil
}
// CheckAnswer is what slack_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises: Slack installed, one start (the module's line), the window
// rules once, one Slack running with its output read, its icon in the tray, and a notifier.
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("Slack ("+packageFor+") is not installed", "install it from the AUR: the module does not install it, because the host installs packages from the official repositories only")
}
mine := 0
for _, st := range m.starts() {
a.Starts = append(a.Starts, st.What)
if st.Mine {
mine++
} else {
add("a second start: "+st.What, st.Do)
}
}
if mine == 0 {
add("i3's configuration has not got the module's start of Slack", "assign the i3 module, which places this module's lines, and push the node")
} else if mine > 1 {
add(fmt.Sprintf("i3's configuration starts Slack %d times", mine), "push the node: the module's lines are placed once")
}
rules, operators := m.windowRules()
if rules == 0 {
add("i3's configuration has not got the module's window rules for Slack", "assign the i3 module and push the node")
}
for _, f := range operators {
add("the operator's "+f+" repeats Slack's window rules, which are this module's now", "delete "+f+": i3 reads it after the module's lines")
}
running := m.mainProcs()
sess, serr := m.session()
if serr == nil {
switch {
case len(running) == 0:
add("Slack does not run in the desktop session", "slack_restart")
case len(running) > 1:
add(fmt.Sprintf("%d Slack main processes run", len(running)), "slack_restart ends them all and starts one")
}
}
if len(running) > 0 {
for _, st := range m.streams(running[0].PID) {
if st.Dead {
add(fmt.Sprintf("Slack's fd %d is a pipe nobody reads (a session started before its output went to the journal): "+
"its logger silences the EPIPE it gets there, any other write it makes fails", st.FD),
"slack_restart: its output then goes to the journal. Every later login sends the session's output there (the i3 module's login entry)")
}
}
if serr == nil {
if t := m.tray(sess); !t.Present && t.Unknown == "" {
add("Slack's icon is in no tray: closing its window leaves it unreachable", "give the bar a tray (i3bar's tray_output), then slack_restart")
}
}
}
if n := m.notifier(); n.Unknown == "" && n.Owner == "" && !n.Activatable {
add("nobody shows notifications on the session bus ("+notifyName+"): Slack's go nowhere", "assign dunst, the node's notifier")
}
a.OK = len(a.Findings) == 0
return a, nil
}
// windowRules counts the module's rule lines in i3's main file, and names the operator's drop-ins that
// carry Slack's rules too.
func (m *Machine) windowRules() (int, []string) {
n := 0
if raw, err := readBounded(m.home(i3Main)); err == nil {
for _, l := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(strings.TrimSpace(l), ruleMark) {
n++
}
}
}
var files []string
more, _ := filepath.Glob(m.home(i3DropIns, "*.conf"))
for _, f := range more {
raw, err := readBounded(f)
if err != nil {
continue
}
for _, l := range strings.Split(string(raw), "\n") {
t := strings.ToLower(strings.TrimSpace(l))
if strings.HasPrefix(t, "for_window") && strings.Contains(t, "slack") {
files = append(files, m.tilde(strings.TrimPrefix(f, m.Root)))
break
}
}
}
return n, files
}
+314
View File
@@ -0,0 +1,314 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// Slack's settings as Slack writes them, with keys no answer may carry.
const rootState = `{"appTeams":{"selectedTeamId":null,"teamsByIndex":[]},
"settings":{"launchOnStartup":true,"hideOnStartup":true,"runFromTray":true,"notificationMethod":null,
"useHwAcceleration":true,"PrefSSBFileDownloadPath":"/home/operator/Downloads","signInMethod":"secret-sso"}}`
// A main-process log across a restart: the accounts before it are not counted.
const browserLog = `[10/03/26, 09:00:00:001] info: Store: INITIALIZE
[10/03/26, 09:00:05:001] info: Store: STORE_USER_WORKSPACES
{
"userTeamId": "TOLD0000001",
"workspaceIds": [
"TOLD0000001",
"TOLD0000002"
]
}
[10/04/26, 16:26:20:100] info: Store: INITIALIZE
[10/04/26, 16:26:30:100] info: Store: STORE_USER_WORKSPACES
{
"userTeamId": "TAAAA000001",
"workspaceIds": [
"TAAAA000001"
]
}
[10/04/26, 16:26:31:100] warn: Request failed with token xoxc-1234-5678-abcdef and cookie d=xoxd-AbCdEfGhIjKlMnOpQrStUv%2Fwx
[10/04/26, 16:27:00:100] info: Store: NEW_NOTIFICATION
{
"title": "A private message",
"workspaceName": "Secret Workspace",
"teamId": "TAAAA000001"
}
[10/04/26, 23:41:06:340] info: Store: STORE_USER_WORKSPACES
{
"userTeamId": "TBBBB000001",
"workspaceIds": [
"TBBBB000001",
"TAAAA000001"
]
}
[10/04/26, 23:41:07:000] error: Authorization: Bearer abc.def failed
`
// xwininfo -root -tree as the laptop answered it: the chat window under i3's frame, the tray icon in
// i3bar, two helper windows at the top.
const tree = `xwininfo: Window id: 0x3d7 (the root window) (has no name)
Root window id: 0x3d7 (the root window) (has no name)
Parent window id: 0x0 (none)
12 children:
0x28000d1 "slack": ("slack" "Slack") 200x200+0+0 +0+0
0x1400003 (has no name): ("i3-frame" "i3-frame") 1440x1774+0+26 +0+26
1 child:
0x2600004 "Slack | general": ("slack" "slack") 1436x1744+2+2 +2+28
0x1002168 (has no name): () 1x1+0+0 +0+0
0x1002169 "i3bar for output eDP-1": ("bar-1" "i3bar") 2880x26+0+0 +0+0
1 child:
0x2800029 "slack": ("slack" "Slack") 22x22+2832+2 +2832+2
0x2800001 "slack": ("slack" "Slack") 10x10+10+10 +10+10
`
// newSlack is a machine with Slack installed from the AUR, running from a session whose output pipe
// nobody reads, its icon in i3bar's tray, dunst on the bus.
func newSlack(t *testing.T) (*fake, map[string]string) {
f := newFake(t)
links := map[string]string{}
readLink = func(p string) (string, error) {
rel := strings.TrimPrefix(p, f.Root)
if l, ok := links[rel]; ok {
return l, nil
}
return "", errors.New("no such link")
}
t.Cleanup(func() { readLink = os.Readlink })
f.desktopSession()
f.write(testHome+"/"+stateFile, rootState)
f.write(testHome+"/"+localFile, `{"lastElectronVersionLaunched":"43.4.0","electronFeatureOverrides":["x"]}`)
f.write(testHome+"/"+logDir+"/browser.log", browserLog)
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n# slack\n"+contribution(t))
f.proc(3867, 1000, slackComm, []string{"/usr/lib/slack/slack", "--gtk-version=3", "-s"}, "user@1000.service/app.slice/app-slack-3867.scope")
f.proc(3945, 1000, slackComm, []string{"/usr/lib/slack/slack", "--type=zygote"}, "session-c1.scope")
links["/proc/3867/fd/1"] = "pipe:[36802]"
links["/proc/3867/fd/2"] = "/dev/null"
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman" && args[0] == "-Q":
return Output{Stdout: "slack-desktop 4.51.191-1\n"}
case name == "pacman" && args[0] == "-Qmq":
return Output{Stdout: "slack-desktop\n"}
case name == "xwininfo":
return Output{Stdout: tree}
case name == "busctl":
return Output{Stdout: ":1.19 3923 dunst operator :1.19 user@1000.service - -\n" +
"org.freedesktop.Notifications 3923 dunst operator :1.19 user@1000.service - -\n"}
}
return Output{}
}
return f, links
}
func contribution(t *testing.T) string {
m, _ := readManifest(t)
return m.Contributions[0].Content
}
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{"xoxc", "xoxd", "AbCdEf", "abc.def", "private message", "Secret Workspace", "TAAAA", "TBBBB",
"secret-sso", "/home/operator"} {
if strings.Contains(s, never) {
t.Errorf("the answer carries %q: %s", never, s)
}
}
return s
}
func TestStatusCountsWorkspacesSinceTheLastStartAndNamesNone(t *testing.T) {
f, _ := newSlack(t)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
noSecrets(t, s)
if s.Installed != "4.51.191-1" || !s.Foreign || len(s.Running) != 1 || s.Running[0].PID != 3867 || s.Helpers != 1 {
t.Fatalf("%+v", s)
}
// Two accounts since the start of 10/04 (the earlier start's account is not counted), and the
// workspaces they open together: TBBBB's two, one shared with TAAAA.
if s.Workspaces.Accounts != 2 || s.Workspaces.Workspaces != 2 || s.Workspaces.AsOf != "10/04/26, 23:41:06:340" {
t.Fatalf("%+v", s.Workspaces)
}
st := s.Settings
if !st.Found || !*st.LaunchOnLogin || !*st.HideOnStartup || !*st.RunFromTray || st.NotificationMethod != "Slack's default" || st.Electron != "43.4.0" {
t.Fatalf("%+v", st)
}
if s.Tray == nil || !s.Tray.Present || s.Tray.In != "i3bar" {
t.Fatalf("%+v", s.Tray)
}
if s.Notifier.Owner != "dunst" || s.Notifier.PID != 3923 {
t.Fatalf("%+v", s.Notifier)
}
if len(s.Output) != 2 || !s.Output[0].Dead || s.Output[1].Goes != "discarded (/dev/null)" {
t.Fatalf("%+v", s.Output)
}
if len(s.Starts) != 1 || !strings.Contains(s.Starts[0], "this module's line") {
t.Fatalf("%q", s.Starts)
}
}
func TestAPipeWithAReaderIsLiveAndTheJournalIsASocket(t *testing.T) {
f, links := newSlack(t)
f.proc(500, 1000, "systemd-cat", []string{"systemd-cat", "-t", "x-session"}, "session-c1.scope")
links["/proc/500/fd/0"] = "pipe:[36802]"
f.write("/proc/500/fdinfo/0", "pos:\t0\nflags:\t02000000\nmnt_id:\t15\n")
// The writer's own end, elsewhere, is not a reader.
f.proc(501, 1000, "sh", []string{"sh"}, "session-c1.scope")
links["/proc/501/fd/1"] = "pipe:[36802]"
f.write("/proc/501/fdinfo/1", "pos:\t0\nflags:\t01\n")
links["/proc/3867/fd/2"] = "socket:[22289]"
for _, fd := range []string{"0", "1"} {
_ = os.MkdirAll(filepath.Join(f.Root, "/proc/500/fd"), 0o755)
_ = os.WriteFile(filepath.Join(f.Root, "/proc/500/fd", fd), nil, 0o644)
_ = os.MkdirAll(filepath.Join(f.Root, "/proc/501/fd"), 0o755)
_ = os.WriteFile(filepath.Join(f.Root, "/proc/501/fd", fd), nil, 0o644)
}
got := f.streams(3867)
if got[0].Dead || got[0].Goes != "a pipe read by systemd-cat (pid 500)" || !strings.HasPrefix(got[1].Goes, "a socket") {
t.Fatalf("%+v", got)
}
}
func TestTheTrayIsAnIconInsideAnotherProgramsWindow(t *testing.T) {
if ok, in := trayIn(tree); !ok || in != "i3bar" {
t.Fatal(ok, in)
}
without := strings.Replace(tree, ` 0x2800029 "slack": ("slack" "Slack") 22x22+2832+2 +2832+2`+"\n", "", 1)
if ok, _ := trayIn(without); ok {
t.Fatal("the top-level Slack windows and the managed chat window are not a tray icon")
}
}
func TestTheLogIsMasked(t *testing.T) {
f, _ := newSlack(t)
l, err := f.Log("browser", 2000, false)
if err != nil {
t.Fatal(err)
}
all := strings.Join(l.Lines, "\n")
for _, never := range []string{"xoxc", "xoxd", "AbCdEf", "abc.def", "private message", "Secret Workspace"} {
if strings.Contains(all, never) {
t.Errorf("the log carries %q", never)
}
}
if !strings.Contains(all, `"title": "<hidden>"`) || !strings.Contains(all, "token <hidden>") || l.Files[0] != "~/"+logDir+"/browser.log" {
t.Fatalf("%+v", l)
}
p, _ := f.Log("browser", 10, true)
if len(p.Lines) != 2 || !strings.Contains(p.Lines[0], "warn:") || !strings.Contains(p.Lines[1], "Authorization: <hidden>") {
t.Fatalf("%q", p.Lines)
}
if _, err := f.Log("cookies", 5, false); err == nil {
t.Fatal("an unknown source is refused")
}
if l, err := newFake(t).Log("webapp", 5, false); err != nil || l.Note == "" {
t.Fatalf("%+v %v", l, err)
}
}
func TestRestartEndsSlackAndStartsItToTheTray(t *testing.T) {
f, _ := newSlack(t)
f.onStart = func(argv []string) {
f.proc(9000, 1000, slackComm, argv, "user@1000.service/app.slice/"+restartAs+".service")
f.proc(9001, 1000, slackComm, []string{"/usr/lib/slack/slack", "--type=zygote"}, "user@1000.service/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 != 9000 || a.Running[0].Command != "/usr/bin/slack --gtk-version=3 -s" {
t.Fatalf("%+v", a)
}
if !f.called("systemd-run --user --collect --quiet --unit=mesh-slack --setenv=DISPLAY=:1") {
t.Fatalf("%q", f.calls)
}
f.onStart = nil
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "journalctl --user -u mesh-slack.service") {
t.Fatalf("a start that shows no process: %v", err)
}
none := newFake(t)
if _, err := none.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
}
func TestCheckNamesEveryOtherStartTheOperatorsRulesAndTheDeadPipe(t *testing.T) {
f, links := newSlack(t)
c, err := f.Check()
if err != nil {
t.Fatal(err)
}
// As the desktop machine is: the one finding is the session's dead pipe.
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "fd 1 is a pipe nobody reads") || len(c.Starts) != 1 {
t.Fatalf("%+v", c)
}
links["/proc/3867/fd/1"] = "socket:[1]"
if c, _ = f.Check(); !c.OK {
t.Fatalf("%+v", c)
}
// As the laptop is before migration: the predecessor's entry, the operator's rules file.
f.write(testHome+"/.config/autostart/slack.desktop", "[Desktop Entry]\nExec=/usr/bin/slack --gtk-version=3 -s %U\nHidden=false\n")
f.write(testHome+"/.config/i3/config.d/50-slack.conf", "# Slack window rules.\n"+
`for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3`+"\n")
f.write(testHome+"/.config/i3/config.d/60-mine.conf", "exec --no-startup-id slack\n")
c, _ = f.Check()
all := noSecrets(t, c)
for _, want := range []string{"a second start: XDG autostart: ~/.config/autostart/slack.desktop", "(the predecessor's)",
"a second start: window manager: ~/.config/i3/config.d/60-mine.conf:1", "~/.config/i3/config.d/50-slack.conf repeats Slack's window rules"} {
if !strings.Contains(all, want) {
t.Errorf("no finding %q in %s", want, all)
}
}
if len(c.Starts) != 3 {
t.Fatalf("%q", c.Starts)
}
// Nothing installed, nothing placed, two Slacks, no tray, no notifier.
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n")
f.proc(4000, 1000, slackComm, []string{"/usr/lib/slack/slack"}, "s.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "xwininfo":
return Output{Stdout: strings.Replace(tree, `("slack" "Slack") 22x22`, `("other" "Other") 22x22`, 1)}
case "busctl":
return Output{Stdout: ":1.19 3923 dunst operator :1.19 user@1000.service - -\n"}
}
return Output{}
}
c, _ = f.Check()
all = noSecrets(t, c)
for _, want := range []string{"is not installed", "from the AUR", "not got the module's start", "not got the module's window rules",
"2 Slack main processes run", "icon is in no tray", "nobody shows notifications"} {
if !strings.Contains(all, want) {
t.Errorf("no finding %q in %s", want, all)
}
}
}
func TestSlacksOwnLaunchOnLoginLinkIsNamedAsItsSetting(t *testing.T) {
f, _ := newSlack(t)
// Slack's setting makes ~/.config/autostart/slack.desktop a link to the package's entry.
f.write(testHome+"/.config/autostart/slack.desktop", "[Desktop Entry]\nExec=/usr/bin/slack --gtk-version=3 -s %U\n")
orig := isLink
isLink = func(p string) bool { return strings.HasSuffix(p, "/.config/autostart/slack.desktop") }
t.Cleanup(func() { isLink = orig })
c, _ := f.Check()
if !strings.Contains(noSecrets(t, c), "untick 'Launch app on login'") {
t.Fatalf("%+v", c)
}
}
+5
View File
@@ -0,0 +1,5 @@
module slack
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=
+35
View File
@@ -0,0 +1,35 @@
{
"module": "slack",
"version": "1",
"requires": [
"x11-display"
],
"tools": [
"slack_status",
"slack_log",
"slack_restart",
"slack_check"
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/slack-tools",
"binary": "slack-tools",
"loads": [
"slack-tools"
]
}
]
},
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.\n# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.\n# No XDG autostart entry starts it as well; slack_check names one if it appears.\nexec --no-startup-id /usr/bin/slack --gtk-version=3 -s\n# The chat window. Slack owns four X windows, and the only one i3 manages has the class \"slack\" in\n# lower case; the three of class \"Slack\" (the packaged entry's StartupWMClass) are its unmanaged\n# helpers and its tray icon, so a rule on class=\"Slack\" matches nothing. window_role keeps a call or\n# screen-share window out of the rules. $ws3 is i3's, in scope here.\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] move to workspace $ws3\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] floating disable\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] border pixel 2\n"
}
]
}
-242
View File
@@ -1,242 +0,0 @@
// systemctl and journalctl, asked in one scope or the other (novox/hq ADR 0177).
//
// Who asks. The node tools runtime runs as the operator account, not root (novox/hq ADR 0175 §4),
// and launches this bundle as a process of its own (ADR 0188, ADR 0193) with the runtime's words:
// HOME, a PATH, MESH_OPERATOR_ACCOUNT and MESH_OPERATOR_HOME — and no session words.
//
// The system manager is the machine's. Reading it needs nothing; acting on it (start, stop,
// restart, enable, disable) is refused by polkit to an account that is not root, so those acts go
// through `sudo -n`, as the packet filter's and the intrusion prevention's do, and a refusal is
// named by how it failed.
//
// The user manager is the operator account's own, and this process IS that account. systemctl and
// journalctl find it by the account's runtime directory, /run/user/<uid>, which the runtime's
// environment does not name; so a user-scope call is given XDG_RUNTIME_DIR and the session bus
// there. It answers only while the account's manager runs — a login, or lingering enabled — and
// when it does not, that is said, never read as "no units".
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { userInfo } from "node:os";
export type Scope = "system" | "user";
export type Act = "start" | "stop" | "restart" | "enable" | "disable";
export interface Unit {
unit: string;
load: string;
active: string;
sub: string;
description: string;
}
/** What a command did: its output, its exit status, and the spawn error when it never ran. */
export interface Ran {
stdout: string;
stderr: string;
status: number;
/** Why it did not run to an answer: the spawn failure's code ("ENOENT" when the program is not
* there), or that it was ended for taking too long. */
error?: string;
}
/** A command runner, so the verbs can be tested without a service manager. */
export type Runner = (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<Ran>;
/** How long one systemctl or journalctl may take: below the runtime's thirty-second call limit, so
* a manager that hangs is answered as such rather than as a call the runtime gave up on. */
export const CALL_TIMEOUT_MS = 20_000;
export const execRunner: Runner = (cmd, args, env) =>
new Promise((resolve) => {
execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024, env: env ?? process.env, timeout: CALL_TIMEOUT_MS }, (err, stdout, stderr) => {
const e = err as (Error & { code?: unknown; killed?: boolean }) | null;
if (e?.killed) {
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: 124, error: `no answer within ${CALL_TIMEOUT_MS / 1000} s` });
return;
}
if (e && typeof e.code === "string") {
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: 127, error: e.code });
return;
}
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: e ? (typeof e.code === "number" ? e.code : 1) : 0 });
});
});
/** The first line of a unit file the host writes for a module's own process (mesh-host
* internal/apply/process.go, unitFor). A unit loaded from a file that begins so is one the mesh
* declares, and the host writes it back at its next apply. */
export const MESH_UNIT_HEADER = "# Generated by the mesh.";
/** The acts that change the system manager's state, which polkit keeps from a non-root account. */
const ACTS: ReadonlySet<string> = new Set<Act>(["start", "stop", "restart", "enable", "disable"]);
/** The command as it is run: as given when this process is root or the call only reads, else an
* act on the system manager through sudo without a prompt. */
export function escalated(cmd: string, args: string[], scope: Scope, uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0 || scope === "user" || cmd !== "systemctl" || !ACTS.has(args[0] ?? "")) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
/** The words that let systemctl and journalctl reach the account's own manager. */
export function sessionEnv(uid: number, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const runtime = `/run/user/${uid}`;
return { ...base, XDG_RUNTIME_DIR: runtime, DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtime}/bus` };
}
export interface Options {
/** The operator account, as the mesh told the runtime. */
account: string;
/** This process's user id and name. */
uid: number;
user: string;
run?: Runner;
/** Reads a unit file, to tell whether the mesh wrote it. */
read?: (path: string) => Promise<string>;
}
export class ServiceManager {
private readonly o: Options;
private readonly run: Runner;
private readonly read: (path: string) => Promise<string>;
constructor(o: Options) {
this.o = o;
this.run = o.run ?? execRunner;
this.read = o.read ?? ((p) => readFile(p, "utf8"));
}
static fromEnv(env: NodeJS.ProcessEnv): ServiceManager {
const me = userInfo();
return new ServiceManager({ account: env.MESH_OPERATOR_ACCOUNT?.trim() || me.username, uid: me.uid, user: me.username });
}
/** One call to systemctl or journalctl in a scope, failing with what went wrong named. */
async call(scope: Scope, cmd: "systemctl" | "journalctl", ...args: string[]): Promise<string> {
let env: NodeJS.ProcessEnv | undefined;
if (scope === "user") {
// The user manager is the account's, and only the account's own process reaches it with
// plain --user. The runtime is that account; anything else is a runtime this was not
// written for, and is said rather than answered from the wrong manager.
if (this.o.user !== this.o.account) {
throw new Error(`the user scope is ${this.o.account}'s service manager, and this runs as ${this.o.user}`);
}
env = sessionEnv(this.o.uid);
args = ["--user", ...args];
}
const [program, argv] = escalated(cmd, args, scope, this.o.uid);
const r = await this.run(program, argv, env);
if (r.status === 0 && !r.error) {
// systemctl answers a user manager it cannot reach on stderr and still exits 0 for some
// verbs (list-units among them): that is a failure, not an empty answer.
if (scope === "user" && /Failed to connect to (user scope )?bus/i.test(r.stderr)) throw this.unreachable(r.stderr);
return r.stdout;
}
throw this.failure(cmd, program, scope, r);
}
private unreachable(said: string): Error {
return new Error(
`${this.o.account}'s own service manager does not answer at /run/user/${this.o.uid} — the account has no ` +
`session and does not linger (loginctl enable-linger ${this.o.account}): ${firstLine(said)}`,
);
}
/** What failed, named by how it failed: sudo missing is a spawn error, sudo refusing speaks on its
* own stderr line, polkit refusing says so, an unreachable user manager says so, and the rest is
* the tool's own last line. */
private failure(cmd: string, program: string, scope: Scope, r: Ran): Error {
const said = `${r.stderr}\n${r.stdout}`.trim();
if (r.error === "ENOENT") {
return program === "sudo"
? new Error(`${cmd} needs root for this, and sudo is not installed here for the runtime's account to escalate with`)
: new Error(`${cmd} is not installed on this machine`);
}
if (r.error) return new Error(`${cmd} did not answer: ${r.error}`);
if (program === "sudo" && /^sudo:/m.test(said)) {
return new Error(`${cmd} needs root for this and the runtime's account may not run it without a prompt: ${firstLine(said)}`);
}
if (/interactive authentication/i.test(said)) {
return new Error(`the service manager refused the runtime's account: ${firstLine(said)}`);
}
if (scope === "user" && /Failed to connect to (user scope )?bus/i.test(said)) return this.unreachable(said);
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
return new Error(lines.length ? `${cmd} failed (${r.status}): ${lines[0]}` : `${cmd} failed with status ${r.status}`);
}
async units(scope: Scope, pattern?: string): Promise<Unit[]> {
const args = ["list-units", "--all", "--no-legend", "--plain", "--no-pager"];
if (pattern) args.push("--", pattern);
const stdout = await this.call(scope, "systemctl", ...args);
return stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const [unit, load, active, sub, ...rest] = l.split(/\s+/);
return { unit, load, active, sub, description: rest.join(" ") };
});
}
/** One unit's state, and whether the mesh declares it.
*
* **Declared** is read from the unit file systemd loaded (FragmentPath): the host writes every
* unit of a module's own process whole, under its own header, and writes it back at its next
* apply. That is the case a person's act is undone in, so it is the one the answer must name.
* A unit the mesh only puts into a state through the `service` shape — a package's own unit —
* carries no mark, and the host's record of it is root's; such a unit answers false here. */
async status(scope: Scope, unit: string): Promise<Record<string, string | boolean>> {
const props = ["LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"];
const stdout = await this.call(scope, "systemctl", "show", unitArg(unit), "--no-pager", ...props.map((p) => `--property=${p}`));
const out: Record<string, string | boolean> = { unit, scope };
for (const line of stdout.split("\n")) {
const i = line.indexOf("=");
if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
}
out.mesh_declared = await this.writtenByMesh(String(out.FragmentPath ?? ""));
return out;
}
private async writtenByMesh(path: string): Promise<boolean> {
if (!path) return false;
const text = await this.read(path).catch(() => "");
return text.startsWith(MESH_UNIT_HEADER);
}
async act(scope: Scope, verb: Act, unit: string): Promise<Record<string, unknown>> {
await this.call(scope, "systemctl", verb, unitArg(unit));
const after = await this.status(scope, unit);
const answer: Record<string, unknown> = { unit, scope, verb, ok: true, active: after.ActiveState, boot: after.UnitFileState, mesh_declared: after.mesh_declared };
if (after.mesh_declared) answer.note = "the mesh declares this unit: the host restores its declared state at its next apply";
return answer;
}
async journal(scope: Scope, unit: string, lines: number): Promise<{ unit: string; scope: Scope; lines: string[] }> {
const stdout = await this.call(scope, "journalctl", "--no-pager", "-n", String(lines), "-u", unitArg(unit), "-o", "short-iso");
return { unit, scope, lines: stdout.split("\n").filter(Boolean) };
}
/** Every failed unit in both managers. A manager that does not answer is reported as such,
* beside the other's answer — never as "nothing failed". */
async failed(): Promise<{ system: Unit[] | { error: string }; user: Unit[] | { error: string } }> {
const failedIn = async (scope: Scope) => {
try {
return (await this.units(scope)).filter((u) => u.active === "failed");
} catch (err) {
return { error: (err as Error).message };
}
};
return { system: await failedIn("system"), user: await failedIn("user") };
}
}
/** A unit's name as an argument: never something systemctl or journalctl would read as an option,
* which under sudo would be root's option. */
export function unitArg(unit: string): string {
if (!unit || unit.startsWith("-") || /[\s\0]/.test(unit)) throw new Error(`${JSON.stringify(unit)} is not a unit's name`);
return unit;
}
function firstLine(text: string): string {
return text.split("\n").map((l) => l.trim()).find(Boolean) ?? "";
}

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