Author SHA1 Message Date
jschoubben d0b74f1545 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-04 17:32:15 +02:00
80 changed files with 535 additions and 3878 deletions
-7
View File
@@ -376,10 +376,3 @@ module's contribution to `node-power`'s `after-wake` moment, so the module depen
module. `logind.conf.d/power.conf` is the power module's. This laptop's values (suspend on the power module. `logind.conf.d/power.conf` is the power module's. This laptop's values (suspend on the power
key and on the lid in every case) are that module's settings for this machine. The NVIDIA driver's key and on the lid in every case) are that module's settings for this machine. The NVIDIA driver's
sleep drop-ins stay here: they must run inside the sleep transaction, which a contribution cannot. sleep drop-ins stay here: they must run inside the sleep transaction, which a contribution cannot.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
+14 -50
View File
@@ -28,48 +28,8 @@ type KeysReport struct {
// TriggerDir is triggerhappy's directory of trigger files; the module owns one file in it. // TriggerDir is triggerhappy's directory of trigger files; the module owns one file in it.
const TriggerDir = "/etc/triggerhappy/triggers.d" const TriggerDir = "/etc/triggerhappy/triggers.d"
// I3Config is the window manager's configuration, relative to the account's home. This module's lines // I3Fragments are the module's own files in i3's include directory, relative to the account's home.
// are its contribution to node-display-session (novox/hq ADR 0212), placed there by the i3 module var I3Fragments = []string{".config/i3/config.d/10-asus.conf", ".config/i3/config.d/20-g14.conf"}
// under a line that names this module.
const I3Config = ".config/i3/config"
// ModuleName is the line the controller writes before this module's contributed lines.
const ModuleName = "asus-zephyrus-g14"
// ownSection is this module's contributed lines in a placed file: from the line naming it to the next
// line naming another module, a section header, or the file's end.
func ownSection(text string) []string {
var out []string
in := false
for _, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if t == "# "+ModuleName {
in = true
continue
}
if !in {
continue
}
if strings.HasPrefix(t, "####") || (strings.HasPrefix(t, "# ") && isModuleLine(strings.TrimPrefix(t, "# "))) {
break
}
out = append(out, line)
}
return out
}
// isModuleLine is a comment that is one catalogue module's name: lower case, digits and dashes.
func isModuleLine(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
// physicalKeys names the key behind an evdev code, as far as it is known on this model. The media // physicalKeys names the key behind an evdev code, as far as it is known on this model. The media
// codes are the M4 key and Fn+F4/F5 together; which code is which key was not recorded. // codes are the M4 key and Fn+F4/F5 together; which code is which key was not recorded.
@@ -131,22 +91,26 @@ func (m *Machine) Keys() KeysReport {
if home == "" { if home == "" {
r.NotRead = append(r.NotRead, "the module's i3 lines (MESH_OPERATOR_HOME is not set)") r.NotRead = append(r.NotRead, "the module's i3 lines (MESH_OPERATOR_HOME is not set)")
} }
if home != "" { for _, rel := range I3Fragments {
lines := ownSection(m.read(filepath.Join(home, I3Config))) if home == "" {
if len(lines) == 0 { break
r.Warnings = append(r.Warnings, "~/"+I3Config+" holds no lines of this module's: is the i3 module assigned and pushed?")
} }
from := "~/" + I3Config + " (" + ModuleName + "'s contribution)" p := filepath.Join(home, rel)
for _, line := range lines { text := m.read(p)
if text == "" {
r.Warnings = append(r.Warnings, "~/"+rel+" is missing or empty")
continue
}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if g := i3Bind.FindStringSubmatch(line); g != nil { if g := i3Bind.FindStringSubmatch(line); g != nil {
when := "pressed" when := "pressed"
if strings.Contains(g[1], "--release") { if strings.Contains(g[1], "--release") {
when = "released" when = "released"
} }
r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: from}) r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: "~/" + rel})
} else if g := i3Exec.FindStringSubmatch(line); g != nil { } else if g := i3Exec.FindStringSubmatch(line); g != nil {
r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: from}) r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: "~/" + rel})
} }
} }
} }
@@ -33,15 +33,8 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
put(t, f.root, TriggerDir+"/mesh.conf", "# asus-zephyrus-g14\n"+m.triggers()) put(t, f.root, TriggerDir+"/mesh.conf", "# asus-zephyrus-g14\n"+m.triggers())
home := "/home/operator" home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home) t.Setenv("MESH_OPERATOR_HOME", home)
// The i3 module's file as the controller composes it: other modules' lines around this one's. put(t, f.root, home+"/"+I3Fragments[0], content["i3-vendor-keys"])
var own string put(t, f.root, home+"/"+I3Fragments[1], content["i3-model"])
for _, c := range m.Contributions {
if c.Seat == "node-display-session" {
own += c.Content
}
}
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
r := f.machine().Keys() r := f.machine().Keys()
got := map[string]Key{} got := map[string]Key{}
+16 -10
View File
@@ -175,6 +175,22 @@
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf", "path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
"mode": "0644", "mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n" "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
},
{
"id": "i3-vendor-keys",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/10-asus.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"id": "i3-model",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/20-g14.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
} }
], ],
"build": { "build": {
@@ -202,16 +218,6 @@
"seat": "node-hotkeys", "seat": "node-hotkeys",
"kind": "trigger", "kind": "trigger",
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" "content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
} }
], ],
"shell": [ "shell": [
+1 -7
View File
@@ -22,7 +22,7 @@ whenever the node's tool runtime collects the module's tools:
| file | holds | | file | holds |
|---|---| |---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | | `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence | | `managed-settings.json` | the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions | | `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
@@ -58,12 +58,6 @@ Per node or for the whole mesh, through `mesh-controller.settings module=claude-
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the (`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
module's own and cannot be set. Put a person's own servers here, or they stop loading. module's own and cannot be set. Put a person's own servers here, or they stop loading.
- `managed_settings` — keys of the agent's managed settings, in the vendor's `settings.json` shape:
`permissions` (allow, ask, deny), `autoMode` (environment, allow, soft_deny), `env`, hooks and so on.
Managed settings outrank every other scope, so a rule here holds in every session on the node. The
mesh's own keys (`attribution`, `allowAllClaudeAiMcps`, `apiKeyHelper`) are laid last and cannot be
set. A setting layer is replaced whole: setting `managed_settings` without `role` or `mcp_servers`
clears those in that layer.
## On a machine that carried the predecessor ## On a machine that carried the predecessor
@@ -94,40 +94,6 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
} }
} }
func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
settings := Settings{ManagedSettings: map[string]any{
"autoMode": map[string]any{"allow": []any{"merging an approved pull request"}},
"permissions": map[string]any{"deny": []any{"Bash(rm -rf /)"}},
"attribution": map[string]any{"commit": "made by a machine"},
"allowAllClaudeAiMcps": false,
"apiKeyHelper": "/somewhere/else",
}}
read := func(binding *Binding) map[string]any {
var m map[string]any
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil)
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
t.Fatal(err)
}
return m
}
m := read(nil)
if allow := m["autoMode"].(map[string]any)["allow"].([]any); len(allow) != 1 || allow[0] != "merging an approved pull request" {
t.Errorf("the operator's auto mode was not carried: %v", m["autoMode"])
}
if m["permissions"] == nil {
t.Errorf("the operator's permissions were not carried: %v", m)
}
if !reflect.DeepEqual(m["attribution"], map[string]any{"commit": "", "pr": ""}) || m["allowAllClaudeAiMcps"] != true {
t.Errorf("a setting replaced the mesh's own keys: %v", m)
}
if _, ok := m["apiKeyHelper"]; ok {
t.Errorf("a setting named a key-helper the licence did not: %v", m)
}
if helper := read(&Binding{Licence: "api", Kind: "api-key"})["apiKeyHelper"]; helper != "/h" {
t.Errorf("an API-key licence's key-helper was replaced: %v", helper)
}
}
// ---- the credentials file ----------------------------------------------------------------------------- // ---- the credentials file -----------------------------------------------------------------------------
func i64(v int64) *int64 { return &v } func i64(v int64) *int64 { return &v }
@@ -433,31 +399,3 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
t.Fatalf("the key crossed in the clear: %v", sent) t.Fatalf("the key crossed in the clear: %v", sent)
} }
} }
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
t.Fatal(err)
}
// The rotation that came with it: a later token, as a refresh hands one over.
later := func(address string, args any) (json.RawMessage, error) {
asked = append(asked, address)
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
if err != nil {
t.Fatal(err)
}
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
}
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
later, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
}
}
+3 -8
View File
@@ -255,19 +255,14 @@ func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
} }
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is // OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
// fetched when the binding differs from the one applied. A released binding keeps the last token, which // fetched when the generation is newer than the one applied. A released binding keeps the last token,
// lives hours, and says so. // which lives hours, and says so.
//
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
// a node that waited for a number above its own ignored every binding it was sent, its login and the
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) { func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
if b == nil { if b == nil {
return "this node's binding was released; it keeps its last token until it expires", nil return "this node's binding was released; it keeps its last token until it expires", nil
} }
var applied Binding var applied Binding
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence { if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
return "", nil return "", nil
} }
out, err := Pull(p, ask, write) out, err := Pull(p, ask, write)
+4 -15
View File
@@ -10,11 +10,9 @@ package main
// servers the operator declared or registered through this module. Exclusive by // servers the operator declared or registered through this module. Exclusive by
// the vendor's rule — a server not listed here does not load (operator's choice, // the vendor's rule — a server not listed here does not load (operator's choice,
// 2026-10-03). // 2026-10-03).
// managed-settings.json the keys the operator set in this module's `managed_settings` (the agent's // managed-settings.json the mesh's keys only: the repositories' attribution convention, the claude.ai
// permissions and auto mode, say), under the mesh's own keys, which always win: // connectors kept beside the managed servers, and — for an API-key licence only —
// the repositories' attribution convention, the claude.ai connectors kept beside // the key-helper. A person's preferences are theirs.
// the managed servers, and — for an API-key licence only — the key-helper. A
// person's preferences are theirs, in their own settings.
// CLAUDE.md how a session on this mesh works, who this node is, the conventions. // CLAUDE.md how a session on this mesh works, who this node is, the conventions.
import ( import (
@@ -40,8 +38,6 @@ type Facts struct {
type Settings struct { type Settings struct {
Role string `json:"role"` Role string `json:"role"`
MCPServers map[string]map[string]any `json:"mcp_servers"` MCPServers map[string]map[string]any `json:"mcp_servers"`
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
ManagedSettings map[string]any `json:"managed_settings"`
} }
// Binding is the licence this node holds, as it was last applied. // Binding is the licence this node holds, as it was last applied.
@@ -109,14 +105,7 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
} }
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console} servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
managed := map[string]any{} managed := map[string]any{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
for key, value := range settings.ManagedSettings {
managed[key] = value
}
// The mesh's own keys are laid last: a setting never replaces them.
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
managed["allowAllClaudeAiMcps"] = true
delete(managed, "apiKeyHelper")
if binding != nil && binding.Kind == "api-key" { if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath managed["apiKeyHelper"] = helperPath
} }
+1 -1
View File
@@ -69,7 +69,7 @@
"mode": "0600", "mode": "0600",
"owner": "${machine:account}", "owner": "${machine:account}",
"merge": "json", "merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {}\n}\n" "content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
} }
], ],
"build": { "build": {
@@ -49,8 +49,6 @@ type Holdings struct {
Fingerprint string `json:"fingerprint"` Fingerprint string `json:"fingerprint"`
} `json:"refresh"` } `json:"refresh"`
ChangedAt string `json:"changedAt"` ChangedAt string `json:"changedAt"`
// Generation is the binding generation the node last applied (novox/hq issue 243).
Generation int64 `json:"generation"`
} }
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which // BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
@@ -191,17 +189,6 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being // newest first; the first that refreshes is adopted and the rest are settled as skipped without being
// exchanged. Answers what was adopted. // exchanged. Answers what was adopted.
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) { func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
// counts from one again, while every node still holds the number it last applied. The nodes no longer
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
// skipped. So the count is moved past every number reported, before anything here binds.
var highest int64
for _, r := range reports {
highest = max(highest, r.Generation)
}
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
return nil, err
}
byAccount, err := m.CandidatesIn(ctx, reports) byAccount, err := m.CandidatesIn(ctx, reports)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -423,18 +423,3 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
} }
} }
} }
// A store rebuilt after a loss counts generations from one again (novox/hq issue 243): the first look at the
// reports moves the count past every generation a node says it applied, so no binding repeats one.
func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
laptop := mm.login("laptop", "rt-a", true, t0)
laptop.Generation = 16
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
t.Fatal(err)
}
if g := mm.state["laptop"].Generation; g <= 16 {
t.Fatalf("the laptop applied generation 16 and was given %d", g)
}
}
@@ -225,16 +225,6 @@ func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
return err == nil && tag.RowsAffected() == 1, err return err == nil && tag.RowsAffected() == 1, err
} }
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
if generation <= 0 {
return nil
}
// setval with is_called, so the next nextval is generation+1; only ever forward.
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
where not is_called or last_value < $1`, generation)
return err
}
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) { func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1 return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
returning consumer, licence, generation`, licence) returning consumer, licence, generation`, licence)
@@ -70,8 +70,6 @@ type Store interface {
Unbind(ctx context.Context, consumer string) (bool, error) Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence a new generation: what a rotation is to them. // Advance gives every consumer of a licence a new generation: what a rotation is to them.
Advance(ctx context.Context, licence string) ([]Binding, error) Advance(ctx context.Context, licence string) ([]Binding, error)
// GenerationsAbove makes every generation given from now on greater than this one.
GenerationsAbove(ctx context.Context, generation int64) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error) Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
@@ -202,13 +200,6 @@ func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
return ok, nil return ok, nil
} }
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.generation = max(m.generation, generation)
return nil
}
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) { func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
-7
View File
@@ -80,10 +80,3 @@ So clipmenud runs with the module's own `xsel` first on its `PATH`
(`/usr/local/lib/mesh-clipmenu/xsel`). Before a read it asks the selection for its `TARGETS` and goes (`/usr/local/lib/mesh-clipmenu/xsel`). Before a read it asks the selection for its `TARGETS` and goes
ahead only when the selection offers text. It uses `xclip` for that question, which the `xclip` ahead only when the selection offers text. It uses `xclip` for that question, which the `xclip`
module installs on every workstation. module installs on every workstation.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -22,9 +22,7 @@ type manifest struct {
Environment *environment `json:"environment"` Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"` Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"` Resources []map[string]any `json:"resources"`
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. Build struct {
Contributions []contribution `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"` Artifacts []map[string]any `json:"artifacts"`
} `json:"build"` } `json:"build"`
} }
@@ -175,32 +173,3 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
} }
} }
} }
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// i3Lines is what the module contributes to the window manager.
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
}
}
@@ -53,8 +53,8 @@ func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) {
if _, set := m.Environment.Variables["CM_LAUNCHER"]; set { if _, set := m.Environment.Variables["CM_LAUNCHER"]; set {
t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command") t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command")
} }
m.i3LinesAreSource(t, "files/i3/50-clipmenu.conf") m.sameAsSource(t, "i3-bindings", "files/i3/50-clipmenu.conf")
if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") { if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") {
t.Fatalf("%s", c) t.Fatalf("%s", c)
} }
} }
+9 -8
View File
@@ -48,6 +48,14 @@
"package": "rofi-greenclip", "package": "rofi-greenclip",
"absent": true "absent": true
}, },
{
"id": "i3-bindings",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/50-clipmenu.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
},
{ {
"id": "text-only", "id": "text-only",
"type": "file", "type": "file",
@@ -70,12 +78,5 @@
] ]
} }
] ]
}, }
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
}
]
} }
-111
View File
@@ -1,111 +0,0 @@
# dbus
A machine's message bus (novox/hq ADR 0215). This module holds `node-message-bus` on every machine,
servers included, because every machine runs a D-Bus system bus. It owns the bus implementation's
packages and declares its system service running. It publishes what matters about the bus on the
mesh's bus, and serves tools to look at the system bus and at the operator's session bus.
## What it owns
| | |
|---|---|
| package `dbus-broker` | the bus every machine runs |
| package `dbus-broker-units` | `dbus.service` as an alias of `dbus-broker.service`, for the system and for each user |
| package `dbus` | the bus's configuration (`system.conf`, `session.conf`), `dbus.socket`, which starts the bus at boot, and libdbus |
| service `dbus-broker.service` | declared running, with no boot state and no restart or reload trigger (below) |
All four machines were found with dbus-broker 37 behind `dbus.service` and dbus 1.16.2, from the
distribution. One server also has an old `dbus-units` package, an empty package that depends on
`dbus-broker-units`. It is not declared: nothing needs it, and removing it is a choice for its
operator.
## The bus is never restarted live
On 2026-10-04 a full upgrade on a workstation restarted the system bus while the upgrade was still
running. From then on every login hung, sshd answered nothing and the machine's host stopped
reporting, until someone rebooted it at its keyboard. Every program that speaks on the bus (the
service manager, logind, the network manager, the keyring, the power module's sleep lock) holds a
connection to it. A restart takes all of them away at once, and not all of them come back.
So the module never restarts or reloads the bus, for any change. Its service has no `restart-on` and
no `reload-on`, and the host never restarts a service that has neither. A new bus takes effect at
the next boot. `dbus_check` says when the running bus is older than an installed package, which is
the sign that a reboot is due. The distribution's own upgrade can still restart the bus. This rule
covers what the mesh does, not what pacman does.
**Why `state: running` and no `boot`.** `dbus.service` is an alias (`systemctl is-enabled` says
`alias`), so it cannot be declared enabled: the host would run `systemctl enable` and read back
something other than `enabled`. The real unit, `dbus-broker.service`, reads `disabled` on three
machines and `enabled` on one. It needs no enabling. At boot `dbus.socket`, which the `dbus` package
links into `sockets.target`, pulls in `dbus.service`, and `dbus-broker-units` ships that name as a
link to `dbus-broker.service`. On the three machines where it reads `disabled`, enabling it would
only write a second alias link into `/etc`, and a module's apply would change something on a running
machine for no gain. The service is declared so that the mesh knows the bus is the module's and says
so when it is not running. A host finding it stopped would start it, which can only help a machine
whose bus is down. ADR 0215 §1 says "running and enabled". On these machines the package's own
socket link is what makes it start at boot.
**A policy change** a module needs in the future uses the bus's own reload (`dbus-broker.service` is
`Type=notify-reload`), which keeps every connection. No module needs one today (below).
## Events
The watcher runs beside the tools in the same process, on its own connection to the system bus as the
operator's account. It reads only the bus driver's answers, the driver's `NameOwnerChanged` signal and
the bus unit's journal.
| event | when | carries |
|---|---|---|
| `bus.stalled` | the bus does not answer the driver's `GetId` within 3 s, or cannot be reached | the reason |
| `bus.recovered` | a stalled bus answers again | since when it was stalled, and for how long |
| `bus.restarted` | after reconnecting, the bus's id or the driver's pid has changed within the same boot (after a boot both change, and `power` says `booted`) | the old and new id and pid, the unit |
| `service.appeared` | a well-known name appears and is still there 10 s later | the name, the owner's pid, process and unit, whether it is activatable |
| `service.left` | a well-known name is gone and still gone 10 s later | the name and what held it |
| `policy.denied` | the bus logged a policy denial, at most once per 5 minutes | how many since the last one, and up to 5 distinct examples: the refused message's type, sender, destination, path, interface and member |
Unique names (`:1.42`) come and go with every client and are never published. A service restarted
within the 10 s, or one that came and went, publishes nothing. Activatable services that exit when
idle, such as hostnamed, do appear and leave, and their events say `activatable: true`.
**Why traffic never leaves the machine.** The bus carries secrets from the keyring, notification text
and the clipboard. The watcher never becomes a monitor, so it never sees another peer's messages. Its
events are built from names, pids, units and the header fields the broker logs with a denial. The log
line itself is not passed on. The tests hold every event's body to those fields.
Events the mesh's bus does not take wait in order and go out when it answers again, as `power`'s do.
At most 1000 events wait. When there are more, the oldest are dropped and counted.
The watcher's ping is `GetId`, not `org.freedesktop.DBus.Peer.Ping`. The system policy refuses the
peer ping to an account that is not root and logs a denial each time, which the watcher would then
publish.
## Tools
| tool | |
|---|---|
| `dbus_names` | `bus`: system or session. Every well-known name with its owner's pid, process, user and unit, the activatable names that are not running, and the number of connections |
| `dbus_introspect` | `bus`, `service`, `path`. The object's interfaces with their methods, properties (type and access, never values) and signals, and its children. Uses `--auto-start=no`, so looking never starts a service |
| `dbus_monitor` | `bus`, `seconds` (at most 15), optional `match` rule and `names`. The headers of what passed (type, sender, destination, path, interface, member, error name), at most 500. Never a body: each line is decoded into the header alone. On the system bus only root may monitor, so it runs through `sudo -n` |
| `dbus_check` | the packages are installed. `dbus.service` is `dbus-broker.service` and active. The running bus is not older than the installed `dbus-broker` or `dbus` (otherwise a reboot is due). Every activatable service file's `SystemdService` exists. Policy denials in the last hour. The watcher is connected, not stalled, and has nothing stuck |
| `dbus_health` | the round trip of a ping on the watcher's connection, the connection count (from `Debug.Stats` through `sudo -n`, else the unique names), and the bus's unit, pid, start and uptime |
Every command is bounded at 20 s and its output read up to 1 MiB. Lists stop at 500 entries.
`Debug.Stats` is asked only as root: asked as the account it is refused and logged as a denial.
The session bus is the runtime's `DBUS_SESSION_BUS_ADDRESS`, else `$XDG_RUNTIME_DIR/bus`, else
`/run/user/<uid>/bus`. On a machine where the account has no session, the tools say so.
The check notes, without failing, systemd's own services whose `dbus-org.*` alias is missing because
they are not enabled, such as resolved, networkd and homed on the workstations. Activating them fails
by design.
## Assignment
On every machine, as the first holder of `node-message-bus`. The module needs nothing set: no
settings, no secrets, no ports.
## What comes later
ADR 0215 §4: the seat receives nothing yet. Packages ship their own D-Bus policy and service files,
and no module writes one of its own. When one does, that file will be a contribution to this seat,
and this module will list the kind and reload the bus's policy for it, without a restart.
-103
View File
@@ -1,103 +0,0 @@
package main
import (
"context"
"github.com/godbus/dbus/v5"
)
const (
busName = "org.freedesktop.DBus"
busPath = "/org/freedesktop/DBus"
)
// systemBus is the machine's system bus as the watcher uses it, over its own private connection: the
// bus driver's answers and its NameOwnerChanged signal, never another peer's messages.
type systemBus struct {
conn *dbus.Conn
out chan NameChange
}
// DialSystemBus connects to the system bus as this account and listens for names changing owner.
func DialSystemBus() (Bus, error) {
conn, err := dbus.SystemBusPrivate()
if err != nil {
return nil, err
}
if err := conn.Auth(nil); err != nil {
conn.Close()
return nil, err
}
if err := conn.Hello(); err != nil {
conn.Close()
return nil, err
}
if err := conn.AddMatchSignal(dbus.WithMatchSender(busName), dbus.WithMatchInterface(busName),
dbus.WithMatchMember("NameOwnerChanged")); err != nil {
conn.Close()
return nil, err
}
raw := make(chan *dbus.Signal, 256)
conn.Signal(raw)
b := &systemBus{conn: conn, out: make(chan NameChange, 256)}
go func() {
// The signal channel closes when the connection is lost; so does this one, which is how the
// watcher learns the bus went away.
defer close(b.out)
for {
select {
case s, open := <-raw:
if !open {
return
}
if s.Name != busName+".NameOwnerChanged" || len(s.Body) != 3 {
continue
}
name, _ := s.Body[0].(string)
old, _ := s.Body[1].(string)
nw, _ := s.Body[2].(string)
b.out <- NameChange{Name: name, Old: old, New: nw}
case <-conn.Context().Done():
return
}
}
}()
return b, nil
}
func (b *systemBus) driver() dbus.BusObject { return b.conn.Object(busName, busPath) }
// Ping asks the bus driver its id. Not org.freedesktop.DBus.Peer.Ping: dbus-broker's system policy
// refuses that to an account that is not root, and logs the refusal as a denial.
func (b *systemBus) Ping(ctx context.Context) error {
var id string
return b.driver().CallWithContext(ctx, busName+".GetId", 0).Store(&id)
}
func (b *systemBus) ID(ctx context.Context) (string, error) {
var id string
err := b.driver().CallWithContext(ctx, busName+".GetId", 0).Store(&id)
return id, err
}
func (b *systemBus) PID(ctx context.Context, name string) (uint32, error) {
var pid uint32
err := b.driver().CallWithContext(ctx, busName+".GetConnectionUnixProcessID", 0, name).Store(&pid)
return pid, err
}
func (b *systemBus) Names(ctx context.Context) ([]string, error) {
var names []string
err := b.driver().CallWithContext(ctx, busName+".ListNames", 0).Store(&names)
return names, err
}
func (b *systemBus) Activatable(ctx context.Context) ([]string, error) {
var names []string
err := b.driver().CallWithContext(ctx, busName+".ListActivatableNames", 0).Store(&names)
return names, err
}
func (b *systemBus) Changes() <-chan NameChange { return b.out }
func (b *systemBus) Close() { b.conn.Close() }
-290
View File
@@ -1,290 +0,0 @@
package main
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// Packages are the bus implementation's packages, as the manifest declares them: dbus-broker (the
// bus), its units (the dbus.service alias), and the reference package, which ships the bus's
// configuration, the socket that starts it at boot, and libdbus.
var Packages = []string{"dbus", "dbus-broker", "dbus-broker-units"}
// RunningPackages are the packages whose files the running bus loaded when it started: a newer one
// installed since takes effect only at the next boot (novox/hq ADR 0215 §2).
var RunningPackages = []string{"dbus-broker", "dbus"}
// SystemUnit is the bus's real unit; dbus.service is its alias.
const SystemUnit = "dbus-broker.service"
// ServiceDirs are where activatable system services are described.
var ServiceDirs = []string{"/usr/share/dbus-1/system-services", "/usr/local/share/dbus-1/system-services",
"/usr/lib/dbus-1/system-services"}
// Package is one installed package as the package manager's local database records it.
type Package struct {
Name string
Version string
Installed time.Time
}
// ParseDesc reads one package's desc file in the local database.
func ParseDesc(desc string) Package {
var p Package
lines := strings.Split(desc, "\n")
for i := 0; i+1 < len(lines); i++ {
v := strings.TrimSpace(lines[i+1])
switch strings.TrimSpace(lines[i]) {
case "%NAME%":
p.Name = v
case "%VERSION%":
p.Version = v
case "%INSTALLDATE%":
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
p.Installed = time.Unix(n, 0)
}
}
}
return p
}
// InstalledPackage reads a package from the local database, without running the package manager.
func (m *Machine) InstalledPackage(name string) (Package, bool) {
for _, d := range m.glob("/var/lib/pacman/local/" + name + "-*/desc") {
if p := ParseDesc(m.read(d) + "\n"); p.Name == name {
return p, true
}
}
return Package{}, false
}
// ParseShow reads `systemctl show` blocks: one map per unit, in the order asked.
func ParseShow(out string) []map[string]string {
var blocks []map[string]string
cur := map[string]string{}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
if len(cur) > 0 {
blocks = append(blocks, cur)
cur = map[string]string{}
}
continue
}
if k, v, ok := strings.Cut(line, "="); ok {
cur[k] = v
}
}
if len(cur) > 0 {
blocks = append(blocks, cur)
}
return blocks
}
// unixStamp reads systemd's "@<seconds>" timestamp.
func unixStamp(s string) (time.Time, bool) {
n, err := strconv.ParseInt(strings.TrimPrefix(s, "@"), 10, 64)
if err != nil || n == 0 {
return time.Time{}, false
}
return time.Unix(n, 0), true
}
// BusUnit is the system bus's unit as the service manager has it.
type BusUnit struct {
Unit string `json:"unit"`
Active string `json:"active"`
PID uint32 `json:"pid,omitempty"`
Since time.Time `json:"-"`
Started string `json:"started,omitempty"`
}
// SystemBusUnit asks the service manager about the system bus through its alias, so the answer is
// the implementation's unit whichever it is.
func (m *Machine) SystemBusUnit(ctx context.Context) (BusUnit, error) {
out, err := m.Run(ctx, "systemctl", "show", "dbus.service", "-p", "Id,ActiveState,MainPID,ActiveEnterTimestamp",
"--timestamp=unix")
if err != nil {
return BusUnit{}, err
}
b := ParseShow(out)
if len(b) == 0 {
return BusUnit{}, fmt.Errorf("systemctl show answered nothing for dbus.service")
}
u := BusUnit{Unit: b[0]["Id"], Active: b[0]["ActiveState"], PID: parsePID(b[0]["MainPID"])}
if t, ok := unixStamp(b[0]["ActiveEnterTimestamp"]); ok {
u.Since, u.Started = t, t.UTC().Format(time.RFC3339)
}
return u, nil
}
// ServiceFile is one activatable system service's description.
type ServiceFile struct {
File string
Name string
Unit string
}
// ParseServiceFile reads the Name and SystemdService of a D-Bus service file.
func ParseServiceFile(file, content string) ServiceFile {
s := ServiceFile{File: file}
for _, line := range strings.Split(content, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "Name":
s.Name = strings.TrimSpace(v)
case "SystemdService":
s.Unit = strings.TrimSpace(v)
}
}
return s
}
// Check is one thing the module expects of the machine.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// RebootDue says, per package the running bus loaded, whether a newer one was installed after the bus
// started: the bus is never restarted live, so that package waits for a boot.
func RebootDue(started time.Time, pkgs []Package) (bool, string) {
var newer []string
for _, p := range pkgs {
if !p.Installed.IsZero() && p.Installed.After(started) {
newer = append(newer, fmt.Sprintf("%s %s installed %s", p.Name, p.Version, p.Installed.UTC().Format(time.RFC3339)))
}
}
if len(newer) == 0 {
return false, "the running bus started " + started.UTC().Format(time.RFC3339) + ", after every package it loaded was installed"
}
return true, "the running bus started " + started.UTC().Format(time.RFC3339) + " and is older than " +
strings.Join(newer, ", ") + ": a reboot is due (the bus is never restarted live, novox/hq ADR 0215)"
}
// Check says what this module expects and whether the machine meets it.
func (m *Machine) Check(ctx context.Context, w *Watcher) map[string]any {
var checks []Check
var notes []string
add := func(name string, ok bool, format string, args ...any) {
checks = append(checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, args...)})
}
var running []Package
for _, name := range Packages {
p, ok := m.InstalledPackage(name)
add("package "+name, ok, "installed: %v %s", ok, p.Version)
for _, r := range RunningPackages {
if ok && r == name {
running = append(running, p)
}
}
}
unit, err := m.SystemBusUnit(ctx)
if err != nil {
add("system bus", false, "%v", err)
} else {
add("system bus", unit.Unit == SystemUnit && unit.Active == "active",
"dbus.service is %s, %s, pid %d, since %s (want %s active)", orWord(unit.Unit, "unknown"),
orWord(unit.Active, "unknown"), unit.PID, orWord(unit.Started, "unknown"), SystemUnit)
if !unit.Since.IsZero() {
due, detail := RebootDue(unit.Since, running)
add("running bus is the installed one", !due, "%s", detail)
}
}
var files []ServiceFile
for _, dir := range ServiceDirs {
for _, f := range m.glob(dir + "/*.service") {
if s := ParseServiceFile(f, m.read(f)); s.Unit != "" {
files = append(files, s)
}
}
}
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
if len(files) > 0 {
args := []string{"show", "-p", "Id,LoadState"}
for _, f := range files {
args = append(args, f.Unit)
}
out, err := m.Run(ctx, "systemctl", args...)
blocks := ParseShow(out)
switch {
case err != nil:
add("activatable services", false, "systemctl show: %v", err)
case len(blocks) != len(files):
add("activatable services", false, "systemctl show answered %d units for %d service files", len(blocks), len(files))
default:
var broken, disabled []string
for i, f := range files {
if blocks[i]["LoadState"] != "not-found" {
continue
}
// systemd's own bus services are reached through a dbus-org.* alias that exists only
// while the service is enabled: a disabled one is a choice, not a fault.
if strings.HasPrefix(f.Unit, "dbus-org.") {
disabled = append(disabled, f.Name+" → "+f.Unit)
} else {
broken = append(broken, f.Name+" → "+f.Unit+" ("+f.File+")")
}
}
add("activatable services", len(broken) == 0, "%d service files; whose unit does not exist: %s",
len(files), orWord(strings.Join(broken, ", "), "none"))
if len(disabled) > 0 {
notes = append(notes, "activation fails for "+strings.Join(disabled, ", ")+
": the service is not enabled, so its dbus-org alias does not exist")
}
}
}
denials, _, err := m.Denials(ctx, "", m.Now().Add(-time.Hour))
if err != nil {
add("policy denials in the last hour", false, "reading the journal: %v", err)
} else {
seen := map[string]bool{}
var ex []string
for _, d := range denials {
k := strings.TrimSpace(d.Type + " " + d.Interface + "." + d.Member + " to " + d.Destination)
if !seen[k] && len(ex) < DenialExamples {
seen[k] = true
ex = append(ex, k)
}
}
add("policy denials in the last hour", len(denials) == 0, "%d: %s", len(denials), orWord(strings.Join(ex, "; "), "none"))
}
if a, err := m.SessionAddress(); err == nil {
notes = append(notes, "this account's session bus: "+a)
} else {
notes = append(notes, err.Error())
}
if w != nil {
s := w.Snapshot()
add("watcher", s.Connected && !s.Stalled, "connected %v, stalled %v%s, last ping %.1f ms, %d well-known names",
s.Connected, s.Stalled, orNote(s.StallReason), s.LastPingMS, s.Services)
add("events reach the mesh's bus", s.Pending == 0 && s.Problem == "", "%d event(s) waiting, %d dropped%s",
s.Pending, s.Dropped, orNote(s.Problem))
}
failing := 0
for _, c := range checks {
if !c.OK {
failing++
}
}
return map[string]any{"checks": checks, "failing": failing, "notes": notes}
}
func orNote(s string) string {
if s == "" {
return ""
}
return " (" + s + ")"
}
-91
View File
@@ -1,91 +0,0 @@
package main
import (
"context"
"encoding/json"
"strings"
"time"
)
// CountConnections reads the bus driver's Debug.Stats answer (`busctl call … GetStats --json=short`):
// dbus-broker lists one accounting entry per peer, the reference daemon says ActiveConnections.
func CountConnections(out string) (int, bool) {
var reply struct {
Data []map[string]struct {
Data json.RawMessage `json:"data"`
} `json:"data"`
}
if json.Unmarshal([]byte(strings.TrimSpace(out)), &reply) != nil || len(reply.Data) != 1 {
return 0, false
}
if v, ok := reply.Data[0]["org.bus1.DBus.Debug.Stats.PeerAccounting"]; ok {
var peers []json.RawMessage
if json.Unmarshal(v.Data, &peers) == nil {
return len(peers), true
}
}
if v, ok := reply.Data[0]["ActiveConnections"]; ok {
var n int
if json.Unmarshal(v.Data, &n) == nil {
return n, true
}
}
return 0, false
}
// CountUniqueNames counts the connections among ListNames' answer (`busctl call … ListNames`).
func CountUniqueNames(out string) (int, bool) {
var reply struct {
Data [][]string `json:"data"`
}
if json.Unmarshal([]byte(strings.TrimSpace(out)), &reply) != nil || len(reply.Data) != 1 {
return 0, false
}
n := 0
for _, name := range reply.Data[0] {
if strings.HasPrefix(name, ":") {
n++
}
}
return n, true
}
// Health is the system bus's health now: a ping through the watcher's connection, how many
// connections the bus has, and how long it has run.
func (m *Machine) Health(ctx context.Context, w *Watcher) map[string]any {
h := map[string]any{"bus": "system"}
if w != nil {
took, err := w.PingNow()
if err != nil {
h["ping"] = "no answer: " + err.Error()
} else {
h["ping_ms"] = float64(took.Microseconds()) / 1000
}
h["watcher"] = w.Snapshot()
}
// Debug.Stats is root's on the system bus; asked as the account it is refused and logged as a
// denial, which the watcher would then publish. So it is asked through sudo -n or not at all.
call := []string{"busctl", "--system", "--json=short", "--no-pager", "call", busName, busPath}
if out, err := m.privileged(ctx, call[0], append(call[1:], busName+".Debug.Stats", "GetStats")...); err == nil {
if n, ok := CountConnections(out); ok {
h["connections"], h["connections_from"] = n, "the bus's Debug.Stats"
}
}
if _, ok := h["connections"]; !ok {
if out, err := m.Run(ctx, call[0], append(call[1:], busName, "ListNames")...); err == nil {
if n, ok := CountUniqueNames(out); ok {
h["connections"], h["connections_from"] = n, "the unique names on the bus (Debug.Stats needs sudo -n)"
}
}
}
if u, err := m.SystemBusUnit(ctx); err != nil {
h["unit"] = err.Error()
} else {
h["unit"], h["active"], h["pid"] = u.Unit, u.Active, u.PID
if !u.Since.IsZero() {
h["started"] = u.Started
h["uptime"] = m.Now().Sub(u.Since).Round(time.Second).String()
}
}
return h
}
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"regexp"
"strings"
"github.com/godbus/dbus/v5/introspect"
)
// A bus name and an object path as the specification allows them; anything else is refused before
// it reaches busctl, so no argument is ever taken for an option.
var (
busNameRE = regexp.MustCompile(`^(:[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+|[A-Za-z_-][A-Za-z0-9_-]*(\.[A-Za-z_-][A-Za-z0-9_-]*)+)$`)
objectPathRE = regexp.MustCompile(`^/([A-Za-z0-9_]+(/[A-Za-z0-9_]+)*)?$`)
)
// Member is one method or signal, with its arguments as "name type".
type Member struct {
Name string `json:"name"`
In []string `json:"in,omitempty"`
Out []string `json:"out,omitempty"`
Args []string `json:"args,omitempty"`
}
// Property is one property's name, type and access, never its value: a value can be anything a
// service holds, and reading it is a call of its own.
type Property struct {
Name string `json:"name"`
Type string `json:"type"`
Access string `json:"access"`
}
// Interface is one interface of an object.
type Interface struct {
Name string `json:"name"`
Methods []Member `json:"methods,omitempty"`
Properties []Property `json:"properties,omitempty"`
Signals []Member `json:"signals,omitempty"`
}
// Object is dbus_introspect's answer.
type Object struct {
Bus string `json:"bus"`
Service string `json:"service"`
Path string `json:"path"`
Interfaces []Interface `json:"interfaces"`
Children []string `json:"children,omitempty"`
}
// ParseIntrospection reads `busctl call … Introspect --json=short` ({"type":"s","data":["<xml>"]}).
func ParseIntrospection(out string) (introspect.Node, error) {
var reply struct {
Data []string `json:"data"`
}
var node introspect.Node
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &reply); err != nil || len(reply.Data) != 1 {
return node, fmt.Errorf("the introspection answer is not a single string")
}
if err := xml.Unmarshal([]byte(reply.Data[0]), &node); err != nil {
return node, fmt.Errorf("the introspection document does not parse: %w", err)
}
return node, nil
}
// Shape turns an introspection document into the tool's answer.
func Shape(bus, service, path string, node introspect.Node) Object {
o := Object{Bus: bus, Service: service, Path: path, Interfaces: []Interface{}}
for _, i := range node.Interfaces {
iface := Interface{Name: i.Name}
for _, m := range i.Methods {
mem := Member{Name: m.Name}
for _, a := range m.Args {
s := strings.TrimSpace(a.Name + " " + a.Type)
if a.Direction == "out" {
mem.Out = append(mem.Out, s)
} else {
mem.In = append(mem.In, s)
}
}
iface.Methods = append(iface.Methods, mem)
}
for _, p := range i.Properties {
iface.Properties = append(iface.Properties, Property{Name: p.Name, Type: p.Type, Access: p.Access})
}
for _, s := range i.Signals {
mem := Member{Name: s.Name}
for _, a := range s.Args {
mem.Args = append(mem.Args, strings.TrimSpace(a.Name+" "+a.Type))
}
iface.Signals = append(iface.Signals, mem)
}
o.Interfaces = append(o.Interfaces, iface)
}
for _, c := range node.Children {
if len(o.Children) >= AnswerCap {
break
}
o.Children = append(o.Children, c.Name)
}
return o
}
// Introspect asks one object what it offers, as this account, without starting a service that is not
// running (--auto-start=no): looking must not change what runs.
func (m *Machine) Introspect(ctx context.Context, bus, service, path string) (Object, error) {
if !busNameRE.MatchString(service) {
return Object{}, fmt.Errorf("%q is not a bus name", service)
}
if path == "" {
path = "/"
}
if !objectPathRE.MatchString(path) {
return Object{}, fmt.Errorf("%q is not an object path", path)
}
args, err := m.busArgs(bus)
if err != nil {
return Object{}, err
}
out, err := m.Run(ctx, "busctl", append(args, "--json=short", "--no-pager", "--auto-start=no", "call",
service, path, "org.freedesktop.DBus.Introspectable", "Introspect")...)
if err != nil {
return Object{}, err
}
node, err := ParseIntrospection(out)
if err != nil {
return Object{}, err
}
return Shape(orWord(bus, "system"), service, path, node), nil
}
-114
View File
@@ -1,114 +0,0 @@
package main
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
)
// BusUnits are the system bus's units as the journal knows them: dbus-broker's own name, and the
// alias every implementation answers to.
var BusUnits = []string{"dbus-broker.service", "dbus.service"}
// JournalLines is the most journal entries one read takes.
const JournalLines = 2000
// Denial is one policy denial as the bus logged it: the header of the message it refused, never its
// body, which the bus does not log either.
type Denial struct {
At string `json:"at"`
Action string `json:"action,omitempty"`
Type string `json:"type,omitempty"`
Sender string `json:"sender,omitempty"`
Destination string `json:"destination,omitempty"`
Path string `json:"path,omitempty"`
Interface string `json:"interface,omitempty"`
Member string `json:"member,omitempty"`
Policy string `json:"policy,omitempty"`
}
// key is what makes two denials the same example: who was refused what, ignoring the sender's unique
// name, which differs at every connection.
func (d Denial) key() string {
return d.Action + "|" + d.Type + "|" + d.Destination + "|" + d.Interface + "|" + d.Member
}
// journalEntry is the part of a journal entry the module reads. MESSAGE is read only to recognise a
// denial; it is never answered or published.
type journalEntry struct {
Cursor string `json:"__CURSOR"`
Realtime string `json:"__REALTIME_TIMESTAMP"`
Message any `json:"MESSAGE"`
Action string `json:"DBUS_BROKER_TRANSMIT_ACTION"`
Type string `json:"DBUS_BROKER_MESSAGE_TYPE"`
Sender string `json:"DBUS_BROKER_SENDER_UNIQUE_NAME"`
Destination string `json:"DBUS_BROKER_MESSAGE_DESTINATION"`
Path string `json:"DBUS_BROKER_MESSAGE_PATH"`
Interface string `json:"DBUS_BROKER_MESSAGE_INTERFACE"`
Member string `json:"DBUS_BROKER_MESSAGE_MEMBER"`
Policy string `json:"DBUS_BROKER_POLICY_TYPE"`
}
// IsDenial is whether a bus's log line is a policy denial: dbus-broker's "A security policy denied",
// or the reference daemon's "Rejected send message".
func IsDenial(message string) bool {
return strings.Contains(message, "security policy denied") || strings.Contains(message, "Rejected send message") ||
strings.Contains(message, "Rejected receive message")
}
// ParseDenials reads journalctl's JSON lines and answers the denials among them and the last cursor.
func ParseDenials(out string) ([]Denial, string) {
var got []Denial
cursor := ""
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || line[0] != '{' {
continue
}
var e journalEntry
if json.Unmarshal([]byte(line), &e) != nil {
continue
}
if e.Cursor != "" {
cursor = e.Cursor
}
msg, _ := e.Message.(string) // a binary MESSAGE comes as an array of bytes and is no denial
if !IsDenial(msg) {
continue
}
at := ""
if us, err := strconv.ParseInt(e.Realtime, 10, 64); err == nil {
at = time.UnixMicro(us).UTC().Format(time.RFC3339)
}
got = append(got, Denial{At: at, Action: e.Action, Type: e.Type, Sender: e.Sender,
Destination: e.Destination, Path: e.Path, Interface: e.Interface, Member: e.Member, Policy: e.Policy})
}
return got, cursor
}
func journalArgs() []string {
args := []string{"--no-pager", "-o", "json", "-n", strconv.Itoa(JournalLines)}
for _, u := range BusUnits {
args = append(args, "-u", u)
}
return args
}
// Denials is the watcher's Journal on this machine: journalctl as the operator's account, which
// reads the system journal through its group.
func (m *Machine) Denials(ctx context.Context, after string, since time.Time) ([]Denial, string, error) {
args := journalArgs()
if after != "" {
args = append(args, "--after-cursor", after)
} else {
args = append(args, "--since", "@"+strconv.FormatInt(since.Unix(), 10))
}
out, err := m.Run(ctx, "journalctl", args...)
if err != nil {
return nil, "", err
}
d, cursor := ParseDenials(out)
return d, cursor, nil
}
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// Run on a real machine with MESH_LIVE=1: every tool reads this machine's buses, and the watcher's
// connection answers. Nothing is changed.
func TestLiveTools(t *testing.T) {
if os.Getenv("MESH_LIVE") == "" {
t.Skip("set MESH_LIVE=1 on a machine with a system bus")
}
ctx := context.Background()
m := Here()
b, err := DialSystemBus()
if err != nil {
t.Fatal(err)
}
defer b.Close()
if err := b.Ping(ctx); err != nil {
t.Fatal(err)
}
id, _ := b.ID(ctx)
pid, _ := b.PID(ctx, busName)
t.Logf("bus %s, driver pid %d in %s", id, pid, m.UnitOf(pid))
show := func(name string, v any, err error) {
raw, _ := json.Marshal(v)
if len(raw) > 1500 {
raw = append(raw[:1500], "…"...)
}
t.Logf("%s: %v\n%s", name, err, raw)
}
n, err := m.Names(ctx, "system")
show("names system", n, err)
n, err = m.Names(ctx, "session")
show("names session", n, err)
o, err := m.Introspect(ctx, "system", "org.freedesktop.login1", "/org/freedesktop/login1")
show("introspect", o, err)
w, err := m.Monitor(ctx, "system", 2, "", nil)
show("monitor", w, err)
show("health", m.Health(ctx, nil), nil)
show("check", m.Check(ctx, nil), nil)
}
// Run with MESH_LIVE=1: the watcher connects, pings and baselines the names, and says nothing.
func TestLiveWatcher(t *testing.T) {
if os.Getenv("MESH_LIVE") == "" {
t.Skip("set MESH_LIVE=1 on a machine with a system bus")
}
m := Here()
var said []string
w := NewWatcher(m, func(e string, b any) error { said = append(said, e); return nil }, DialSystemBus, m.Denials)
w.state = filepath.Join(t.TempDir(), "bus")
ctx, cancel := context.WithTimeout(context.Background(), PingEvery+2*time.Second)
defer cancel()
w.Run(ctx)
s := w.Snapshot()
t.Logf("%+v said %v", s, said)
if s.BusID == "" || s.LastPingAt == "" || s.Stalled || s.Services == 0 {
t.Fatalf("%+v", s)
}
}
-247
View File
@@ -1,247 +0,0 @@
package main
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// CommandTimeout bounds every command a tool runs: a bus that hangs must cost a tool call twenty
// seconds, never the runtime's thirty.
const CommandTimeout = 20 * time.Second
// ReadCap is the most of one command's output the module reads. An introspection document of the
// service manager is about 100 KiB; nothing the module asks is near a mebibyte.
const ReadCap = 1024 * 1024
// AnswerCap is the most entries a tool answers in one list (names, messages, denials).
const AnswerCap = 500
// Runner runs one command and answers its standard output. Injected, so every tool is tested against
// recorded answers rather than this machine's bus.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Streamer runs one command for at most the context's time and hands each line of its output to
// line, which says whether it wants more. The end of the time is the normal end, not an error.
// Injected, so dbus_monitor is tested without a bus.
type Streamer func(ctx context.Context, line func(string) bool, name string, args ...string) error
// ExecRunner runs a command, bounded by CommandTimeout. A failure carries what it said on stderr.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
out := capped(stdout.String(), ReadCap)
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
}
if err != nil {
said := strings.TrimSpace(stderr.String())
if said == "" {
said = strings.TrimSpace(stdout.String())
}
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, capped(said, 2048))
}
return out, nil
}
// ExecStreamer runs a command until the context ends, line by line. A line longer than ReadCap is
// skipped, never held: a monitored message's line carries its body, which the module never keeps.
func ExecStreamer(ctx context.Context, line func(string) bool, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
// A terminate, which sudo passes on to what it runs; a kill would leave a root monitor behind.
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 2 * time.Second
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
r := bufio.NewReaderSize(out, 64*1024)
var cur []byte
tooLong := false
for {
chunk, isPrefix, err := r.ReadLine()
if err != nil {
break
}
if !tooLong {
cur = append(cur, chunk...)
if len(cur) > ReadCap {
cur, tooLong = cur[:0], true
}
}
if isPrefix {
continue
}
if !tooLong && !line(string(cur)) {
break
}
cur, tooLong = cur[:0], false
}
_ = cmd.Process.Signal(syscall.SIGTERM)
werr := cmd.Wait()
if ctx.Err() != nil {
return nil // the time ran out: the normal end of a bounded watch
}
if werr != nil && stderr.Len() > 0 {
return fmt.Errorf("%s: %w: %s", name, werr, capped(strings.TrimSpace(stderr.String()), 2048))
}
return nil
}
func capped(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "\n… (cut)"
}
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree),
// a way to run commands, a way to stream one, and the environment the runtime gave it.
type Machine struct {
Root string
Run Runner
Stream Streamer
Env func(string) string
UID int
Now func() time.Time
}
// Here is the machine this process runs on.
func Here() *Machine {
return &Machine{Root: "/", Run: ExecRunner, Stream: ExecStreamer, Env: os.Getenv, UID: os.Getuid(), Now: time.Now}
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
func (m *Machine) read(p string) string {
b, err := os.ReadFile(m.path(p))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (m *Machine) exists(p string) bool {
_, err := os.Stat(m.path(p))
return err == nil
}
func (m *Machine) glob(pattern string) []string {
got, _ := filepath.Glob(m.path(pattern))
out := make([]string, 0, len(got))
for _, g := range got {
rel, err := filepath.Rel(m.Root, g)
if err != nil {
continue
}
out = append(out, "/"+rel)
}
return out
}
// privileged runs a command as root without asking for a password (sudo -n), as the other modules'
// tools do: the runtime runs as the operator's account, and watching the system bus is root's.
func (m *Machine) privileged(ctx context.Context, name string, args ...string) (string, error) {
return m.Run(ctx, "sudo", append([]string{"-n", name}, args...)...)
}
// Buses are the two buses a tool can be pointed at.
var Buses = []string{"system", "session"}
// ErrNoSession is the answer on a machine where this account has no session bus: the servers.
var ErrNoSession = errors.New("no session bus for this account on this machine")
// SessionAddress is the account's session bus: the runtime's DBUS_SESSION_BUS_ADDRESS, else the
// user manager's socket under XDG_RUNTIME_DIR or /run/user/<uid>. Only a socket that exists counts.
func (m *Machine) SessionAddress() (string, error) {
if a := m.Env("DBUS_SESSION_BUS_ADDRESS"); strings.HasPrefix(a, "unix:path=") {
p := strings.TrimPrefix(a, "unix:path=")
if i := strings.IndexByte(p, ','); i >= 0 {
p = p[:i]
}
if m.exists(p) {
return a, nil
}
} else if a != "" {
return a, nil // an abstract or other address: taken as given
}
dir := m.Env("XDG_RUNTIME_DIR")
if dir == "" {
dir = "/run/user/" + strconv.Itoa(m.UID)
}
if p := dir + "/bus"; m.exists(p) {
return "unix:path=" + p, nil
}
return "", fmt.Errorf("%w (no socket at %s/bus)", ErrNoSession, dir)
}
// busArgs are busctl's words for one bus.
func (m *Machine) busArgs(bus string) ([]string, error) {
switch bus {
case "", "system":
return []string{"--system"}, nil
case "session":
a, err := m.SessionAddress()
if err != nil {
return nil, err
}
return []string{"--address=" + a}, nil
}
return nil, fmt.Errorf("bus is system or session, not %q", bus)
}
// UnitOf is the systemd unit a process runs in, from its cgroup: readable for any process.
func (m *Machine) UnitOf(pid uint32) string {
if pid == 0 {
return ""
}
return UnitFromCgroup(m.read(fmt.Sprintf("/proc/%d/cgroup", pid)))
}
// ProcessName is a process's command name.
func (m *Machine) ProcessName(pid uint32) string {
if pid == 0 {
return ""
}
return m.read(fmt.Sprintf("/proc/%d/comm", pid))
}
// UnitFromCgroup is the innermost service, socket or scope in a cgroup v2 path.
func UnitFromCgroup(cgroup string) string {
for _, line := range strings.Split(cgroup, "\n") {
if !strings.HasPrefix(line, "0::") {
continue
}
parts := strings.Split(strings.TrimPrefix(line, "0::"), "/")
for i := len(parts) - 1; i >= 0; i-- {
p := parts[i]
if strings.HasSuffix(p, ".service") || strings.HasSuffix(p, ".scope") {
return p
}
}
}
return ""
}
// BootID is this boot's id: a bus that changed across a boot did not restart, the machine did.
func (m *Machine) BootID() string { return m.read("/proc/sys/kernel/random/boot_id") }
-26
View File
@@ -1,26 +0,0 @@
// The dbus module's Go bundle (novox/hq ADR 0215): the holder of node-message-bus. One process the
// node's runtime launches as the operator's account, serving the module's tools over MCP on stdio and
// running its watcher beside them, which publishes what matters about the system bus, never its
// traffic.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
m := Here()
w := NewWatcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) },
DialSystemBus, m.Denials)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
if err := stdio.Serve("", Tools(m, w)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -1,159 +0,0 @@
package main
// The dbus module's shape (novox/hq ADR 0215): it holds node-message-bus, owns the bus's packages,
// declares the bus running and never restarts or reloads it, and publishes only its curated events.
import (
"encoding/json"
"os"
"reflect"
"regexp"
"sort"
"strings"
"testing"
)
type manifestShape struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Claims []map[string]any `json:"claims"`
Emits []string `json:"emits"`
Consumes []string `json:"consumes"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func manifest(t *testing.T) (manifestShape, string) {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifestShape
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m, string(raw)
}
func TestItHoldsTheMessageBusSeatWithOnlyWhatItNeeds(t *testing.T) {
m, _ := manifest(t)
if m.Module != "dbus" || len(m.Claims) != 1 || m.Claims[0]["name"] != "node-message-bus" || m.Claims[0]["scope"] != "node" {
t.Fatalf("claims: %+v", m.Claims)
}
if _, serves := m.Claims[0]["serves"]; serves {
t.Error("the seat receives nothing yet (ADR 0215 §4) and serves no verbs")
}
if !reflect.DeepEqual(m.Capabilities, []string{"package-manager", "service-manager"}) {
t.Fatalf("capabilities: %v", m.Capabilities)
}
}
func TestItOwnsTheBusImplementationsPackages(t *testing.T) {
m, _ := manifest(t)
var pkgs []string
for _, r := range m.Resources {
if r["type"] == "package" {
pkgs = append(pkgs, r["package"].(string))
if r["absent"] == true {
t.Errorf("%v is declared absent", r["package"])
}
}
}
sort.Strings(pkgs)
if !reflect.DeepEqual(pkgs, Packages) {
t.Fatalf("packages %v, the check reads %v", pkgs, Packages)
}
}
// TestTheBusIsNeverRestartedOrReloaded is ADR 0215 §2: the bus is declared running, with no trigger
// that would restart or reload it, and no boot state: dbus.service is an alias and dbus-broker.service
// is started at boot through dbus.socket, so enabling would write an alias link the machines do not
// have today.
func TestTheBusIsNeverRestartedOrReloaded(t *testing.T) {
m, raw := manifest(t)
if strings.Contains(raw, "restart-on") || strings.Contains(raw, "reload-on") {
t.Fatal("the manifest carries a restart or reload trigger")
}
var services []map[string]any
for _, r := range m.Resources {
if r["type"] == "service" {
services = append(services, r)
}
}
if len(services) != 1 {
t.Fatalf("services: %v", services)
}
s := services[0]
if s["unit"] != SystemUnit || s["state"] != "running" {
t.Fatalf("%v", s)
}
if _, ok := s["boot"]; ok {
t.Fatal("a boot state on the bus would have the host enable it")
}
for k := range s {
if k != "id" && k != "type" && k != "unit" && k != "state" {
t.Errorf("the bus's service carries %q", k)
}
}
}
func TestItEmitsTheCuratedEventsAndConsumesNothing(t *testing.T) {
m, _ := manifest(t)
if !reflect.DeepEqual(m.Emits, Emits) {
t.Fatalf("emits %v, the watcher publishes %v", m.Emits, Emits)
}
if len(m.Consumes) != 0 {
t.Fatalf("consumes %v", m.Consumes)
}
}
func TestToolsAreTheManifests(t *testing.T) {
m, _ := manifest(t)
served := map[string]bool{}
for _, tool := range Tools(testMachine(t, nil), nil) {
if served[tool.Name] {
t.Errorf("%s is served twice", tool.Name)
}
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "dbus_") {
t.Errorf("%s is not named dbus_<verb>", tool.Name)
}
}
for _, want := range m.Tools {
if !served[want] {
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
}
delete(served, want)
}
for extra := range served {
t.Errorf("the bundle serves %s, which the manifest does not list", extra)
}
}
func TestTheBundleIsTheBuildersShape(t *testing.T) {
m, _ := manifest(t)
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
a := m.Build.Artifacts[0]
for k, v := range map[string]string{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/dbus-tools", "binary": "dbus-tools"} {
if a[k] != v {
t.Errorf("%s is %v, want %s", k, a[k], v)
}
}
}
// TestNothingInstallationSpecific holds the manifest to the catalogue's rule: no node names, domains
// or home paths.
func TestNothingInstallationSpecific(t *testing.T) {
_, raw := manifest(t)
for _, re := range []*regexp.Regexp{regexp.MustCompile(`/home/`), regexp.MustCompile(`\.(be|internal|com)\b`)} {
if loc := re.FindString(raw); loc != "" {
t.Errorf("the manifest carries %q", loc)
}
}
}
-115
View File
@@ -1,115 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// MaxMonitorSeconds bounds a watch of the bus: long enough to catch an exchange, short enough that
// nobody leaves a monitor running.
const MaxMonitorSeconds = 15
// MonitorLimit is the most messages busctl reads in one watch before it stops by itself.
const MonitorLimit = 20000
// Header is what dbus_monitor answers of one message: who sent what to whom. The body is never read
// into it: the struct has no field for it, so a payload is dropped as each line is decoded.
type Header struct {
Time string `json:"time,omitempty"`
Type string `json:"type"`
Sender string `json:"sender,omitempty"`
Destination string `json:"destination,omitempty"`
Path string `json:"path,omitempty"`
Interface string `json:"interface,omitempty"`
Member string `json:"member,omitempty"`
ErrorName string `json:"error_name,omitempty"`
}
type monitorLine struct {
Type string `json:"type"`
Sender string `json:"sender"`
Destination string `json:"destination"`
Path string `json:"path"`
Interface string `json:"interface"`
Member string `json:"member"`
ErrorName string `json:"error_name"`
Realtime int64 `json:"timestamp-realtime"`
}
// ParseHeader reads one line of `busctl monitor --json=short` into its header alone.
func ParseHeader(line string) (Header, bool) {
var l monitorLine
if json.Unmarshal([]byte(line), &l) != nil || l.Type == "" {
return Header{}, false
}
h := Header{Type: l.Type, Sender: l.Sender, Destination: l.Destination, Path: l.Path,
Interface: l.Interface, Member: l.Member, ErrorName: l.ErrorName}
if l.Realtime > 0 {
h.Time = time.UnixMicro(l.Realtime).UTC().Format("15:04:05.000000")
}
return h, true
}
// Watch is dbus_monitor's answer.
type Watch struct {
Bus string `json:"bus"`
Seconds int `json:"seconds"`
Match string `json:"match,omitempty"`
Names []string `json:"names,omitempty"`
Total int `json:"total"`
Messages []Header `json:"messages"`
Cut bool `json:"cut,omitempty"`
Note string `json:"note"`
}
// Monitor watches one bus for a few seconds and answers the headers of what passed. The system bus is
// watched as root (sudo -n): only root may become a monitor there. The session bus is the account's
// own. The bodies, which carry secrets, notification text and the clipboard, are never kept.
func (m *Machine) Monitor(ctx context.Context, bus string, seconds int, match string, names []string) (Watch, error) {
if seconds < 1 || seconds > MaxMonitorSeconds {
return Watch{}, fmt.Errorf("seconds is 1 to %d", MaxMonitorSeconds)
}
if len(match) > 512 || strings.ContainsAny(match, "\n\x00") {
return Watch{}, fmt.Errorf("the match rule is not one line of at most 512 characters")
}
for _, n := range names {
if !busNameRE.MatchString(n) {
return Watch{}, fmt.Errorf("%q is not a bus name", n)
}
}
args, err := m.busArgs(bus)
if err != nil {
return Watch{}, err
}
cmd := append([]string{"timeout", strconv.Itoa(seconds) + "s", "busctl"}, args...)
cmd = append(cmd, "--json=short", "--no-pager", "--limit-messages="+strconv.Itoa(MonitorLimit), "monitor")
if match != "" {
cmd = append(cmd, "--match="+match)
}
cmd = append(cmd, names...)
if orWord(bus, "system") == "system" {
cmd = append([]string{"sudo", "-n"}, cmd...)
}
w := Watch{Bus: orWord(bus, "system"), Seconds: seconds, Match: match, Names: names, Messages: []Header{},
Note: "headers only; message bodies are never read into the answer"}
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second+5*time.Second)
defer cancel()
err = m.Stream(ctx, func(line string) bool {
h, ok := ParseHeader(line)
if !ok {
return true
}
w.Total++
if len(w.Messages) < AnswerCap {
w.Messages = append(w.Messages, h)
} else {
w.Cut = true
}
return true
}, cmd[0], cmd[1:]...)
return w, err
}
-103
View File
@@ -1,103 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
)
// busctlName is one line of `busctl list --json`.
type busctlName struct {
Name string `json:"name"`
PID *uint32 `json:"pid"`
Process *string `json:"process"`
User *string `json:"user"`
Connection string `json:"connection"`
Unit *string `json:"unit"`
}
// Name is one well-known name on a bus, with what holds it.
type Name struct {
Name string `json:"name"`
PID uint32 `json:"pid,omitempty"`
Process string `json:"process,omitempty"`
User string `json:"user,omitempty"`
Unit string `json:"unit,omitempty"`
Connection string `json:"connection,omitempty"`
}
// Names is dbus_names's answer.
type Names struct {
Bus string `json:"bus"`
Running []Name `json:"running"`
ActivatableNotRunning []string `json:"activatable_not_running"`
Connections int `json:"connections"`
Cut bool `json:"cut,omitempty"`
}
// ParseNames reads `busctl list --json=short`: the well-known names running, the activatable names
// that are not, and how many connections (unique names) the bus has.
func ParseNames(bus, out string) (Names, error) {
var raw []busctlName
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &raw); err != nil {
return Names{}, fmt.Errorf("busctl list answered what is not its JSON: %w", err)
}
n := Names{Bus: bus, Running: []Name{}, ActivatableNotRunning: []string{}}
for _, r := range raw {
switch {
case r.Name == busName: // the bus itself, which busctl attributes to the service manager
case strings.HasPrefix(r.Name, ":"):
n.Connections++
case r.Connection == "(activatable)" || r.PID == nil:
n.ActivatableNotRunning = append(n.ActivatableNotRunning, r.Name)
default:
n.Running = append(n.Running, Name{Name: r.Name, PID: deref(r.PID), Process: derefS(r.Process),
User: derefS(r.User), Unit: derefS(r.Unit), Connection: r.Connection})
}
}
sort.Slice(n.Running, func(i, j int) bool { return n.Running[i].Name < n.Running[j].Name })
sort.Strings(n.ActivatableNotRunning)
if len(n.Running) > AnswerCap {
n.Running, n.Cut = n.Running[:AnswerCap], true
}
if len(n.ActivatableNotRunning) > AnswerCap {
n.ActivatableNotRunning, n.Cut = n.ActivatableNotRunning[:AnswerCap], true
}
return n, nil
}
// Names lists one bus's names as this account sees them.
func (m *Machine) Names(ctx context.Context, bus string) (Names, error) {
args, err := m.busArgs(bus)
if err != nil {
return Names{}, err
}
out, err := m.Run(ctx, "busctl", append(args, "--json=short", "--no-pager", "list")...)
if err != nil {
return Names{}, err
}
return ParseNames(orWord(bus, "system"), out)
}
func deref(p *uint32) uint32 {
if p == nil {
return 0
}
return *p
}
func derefS(p *string) string {
if p == nil {
return ""
}
return *p
}
func orWord(s, word string) string {
if strings.TrimSpace(s) == "" {
return word
}
return s
}
-304
View File
@@ -1,304 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
)
// Recorded answers, trimmed, from a workstation of 2026-10-05.
const busctlList = `[{"name":":1.0","pid":456,"process":"systemd-timesyn","user":"systemd-timesync","connection":":1.0","unit":"systemd-timesyncd.service","session":null,"description":null},` +
`{"name":"fi.w1.wpa_supplicant1","pid":1442,"process":"wpa_supplicant","user":"root","connection":":1.23","unit":"wpa_supplicant.service","session":null,"description":null},` +
`{"name":"org.blueman.Mechanism","pid":null,"process":null,"user":null,"connection":"(activatable)","unit":null,"session":null,"description":null},` +
`{"name":":1.11","pid":975,"process":"polkitd","user":"polkitd","connection":":1.11","unit":"polkit.service","session":null,"description":null},` +
`{"name":"org.freedesktop.DBus","pid":807,"process":"dbus-broker-lau","user":"root","connection":"org.freedesktop.DBus","unit":"dbus-broker.service","session":null,"description":null}]`
func TestNamesAreSplitIntoRunningActivatableAndConnections(t *testing.T) {
n, err := ParseNames("system", busctlList)
if err != nil {
t.Fatal(err)
}
if n.Connections != 2 || len(n.Running) != 1 || n.Running[0].Name != "fi.w1.wpa_supplicant1" ||
n.Running[0].Unit != "wpa_supplicant.service" || n.Running[0].PID != 1442 {
t.Fatalf("%+v", n)
}
if !reflect.DeepEqual(n.ActivatableNotRunning, []string{"org.blueman.Mechanism"}) {
t.Fatalf("%v", n.ActivatableNotRunning)
}
}
const introspection = `{"type":"s","data":["<!DOCTYPE node PUBLIC \"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN\" \"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd\">\n<node>\n <interface name=\"org.freedesktop.login1.Manager\">\n <property name=\"IdleHint\" type=\"b\" access=\"read\"></property>\n <method name=\"Inhibit\">\n <arg type=\"s\" name=\"what\" direction=\"in\"/>\n <arg type=\"h\" name=\"pipe_fd\" direction=\"out\"/>\n </method>\n <signal name=\"PrepareForSleep\">\n <arg type=\"b\" name=\"start\"/>\n </signal>\n </interface>\n <node name=\"session\"/>\n <node name=\"seat\"/>\n</node>\n"]}`
func TestAnIntrospectionIsShapedWithoutValues(t *testing.T) {
node, err := ParseIntrospection(introspection)
if err != nil {
t.Fatal(err)
}
o := Shape("system", "org.freedesktop.login1", "/org/freedesktop/login1", node)
if len(o.Interfaces) != 1 || !reflect.DeepEqual(o.Children, []string{"session", "seat"}) {
t.Fatalf("%+v", o)
}
i := o.Interfaces[0]
if !reflect.DeepEqual(i.Methods[0], Member{Name: "Inhibit", In: []string{"what s"}, Out: []string{"pipe_fd h"}}) ||
!reflect.DeepEqual(i.Properties[0], Property{Name: "IdleHint", Type: "b", Access: "read"}) ||
!reflect.DeepEqual(i.Signals[0], Member{Name: "PrepareForSleep", Args: []string{"start b"}}) {
t.Fatalf("%+v", i)
}
}
func TestIntrospectRefusesWhatIsNotANameOrAPathAndNeverStartsAService(t *testing.T) {
var asked []string
m := testMachine(t, nil)
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
asked = append([]string{name}, args...)
return introspection, nil
}
if _, err := m.Introspect(context.Background(), "system", "--address=x", "/"); err == nil {
t.Fatal("an option was taken for a bus name")
}
if _, err := m.Introspect(context.Background(), "system", "org.freedesktop.login1", "relative"); err == nil {
t.Fatal("a relative path was taken")
}
if _, err := m.Introspect(context.Background(), "system", "org.freedesktop.login1", ""); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(asked, " "), "--auto-start=no") || asked[len(asked)-3] != "/" {
t.Fatalf("%v", asked)
}
}
const monitorLines = `{"type":"method_call","endian":"l","flags":0,"version":1,"cookie":186058,"timestamp-realtime":1791193058632202,"sender":":1.797","destination":"org.freedesktop.UPower","path":"/org/freedesktop/UPower","interface":"org.freedesktop.UPower","member":"GetDisplayDevice","payload":{"type":"","data":[]}}
{"type":"signal","endian":"l","flags":1,"version":1,"cookie":7,"timestamp-realtime":1791193058632725,"sender":":1.40","path":"/org/freedesktop/Notifications","interface":"org.freedesktop.Notifications","member":"Notify","payload":{"type":"susssasa{sv}i","data":["app",0,"","the secret notification text","the clipboard's password",[],{},5000]}}
not json at all
{"type":"error","endian":"l","flags":1,"version":1,"cookie":9,"sender":":1.9","destination":":1.797","error_name":"org.freedesktop.DBus.Error.AccessDenied","payload":{"type":"s","data":["the reason, with a token"]}}`
func TestAMonitoredMessageIsItsHeaderOnly(t *testing.T) {
var asked []string
m := testMachine(t, nil)
m.Stream = func(ctx context.Context, line func(string) bool, name string, args ...string) error {
asked = append([]string{name}, args...)
if _, ok := ctx.Deadline(); !ok {
t.Error("the watch has no deadline")
}
for _, l := range strings.Split(monitorLines, "\n") {
if !line(l) {
break
}
}
return nil
}
w, err := m.Monitor(context.Background(), "system", 3, "type='signal'", []string{"org.freedesktop.Notifications"})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(w)
for _, secret := range []string{"secret notification", "password", "token", "payload", "data"} {
if strings.Contains(string(raw), secret) {
t.Errorf("the answer carries %q: %s", secret, raw)
}
}
if w.Total != 3 || w.Messages[1].Member != "Notify" || w.Messages[2].ErrorName != "org.freedesktop.DBus.Error.AccessDenied" {
t.Fatalf("%+v", w)
}
cmd := strings.Join(asked, " ")
if !strings.HasPrefix(cmd, "sudo -n timeout 3s busctl --system") || !strings.Contains(cmd, "--match=type='signal'") ||
!strings.HasSuffix(cmd, "org.freedesktop.Notifications") {
t.Fatalf("%s", cmd)
}
}
func TestAMonitorIsBoundedAndTheSessionBusIsTheAccounts(t *testing.T) {
m := testMachine(t, map[string]string{"/run/user/1000/bus": ""})
var asked string
m.Stream = func(_ context.Context, _ func(string) bool, name string, args ...string) error {
asked = name + " " + strings.Join(args, " ")
return nil
}
for _, s := range []int{0, MaxMonitorSeconds + 1} {
if _, err := m.Monitor(context.Background(), "system", s, "", nil); err == nil {
t.Errorf("%d seconds were accepted", s)
}
}
if _, err := m.Monitor(context.Background(), "session", 2, "", []string{"-x"}); err == nil {
t.Error("an option was taken for a name")
}
if _, err := m.Monitor(context.Background(), "session", 2, "", nil); err != nil {
t.Fatal(err)
}
if strings.HasPrefix(asked, "sudo") || !strings.Contains(asked, "--address=unix:path=/run/user/1000/bus") {
t.Fatalf("%s", asked)
}
}
func TestASessionBusIsFoundOrSaidMissing(t *testing.T) {
m := testMachine(t, nil)
if _, err := m.SessionAddress(); !errors.Is(err, ErrNoSession) {
t.Fatalf("a server without a session said %v", err)
}
m = testMachine(t, map[string]string{"/run/user/1000/bus": ""})
if a, err := m.SessionAddress(); err != nil || a != "unix:path=/run/user/1000/bus" {
t.Fatalf("%q %v", a, err)
}
m.Env = func(k string) string {
if k == "DBUS_SESSION_BUS_ADDRESS" {
return "unix:path=/run/user/1000/bus,guid=abc"
}
return ""
}
if a, _ := m.SessionAddress(); a != "unix:path=/run/user/1000/bus,guid=abc" {
t.Fatalf("%q", a)
}
}
const journalLines = `{"__CURSOR":"s=1;i=1","__REALTIME_TIMESTAMP":"1791192960341045","MESSAGE":"Ready","_PID":"807"}
{"__CURSOR":"s=1;i=2","__REALTIME_TIMESTAMP":"1791192960341045","MESSAGE":"A security policy denied :1.1199 to send method call /org/freedesktop/DBus:org.freedesktop.DBus.Debug.Stats.GetStats to org.freedesktop.DBus.","DBUS_BROKER_TRANSMIT_ACTION":"send","DBUS_BROKER_MESSAGE_TYPE":"method_call","DBUS_BROKER_SENDER_UNIQUE_NAME":":1.1199","DBUS_BROKER_MESSAGE_DESTINATION":"org.freedesktop.DBus","DBUS_BROKER_MESSAGE_PATH":"/org/freedesktop/DBus","DBUS_BROKER_MESSAGE_INTERFACE":"org.freedesktop.DBus.Debug.Stats","DBUS_BROKER_MESSAGE_MEMBER":"GetStats","DBUS_BROKER_POLICY_TYPE":"internal"}
{"__CURSOR":"s=1;i=3","MESSAGE":[65,66]}`
func TestDenialsAreReadFromTheJournalByTheirFields(t *testing.T) {
d, cursor := ParseDenials(journalLines)
if cursor != "s=1;i=3" || len(d) != 1 {
t.Fatalf("%q %+v", cursor, d)
}
want := Denial{At: "2026-10-05T09:36:00Z", Action: "send", Type: "method_call", Sender: ":1.1199",
Destination: "org.freedesktop.DBus", Path: "/org/freedesktop/DBus", Interface: "org.freedesktop.DBus.Debug.Stats",
Member: "GetStats", Policy: "internal"}
if d[0] != want {
t.Fatalf("%+v", d[0])
}
}
func TestTheJournalIsReadFromTheCursorOn(t *testing.T) {
m := testMachine(t, nil)
var asked []string
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
asked = append([]string{name}, args...)
return journalLines, nil
}
_, next, _ := m.Denials(context.Background(), "", time.Unix(100, 0))
if !strings.Contains(strings.Join(asked, " "), "--since @100") {
t.Fatalf("%v", asked)
}
m.Denials(context.Background(), next, time.Time{})
if !strings.Contains(strings.Join(asked, " "), "--after-cursor s=1;i=3") || !strings.Contains(strings.Join(asked, " "), "-u dbus-broker.service") {
t.Fatalf("%v", asked)
}
}
func TestAUnitIsReadFromACgroup(t *testing.T) {
for in, want := range map[string]string{
"0::/system.slice/bluetooth.service\n": "bluetooth.service",
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/dunst.service": "dunst.service",
"0::/user.slice/user-1000.slice/session-2.scope": "session-2.scope",
"0::/init.scope": "init.scope",
"": "",
} {
if got := UnitFromCgroup(in); got != want {
t.Errorf("%q: %q, want %q", in, got, want)
}
}
}
func TestShowBlocksAreReadInOrder(t *testing.T) {
b := ParseShow("Id=dbus-org.freedesktop.resolve1.service\nLoadState=not-found\n\nId=systemd-hostnamed.service\nLoadState=loaded\n")
if len(b) != 2 || b[0]["LoadState"] != "not-found" || b[1]["Id"] != "systemd-hostnamed.service" {
t.Fatalf("%v", b)
}
}
func TestARunningBusOlderThanItsPackageIsSaid(t *testing.T) {
p := ParseDesc("%NAME%\ndbus-broker\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1772097880\n")
if p.Name != "dbus-broker" || p.Version != "37-3" || p.Installed.Unix() != 1772097880 {
t.Fatalf("%+v", p)
}
if due, _ := RebootDue(time.Unix(1791123478, 0), []Package{p}); due {
t.Fatal("a bus started after its package was said to be older")
}
due, detail := RebootDue(time.Unix(1772000000, 0), []Package{p})
if !due || !strings.Contains(detail, "reboot is due") || !strings.Contains(detail, "dbus-broker 37-3") {
t.Fatalf("%v %s", due, detail)
}
}
func TestConnectionsAreCountedFromStatsOrNames(t *testing.T) {
stats := `{"type":"a{sv}","data":[{"org.bus1.DBus.Debug.Stats.PeerAccounting":{"type":"a(sa{sv}a{su})","data":[[":1.0",{},{}],[":1.1",{},{}],[":1.7",{},{}]]}}]}`
if n, ok := CountConnections(stats); !ok || n != 3 {
t.Fatalf("%d %v", n, ok)
}
if n, ok := CountConnections(`{"type":"a{sv}","data":[{"ActiveConnections":{"type":"u","data":12}}]}`); !ok || n != 12 {
t.Fatalf("%d %v", n, ok)
}
if n, ok := CountUniqueNames(`{"type":"as","data":[["org.freedesktop.DBus",":1.0","org.bluez",":1.5"]]}`); !ok || n != 2 {
t.Fatalf("%d %v", n, ok)
}
}
func TestStatsAreNeverAskedAsTheAccount(t *testing.T) {
m := testMachine(t, nil)
var calls []string
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
line := name + " " + strings.Join(args, " ")
calls = append(calls, line)
if name == "sudo" {
return "", errors.New("sudo: a password is required")
}
if strings.Contains(line, "ListNames") {
return `{"type":"as","data":[[":1.0",":1.1"]]}`, nil
}
return "Id=dbus-broker.service\nActiveState=active\nMainPID=807\nActiveEnterTimestamp=@1791123478\n", nil
}
h := m.Health(context.Background(), nil)
for _, c := range calls {
if strings.Contains(c, "GetStats") && !strings.HasPrefix(c, "sudo -n ") {
t.Fatalf("Debug.Stats asked as the account, which the bus logs as a denial: %s", c)
}
}
if h["connections"] != 2 || h["unit"] != "dbus-broker.service" {
t.Fatalf("%v", h)
}
}
func TestTheCheckReadsPackagesUnitsServiceFilesAndDenials(t *testing.T) {
m := testMachine(t, map[string]string{
"/var/lib/pacman/local/dbus-1.16.2-1/desc": "%NAME%\ndbus\n\n%VERSION%\n1.16.2-1\n\n%INSTALLDATE%\n1741340000\n",
"/var/lib/pacman/local/dbus-broker-37-3/desc": "%NAME%\ndbus-broker\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1800000000\n",
"/var/lib/pacman/local/dbus-broker-units-37-3/desc": "%NAME%\ndbus-broker-units\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1772097880\n",
"/usr/share/dbus-1/system-services/org.bluez.service": "[D-BUS Service]\nName=org.bluez\nExec=/bin/false\nUser=root\nSystemdService=dbus-org.bluez.service\n",
"/usr/share/dbus-1/system-services/org.example.service": "[D-BUS Service]\nName=org.example\nSystemdService=example.service\n",
"/usr/share/dbus-1/system-services/org.freedesktop.systemd1.service": "[D-BUS Service]\nName=org.freedesktop.systemd1\nExec=/bin/false\n",
})
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
line := strings.Join(args, " ")
switch {
case name == "journalctl":
return journalLines, nil
case strings.Contains(line, "Id,LoadState"):
return "Id=dbus-org.bluez.service\nLoadState=not-found\n\nId=example.service\nLoadState=not-found\n", nil
default:
return "Id=dbus-broker.service\nActiveState=active\nMainPID=807\nActiveEnterTimestamp=@1791123478\n", nil
}
}
got := m.Check(context.Background(), nil)
byName := map[string]Check{}
for _, c := range got["checks"].([]Check) {
byName[c.Name] = c
}
if !byName["package dbus-broker-units"].OK || !byName["system bus"].OK {
t.Fatalf("%+v", got)
}
if c := byName["running bus is the installed one"]; c.OK || !strings.Contains(c.Detail, "dbus-broker 37-3") {
t.Fatalf("an upgraded broker was not said: %+v", c)
}
if c := byName["activatable services"]; c.OK || !strings.Contains(c.Detail, "org.example → example.service") || strings.Contains(c.Detail, "bluez") {
t.Fatalf("%+v", c)
}
if c := byName["policy denials in the last hour"]; c.OK || !strings.Contains(c.Detail, "GetStats") {
t.Fatalf("%+v", c)
}
if !strings.Contains(strings.Join(got["notes"].([]string), " "), "org.bluez → dbus-org.bluez.service") {
t.Fatalf("%v", got["notes"])
}
}
-99
View File
@@ -1,99 +0,0 @@
package main
import (
"context"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
)
var busArg = map[string]any{"type": "string", "enum": Buses,
"description": "system (the default) or session (this account's login session bus, where one exists)"}
func busOf(args map[string]any) string {
b, _ := args["bus"].(string)
return b
}
// Tools are the module's tools over MCP (novox/hq ADR 0215): the names on either bus, what an object
// offers, a bounded watch of message headers, the module's check, and the bus's health.
func Tools(m *Machine, w *Watcher) []stdio.Tool {
return []stdio.Tool{
{
Name: "dbus_names",
Description: "Every well-known name on the system or session bus: its owner's pid, process, user and " +
"systemd unit, the activatable names that are not running, and how many connections the bus has. " +
"Read as the operator's account.",
Input: map[string]any{"type": "object", "properties": map[string]any{"bus": busArg}},
Run: func(args map[string]any) (any, error) {
return m.Names(context.Background(), busOf(args))
},
},
{
Name: "dbus_introspect",
Description: "What one object on a bus offers: its interfaces with their methods (arguments in and out), " +
"properties (type and access, never their values) and signals, and its child objects. A service " +
"that is not running is not started for it.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"bus": busArg,
"service": map[string]any{"type": "string", "description": "the bus name, e.g. org.freedesktop.login1"},
"path": map[string]any{"type": "string", "description": "the object path (default /)"},
},
"required": []string{"service"},
},
Run: func(args map[string]any) (any, error) {
service, _ := args["service"].(string)
path, _ := args["path"].(string)
return m.Introspect(context.Background(), busOf(args), service, path)
},
},
{
Name: "dbus_monitor",
Description: fmt.Sprintf("Watch a bus for a few seconds (at most %d) and answer the headers of the "+
"messages that passed: type, sender, destination, path, interface, member. Never a message's body, "+
"which carries secrets, notification text and the clipboard. Optional match rule and names to "+
"narrow it. The system bus is watched as root through sudo -n.", MaxMonitorSeconds),
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"bus": busArg,
"seconds": map[string]any{"type": "integer", "description": fmt.Sprintf("how long (default 5, at most %d)", MaxMonitorSeconds)},
"match": map[string]any{"type": "string", "description": "a D-Bus match rule, e.g. type='signal',interface='org.freedesktop.login1.Manager'"},
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "only messages to or from these bus names"},
},
},
Run: func(args map[string]any) (any, error) {
s := 5
if v, ok := args["seconds"].(float64); ok {
s = int(v)
}
match, _ := args["match"].(string)
var names []string
if list, ok := args["names"].([]any); ok {
for _, n := range list {
if str, ok := n.(string); ok {
names = append(names, str)
}
}
}
return m.Monitor(context.Background(), busOf(args), s, match, names)
},
},
{
Name: "dbus_check",
Description: "Whether this machine's message bus is as the mesh expects: the bus's packages installed, " +
"dbus-broker running behind dbus.service, whether the running bus is older than its installed " +
"package (then a reboot is due: the bus is never restarted live), activatable services whose " +
"unit does not exist, policy denials in the last hour, and the watcher's state.",
Run: func(map[string]any) (any, error) { return m.Check(context.Background(), w), nil },
},
{
Name: "dbus_health",
Description: "The system bus's health now: a ping's round trip, how many connections it has, its unit, " +
"pid and uptime, and what the watcher last saw.",
Run: func(map[string]any) (any, error) { return m.Health(context.Background(), w), nil },
},
}
}
-619
View File
@@ -1,619 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// The watcher publishes what matters about the machine's system bus as this module's events
// (novox/hq ADR 0215 §3): its health, a well-known service appearing or leaving, and a policy
// denial. It reads only the bus driver's own answers and signals and the bus unit's journal. It never
// becomes a monitor and never sees another peer's messages, so traffic cannot leave the machine
// through it: the event bodies are built from names, pids, units and the denial's header fields.
// Event types, as the module's manifest declares them in `emits`.
const (
BusStalled = "bus.stalled"
BusRecovered = "bus.recovered"
BusRestarted = "bus.restarted"
ServiceAppeared = "service.appeared"
ServiceLeft = "service.left"
PolicyDenied = "policy.denied"
)
// Emits is every event the watcher publishes, in the manifest's order.
var Emits = []string{BusStalled, BusRecovered, BusRestarted, ServiceAppeared, ServiceLeft, PolicyDenied}
const (
// PingEvery is how often the bus is pinged.
PingEvery = 10 * time.Second
// StallAfter is how long a ping may take before the bus counts as stalled.
StallAfter = 3 * time.Second
// Debounce is how long a name must stay as it is before its change is said: a service restarted
// within it, or one that flaps, says nothing.
Debounce = 10 * time.Second
// DenialsEvery is how often the journal is read for policy denials.
DenialsEvery = 30 * time.Second
// DenialEventEvery is the least time between two policy.denied events; denials in between are
// counted into the next one.
DenialEventEvery = 5 * time.Minute
// MaxQueue is the most events kept while the mesh's bus does not take them; the oldest go first.
MaxQueue = 1000
// DenialExamples is the most distinct denials one policy.denied names.
DenialExamples = 5
)
// NameChange is the bus driver's NameOwnerChanged: a name, its old owner and its new one.
type NameChange struct{ Name, Old, New string }
// Bus is the system bus as the watcher uses it, behind an interface so it is tested without one.
type Bus interface {
Ping(ctx context.Context) error
ID(ctx context.Context) (string, error)
PID(ctx context.Context, name string) (uint32, error)
Names(ctx context.Context) ([]string, error)
Activatable(ctx context.Context) ([]string, error)
// Changes closes when the connection is lost.
Changes() <-chan NameChange
Close()
}
// Emitter publishes one event and returns once the mesh's bus has it.
type Emitter func(eventType string, body any) error
// Journal answers the policy denials in the bus unit's journal after a cursor (or, with none, since
// a time), and the cursor to continue from.
type Journal func(ctx context.Context, after string, since time.Time) ([]Denial, string, error)
type queued struct {
Type string
Body map[string]any
}
type owner struct {
PID uint32
Process string
Unit string
}
// Watcher is the long-running half of the module.
type Watcher struct {
m *Machine
emit Emitter
dial func() (Bus, error)
journal Journal
now func() time.Time
state string // where the last seen bus identity is kept, across restarts of the runtime
retry time.Duration
mu sync.Mutex
bus Bus
queue []queued
dropped int
issue string
connected bool
stalled bool
stallSince time.Time
stallReason string
lastPing time.Duration
lastPingAt time.Time
busID string
brokerPID uint32
baselined bool
current map[string]bool
published map[string]owner
dirty map[string]time.Time
activatable map[string]bool
cursor string
denials int
denialSince time.Time
examples []Denial
lastDenial time.Time
kick chan struct{}
}
// NewWatcher is a watcher for this machine, emitting through emit, reaching the bus through dial and
// the journal through journal.
func NewWatcher(m *Machine, emit Emitter, dial func() (Bus, error), journal Journal) *Watcher {
home, _ := os.UserHomeDir()
now := time.Now
if m != nil && m.Now != nil {
now = m.Now
}
return &Watcher{m: m, emit: emit, dial: dial, journal: journal, now: now,
state: filepath.Join(home, ".local", "state", "mesh-dbus", "bus"), retry: 5 * time.Second,
current: map[string]bool{}, published: map[string]owner{}, dirty: map[string]time.Time{},
activatable: map[string]bool{}, kick: make(chan struct{}, 1)}
}
// Snapshot is what dbus_check and dbus_health show of the watcher.
type Snapshot struct {
Connected bool `json:"connected"`
Stalled bool `json:"stalled"`
StalledSince string `json:"stalled_since,omitempty"`
StallReason string `json:"stall_reason,omitempty"`
LastPingMS float64 `json:"last_ping_ms"`
LastPingAt string `json:"last_ping_at,omitempty"`
BusID string `json:"bus_id,omitempty"`
BrokerPID uint32 `json:"broker_pid,omitempty"`
Services int `json:"well_known_names"`
Pending int `json:"pending_events"`
Dropped int `json:"dropped_events,omitempty"`
Problem string `json:"problem,omitempty"`
}
func (w *Watcher) Snapshot() Snapshot {
w.mu.Lock()
defer w.mu.Unlock()
s := Snapshot{Connected: w.connected, Stalled: w.stalled, StallReason: w.stallReason,
LastPingMS: float64(w.lastPing.Microseconds()) / 1000, BusID: w.busID, BrokerPID: w.brokerPID,
Services: len(w.published), Pending: len(w.queue), Dropped: w.dropped, Problem: w.issue}
if w.stalled {
s.StalledSince = w.stallSince.UTC().Format(time.RFC3339)
}
if !w.lastPingAt.IsZero() {
s.LastPingAt = w.lastPingAt.UTC().Format(time.RFC3339)
}
return s
}
func (w *Watcher) problem(s string) {
w.mu.Lock()
w.issue = s
w.mu.Unlock()
}
// enqueue adds an event in order, stamped with when it happened. A full queue lets the oldest go and
// counts it: a mesh bus gone for a day must not grow the process without bound.
func (w *Watcher) enqueue(eventType string, body map[string]any) {
if body == nil {
body = map[string]any{}
}
body["at"] = w.now().UTC().Format(time.RFC3339)
w.mu.Lock()
w.queue = append(w.queue, queued{eventType, body})
if len(w.queue) > MaxQueue {
w.dropped += len(w.queue) - MaxQueue
w.queue = w.queue[len(w.queue)-MaxQueue:]
}
w.mu.Unlock()
select {
case w.kick <- struct{}{}:
default:
}
}
// flush publishes what waits, in order, and stops at the first the mesh's bus does not take.
func (w *Watcher) flush() {
for {
w.mu.Lock()
if len(w.queue) == 0 {
w.mu.Unlock()
return
}
next := w.queue[0]
w.mu.Unlock()
if err := w.emit(next.Type, next.Body); err != nil {
w.problem("the mesh's bus did not take " + next.Type + ": " + err.Error())
return
}
w.mu.Lock()
if len(w.queue) > 0 {
w.queue = w.queue[1:]
}
if len(w.queue) == 0 && strings.HasPrefix(w.issue, "the mesh's bus") {
w.issue = ""
}
w.mu.Unlock()
}
}
// flusher publishes on its own, so an emit waiting on the runtime never delays a ping.
func (w *Watcher) flusher(ctx context.Context) {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-w.kick:
case <-tick.C:
}
w.flush()
}
}
// markStalled says bus.stalled once, until the bus answers again.
func (w *Watcher) markStalled(reason string) {
w.mu.Lock()
if w.stalled {
w.stallReason = reason
w.mu.Unlock()
return
}
w.stalled, w.stallSince, w.stallReason = true, w.now(), reason
w.mu.Unlock()
w.enqueue(BusStalled, map[string]any{"reason": reason})
}
// answered says bus.recovered when a stalled bus answers again.
func (w *Watcher) answered() {
w.mu.Lock()
if !w.stalled {
w.mu.Unlock()
return
}
since := w.stallSince
w.stalled, w.stallReason = false, ""
w.mu.Unlock()
w.enqueue(BusRecovered, map[string]any{"stalled_since": since.UTC().Format(time.RFC3339),
"stalled_seconds": int(w.now().Sub(since).Seconds())})
}
// ping asks the bus driver to answer within StallAfter.
func (w *Watcher) ping(b Bus) {
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
start := time.Now()
err := b.Ping(ctx)
took := time.Since(start)
if err != nil {
if ctx.Err() != nil {
w.markStalled("the bus did not answer a ping within " + StallAfter.String())
} else {
w.markStalled("the bus answered a ping with an error: " + err.Error())
}
return
}
w.mu.Lock()
w.lastPing, w.lastPingAt = took, w.now()
w.mu.Unlock()
w.answered()
}
// PingNow pings the bus on the watcher's connection, for dbus_health.
func (w *Watcher) PingNow() (time.Duration, error) {
w.mu.Lock()
b := w.bus
w.mu.Unlock()
if b == nil {
return 0, errNotConnected
}
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
start := time.Now()
err := b.Ping(ctx)
return time.Since(start), err
}
type watcherError string
func (e watcherError) Error() string { return string(e) }
const errNotConnected = watcherError("the watcher is not connected to the system bus")
// identity notes the bus's id and the bus driver's pid, and says bus.restarted when either changed
// within one boot: after a boot both change, and that is the machine's news, not the bus's.
func (w *Watcher) identity(id string, pid uint32) {
boot := w.m.BootID()
w.mu.Lock()
prevID, prevPID := w.busID, w.brokerPID
w.busID, w.brokerPID = id, pid
w.mu.Unlock()
prevBoot := boot
if prevID == "" {
if b, err := os.ReadFile(w.state); err == nil {
f := strings.Fields(string(b))
if len(f) == 3 {
prevBoot, prevID = f[0], f[1]
prevPID = parsePID(f[2])
}
}
}
if prevID != "" && prevBoot == boot && (prevID != id || prevPID != pid) {
w.enqueue(BusRestarted, map[string]any{"previous_bus_id": prevID, "bus_id": id,
"previous_pid": prevPID, "pid": pid, "unit": w.m.UnitOf(pid)})
}
if err := os.MkdirAll(filepath.Dir(w.state), 0o755); err == nil {
_ = os.WriteFile(w.state, []byte(boot+" "+id+" "+itoa(pid)+"\n"), 0o644)
}
}
// IsWellKnown is whether a name is a service's name rather than a connection's: unique names (":1.42")
// come and go with every client and are never said, nor is the bus driver's own.
func IsWellKnown(name string) bool {
return name != "" && !strings.HasPrefix(name, ":") && name != busName
}
// connected baselines the names after a (re)connect. The first time it says nothing; after a lost
// connection the difference with what was said is debounced like any other change, so a service that
// did not come back with a restarted bus is said to have left.
func (w *Watcher) connectedTo(b Bus) {
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
names, err := b.Names(ctx)
if err != nil {
w.problem("listing the bus's names: " + err.Error())
return
}
act, _ := b.Activatable(ctx)
now := w.now()
w.mu.Lock()
defer w.mu.Unlock()
w.activatable = map[string]bool{}
for _, n := range act {
w.activatable[n] = true
}
cur := map[string]bool{}
for _, n := range names {
if IsWellKnown(n) {
cur[n] = true
}
}
if !w.baselined {
w.baselined = true
w.current = cur
for n := range cur {
w.published[n] = owner{}
w.dirty[n] = time.Time{} // resolved silently at the next settle
}
return
}
for n := range cur {
if _, said := w.published[n]; !said {
w.dirty[n] = now
}
}
for n := range w.published {
if !cur[n] {
w.dirty[n] = now
}
}
w.current = cur
}
// observe takes one NameOwnerChanged. Only well-known names count.
func (w *Watcher) observe(c NameChange) {
if !IsWellKnown(c.Name) {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if c.New != "" {
w.current[c.Name] = true
} else {
delete(w.current, c.Name)
}
w.dirty[c.Name] = w.now()
}
// settle says what changed and stayed changed for Debounce. A name's owner is resolved when it is
// said, so the event names the process and the unit that holds it.
func (w *Watcher) settle(b Bus) {
now := w.now()
w.mu.Lock()
var due []string
for n, at := range w.dirty {
if now.Sub(at) >= Debounce {
due = append(due, n)
}
}
sort.Strings(due)
w.mu.Unlock()
for _, n := range due {
w.mu.Lock()
present := w.current[n]
was, said := w.published[n]
silent := w.dirty[n].IsZero()
delete(w.dirty, n)
activatable := w.activatable[n]
w.mu.Unlock()
switch {
case present && (!said || silent):
o := w.resolve(b, n)
w.mu.Lock()
w.published[n] = o
w.mu.Unlock()
if !silent {
w.enqueue(ServiceAppeared, o.body(n, activatable))
}
case !present && said:
w.mu.Lock()
delete(w.published, n)
w.mu.Unlock()
w.enqueue(ServiceLeft, was.body(n, activatable))
}
}
}
func (w *Watcher) resolve(b Bus, name string) owner {
if b == nil {
return owner{}
}
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
defer cancel()
pid, err := b.PID(ctx, name)
if err != nil {
return owner{}
}
return owner{PID: pid, Process: w.m.ProcessName(pid), Unit: w.m.UnitOf(pid)}
}
func (o owner) body(name string, activatable bool) map[string]any {
body := map[string]any{"name": name, "activatable": activatable}
if o.PID != 0 {
body["pid"] = o.PID
}
if o.Process != "" {
body["process"] = o.Process
}
if o.Unit != "" {
body["unit"] = o.Unit
}
return body
}
// readDenials takes the denials logged since the last read, and says policy.denied at most once per
// DenialEventEvery, with the count and a few distinct examples: a client denied in a loop must not
// flood the mesh's bus.
func (w *Watcher) readDenials(ctx context.Context, start time.Time) {
if w.journal == nil {
return
}
w.mu.Lock()
cursor := w.cursor
w.mu.Unlock()
got, next, err := w.journal(ctx, cursor, start)
if err != nil {
w.problem("reading the bus's journal: " + err.Error())
return
}
now := w.now()
w.mu.Lock()
if next != "" {
w.cursor = next
}
if strings.HasPrefix(w.issue, "reading the bus's journal") {
w.issue = ""
}
for _, d := range got {
if w.denials == 0 {
w.denialSince = now
}
w.denials++
if len(w.examples) < DenialExamples && !containsDenial(w.examples, d) {
w.examples = append(w.examples, d)
}
}
due := w.denials > 0 && (w.lastDenial.IsZero() || now.Sub(w.lastDenial) >= DenialEventEvery)
var body map[string]any
if due {
body = map[string]any{"count": w.denials, "since": w.denialSince.UTC().Format(time.RFC3339),
"examples": w.examples}
w.denials, w.examples, w.lastDenial = 0, nil, now
}
w.mu.Unlock()
if due {
w.enqueue(PolicyDenied, body)
}
}
// Run watches until ctx ends. Without the system bus it says the bus stalled, and tries again every
// few seconds; a lost connection is followed at once by a new one.
func (w *Watcher) Run(ctx context.Context) {
go w.flusher(ctx)
start := w.now()
tick := time.NewTicker(PingEvery)
defer tick.Stop()
denials := time.NewTicker(DenialsEvery)
defer denials.Stop()
retry := time.NewTimer(0)
defer retry.Stop()
var changes <-chan NameChange
for {
select {
case <-ctx.Done():
w.mu.Lock()
b := w.bus
w.bus, w.connected = nil, false
w.mu.Unlock()
if b != nil {
b.Close()
}
w.flush()
return
case <-retry.C:
b, err := w.dial()
if err != nil {
w.markStalled("the system bus is not reachable: " + err.Error())
retry.Reset(w.retry)
continue
}
idCtx, cancel := context.WithTimeout(ctx, StallAfter)
id, idErr := b.ID(idCtx)
pid, _ := b.PID(idCtx, busName)
cancel()
if idErr != nil {
b.Close()
w.markStalled("the system bus did not say its id: " + idErr.Error())
retry.Reset(w.retry)
continue
}
w.mu.Lock()
w.bus, w.connected = b, true
w.mu.Unlock()
changes = b.Changes()
w.identity(id, pid)
w.connectedTo(b)
w.answered()
w.settle(b)
case c, open := <-changes:
if !open {
w.mu.Lock()
b := w.bus
w.bus, w.connected = nil, false
w.mu.Unlock()
if b != nil {
b.Close()
}
changes = nil
retry.Reset(time.Second)
continue
}
w.observe(c)
case <-tick.C:
w.mu.Lock()
b := w.bus
w.mu.Unlock()
if b != nil {
w.ping(b)
w.settle(b)
}
case <-denials.C:
w.readDenials(ctx, start)
}
}
}
func containsDenial(list []Denial, d Denial) bool {
for _, x := range list {
if x.key() == d.key() {
return true
}
}
return false
}
func parsePID(s string) uint32 {
var n uint32
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + uint32(c-'0')
}
return n
}
func itoa(n uint32) string {
if n == 0 {
return "0"
}
var b [10]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
-446
View File
@@ -1,446 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// fakeBus is a system bus in memory: names with their owners' pids, a ping that can hang, and the
// NameOwnerChanged stream.
type fakeBus struct {
mu sync.Mutex
id string
pid uint32
names map[string]uint32
hang bool
changes chan NameChange
}
func newFakeBus(id string, pid uint32, names map[string]uint32) *fakeBus {
return &fakeBus{id: id, pid: pid, names: names, changes: make(chan NameChange, 64)}
}
func (f *fakeBus) Ping(ctx context.Context) error {
f.mu.Lock()
hang := f.hang
f.mu.Unlock()
if hang {
<-ctx.Done()
return ctx.Err()
}
return nil
}
func (f *fakeBus) ID(context.Context) (string, error) { return f.id, nil }
func (f *fakeBus) PID(_ context.Context, name string) (uint32, error) {
if name == busName {
return f.pid, nil
}
f.mu.Lock()
defer f.mu.Unlock()
if p, ok := f.names[name]; ok {
return p, nil
}
return 0, errors.New("no such name")
}
func (f *fakeBus) Names(context.Context) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := []string{busName, ":1.0", ":1.1"}
for n := range f.names {
out = append(out, n)
}
return out, nil
}
func (f *fakeBus) Activatable(context.Context) ([]string, error) {
return []string{"org.freedesktop.hostname1"}, nil
}
func (f *fakeBus) Changes() <-chan NameChange { return f.changes }
func (f *fakeBus) Close() {}
// meshBus records what was published, and can refuse.
type meshBus struct {
mu sync.Mutex
down bool
types []string
bodies []map[string]any
}
func (b *meshBus) emit(t string, body any) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.down {
return errors.New("no bus")
}
b.types = append(b.types, t)
m, _ := body.(map[string]any)
b.bodies = append(b.bodies, m)
return nil
}
func (b *meshBus) seen() []string {
b.mu.Lock()
defer b.mu.Unlock()
return append([]string(nil), b.types...)
}
// clock is a time the test moves.
type clock struct{ t time.Time }
func (c *clock) now() time.Time { return c.t }
func (c *clock) advance(d time.Duration) { c.t = c.t.Add(d) }
func testMachine(t *testing.T, files map[string]string) *Machine {
t.Helper()
root := t.TempDir()
for p, c := range files {
full := filepath.Join(root, p)
os.MkdirAll(filepath.Dir(full), 0o755)
os.WriteFile(full, []byte(c), 0o644)
}
return &Machine{Root: root, Env: func(string) string { return "" }, UID: 1000, Now: time.Now}
}
func testWatcher(t *testing.T, b *meshBus, c *clock) *Watcher {
m := testMachine(t, map[string]string{
"/proc/sys/kernel/random/boot_id": "boot-1\n",
"/proc/700/comm": "systemd-logind\n",
"/proc/700/cgroup": "0::/system.slice/systemd-logind.service\n",
"/proc/900/comm": "bluetoothd\n",
"/proc/900/cgroup": "0::/system.slice/bluetooth.service\n",
})
w := NewWatcher(m, b.emit, nil, nil)
w.now = c.now
w.state = filepath.Join(t.TempDir(), "bus")
return w
}
func start() *clock { return &clock{t: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)} }
func TestTheFirstConnectionSaysNothingAboutNames(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.identity("id-1", 500)
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("the baseline was announced: %v", got)
}
if w.Snapshot().Services != 1 {
t.Fatalf("%+v", w.Snapshot())
}
}
func TestAServiceAppearingIsSaidOnceItStaysWithItsUnit(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
fb.names["org.bluez"] = 900
w.observe(NameChange{Name: "org.bluez", New: ":1.9"})
c.advance(Debounce / 2)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("said before the debounce: %v", got)
}
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceAppeared}) {
t.Fatalf("%v", got)
}
body := mb.bodies[0]
if body["name"] != "org.bluez" || body["unit"] != "bluetooth.service" || body["process"] != "bluetoothd" || body["pid"] != uint32(900) {
t.Fatalf("%v", body)
}
}
func TestAFlapIsDebouncedAway(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
// logind restarted: left and back within the debounce, and a newcomer that came and went.
w.observe(NameChange{Name: "org.freedesktop.login1", Old: ":1.5"})
c.advance(time.Second)
w.observe(NameChange{Name: "org.freedesktop.login1", New: ":1.80"})
w.observe(NameChange{Name: "org.example.Brief", New: ":1.81"})
w.observe(NameChange{Name: "org.example.Brief", Old: ":1.81"})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("a flap was said: %v", got)
}
}
func TestAServiceLeavingIsSaidWithTheUnitItHad(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
delete(fb.names, "org.freedesktop.login1")
w.observe(NameChange{Name: "org.freedesktop.login1", Old: ":1.5"})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceLeft}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["unit"] != "systemd-logind.service" {
t.Fatalf("%v", mb.bodies[0])
}
}
func TestUniqueNamesAndTheDriverAreNeverSaid(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
w.observe(NameChange{Name: ":1.42", New: ":1.42"})
w.observe(NameChange{Name: ":1.42", Old: ":1.42"})
w.observe(NameChange{Name: busName, New: busName})
c.advance(Debounce)
w.settle(fb)
w.flush()
if got := mb.seen(); len(got) != 0 {
t.Fatalf("%v", got)
}
for _, n := range []string{":1.1", busName, ""} {
if IsWellKnown(n) {
t.Errorf("%q counted as a service", n)
}
}
}
func TestAPingThatHangsIsAStallAndAnAnswerARecovery(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, nil)
fb.hang = true
begun := time.Now()
w.ping(fb)
w.ping(fb)
if time.Since(begun) > 2*StallAfter+time.Second {
t.Fatal("a ping waited longer than its bound")
}
c.advance(42 * time.Second)
fb.hang = false
w.ping(fb)
w.ping(fb)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusStalled, BusRecovered}) {
t.Fatalf("%v", got)
}
if mb.bodies[1]["stalled_seconds"] != 42 || w.Snapshot().Stalled {
t.Fatalf("%v %+v", mb.bodies[1], w.Snapshot())
}
}
func TestARestartWithinABootIsSaidAndABootIsNot(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
w.identity("id-1", 500)
w.identity("id-1", 500) // a reconnect to the same bus
w.identity("id-2", 501) // the bus came back as another
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusRestarted}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["previous_bus_id"] != "id-1" || mb.bodies[0]["pid"] != uint32(501) {
t.Fatalf("%v", mb.bodies[0])
}
// The runtime restarts: the bus it remembers is the one still running, so nothing is said.
again := NewWatcher(w.m, mb.emit, nil, nil)
again.state, again.now = w.state, c.now
again.identity("id-2", 501)
// After a boot both change, and that is not the bus's restart.
os.WriteFile(filepath.Join(w.m.Root, "/proc/sys/kernel/random/boot_id"), []byte("boot-2\n"), 0o644)
third := NewWatcher(w.m, mb.emit, nil, nil)
third.state, third.now = w.state, c.now
third.identity("id-3", 400)
again.flush()
third.flush()
if got := mb.seen(); len(got) != 1 {
t.Fatalf("a runtime restart or a boot was taken for the bus's restart: %v", got)
}
}
func TestAServiceThatDidNotComeBackAfterAReconnectHasLeft(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700, "org.bluez": 900})
w.connectedTo(fb)
c.advance(Debounce)
w.settle(fb)
back := newFakeBus("id-2", 501, map[string]uint32{"org.freedesktop.login1": 700})
w.connectedTo(back)
c.advance(Debounce)
w.settle(back)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceLeft}) || mb.bodies[0]["name"] != "org.bluez" {
t.Fatalf("%v %v", got, mb.bodies)
}
}
func TestEventsWaitInOrderWhileTheMeshBusIsGone(t *testing.T) {
mb, c := &meshBus{down: true}, start()
w := testWatcher(t, mb, c)
w.markStalled("test")
c.advance(time.Minute)
w.answered()
w.flush()
if s := w.Snapshot(); s.Pending != 2 || s.Problem == "" {
t.Fatalf("what the mesh's bus did not take is not kept and said: %+v", s)
}
mb.mu.Lock()
mb.down = false
mb.mu.Unlock()
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusStalled, BusRecovered}) {
t.Fatalf("%v", got)
}
if s := w.Snapshot(); s.Pending != 0 || s.Problem != "" {
t.Fatalf("%+v", s)
}
}
func TestAFullQueueLetsTheOldestGo(t *testing.T) {
mb, c := &meshBus{down: true}, start()
w := testWatcher(t, mb, c)
for i := 0; i < MaxQueue+5; i++ {
w.enqueue(ServiceAppeared, map[string]any{"i": i})
}
s := w.Snapshot()
if s.Pending != MaxQueue || s.Dropped != 5 || w.queue[0].Body["i"] != 5 {
t.Fatalf("%+v first %v", s, w.queue[0].Body)
}
}
func TestDenialsAreSaidAtMostOncePerWindowWithACount(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
batches := [][]Denial{
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"}},
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"},
{Type: "method_call", Interface: "org.example.B", Member: "Other", Destination: "org.example"}},
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"}},
}
n := 0
w.journal = func(ctx context.Context, after string, since time.Time) ([]Denial, string, error) {
if n > 0 && after != "c"+string(rune('0'+n-1)) {
t.Errorf("read %d did not continue from the cursor: %q", n, after)
}
d := batches[n]
n++
return d, "c" + string(rune('0'+n-1)), nil
}
w.readDenials(context.Background(), c.t)
c.advance(DenialsEvery)
w.readDenials(context.Background(), c.t)
c.advance(DenialEventEvery)
w.readDenials(context.Background(), c.t)
w.flush()
if got := mb.seen(); !reflect.DeepEqual(got, []string{PolicyDenied, PolicyDenied}) {
t.Fatalf("%v", got)
}
if mb.bodies[0]["count"] != 1 || mb.bodies[1]["count"] != 3 {
t.Fatalf("%v", mb.bodies)
}
if ex := mb.bodies[1]["examples"].([]Denial); len(ex) != 2 {
t.Fatalf("the same denial is one example: %v", ex)
}
}
// TestNoEventCarriesTraffic holds every event body to names, pids, units, times, counts and the
// header fields of a denial: nothing in the watcher can carry a message's body.
func TestNoEventCarriesTraffic(t *testing.T) {
mb, c := &meshBus{}, start()
w := testWatcher(t, mb, c)
fb := newFakeBus("id-1", 500, map[string]uint32{})
w.connectedTo(fb)
fb.names["org.bluez"] = 900
w.observe(NameChange{Name: "org.bluez", New: ":1.9"})
c.advance(Debounce)
w.settle(fb)
w.markStalled("x")
w.answered()
w.identity("a", 1)
w.identity("b", 2)
w.journal = func(context.Context, string, time.Time) ([]Denial, string, error) {
d, cur := ParseDenials(`{"__CURSOR":"c","MESSAGE":"A security policy denied :1.9 to send method call /p:i.m to d.","DBUS_BROKER_MESSAGE_MEMBER":"m","SECRET_BODY":"hunter2"}`)
return d, cur, nil
}
w.readDenials(context.Background(), c.t)
w.flush()
allowed := map[string]bool{"at": true, "name": true, "pid": true, "process": true, "unit": true, "activatable": true,
"reason": true, "stalled_since": true, "stalled_seconds": true, "previous_bus_id": true, "bus_id": true,
"previous_pid": true, "count": true, "since": true, "examples": true}
if len(mb.types) != 5 {
t.Fatalf("%v", mb.types)
}
for i, b := range mb.bodies {
for k := range b {
if !allowed[k] {
t.Errorf("%s carries %q", mb.types[i], k)
}
}
raw, _ := json.Marshal(b)
if strings.Contains(string(raw), "hunter2") || strings.Contains(string(raw), "security policy") {
t.Errorf("%s carries what the bus logged verbatim: %s", mb.types[i], raw)
}
}
}
func TestRunWithoutABusSaysItStalledAndReconnects(t *testing.T) {
mb := &meshBus{}
m := testMachine(t, map[string]string{"/proc/sys/kernel/random/boot_id": "b\n"})
fb := newFakeBus("id-1", 500, map[string]uint32{})
var dials int
w := NewWatcher(m, mb.emit, func() (Bus, error) {
dials++
if dials == 1 {
return nil, errors.New("no socket")
}
return fb, nil
}, nil)
w.state = filepath.Join(t.TempDir(), "bus")
w.retry = 20 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
done := make(chan struct{})
go func() { w.Run(ctx); close(done) }()
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) && !w.Snapshot().Connected {
time.Sleep(50 * time.Millisecond)
}
if !w.Snapshot().Connected {
t.Fatal("the watcher did not reconnect")
}
close(fb.changes) // the bus goes away
for time.Now().Before(deadline) && w.Snapshot().Connected {
time.Sleep(10 * time.Millisecond)
}
cancel()
<-done
got := mb.seen()
if len(got) < 2 || got[0] != BusStalled || got[1] != BusRecovered {
t.Fatalf("%v", got)
}
}
-8
View File
@@ -1,8 +0,0 @@
module dbus
go 1.22
require (
git.novox.be/novox/mesh-sdk/go v0.1.7
github.com/godbus/dbus/v5 v5.1.0
)
-4
View File
@@ -1,4 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-67
View File
@@ -1,67 +0,0 @@
{
"module": "dbus",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"claims": [
{
"name": "node-message-bus",
"scope": "node"
}
],
"emits": [
"bus.stalled",
"bus.recovered",
"bus.restarted",
"service.appeared",
"service.left",
"policy.denied"
],
"tools": [
"dbus_names",
"dbus_introspect",
"dbus_monitor",
"dbus_check",
"dbus_health"
],
"resources": [
{
"id": "dbus",
"type": "package",
"package": "dbus"
},
{
"id": "broker",
"type": "package",
"package": "dbus-broker"
},
{
"id": "broker-units",
"type": "package",
"package": "dbus-broker-units"
},
{
"id": "system-bus",
"type": "service",
"unit": "dbus-broker.service",
"state": "running"
}
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/dbus-tools",
"binary": "dbus-tools",
"loads": [
"dbus-tools"
]
}
]
}
}
File diff suppressed because one or more lines are too long
-7
View File
@@ -45,10 +45,3 @@ setting once issue 168 closes, not a file the next push would overwrite.
- `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads - `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads
them as unknown. them as unknown.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -22,9 +22,7 @@ type manifest struct {
Environment *environment `json:"environment"` Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"` Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"` Resources []map[string]any `json:"resources"`
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. Build struct {
Contributions []contribution `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"` Artifacts []map[string]any `json:"artifacts"`
} `json:"build"` } `json:"build"`
} }
@@ -175,32 +173,3 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
} }
} }
} }
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// i3Lines is what the module contributes to the window manager.
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
}
}
+3 -3
View File
@@ -63,11 +63,11 @@ func TestFehbgIsOwnedAndTheSessionStartRunsItOnce(t *testing.T) {
func TestTheKeyThatRestoresTheWallpaperIsAnI3DropIn(t *testing.T) { func TestTheKeyThatRestoresTheWallpaperIsAnI3DropIn(t *testing.T) {
m := readManifest(t) m := readManifest(t)
m.i3LinesAreSource(t, "files/i3/50-feh.conf") m.sameAsSource(t, "i3-bindings", "files/i3/50-feh.conf")
if p := m.i3Lines(); p == "" { if p := m.resource(t, "i3-bindings")["path"]; p != "${machine:account-home}/.config/i3/config.d/50-feh.conf" {
t.Fatalf("path: %v", p) t.Fatalf("path: %v", p)
} }
if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") { if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") {
t.Fatalf("%s", c) t.Fatalf("%s", c)
} }
} }
+9 -8
View File
@@ -38,6 +38,14 @@
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0755", "mode": "0755",
"content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n" "content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n"
},
{
"id": "i3-bindings",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/50-feh.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
} }
], ],
"build": { "build": {
@@ -59,12 +67,5 @@
] ]
} }
] ]
}, }
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
}
]
} }
-10
View File
@@ -564,16 +564,6 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member); GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member);
} }
/** Withdraw a user and keep everything they own: login prohibited, which ensureUser undoes. */
async prohibitLogin(username: string): Promise<void> {
const res = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
method: "PATCH",
body: JSON.stringify({ login_name: username, prohibit_login: true }),
});
if (res.status === 200 || res.status === 404) return;
GiteaAdmin.fail(`/admin/users/${username}`, res);
}
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not /** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
* an error, so a re-run of remove is safe. */ * an error, so a re-run of remove is safe. */
async deleteUser(username: string): Promise<void> { async deleteUser(username: string): Promise<void> {
+1 -2
View File
@@ -54,8 +54,7 @@ runProvisioner("npm-package-registry", {
}, },
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
// Login prohibited, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): deleting purges every repository the user owns. await gitea.deleteUser(p.as);
await gitea.prohibitLogin(p.as);
}, },
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
+178
View File
@@ -0,0 +1,178 @@
// 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 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.
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { isIP } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileP = promisify(execFile);
/** Where the file is. The manifest's resource names the same path; a test holds the two together. */
export const HOSTS_FILE = "/etc/hosts";
/** A command runner, so the writes can be tested without a machine. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
const run: Runner = async (cmd, args) => {
const [program, argv] = escalated(cmd, args);
const { stdout } = await execFileP(program, argv);
return stdout;
};
/** One line of the file, as a reader sees it. */
export interface Line {
/** The line exactly as it is in the file. */
text: string;
/** Whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator". */
owner: string;
/** For an entry: its address and names. Absent for a comment or blank line. */
address?: string;
names?: string[];
}
const BEGIN = /^#\s*BEGIN\s+(.+?)\s*$/;
const END = /^#\s*END\s+(.+?)\s*$/;
/** Every line of a hosts file, each marked whose it is. */
export function parse(text: string): Line[] {
const out: Line[] = [];
let block: string | null = null;
for (const raw of text.split("\n")) {
const begin = raw.match(BEGIN);
if (!block && begin) {
block = begin[1];
out.push({ text: raw, owner: block });
continue;
}
const owner = block ?? "operator";
const entry = raw.replace(/#.*/, "").trim().split(/\s+/).filter(Boolean);
const line: Line = { text: raw, owner };
if (entry.length >= 2 && isIP(entry[0])) {
line.address = entry[0];
line.names = entry.slice(1);
}
out.push(line);
const end = raw.match(END);
if (block && end && end[1] === block) block = null;
}
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
if (out.length > 0 && out[out.length - 1].text === "" && text.endsWith("\n")) out.pop();
return out;
}
const NAME = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[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. */
function checkAddress(address: string): void {
if (!isIP(address)) throw new Error(`${JSON.stringify(address)} is not an IPv4 or IPv6 address`);
}
function checkName(name: string): void {
if (!NAME.test(name)) throw new Error(`${JSON.stringify(name)} is not a host name`);
}
/** The file with one address and its names added to the operator's lines; unchanged when already there. */
export function withAdded(text: string, address: string, names: string[]): string {
checkAddress(address);
if (names.length === 0) throw new Error("add names at least one name for the address");
names.forEach(checkName);
const lines = parse(text);
const have = new Set(
lines.filter((l) => l.owner === "operator" && l.address === address).flatMap((l) => l.names ?? []),
);
const missing = names.filter((n) => !have.has(n));
if (missing.length === 0) return text;
const body = text.endsWith("\n") || text === "" ? text : text + "\n";
return body + `${address}\t${missing.join(" ")}\n`;
}
/** The file with one name, or every line of one address, taken out of the operator's lines. Blocks are
* never touched: a name only the mesh or another tool writes is refused, naming whose it is. */
export function withRemoved(text: string, what: string): { text: string; removed: number } {
const byAddress = isIP(what) !== 0;
if (!byAddress) checkName(what);
const lines = parse(text);
let removed = 0;
const kept: string[] = [];
for (const l of lines) {
if (l.owner !== "operator" || !l.address) {
kept.push(l.text);
continue;
}
if (byAddress && l.address === what) {
removed++;
continue;
}
if (!byAddress && l.names?.includes(what)) {
removed++;
const rest = l.names.filter((n) => n !== what);
if (rest.length > 0) kept.push(`${l.address}\t${rest.join(" ")}`);
continue;
}
kept.push(l.text);
}
if (removed === 0) {
const elsewhere = lines.find((l) => l.owner !== "operator" && (byAddress ? l.address === what : l.names?.includes(what)));
if (elsewhere) throw new Error(`${what} is written by ${elsewhere.owner}, not the operator; it is not this verb's to remove`);
}
return { text: kept.join("\n") + "\n", removed };
}
export class HostsFile {
private readonly path: string;
private readonly runner: Runner;
constructor(path: string = HOSTS_FILE, runner: Runner = run) {
this.path = path;
this.runner = runner;
}
static onThisMachine(): HostsFile {
return new HostsFile();
}
async read(): Promise<string> {
return readFile(this.path, "utf8");
}
async entries(): Promise<{ path: string; lines: Line[] }> {
return { path: this.path, lines: parse(await this.read()) };
}
async add(address: string, names: string[]): Promise<{ added: boolean; line?: string }> {
const before = await this.read();
const after = withAdded(before, address, names);
if (after === before) return { added: false };
await this.write(after);
return { added: true, line: after.slice(before.length).trim() };
}
async remove(what: string): Promise<{ removed: number }> {
const before = await this.read();
const { text, removed } = withRemoved(before, what);
if (removed > 0) await this.write(text);
return { removed };
}
/** Written whole through a copy beside it, so a reader never sees half a file. */
private async write(content: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), "hosts-"));
const staged = join(dir, "hosts");
try {
await writeFile(staged, content, { mode: 0o644 });
await this.runner("install", ["-m", "0644", staged, this.path]);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
}
+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\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
]
}
]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@novox/module-hosts",
"version": "0.1.0",
"description": "hosts — holds the node-hosts-file seat: writes the machine's own lines into /etc/hosts and serves the verbs entries, add and remove over the operator's lines (novox/hq ADR 0199).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
},
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
}
}
+69
View File
@@ -0,0 +1,69 @@
// 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 { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { HOSTS_FILE, escalated, parse, withAdded, withRemoved } from "../client.ts";
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";
const blocks = (text: string) => parse(text).filter((l) => l.owner !== "operator").map((l) => l.text);
test("every line says whose it is", () => {
const lines = parse(FILE);
assert.equal(lines.length, 10);
assert.deepEqual(lines[1], { text: "127.0.0.1\tlocaldev.example.com", owner: "operator", address: "127.0.0.1", names: ["localdev.example.com"] });
assert.equal(lines[4].owner, "mesh hosts.own");
assert.equal(lines[8].owner, "other-tool");
assert.deepEqual(lines[8].names, ["project.test"]);
});
test("add appends an operator line, and is a no-op when the names are there", () => {
const after = withAdded(FILE, "192.0.2.9", ["lab.test", "www.lab.test"]);
assert.ok(after.endsWith("192.0.2.9\tlab.test www.lab.test\n"));
assert.deepEqual(blocks(after), blocks(FILE));
assert.equal(withAdded(FILE, "127.0.0.1", ["a.example.com"]), FILE);
assert.ok(withAdded(FILE, "127.0.0.1", ["a.example.com", "c.example.com"]).endsWith("127.0.0.1\tc.example.com\n"));
});
test("add refuses what is not an address or a host name", () => {
assert.throws(() => withAdded(FILE, "not-an-ip", ["x.test"]), /not an IPv4 or IPv6 address/);
assert.throws(() => withAdded(FILE, "192.0.2.9", ["bad name\n10.0.0.1 evil"]), /not a host name/);
assert.throws(() => withAdded(FILE, "192.0.2.9", []), /at least one name/);
});
test("remove takes one name or one address from the operator's lines, and blocks stay byte for byte", () => {
const one = withRemoved(FILE, "a.example.com");
assert.equal(one.removed, 1);
assert.ok(one.text.includes("127.0.0.1\tb.example.com\n"));
assert.ok(!one.text.includes("a.example.com"));
assert.deepEqual(blocks(one.text), blocks(FILE));
const all = withRemoved(FILE, "127.0.0.1");
assert.equal(all.removed, 2);
assert.ok(all.text.includes("# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n"), "the mesh's own localhost is not the operator's to remove");
});
test("remove refuses a name only a block writes, naming whose", () => {
assert.throws(() => withRemoved(FILE, "project.test"), /written by other-tool/);
assert.equal(withRemoved(FILE, "nowhere.test").removed, 0);
});
test("the file is written as root through sudo where the account is not root", () => {
assert.deepEqual(escalated("install", ["x"], 1000), ["sudo", ["-n", "install", "x"]]);
assert.deepEqual(escalated("install", ["x"], 0), ["install", ["x"]]);
});
test("the path the code writes is the path the manifest's resource declares", () => {
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
assert.equal(manifest.resources.find((r: { id: string }) => r.id === "own").path, HOSTS_FILE);
});
+40
View File
@@ -0,0 +1,40 @@
// The hosts file's tools: the node-hosts-file seat's three verbs (novox/hq ADR 0199) — 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.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { HostsFile } from "../client.js";
export function getSeatVerbs(hosts: HostsFile): ToolDefinition[] {
return [
{
name: "entries",
description:
"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.",
input: {},
run: async () => hosts.entries(),
},
{
name: "add",
description:
"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.",
input: {
address: { type: "string", description: "the IPv4 or IPv6 address" },
names: { type: "string", description: "the names for it, separated by spaces" },
},
run: async (args) =>
hosts.add(String(args.address ?? ""), String(args.names ?? "").split(/[\s,]+/).filter(Boolean)),
},
{
name: "remove",
description:
"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.",
input: { name: { type: "string", description: "a host name, or an address to remove every line of" } },
run: async (args) => hosts.remove(String(args.name ?? "")),
},
];
}
const hosts = HostsFile.onThisMachine();
// The seat's verbs under the seat's name: the runtime serves them as <node>/node-hosts-file.<verb>.
registerModuleTools("node-hosts-file", () => getSeatVerbs(hosts));
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"tools/index.ts"
]
}
-8
View File
@@ -150,11 +150,3 @@ about to end, or accept it: a reload keeps every window and workspace.
written the files. So at its start the watcher compares the files on disk with what the running i3 written the files. So at its start the watcher compares the files on disk with what the running i3
loaded (`GET_CONFIG`), and reloads when they differ. The push that assigns `i3`, or changes its loaded (`GET_CONFIG`), and reloads when they differ. The push that assigns `i3`, or changes its
configuration, is therefore reloaded although it also restarted the watcher. configuration, is therefore reloaded although it also restarted the watcher.
## Other modules' lines are contributions (changed 2026-10-05, novox/hq ADR 0212)
`node-display-session` receives `config` contributions, and this module places them with
`${contribution:node-display-session:config}` near the end of its configuration, each module's
under a line naming it. `~/.config/i3/config.d/` is now the operator's alone: the include at the very
end reads it after the mesh's lines. The catalogue-wide test composes every module's contribution as
the controller does and checks the result with `i3 -C`.
+14 -39
View File
@@ -10,7 +10,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"testing" "testing"
) )
@@ -139,12 +138,7 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin
t.Fatal("the lock key goes through logind, which the lock screen's module relies on") t.Fatal("the lock key goes through logind, which the lock screen's module relies on")
} }
if i3, err := exec.LookPath("i3"); err == nil { if i3, err := exec.LookPath("i3"); err == nil {
// As a node with no contribution and no file of the operator's would hold it. out, err := exec.Command(i3, "-C", "-c", filepath.Join("..", "..", "config", "config")).CombinedOutput()
alone := strings.Replace(strings.Replace(c, "${contribution:node-display-session:config}", "", 1),
"\ninclude ~/.config/i3/config.d/*.conf", "\n", 1)
path := filepath.Join(t.TempDir(), "config")
os.WriteFile(path, []byte(alone), 0o644)
out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput()
if err != nil || len(ParseCheck(string(out))) > 0 { if err != nil || len(ParseCheck(string(out))) > 0 {
t.Fatalf("i3 -C: %v %s", err, out) t.Fatalf("i3 -C: %v %s", err, out)
} }
@@ -175,56 +169,37 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) {
} }
} }
// Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in // Every module of the catalogue that drops a file into i3's config.d is loaded with the main file, as
// the main file as the controller places them — in module order, each module's under a line naming it // i3 would load them on a machine with all of them assigned: no two bind one key, and every line parses.
// — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and func TestTheMainFileAndEveryModulesDropInLoadTogether(t *testing.T) {
// every line parses.
func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) {
i3, err := exec.LookPath("i3") i3, err := exec.LookPath("i3")
if err != nil { if err != nil {
t.Skip("no i3 here to check with") t.Skip("no i3 here to check with")
} }
dir := t.TempDir() dir := t.TempDir()
drop := filepath.Join(dir, "config.d")
os.MkdirAll(drop, 0o755)
manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json")) manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json"))
sort.Strings(manifests)
var placed strings.Builder
var found []string var found []string
for _, p := range manifests { for _, p := range manifests {
raw, _ := os.ReadFile(p) raw, _ := os.ReadFile(p)
var m struct { var m struct {
Module string `json:"module"` Resources []map[string]any `json:"resources"`
Contributions []struct {
Seat, Kind, Content string
} `json:"contributions"`
} }
json.Unmarshal(raw, &m) json.Unmarshal(raw, &m)
named := false for _, r := range m.Resources {
for _, c := range m.Contributions { path, _ := r["path"].(string)
if c.Seat != "node-display-session" || c.Kind != "config" { if r["type"] == "file" && strings.Contains(path, "/.config/i3/config.d/") {
continue os.WriteFile(filepath.Join(drop, filepath.Base(path)), []byte(r["content"].(string)), 0o644)
} found = append(found, filepath.Base(path))
if !named {
placed.WriteString("# " + m.Module + "\n")
named = true
found = append(found, m.Module)
}
placed.WriteString(c.Content)
if !strings.HasSuffix(c.Content, "\n") {
placed.WriteString("\n")
} }
} }
} }
if len(found) < 5 {
t.Fatalf("expected the launcher, clipboard, wallpaper, bars and the laptop to contribute; found %v", found)
}
main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config")) main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config"))
text := strings.Replace(string(main), "${contribution:node-display-session:config}", placed.String(), 1) text := strings.Replace(string(main), "include ~/.config/i3/config.d/*.conf", "include "+drop+"/*.conf", 1)
// The operator's own files are not the catalogue's: the include line itself goes (a contribution may
// name it in a comment, so only the line that is exactly it).
text = strings.Replace(text, "\ninclude ~/.config/i3/config.d/*.conf", "\n", 1)
os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644) os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644)
out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput() out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput()
if err != nil || len(ParseCheck(string(out))) > 0 { if err != nil || len(ParseCheck(string(out))) > 0 {
t.Fatalf("with the contributions of %v: %v\n%s", found, err, out) t.Fatalf("with the drop-ins %v: %v\n%s", found, err, out)
} }
} }
+7 -14
View File
@@ -1,10 +1,9 @@
# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push; # i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;
# change the module instead. i3's user guide is the reference. # change the module instead. i3's user guide is the reference.
# #
# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR # Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named
# 0212): the mesh places their lines near the end, each module's under a line naming it, where every # <NN>-<module>.conf and read in name order by the include at the end, where every variable set here
# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read # ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours.
# after them, by the include at the very end, and is yours.
# #
# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the # The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the
# result with i3 -C; it never reloads into a configuration with errors. # result with i3 -C; it never reloads into a configuration with errors.
@@ -173,9 +172,9 @@ client.urgent #900000 #900000 #ffffff #900000 #900000
###### Until their modules carry them ## ###### Until their modules carry them ##
######################################### #########################################
# Each line below belongs to something other than i3, named on its line. When that module is written # Each line below belongs to something other than i3, named on its line. When that module is written
# it contributes the line to node-display-session, and the line goes from here in the same change. # it contributes the line as its own drop-in in config.d, and the line goes from here in the same
# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute # change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs
# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14). # (rofi, clipmenu, feh, i3status-rust, gnome-keyring).
# the peripherals' tray (the operator's application) # the peripherals' tray (the operator's application)
exec --no-startup-id polychromatic-tray-applet exec --no-startup-id polychromatic-tray-applet
@@ -191,12 +190,6 @@ bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.s
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
######################################### #########################################
###### Other modules' lines #### ###### Other modules' drop-ins ####
#########################################
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.
${contribution:node-display-session:config}
#########################################
###### Your own files ####
######################################### #########################################
include ~/.config/i3/config.d/*.conf include ~/.config/i3/config.d/*.conf
File diff suppressed because one or more lines are too long
-7
View File
@@ -84,10 +84,3 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod
runs per session and ends with the session, as a session-start line already does. runs per session and ends with the session, as a session-start line already does.
- `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared - `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared
once per node. once per node.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -22,9 +22,7 @@ type manifest struct {
Environment *environment `json:"environment"` Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"` Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"` Resources []map[string]any `json:"resources"`
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. Build struct {
Contributions []contribution `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"` Artifacts []map[string]any `json:"artifacts"`
} `json:"build"` } `json:"build"`
} }
@@ -175,32 +173,3 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
} }
} }
} }
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// i3Lines is what the module contributes to the window manager.
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
}
}
@@ -31,7 +31,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
m := readManifest(t) m := readManifest(t)
for id, src := range map[string]string{ for id, src := range map[string]string{
"top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml", "top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml",
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "i3-bars": "files/i3/60-i3status-rust.conf",
} { } {
m.sameAsSource(t, id, src) m.sameAsSource(t, id, src)
} }
@@ -47,10 +47,13 @@ func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testin
} }
} }
func TestTheBarsAreAContributionToTheWindowManagerAndTheWatchdogRunsOncePerSession(t *testing.T) { func TestTheBarsAreAnI3DropInAndTheWatchdogRunsOncePerSession(t *testing.T) {
m := readManifest(t) m := readManifest(t)
m.i3LinesAreSource(t, "files/i3/60-i3status-rust.conf") bars := m.resource(t, "i3-bars")
c := m.i3Lines() if bars["path"] != "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf" {
t.Fatalf("%v", bars["path"])
}
c := bars["content"].(string)
if strings.Count(c, "bar {") != 2 || !strings.Contains(c, "status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml") || if strings.Count(c, "bar {") != 2 || !strings.Contains(c, "status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml") ||
!strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") { !strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") {
t.Fatalf("%s", c) t.Fatalf("%s", c)
+9 -8
View File
@@ -85,6 +85,14 @@
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0755", "mode": "0755",
"content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n" "content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n"
},
{
"id": "i3-bars",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n"
} }
], ],
"build": { "build": {
@@ -101,12 +109,5 @@
] ]
} }
] ]
}, }
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n"
}
]
} }
-5
View File
@@ -140,11 +140,6 @@ export class MailuClient {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true }); await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true });
} }
/** Withdraw a mailbox and keep its mail: disabled, which applyProvisioned undoes. */
async disableUser(email: string): Promise<void> {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { enabled: false });
}
async deleteUser(email: string): Promise<void> { async deleteUser(email: string): Promise<void> {
await this.api("DELETE", `/user/${encodeURIComponent(email)}`); await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
} }
+1 -3
View File
@@ -75,9 +75,7 @@ runProvisioner("smtp", {
// exists, and left otherwise — a mailbox holding mail is the one thing a background loop // exists, and left otherwise — a mailbox holding mail is the one thing a background loop
// must not guess about (this module's own events file says the same). Withdrawal of a // must not guess about (this module's own events file says the same). Withdrawal of a
// named-account consumer is an operator action until the harness carries values here. // named-account consumer is an operator action until the harness carries values here.
// Disabled, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): a mailbox holding mail is the one thing a background loop must await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
// not destroy. applyProvisioned enables it again when the consumer returns.
await mailu.disableUser(`${p.as}@${domain()}`).catch(() => {});
}, },
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
+8 -3
View File
@@ -47,10 +47,15 @@ runProvisioner("s3-bucket", {
async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> { async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> {
const bucket = bucketNamed(p.derived); const bucket = bucketNamed(p.derived);
// Revoking the key is what cuts the consumer's access, and the bucket is kept, empty or not // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if
// (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). A bucket is removed by a person, never by this loop. // empty; a bucket that still holds objects is left for an operator rather than erroring on every
// reconcile tick — access is already gone, and silently deleting a consumer's data would be worse.
try { await minio.removeAccessKey(p.as); } catch { /* already gone */ } try { await minio.removeAccessKey(p.as); } catch { /* already gone */ }
console.error(`[minio] ${p.as} withdrawn: access key revoked, bucket ${bucket} kept`); try {
await minio.removeBucket(bucket);
} catch (err) {
console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`);
}
await announce("bucket.removed", { bucket, accessKey: p.as }); await announce("bucket.removed", { bucket, accessKey: p.as });
}, },
-12
View File
@@ -125,18 +125,6 @@ export class MongoClient {
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the /** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
* user is removed first so a re-grant of the same login starts clean. */ * user is removed first so a re-grant of the same login starts clean. */
/** Withdraw a consumer and keep its database: the user keeps its name and loses every role. */
async lockUser(database: string, user: string): Promise<void> {
await this.admin(async (client) => {
const target = client.db(database);
try {
await target.command({ updateUser: user, roles: [] });
} catch (err) {
if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
}
});
}
async dropDatabaseAndUser(database: string, user: string): Promise<void> { async dropDatabaseAndUser(database: string, user: string): Promise<void> {
await this.admin(async (client) => { await this.admin(async (client) => {
const target = client.db(database); const target = client.db(database);
+2 -3
View File
@@ -41,9 +41,8 @@ runProvisioner("mongodb-database", {
}, },
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
// Locked, never dropped (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). create gives the roles back. await mongo.dropDatabaseAndUser(p.as, p.as);
await mongo.lockUser(p.as, p.as); await announce("database.deprovisioned", { database: p.as });
await announce("database.deprovisioned", { database: p.as, kept: "true" });
}, },
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
-8
View File
@@ -256,14 +256,6 @@ export class MssqlClient {
} }
/** Drop a database and its login, idempotently, after evicting live connections. */ /** Drop a database and its login, idempotently, after evicting live connections. */
/** Withdraw a consumer and keep its database: its login is disabled, which create undoes. */
async disableLogin(login: string): Promise<void> {
const logins = await this.query(
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
);
if (logins.length > 0) await this.exec(`ALTER LOGIN ${ident(login)} DISABLE`);
}
async dropDatabaseAndLogin(database: string, login: string): Promise<void> { async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
const dbs = await this.query( const dbs = await this.query(
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
+2 -3
View File
@@ -41,9 +41,8 @@ runProvisioner("mssql-database", {
}, },
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
// Disabled, never dropped (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). create enables the login again. await mssql.dropDatabaseAndLogin(p.as, p.as);
await mssql.disableLogin(p.as); await announce("database.deprovisioned", { database: p.as });
await announce("database.deprovisioned", { database: p.as, kept: "true" });
}, },
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
+4 -40
View File
@@ -145,43 +145,14 @@ export class PostgresClient {
} }
} }
/** /** Drop a database and its owning role, idempotently, after evicting live connections. */
* Withdraw a consumer without destroying anything (novox/hq issue 241): its login can no longer log async dropDatabaseAndRole(database: string, role: string): Promise<void> {
* in and its open connections are ended, and its database stays exactly as it was, under its own
* name. A consumer that comes back is given the same database — create sets LOGIN again — which is
* what a provider must do when "no longer asked for" turns out to be a moment's misreading.
*/
async lockRole(role: string): Promise<void> {
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role));
if (roles.rows.length === 0) return;
await this.query(`ALTER ROLE ${ident(role)} NOLOGIN`);
await this.query(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = " +
literal(role) + " AND pid <> pg_backend_pid()",
);
}
/**
* Take a database out of service on purpose: rename it aside to `<name>_deleted_<date>` and lock
* its owner. Never a drop — the data stays on the server under the new name until a person
* removes it by hand. Returns the name it now has.
*/
async retireDatabase(database: string, now: Date = new Date()): Promise<string> {
const found = await this.query(
"SELECT pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = " + literal(database),
);
if (found.rows.length === 0) throw new Error(`no database named ${database}`);
const owner = String((found.rows[0] as Record<string, unknown>).owner ?? "");
const aside = retiredName(database, now);
const taken = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(aside));
if (taken.rows.length > 0) throw new Error(`${aside} already exists; retire it by hand first`);
if (owner && owner !== "postgres") await this.query(`ALTER ROLE ${ident(owner)} NOLOGIN`);
await this.query( await this.query(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " +
literal(database) + " AND pid <> pg_backend_pid()", literal(database) + " AND pid <> pg_backend_pid()",
); );
await this.query(`ALTER DATABASE ${ident(database)} RENAME TO ${ident(aside)}`); await this.query(`DROP DATABASE IF EXISTS ${ident(database)}`);
return aside; await this.query(`DROP ROLE IF EXISTS ${ident(role)}`);
} }
/** List the non-template databases, with size, for the postgres_list_databases tool. */ /** List the non-template databases, with size, for the postgres_list_databases tool. */
@@ -330,10 +301,3 @@ function parseCsv(text: string): string[][] {
endRecord(); endRecord();
return records; return records;
} }
/** The name a retired database is renamed to: `<name>_deleted_<yyyymmdd>`, within postgres's 63 bytes. */
export function retiredName(database: string, now: Date = new Date()): string {
const stamp = now.toISOString().slice(0, 10).replace(/-/g, "");
const suffix = `_deleted_${stamp}`;
return database.slice(0, 63 - suffix.length) + suffix;
}
+2 -6
View File
@@ -41,13 +41,9 @@ runProvisioner("postgres-database", {
}); });
}, },
// **Withdrawn, never dropped** (novox/hq issue 241). The login is locked and the database kept
// under its own name: on 2026-10-04 a misread contributions file withdrew every consumer at once,
// and dropping made that a loss of seven databases. Taking a database out of service is a person's
// act — the postgres_retire_database tool — and even that renames rather than drops.
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
await postgres.lockRole(p.as); await postgres.dropDatabaseAndRole(p.as, p.as);
await announce("database.deprovisioned", { database: p.as, kept: "true" }); await announce("database.deprovisioned", { database: p.as });
}, },
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
-69
View File
@@ -1,69 +0,0 @@
// A withdrawn consumer keeps its database, and retiring one renames it — nothing here ever drops
// (novox/hq issue 241). psql is a fake on PATH that records every statement and answers the lookups
// these acts make; that the server keeps the data is proven against a real server, not here.
// Run against the compiled module (npm test builds first), the way the runtime loads it.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PostgresClient, retiredName } from "../dist/client.js";
let dir: string;
let log: string;
const originalPath = process.env.PATH;
before(async () => {
dir = await mkdtemp(join(tmpdir(), "postgres-withdraw-"));
log = join(dir, "calls.jsonl");
await writeFile(join(dir, "psql"), `#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
const sql = args[args.indexOf("-c") + 1];
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ sql }) + "\\n");
if (/FROM pg_roles/.test(sql)) process.stdout.write("?column?\\n1\\n");
else if (/pg_get_userbyid/.test(sql)) process.stdout.write("owner\\nmesh_anchor_mail\\n");
else if (/FROM pg_database WHERE datname = '.*_deleted_/.test(sql)) process.stdout.write("?column?\\n");
else process.stdout.write("");
`);
await chmod(join(dir, "psql"), 0o755);
process.env.PATH = `${dir}:${originalPath}`;
});
after(async () => {
process.env.PATH = originalPath;
await rm(dir, { recursive: true, force: true });
});
const statements = async (): Promise<string[]> =>
(await readFile(log, "utf8")).trim().split("\n").map((l) => (JSON.parse(l) as { sql: string }).sql);
const client = (): PostgresClient =>
new PostgresClient({ host: "127.0.0.1", port: 5432, user: "postgres", password: "admin-secret" });
test("withdrawing a consumer locks its login and keeps its database", async () => {
await writeFile(log, "");
await client().lockRole("mesh_anchor_mail");
const sql = await statements();
assert.ok(sql.some((s) => /ALTER ROLE "mesh_anchor_mail" NOLOGIN/.test(s)), sql.join("\n"));
assert.ok(!sql.some((s) => /\bDROP\b/i.test(s)), `a withdrawal dropped something:\n${sql.join("\n")}`);
});
test("retiring a database renames it aside and locks its owner, and drops nothing", async () => {
await writeFile(log, "");
const now = new Date("2026-10-05T09:00:00Z");
const aside = await client().retireDatabase("mesh_anchor_mail", now);
assert.equal(aside, "mesh_anchor_mail_deleted_20261005");
const sql = await statements();
assert.ok(sql.some((s) => /ALTER DATABASE "mesh_anchor_mail" RENAME TO "mesh_anchor_mail_deleted_20261005"/.test(s)), sql.join("\n"));
assert.ok(sql.some((s) => /ALTER ROLE "mesh_anchor_mail" NOLOGIN/.test(s)));
assert.ok(!sql.some((s) => /\bDROP\b/i.test(s)), `retiring dropped something:\n${sql.join("\n")}`);
});
test("a retired name fits postgres's 63 bytes", () => {
const long = "x".repeat(70);
const name = retiredName(long, new Date("2026-10-05T00:00:00Z"));
assert.ok(name.length <= 63 && name.endsWith("_deleted_20261005"), name);
});
-17
View File
@@ -30,23 +30,6 @@ export function getPostgresTools(postgres: PostgresClient): ToolDefinition[] {
return { database, command: result.command, rows: result.rows }; return { database, command: result.command, rows: result.rows };
}, },
}, },
{
name: "postgres_retire_database",
description:
"Take one database out of service on purpose: rename it to <name>_deleted_<date> and lock its owner's login. Nothing is dropped — the data stays on the server under the new name until a person removes it by hand. Repeat the database's name in confirm.",
input: {
database: { type: "string", description: "the database to retire" },
confirm: { type: "string", description: "the same name again, to say this is meant" },
},
run: async (args) => {
const database = String(args.database ?? "");
if (!database) throw new Error("postgres_retire_database: database is required");
if (String(args.confirm ?? "") !== database) {
throw new Error("postgres_retire_database: confirm must repeat the database's name");
}
return { database, renamedTo: await postgres.retireDatabase(database), dropped: false };
},
},
]; ];
} }
-4
View File
@@ -80,7 +80,3 @@ declared stopped. The first version tried that and was refused on its first appl
units are therefore not services the mesh manages. They have no install section, and the sleep units are therefore not services the mesh manages. They have no install section, and the sleep
targets want them through this module's drop-ins (`sleep.target.d`, and `suspend.target.d` with targets want them through this module's drop-ins (`sleep.target.d`, and `suspend.target.d` with
its three siblings) — the same way the laptop module asks for the NVIDIA driver's sleep actions. its three siblings) — the same way the laptop module asks for the NVIDIA driver's sleep actions.
**polkit is this module's package.** logind gives a delay lock to a process that is not root only
through polkit. A server without polkit refused the watcher with "Permission denied", and its
sleeps and shutdowns would have gone unsaid. The module installs it; nothing else needed it there.
-22
View File
@@ -1,9 +1,7 @@
package main package main
import ( import (
"fmt"
"os" "os"
"strings"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
@@ -58,29 +56,9 @@ func (l *systemLogin1) Inhibit(what, who, why string) (*os.File, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// What came back must be logind's reference to the lock. Without descriptor passing on the
// connection the value is an index, not a descriptor — 0 is the bundle's own stdin, its channel to
// the runtime — and closing it at the next sleep would cut the module off. Never wrapped unchecked.
if !IsInhibitor(int(fd)) {
return nil, fmt.Errorf("logind answered descriptor %d, which is not an inhibitor reference (%s)", int(fd), fdTarget(int(fd)))
}
return os.NewFile(uintptr(fd), "logind-inhibitor"), nil return os.NewFile(uintptr(fd), "logind-inhibitor"), nil
} }
// fdTarget is what one of this process's descriptors points at.
func fdTarget(fd int) string {
target, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", fd))
if err != nil {
return err.Error()
}
return target
}
// IsInhibitor is whether a descriptor of this process is a logind inhibitor's reference.
func IsInhibitor(fd int) bool {
return fd > 2 && strings.HasPrefix(fdTarget(fd), "/run/systemd/inhibit/")
}
func (l *systemLogin1) Signals() <-chan Signal { return l.out } func (l *systemLogin1) Signals() <-chan Signal { return l.out }
func (l *systemLogin1) Close() { l.conn.Close() } func (l *systemLogin1) Close() { l.conn.Close() }
-24
View File
@@ -164,27 +164,6 @@ func (w *Watcher) inhibit(l Login1) {
return return
} }
w.lock = f w.lock = f
if strings.HasPrefix(w.issue, "no delay lock") || strings.HasPrefix(w.issue, "logind") {
w.issue = "" // solved: a problem that no longer holds is not said
}
}
// stillHeld says whether the lock's descriptor is still logind's reference; replaceable in tests.
var stillHeld = func(f *os.File) bool { return IsInhibitor(int(f.Fd())) }
// verify takes the lock again when it is no longer held, and says so: a lock lost silently would
// let the machine sleep without a word on the bus.
func (w *Watcher) verify(l Login1) {
w.mu.Lock()
lock := w.lock
w.mu.Unlock()
if lock != nil && stillHeld(lock) {
return
}
w.mu.Lock()
w.lock = nil
w.mu.Unlock()
w.inhibit(l)
} }
func (w *Watcher) release() { func (w *Watcher) release() {
@@ -285,9 +264,6 @@ func (w *Watcher) Run(ctx context.Context) {
} }
w.handle(l, s) w.handle(l, s)
case <-tick.C: case <-tick.C:
if l != nil {
w.verify(l)
}
w.supply() w.supply()
w.flush() w.flush()
} }
-30
View File
@@ -53,36 +53,6 @@ func (b *bus) seen() []string {
return append([]string(nil), b.got...) return append([]string(nil), b.got...)
} }
func init() { stillHeld = func(*os.File) bool { return true } }
func TestALostLockIsTakenAgain(t *testing.T) {
l := &fakeLogin{}
w := watcher(t, machine(t, nil), &bus{})
w.inhibit(l)
held := false
stillHeld = func(*os.File) bool { return held }
defer func() { stillHeld = func(*os.File) bool { return true } }()
w.verify(l)
if l.held != 2 || !w.Snapshot().Inhibiting {
t.Fatalf("a lock that was no longer held was not taken again: held %d %+v", l.held, w.Snapshot())
}
}
func TestAProblemIsNoLongerSaidOnceTheLockIsHeld(t *testing.T) {
w := watcher(t, machine(t, nil), &bus{})
w.problem("no delay lock: Permission denied")
w.inhibit(&fakeLogin{})
if s := w.Snapshot(); !s.Inhibiting || s.Problem != "" {
t.Fatalf("%+v", s)
}
}
func TestStdinIsNeverTakenForALock(t *testing.T) {
if IsInhibitor(0) || IsInhibitor(1) || IsInhibitor(2) {
t.Fatal("a standard descriptor was taken for logind's lock")
}
}
func machine(t *testing.T, files map[string]string) *Machine { func machine(t *testing.T, files map[string]string) *Machine {
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()
+1 -7
View File
@@ -2,7 +2,6 @@
"module": "power", "module": "power",
"version": "1", "version": "1",
"capabilities": [ "capabilities": [
"package-manager",
"service-manager" "service-manager"
], ],
"claims": [ "claims": [
@@ -28,11 +27,6 @@
"power_check" "power_check"
], ],
"resources": [ "resources": [
{
"id": "polkit",
"type": "package",
"package": "polkit"
},
{ {
"id": "scripts", "id": "scripts",
"type": "archive", "type": "archive",
@@ -219,7 +213,7 @@
"type": "file", "type": "file",
"path": "/etc/udev/rules.d/90-mesh-power.rules", "path": "/etc/udev/rules.d/90-mesh-power.rules",
"mode": "0644", "mode": "0644",
"content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n# Only the charger's change can switch the machine between mains and battery. A battery reports its\n# level many times a minute, and USB-C supplies come and go; matching them started the supply unit\n# every second or two on the laptop.\nSUBSYSTEM==\"power_supply\", ACTION==\"change\", ATTR{type}==\"Mains\", RUN+=\"/usr/bin/systemctl --no-block start mesh-power-supply.service\"\n" "content": "# Written by the mesh (module power, novox/hq ADR 0211). Replaced on every push.\n# A change of a power supply runs the power source's moment once (the runner says nothing twice).\nSUBSYSTEM==\"power_supply\", ACTION==\"change\", RUN+=\"/usr/bin/systemctl --no-block start mesh-power-supply.service\"\n"
}, },
{ {
"id": "after-boot", "id": "after-boot",
+18 -1
View File
@@ -17,7 +17,24 @@
"type": "file", "type": "file",
"path": "/etc/resolv.conf", "path": "/etc/resolv.conf",
"mode": "0644", "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# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — 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 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"
},
{
"id": "runtime-config",
"type": "file",
"path": "/etc/docker/daemon.json",
"mode": "0644",
"into": "json",
"content": "{\"live-restore\": true}\n"
},
{
"id": "runtime",
"type": "service",
"unit": "docker.service",
"state": "running",
"reload-on": [
"runtime-config"
]
} }
] ]
} }
+1 -1
View File
@@ -23,7 +23,7 @@
"type": "file", "type": "file",
"path": "/etc/systemd/resolved.conf.d/mesh.conf", "path": "/etc/systemd/resolved.conf.d/mesh.conf",
"mode": "0644", "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" "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# The mesh's one resolver (novox/hq ADR 0194), by its private address — a\n# machine cannot resolve the name of the thing it resolves names with.\n[Resolve]\nDNS=${bound:wildcard-resolution:address}\nDomains=~internal\n"
}, },
{ {
"id": "resolved", "id": "resolved",
-7
View File
@@ -86,10 +86,3 @@ Once the `i3` module carries the main i3 configuration:
module's. Until the controller knows the seat, `mctl` reads the claim as unknown. module's. Until the controller knows the seat, `mctl` reads the claim as unknown.
- `~/.local/bin` is on `PATH` through the `zsh` module's environment contribution (ADR 0203). The - `~/.local/bin` is on `PATH` through the `zsh` module's environment contribution (ADR 0203). The
key bindings name `~/.local/bin/…` in full, so they do not depend on it. Callers of `dmenu` do. key bindings name `~/.local/bin/…` in full, so they do not depend on it. Callers of `dmenu` do.
## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212)
The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
@@ -22,9 +22,7 @@ type manifest struct {
Environment *environment `json:"environment"` Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"` Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"` Resources []map[string]any `json:"resources"`
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. Build struct {
Contributions []contribution `json:"contributions"`
Build struct {
Artifacts []map[string]any `json:"artifacts"` Artifacts []map[string]any `json:"artifacts"`
} `json:"build"` } `json:"build"`
} }
@@ -175,32 +173,3 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
} }
} }
} }
type contribution struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
}
// i3Lines is what the module contributes to the window manager.
func (m manifest) i3Lines() string {
var out string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" && c.Kind == "config" {
out += c.Content
}
}
return out
}
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
if m.i3Lines() != string(want) {
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
}
}
+2 -2
View File
@@ -36,6 +36,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
"dmenu": "files/bin/dmenu", "dmenu": "files/bin/dmenu",
"launch": "files/bin/rofi-launch", "launch": "files/bin/rofi-launch",
"powermenu": "files/bin/rofi-powermenu", "powermenu": "files/bin/rofi-powermenu",
"i3-bindings": "files/i3/50-rofi.conf",
} { } {
m.sameAsSource(t, id, source) m.sameAsSource(t, id, source)
} }
@@ -84,8 +85,7 @@ func TestThePowerMenuNamesNoWindowManagerAndNoLocker(t *testing.T) {
func TestTheKeyBindingsAreAnI3DropInRunningTheModulesOwnCommands(t *testing.T) { func TestTheKeyBindingsAreAnI3DropInRunningTheModulesOwnCommands(t *testing.T) {
m := readManifest(t) m := readManifest(t)
m.i3LinesAreSource(t, "files/i3/50-rofi.conf") bindings := m.resource(t, "i3-bindings")["content"].(string)
bindings := m.i3Lines()
for _, line := range strings.Split(bindings, "\n") { for _, line := range strings.Split(bindings, "\n") {
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
+9 -8
View File
@@ -88,6 +88,14 @@
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0755", "mode": "0755",
"content": "#!/usr/bin/env bash\n# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down.\n#\n# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a\n# verb of logind or the service manager, so nothing here names a window manager or a locker:\n# - lock asks logind to lock the session, which the holder of node-lock-screen answers;\n# - log out ends this login session, whichever window manager runs in it.\n# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2).\nset -u\n\ntheme=mesh-powermenu\nuptime=\"$(uptime -p | sed -e 's/^up //')\"\n\nshutdown='󰤂'\nreboot='󰜉'\nlock='󰌾'\nsuspend='󰤄'\nlogout='󰍃'\nyes='󰄲'\nno='󰅖'\n\nmenu() {\n\trofi -dmenu -p \"Uptime: $uptime\" -mesg \"Uptime: $uptime\" -theme \"$theme\"\n}\n\nconfirm() {\n\tprintf '%s\\n%s\\n' \"$yes\" \"$no\" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme \"$theme\" \\\n\t\t-theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \\\n\t\t-theme-str 'mainbox {children: [ \"message\", \"listview\" ];}' \\\n\t\t-theme-str 'listview {columns: 2; lines: 1;}' \\\n\t\t-theme-str 'element-text {horizontal-align: 0.5;}' \\\n\t\t-theme-str 'textbox {horizontal-align: 0.5;}'\n}\n\nsession() {\n\t# The login session this menu runs in: the session's own id, else logind's answer for this process.\n\tif [ -n \"${XDG_SESSION_ID:-}\" ]; then\n\t\techo \"$XDG_SESSION_ID\"\n\telse\n\t\tloginctl show-session auto -p Id --value 2>/dev/null\n\tfi\n}\n\nconfirmed() { [ \"$(confirm)\" = \"$yes\" ]; }\n\nchosen=\"$(printf '%s\\n' \"$lock\" \"$suspend\" \"$logout\" \"$reboot\" \"$shutdown\" | menu)\" || exit 0\ncase $chosen in\n\"$lock\") loginctl lock-session \"$(session)\" ;;\n\"$suspend\") confirmed && systemctl suspend ;;\n\"$logout\") confirmed && loginctl terminate-session \"$(session)\" ;;\n\"$reboot\") confirmed && systemctl reboot ;;\n\"$shutdown\") confirmed && systemctl poweroff ;;\nesac\nexit 0\n" "content": "#!/usr/bin/env bash\n# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down.\n#\n# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a\n# verb of logind or the service manager, so nothing here names a window manager or a locker:\n# - lock asks logind to lock the session, which the holder of node-lock-screen answers;\n# - log out ends this login session, whichever window manager runs in it.\n# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2).\nset -u\n\ntheme=mesh-powermenu\nuptime=\"$(uptime -p | sed -e 's/^up //')\"\n\nshutdown='󰤂'\nreboot='󰜉'\nlock='󰌾'\nsuspend='󰤄'\nlogout='󰍃'\nyes='󰄲'\nno='󰅖'\n\nmenu() {\n\trofi -dmenu -p \"Uptime: $uptime\" -mesg \"Uptime: $uptime\" -theme \"$theme\"\n}\n\nconfirm() {\n\tprintf '%s\\n%s\\n' \"$yes\" \"$no\" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme \"$theme\" \\\n\t\t-theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \\\n\t\t-theme-str 'mainbox {children: [ \"message\", \"listview\" ];}' \\\n\t\t-theme-str 'listview {columns: 2; lines: 1;}' \\\n\t\t-theme-str 'element-text {horizontal-align: 0.5;}' \\\n\t\t-theme-str 'textbox {horizontal-align: 0.5;}'\n}\n\nsession() {\n\t# The login session this menu runs in: the session's own id, else logind's answer for this process.\n\tif [ -n \"${XDG_SESSION_ID:-}\" ]; then\n\t\techo \"$XDG_SESSION_ID\"\n\telse\n\t\tloginctl show-session auto -p Id --value 2>/dev/null\n\tfi\n}\n\nconfirmed() { [ \"$(confirm)\" = \"$yes\" ]; }\n\nchosen=\"$(printf '%s\\n' \"$lock\" \"$suspend\" \"$logout\" \"$reboot\" \"$shutdown\" | menu)\" || exit 0\ncase $chosen in\n\"$lock\") loginctl lock-session \"$(session)\" ;;\n\"$suspend\") confirmed && systemctl suspend ;;\n\"$logout\") confirmed && loginctl terminate-session \"$(session)\" ;;\n\"$reboot\") confirmed && systemctl reboot ;;\n\"$shutdown\") confirmed && systemctl poweroff ;;\nesac\nexit 0\n"
},
{
"id": "i3-bindings",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/50-rofi.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n"
} }
], ],
"build": { "build": {
@@ -104,12 +112,5 @@
] ]
} }
] ]
}, }
"contributions": [
{
"seat": "node-display-session",
"kind": "config",
"content": "# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n"
}
]
} }
+4 -2
View File
@@ -31,7 +31,9 @@ runProvisioner("analytics", {
}, },
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
// The website and its analytics are kept (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once); a person deletes a site, never this loop. const token = await umami.getToken();
console.error(`[umami] ${p.as} withdrawn: website and its analytics kept`); // Keyed on the mesh-derived login, the one identity the harness carries into removal.
const site = await umami.findWebsite(token, p.as);
if (site) await umami.deleteWebsite(token, site.id);
}, },
}); });