Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b5e1d3362 | ||
|
|
4224ac7252 | ||
|
|
1d4de00603 | ||
|
|
7047198146 | ||
|
|
815a55a9fd | ||
|
|
a3c8086d52 | ||
|
|
b91b4c427d | ||
|
|
316576f1da | ||
|
|
1fb7ca3d72 | ||
|
|
190d711a2a | ||
|
|
9275a9e295 | ||
|
|
d26eb1d9a6 | ||
|
|
6c73848ba1 | ||
|
|
94d0caa09c | ||
|
|
5f8869b165 | ||
|
|
91f69d1dd3 | ||
|
|
1ace62b969 | ||
|
|
8067e91409 |
@@ -376,3 +376,10 @@ module's contribution to `node-power`'s `after-wake` moment, so the module depen
|
|||||||
module. `logind.conf.d/power.conf` is the power module's. This laptop's values (suspend on the power
|
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.
|
||||||
|
|||||||
@@ -28,8 +28,48 @@ 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"
|
||||||
|
|
||||||
// I3Fragments are the module's own files in i3's include directory, relative to the account's home.
|
// I3Config is the window manager's configuration, relative to the account's home. This module's lines
|
||||||
var I3Fragments = []string{".config/i3/config.d/10-asus.conf", ".config/i3/config.d/20-g14.conf"}
|
// are its contribution to node-display-session (novox/hq ADR 0212), placed there by the i3 module
|
||||||
|
// under a line that names this module.
|
||||||
|
const I3Config = ".config/i3/config"
|
||||||
|
|
||||||
|
// ModuleName is the line the controller writes before this module's contributed lines.
|
||||||
|
const ModuleName = "asus-zephyrus-g14"
|
||||||
|
|
||||||
|
// ownSection is this module's contributed lines in a placed file: from the line naming it to the next
|
||||||
|
// line naming another module, a section header, or the file's end.
|
||||||
|
func ownSection(text string) []string {
|
||||||
|
var out []string
|
||||||
|
in := false
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if t == "# "+ModuleName {
|
||||||
|
in = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !in {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(t, "####") || (strings.HasPrefix(t, "# ") && isModuleLine(strings.TrimPrefix(t, "# "))) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isModuleLine is a comment that is one catalogue module's name: lower case, digits and dashes.
|
||||||
|
func isModuleLine(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// physicalKeys names the key behind an evdev code, as far as it is known on this model. The media
|
// 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.
|
||||||
@@ -91,26 +131,22 @@ 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)")
|
||||||
}
|
}
|
||||||
for _, rel := range I3Fragments {
|
if home != "" {
|
||||||
if home == "" {
|
lines := ownSection(m.read(filepath.Join(home, I3Config)))
|
||||||
break
|
if len(lines) == 0 {
|
||||||
|
r.Warnings = append(r.Warnings, "~/"+I3Config+" holds no lines of this module's: is the i3 module assigned and pushed?")
|
||||||
}
|
}
|
||||||
p := filepath.Join(home, rel)
|
from := "~/" + I3Config + " (" + ModuleName + "'s contribution)"
|
||||||
text := m.read(p)
|
for _, line := range lines {
|
||||||
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: "~/" + rel})
|
r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: from})
|
||||||
} else if g := i3Exec.FindStringSubmatch(line); g != nil {
|
} else if g := i3Exec.FindStringSubmatch(line); g != nil {
|
||||||
r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: "~/" + rel})
|
r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: from})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,15 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
|
|||||||
put(t, f.root, TriggerDir+"/mesh.conf", "# asus-zephyrus-g14\n"+m.triggers())
|
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)
|
||||||
put(t, f.root, home+"/"+I3Fragments[0], content["i3-vendor-keys"])
|
// The i3 module's file as the controller composes it: other modules' lines around this one's.
|
||||||
put(t, f.root, home+"/"+I3Fragments[1], content["i3-model"])
|
var own string
|
||||||
|
for _, c := range m.Contributions {
|
||||||
|
if c.Seat == "node-display-session" {
|
||||||
|
own += c.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
|
||||||
|
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
|
||||||
|
|
||||||
r := f.machine().Keys()
|
r := f.machine().Keys()
|
||||||
got := map[string]Key{}
|
got := map[string]Key{}
|
||||||
|
|||||||
@@ -175,22 +175,6 @@
|
|||||||
"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": {
|
||||||
@@ -218,6 +202,16 @@
|
|||||||
"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": [
|
||||||
|
|||||||
@@ -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 repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
|
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
|
||||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
|
| `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,6 +58,12 @@ Per node or for the whole mesh, through `mesh-controller.settings module=claude-
|
|||||||
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
|
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,6 +94,40 @@ 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 }
|
||||||
@@ -399,3 +433,31 @@ 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -255,14 +255,19 @@ func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
||||||
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
|
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
|
||||||
// which lives hours, and says so.
|
// 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 {
|
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
out, err := Pull(p, ask, write)
|
out, err := Pull(p, ask, write)
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ 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 mesh's keys only: the repositories' attribution convention, the claude.ai
|
// managed-settings.json the keys the operator set in this module's `managed_settings` (the agent's
|
||||||
// connectors kept beside the managed servers, and — for an API-key licence only —
|
// permissions and auto mode, say), under the mesh's own keys, which always win:
|
||||||
// the key-helper. A person's preferences are theirs.
|
// the repositories' attribution convention, the claude.ai connectors kept beside
|
||||||
|
// the managed servers, and — for an API-key licence only — the key-helper. A
|
||||||
|
// person's preferences are theirs, in their own settings.
|
||||||
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
|
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -38,6 +40,8 @@ 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.
|
||||||
@@ -105,7 +109,14 @@ 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{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
|
managed := map[string]any{}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}\n"
|
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {}\n}\n"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ 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
|
||||||
@@ -189,6 +191,17 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
|
|||||||
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
|
// 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,3 +423,18 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A store rebuilt after a loss counts generations from one again (novox/hq issue 243): the first look at the
|
||||||
|
// reports moves the count past every generation a node says it applied, so no binding repeats one.
|
||||||
|
func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
|
||||||
|
mm := newMesh(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
laptop := mm.login("laptop", "rt-a", true, t0)
|
||||||
|
laptop.Generation = 16
|
||||||
|
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if g := mm.state["laptop"].Generation; g <= 16 {
|
||||||
|
t.Fatalf("the laptop applied generation 16 and was given %d", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,16 @@ func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
|
|||||||
return err == nil && tag.RowsAffected() == 1, err
|
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,6 +70,8 @@ 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
|
||||||
@@ -200,6 +202,13 @@ 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()
|
||||||
|
|||||||
@@ -80,3 +80,10 @@ So clipmenud runs with the module's own `xsel` first on its `PATH`
|
|||||||
(`/usr/local/lib/mesh-clipmenu/xsel`). Before a read it asks the selection for its `TARGETS` and goes
|
(`/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,7 +22,9 @@ 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"`
|
||||||
Build struct {
|
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
|
||||||
|
Contributions []contribution `json:"contributions"`
|
||||||
|
Build struct {
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
} `json:"build"`
|
} `json:"build"`
|
||||||
}
|
}
|
||||||
@@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contribution struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3Lines is what the module contributes to the window manager.
|
||||||
|
func (m manifest) i3Lines() string {
|
||||||
|
var out string
|
||||||
|
for _, c := range m.Contributions {
|
||||||
|
if c.Seat == "node-display-session" && c.Kind == "config" {
|
||||||
|
out += c.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
|
||||||
|
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
|
||||||
|
t.Helper()
|
||||||
|
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.i3Lines() != string(want) {
|
||||||
|
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) {
|
|||||||
if _, set := m.Environment.Variables["CM_LAUNCHER"]; set {
|
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.sameAsSource(t, "i3-bindings", "files/i3/50-clipmenu.conf")
|
m.i3LinesAreSource(t, "files/i3/50-clipmenu.conf")
|
||||||
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") {
|
if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") {
|
||||||
t.Fatalf("%s", c)
|
t.Fatalf("%s", c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,14 +48,6 @@
|
|||||||
"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",
|
||||||
@@ -78,5 +70,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"seat": "node-display-session",
|
||||||
|
"kind": "config",
|
||||||
|
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# dbus
|
||||||
|
|
||||||
|
A machine's message bus (novox/hq ADR 0215). This module holds `node-message-bus` on every machine,
|
||||||
|
servers included, because every machine runs a D-Bus system bus. It owns the bus implementation's
|
||||||
|
packages and declares its system service running. It publishes what matters about the bus on the
|
||||||
|
mesh's bus, and serves tools to look at the system bus and at the operator's session bus.
|
||||||
|
|
||||||
|
## What it owns
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| package `dbus-broker` | the bus every machine runs |
|
||||||
|
| package `dbus-broker-units` | `dbus.service` as an alias of `dbus-broker.service`, for the system and for each user |
|
||||||
|
| package `dbus` | the bus's configuration (`system.conf`, `session.conf`), `dbus.socket`, which starts the bus at boot, and libdbus |
|
||||||
|
| service `dbus-broker.service` | declared running, with no boot state and no restart or reload trigger (below) |
|
||||||
|
|
||||||
|
All four machines were found with dbus-broker 37 behind `dbus.service` and dbus 1.16.2, from the
|
||||||
|
distribution. One server also has an old `dbus-units` package, an empty package that depends on
|
||||||
|
`dbus-broker-units`. It is not declared: nothing needs it, and removing it is a choice for its
|
||||||
|
operator.
|
||||||
|
|
||||||
|
## The bus is never restarted live
|
||||||
|
|
||||||
|
On 2026-10-04 a full upgrade on a workstation restarted the system bus while the upgrade was still
|
||||||
|
running. From then on every login hung, sshd answered nothing and the machine's host stopped
|
||||||
|
reporting, until someone rebooted it at its keyboard. Every program that speaks on the bus (the
|
||||||
|
service manager, logind, the network manager, the keyring, the power module's sleep lock) holds a
|
||||||
|
connection to it. A restart takes all of them away at once, and not all of them come back.
|
||||||
|
|
||||||
|
So the module never restarts or reloads the bus, for any change. Its service has no `restart-on` and
|
||||||
|
no `reload-on`, and the host never restarts a service that has neither. A new bus takes effect at
|
||||||
|
the next boot. `dbus_check` says when the running bus is older than an installed package, which is
|
||||||
|
the sign that a reboot is due. The distribution's own upgrade can still restart the bus. This rule
|
||||||
|
covers what the mesh does, not what pacman does.
|
||||||
|
|
||||||
|
**Why `state: running` and no `boot`.** `dbus.service` is an alias (`systemctl is-enabled` says
|
||||||
|
`alias`), so it cannot be declared enabled: the host would run `systemctl enable` and read back
|
||||||
|
something other than `enabled`. The real unit, `dbus-broker.service`, reads `disabled` on three
|
||||||
|
machines and `enabled` on one. It needs no enabling. At boot `dbus.socket`, which the `dbus` package
|
||||||
|
links into `sockets.target`, pulls in `dbus.service`, and `dbus-broker-units` ships that name as a
|
||||||
|
link to `dbus-broker.service`. On the three machines where it reads `disabled`, enabling it would
|
||||||
|
only write a second alias link into `/etc`, and a module's apply would change something on a running
|
||||||
|
machine for no gain. The service is declared so that the mesh knows the bus is the module's and says
|
||||||
|
so when it is not running. A host finding it stopped would start it, which can only help a machine
|
||||||
|
whose bus is down. ADR 0215 §1 says "running and enabled". On these machines the package's own
|
||||||
|
socket link is what makes it start at boot.
|
||||||
|
|
||||||
|
**A policy change** a module needs in the future uses the bus's own reload (`dbus-broker.service` is
|
||||||
|
`Type=notify-reload`), which keeps every connection. No module needs one today (below).
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
The watcher runs beside the tools in the same process, on its own connection to the system bus as the
|
||||||
|
operator's account. It reads only the bus driver's answers, the driver's `NameOwnerChanged` signal and
|
||||||
|
the bus unit's journal.
|
||||||
|
|
||||||
|
| event | when | carries |
|
||||||
|
|---|---|---|
|
||||||
|
| `bus.stalled` | the bus does not answer the driver's `GetId` within 3 s, or cannot be reached | the reason |
|
||||||
|
| `bus.recovered` | a stalled bus answers again | since when it was stalled, and for how long |
|
||||||
|
| `bus.restarted` | after reconnecting, the bus's id or the driver's pid has changed within the same boot (after a boot both change, and `power` says `booted`) | the old and new id and pid, the unit |
|
||||||
|
| `service.appeared` | a well-known name appears and is still there 10 s later | the name, the owner's pid, process and unit, whether it is activatable |
|
||||||
|
| `service.left` | a well-known name is gone and still gone 10 s later | the name and what held it |
|
||||||
|
| `policy.denied` | the bus logged a policy denial, at most once per 5 minutes | how many since the last one, and up to 5 distinct examples: the refused message's type, sender, destination, path, interface and member |
|
||||||
|
|
||||||
|
Unique names (`:1.42`) come and go with every client and are never published. A service restarted
|
||||||
|
within the 10 s, or one that came and went, publishes nothing. Activatable services that exit when
|
||||||
|
idle, such as hostnamed, do appear and leave, and their events say `activatable: true`.
|
||||||
|
|
||||||
|
**Why traffic never leaves the machine.** The bus carries secrets from the keyring, notification text
|
||||||
|
and the clipboard. The watcher never becomes a monitor, so it never sees another peer's messages. Its
|
||||||
|
events are built from names, pids, units and the header fields the broker logs with a denial. The log
|
||||||
|
line itself is not passed on. The tests hold every event's body to those fields.
|
||||||
|
|
||||||
|
Events the mesh's bus does not take wait in order and go out when it answers again, as `power`'s do.
|
||||||
|
At most 1000 events wait. When there are more, the oldest are dropped and counted.
|
||||||
|
|
||||||
|
The watcher's ping is `GetId`, not `org.freedesktop.DBus.Peer.Ping`. The system policy refuses the
|
||||||
|
peer ping to an account that is not root and logs a denial each time, which the watcher would then
|
||||||
|
publish.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| tool | |
|
||||||
|
|---|---|
|
||||||
|
| `dbus_names` | `bus`: system or session. Every well-known name with its owner's pid, process, user and unit, the activatable names that are not running, and the number of connections |
|
||||||
|
| `dbus_introspect` | `bus`, `service`, `path`. The object's interfaces with their methods, properties (type and access, never values) and signals, and its children. Uses `--auto-start=no`, so looking never starts a service |
|
||||||
|
| `dbus_monitor` | `bus`, `seconds` (at most 15), optional `match` rule and `names`. The headers of what passed (type, sender, destination, path, interface, member, error name), at most 500. Never a body: each line is decoded into the header alone. On the system bus only root may monitor, so it runs through `sudo -n` |
|
||||||
|
| `dbus_check` | the packages are installed. `dbus.service` is `dbus-broker.service` and active. The running bus is not older than the installed `dbus-broker` or `dbus` (otherwise a reboot is due). Every activatable service file's `SystemdService` exists. Policy denials in the last hour. The watcher is connected, not stalled, and has nothing stuck |
|
||||||
|
| `dbus_health` | the round trip of a ping on the watcher's connection, the connection count (from `Debug.Stats` through `sudo -n`, else the unique names), and the bus's unit, pid, start and uptime |
|
||||||
|
|
||||||
|
Every command is bounded at 20 s and its output read up to 1 MiB. Lists stop at 500 entries.
|
||||||
|
`Debug.Stats` is asked only as root: asked as the account it is refused and logged as a denial.
|
||||||
|
|
||||||
|
The session bus is the runtime's `DBUS_SESSION_BUS_ADDRESS`, else `$XDG_RUNTIME_DIR/bus`, else
|
||||||
|
`/run/user/<uid>/bus`. On a machine where the account has no session, the tools say so.
|
||||||
|
|
||||||
|
The check notes, without failing, systemd's own services whose `dbus-org.*` alias is missing because
|
||||||
|
they are not enabled, such as resolved, networkd and homed on the workstations. Activating them fails
|
||||||
|
by design.
|
||||||
|
|
||||||
|
## Assignment
|
||||||
|
|
||||||
|
On every machine, as the first holder of `node-message-bus`. The module needs nothing set: no
|
||||||
|
settings, no secrets, no ports.
|
||||||
|
|
||||||
|
## What comes later
|
||||||
|
|
||||||
|
ADR 0215 §4: the seat receives nothing yet. Packages ship their own D-Bus policy and service files,
|
||||||
|
and no module writes one of its own. When one does, that file will be a contribution to this seat,
|
||||||
|
and this module will list the kind and reload the bus's policy for it, without a restart.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
busName = "org.freedesktop.DBus"
|
||||||
|
busPath = "/org/freedesktop/DBus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemBus is the machine's system bus as the watcher uses it, over its own private connection: the
|
||||||
|
// bus driver's answers and its NameOwnerChanged signal, never another peer's messages.
|
||||||
|
type systemBus struct {
|
||||||
|
conn *dbus.Conn
|
||||||
|
out chan NameChange
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialSystemBus connects to the system bus as this account and listens for names changing owner.
|
||||||
|
func DialSystemBus() (Bus, error) {
|
||||||
|
conn, err := dbus.SystemBusPrivate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := conn.Auth(nil); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := conn.Hello(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := conn.AddMatchSignal(dbus.WithMatchSender(busName), dbus.WithMatchInterface(busName),
|
||||||
|
dbus.WithMatchMember("NameOwnerChanged")); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw := make(chan *dbus.Signal, 256)
|
||||||
|
conn.Signal(raw)
|
||||||
|
b := &systemBus{conn: conn, out: make(chan NameChange, 256)}
|
||||||
|
go func() {
|
||||||
|
// The signal channel closes when the connection is lost; so does this one, which is how the
|
||||||
|
// watcher learns the bus went away.
|
||||||
|
defer close(b.out)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case s, open := <-raw:
|
||||||
|
if !open {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.Name != busName+".NameOwnerChanged" || len(s.Body) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _ := s.Body[0].(string)
|
||||||
|
old, _ := s.Body[1].(string)
|
||||||
|
nw, _ := s.Body[2].(string)
|
||||||
|
b.out <- NameChange{Name: name, Old: old, New: nw}
|
||||||
|
case <-conn.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *systemBus) driver() dbus.BusObject { return b.conn.Object(busName, busPath) }
|
||||||
|
|
||||||
|
// Ping asks the bus driver its id. Not org.freedesktop.DBus.Peer.Ping: dbus-broker's system policy
|
||||||
|
// refuses that to an account that is not root, and logs the refusal as a denial.
|
||||||
|
func (b *systemBus) Ping(ctx context.Context) error {
|
||||||
|
var id string
|
||||||
|
return b.driver().CallWithContext(ctx, busName+".GetId", 0).Store(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *systemBus) ID(ctx context.Context) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := b.driver().CallWithContext(ctx, busName+".GetId", 0).Store(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *systemBus) PID(ctx context.Context, name string) (uint32, error) {
|
||||||
|
var pid uint32
|
||||||
|
err := b.driver().CallWithContext(ctx, busName+".GetConnectionUnixProcessID", 0, name).Store(&pid)
|
||||||
|
return pid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *systemBus) Names(ctx context.Context) ([]string, error) {
|
||||||
|
var names []string
|
||||||
|
err := b.driver().CallWithContext(ctx, busName+".ListNames", 0).Store(&names)
|
||||||
|
return names, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *systemBus) Activatable(ctx context.Context) ([]string, error) {
|
||||||
|
var names []string
|
||||||
|
err := b.driver().CallWithContext(ctx, busName+".ListActivatableNames", 0).Store(&names)
|
||||||
|
return names, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *systemBus) Changes() <-chan NameChange { return b.out }
|
||||||
|
|
||||||
|
func (b *systemBus) Close() { b.conn.Close() }
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Packages are the bus implementation's packages, as the manifest declares them: dbus-broker (the
|
||||||
|
// bus), its units (the dbus.service alias), and the reference package, which ships the bus's
|
||||||
|
// configuration, the socket that starts it at boot, and libdbus.
|
||||||
|
var Packages = []string{"dbus", "dbus-broker", "dbus-broker-units"}
|
||||||
|
|
||||||
|
// RunningPackages are the packages whose files the running bus loaded when it started: a newer one
|
||||||
|
// installed since takes effect only at the next boot (novox/hq ADR 0215 §2).
|
||||||
|
var RunningPackages = []string{"dbus-broker", "dbus"}
|
||||||
|
|
||||||
|
// SystemUnit is the bus's real unit; dbus.service is its alias.
|
||||||
|
const SystemUnit = "dbus-broker.service"
|
||||||
|
|
||||||
|
// ServiceDirs are where activatable system services are described.
|
||||||
|
var ServiceDirs = []string{"/usr/share/dbus-1/system-services", "/usr/local/share/dbus-1/system-services",
|
||||||
|
"/usr/lib/dbus-1/system-services"}
|
||||||
|
|
||||||
|
// Package is one installed package as the package manager's local database records it.
|
||||||
|
type Package struct {
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Installed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDesc reads one package's desc file in the local database.
|
||||||
|
func ParseDesc(desc string) Package {
|
||||||
|
var p Package
|
||||||
|
lines := strings.Split(desc, "\n")
|
||||||
|
for i := 0; i+1 < len(lines); i++ {
|
||||||
|
v := strings.TrimSpace(lines[i+1])
|
||||||
|
switch strings.TrimSpace(lines[i]) {
|
||||||
|
case "%NAME%":
|
||||||
|
p.Name = v
|
||||||
|
case "%VERSION%":
|
||||||
|
p.Version = v
|
||||||
|
case "%INSTALLDATE%":
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
p.Installed = time.Unix(n, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstalledPackage reads a package from the local database, without running the package manager.
|
||||||
|
func (m *Machine) InstalledPackage(name string) (Package, bool) {
|
||||||
|
for _, d := range m.glob("/var/lib/pacman/local/" + name + "-*/desc") {
|
||||||
|
if p := ParseDesc(m.read(d) + "\n"); p.Name == name {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Package{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseShow reads `systemctl show` blocks: one map per unit, in the order asked.
|
||||||
|
func ParseShow(out string) []map[string]string {
|
||||||
|
var blocks []map[string]string
|
||||||
|
cur := map[string]string{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
if len(cur) > 0 {
|
||||||
|
blocks = append(blocks, cur)
|
||||||
|
cur = map[string]string{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if k, v, ok := strings.Cut(line, "="); ok {
|
||||||
|
cur[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cur) > 0 {
|
||||||
|
blocks = append(blocks, cur)
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// unixStamp reads systemd's "@<seconds>" timestamp.
|
||||||
|
func unixStamp(s string) (time.Time, bool) {
|
||||||
|
n, err := strconv.ParseInt(strings.TrimPrefix(s, "@"), 10, 64)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return time.Unix(n, 0), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// BusUnit is the system bus's unit as the service manager has it.
|
||||||
|
type BusUnit struct {
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Active string `json:"active"`
|
||||||
|
PID uint32 `json:"pid,omitempty"`
|
||||||
|
Since time.Time `json:"-"`
|
||||||
|
Started string `json:"started,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemBusUnit asks the service manager about the system bus through its alias, so the answer is
|
||||||
|
// the implementation's unit whichever it is.
|
||||||
|
func (m *Machine) SystemBusUnit(ctx context.Context) (BusUnit, error) {
|
||||||
|
out, err := m.Run(ctx, "systemctl", "show", "dbus.service", "-p", "Id,ActiveState,MainPID,ActiveEnterTimestamp",
|
||||||
|
"--timestamp=unix")
|
||||||
|
if err != nil {
|
||||||
|
return BusUnit{}, err
|
||||||
|
}
|
||||||
|
b := ParseShow(out)
|
||||||
|
if len(b) == 0 {
|
||||||
|
return BusUnit{}, fmt.Errorf("systemctl show answered nothing for dbus.service")
|
||||||
|
}
|
||||||
|
u := BusUnit{Unit: b[0]["Id"], Active: b[0]["ActiveState"], PID: parsePID(b[0]["MainPID"])}
|
||||||
|
if t, ok := unixStamp(b[0]["ActiveEnterTimestamp"]); ok {
|
||||||
|
u.Since, u.Started = t, t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceFile is one activatable system service's description.
|
||||||
|
type ServiceFile struct {
|
||||||
|
File string
|
||||||
|
Name string
|
||||||
|
Unit string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseServiceFile reads the Name and SystemdService of a D-Bus service file.
|
||||||
|
func ParseServiceFile(file, content string) ServiceFile {
|
||||||
|
s := ServiceFile{File: file}
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(k) {
|
||||||
|
case "Name":
|
||||||
|
s.Name = strings.TrimSpace(v)
|
||||||
|
case "SystemdService":
|
||||||
|
s.Unit = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check is one thing the module expects of the machine.
|
||||||
|
type Check struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebootDue says, per package the running bus loaded, whether a newer one was installed after the bus
|
||||||
|
// started: the bus is never restarted live, so that package waits for a boot.
|
||||||
|
func RebootDue(started time.Time, pkgs []Package) (bool, string) {
|
||||||
|
var newer []string
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if !p.Installed.IsZero() && p.Installed.After(started) {
|
||||||
|
newer = append(newer, fmt.Sprintf("%s %s installed %s", p.Name, p.Version, p.Installed.UTC().Format(time.RFC3339)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(newer) == 0 {
|
||||||
|
return false, "the running bus started " + started.UTC().Format(time.RFC3339) + ", after every package it loaded was installed"
|
||||||
|
}
|
||||||
|
return true, "the running bus started " + started.UTC().Format(time.RFC3339) + " and is older than " +
|
||||||
|
strings.Join(newer, ", ") + ": a reboot is due (the bus is never restarted live, novox/hq ADR 0215)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check says what this module expects and whether the machine meets it.
|
||||||
|
func (m *Machine) Check(ctx context.Context, w *Watcher) map[string]any {
|
||||||
|
var checks []Check
|
||||||
|
var notes []string
|
||||||
|
add := func(name string, ok bool, format string, args ...any) {
|
||||||
|
checks = append(checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, args...)})
|
||||||
|
}
|
||||||
|
var running []Package
|
||||||
|
for _, name := range Packages {
|
||||||
|
p, ok := m.InstalledPackage(name)
|
||||||
|
add("package "+name, ok, "installed: %v %s", ok, p.Version)
|
||||||
|
for _, r := range RunningPackages {
|
||||||
|
if ok && r == name {
|
||||||
|
running = append(running, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit, err := m.SystemBusUnit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
add("system bus", false, "%v", err)
|
||||||
|
} else {
|
||||||
|
add("system bus", unit.Unit == SystemUnit && unit.Active == "active",
|
||||||
|
"dbus.service is %s, %s, pid %d, since %s (want %s active)", orWord(unit.Unit, "unknown"),
|
||||||
|
orWord(unit.Active, "unknown"), unit.PID, orWord(unit.Started, "unknown"), SystemUnit)
|
||||||
|
if !unit.Since.IsZero() {
|
||||||
|
due, detail := RebootDue(unit.Since, running)
|
||||||
|
add("running bus is the installed one", !due, "%s", detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []ServiceFile
|
||||||
|
for _, dir := range ServiceDirs {
|
||||||
|
for _, f := range m.glob(dir + "/*.service") {
|
||||||
|
if s := ParseServiceFile(f, m.read(f)); s.Unit != "" {
|
||||||
|
files = append(files, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
||||||
|
if len(files) > 0 {
|
||||||
|
args := []string{"show", "-p", "Id,LoadState"}
|
||||||
|
for _, f := range files {
|
||||||
|
args = append(args, f.Unit)
|
||||||
|
}
|
||||||
|
out, err := m.Run(ctx, "systemctl", args...)
|
||||||
|
blocks := ParseShow(out)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
add("activatable services", false, "systemctl show: %v", err)
|
||||||
|
case len(blocks) != len(files):
|
||||||
|
add("activatable services", false, "systemctl show answered %d units for %d service files", len(blocks), len(files))
|
||||||
|
default:
|
||||||
|
var broken, disabled []string
|
||||||
|
for i, f := range files {
|
||||||
|
if blocks[i]["LoadState"] != "not-found" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// systemd's own bus services are reached through a dbus-org.* alias that exists only
|
||||||
|
// while the service is enabled: a disabled one is a choice, not a fault.
|
||||||
|
if strings.HasPrefix(f.Unit, "dbus-org.") {
|
||||||
|
disabled = append(disabled, f.Name+" → "+f.Unit)
|
||||||
|
} else {
|
||||||
|
broken = append(broken, f.Name+" → "+f.Unit+" ("+f.File+")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("activatable services", len(broken) == 0, "%d service files; whose unit does not exist: %s",
|
||||||
|
len(files), orWord(strings.Join(broken, ", "), "none"))
|
||||||
|
if len(disabled) > 0 {
|
||||||
|
notes = append(notes, "activation fails for "+strings.Join(disabled, ", ")+
|
||||||
|
": the service is not enabled, so its dbus-org alias does not exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
denials, _, err := m.Denials(ctx, "", m.Now().Add(-time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
add("policy denials in the last hour", false, "reading the journal: %v", err)
|
||||||
|
} else {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var ex []string
|
||||||
|
for _, d := range denials {
|
||||||
|
k := strings.TrimSpace(d.Type + " " + d.Interface + "." + d.Member + " to " + d.Destination)
|
||||||
|
if !seen[k] && len(ex) < DenialExamples {
|
||||||
|
seen[k] = true
|
||||||
|
ex = append(ex, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("policy denials in the last hour", len(denials) == 0, "%d: %s", len(denials), orWord(strings.Join(ex, "; "), "none"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a, err := m.SessionAddress(); err == nil {
|
||||||
|
notes = append(notes, "this account's session bus: "+a)
|
||||||
|
} else {
|
||||||
|
notes = append(notes, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if w != nil {
|
||||||
|
s := w.Snapshot()
|
||||||
|
add("watcher", s.Connected && !s.Stalled, "connected %v, stalled %v%s, last ping %.1f ms, %d well-known names",
|
||||||
|
s.Connected, s.Stalled, orNote(s.StallReason), s.LastPingMS, s.Services)
|
||||||
|
add("events reach the mesh's bus", s.Pending == 0 && s.Problem == "", "%d event(s) waiting, %d dropped%s",
|
||||||
|
s.Pending, s.Dropped, orNote(s.Problem))
|
||||||
|
}
|
||||||
|
failing := 0
|
||||||
|
for _, c := range checks {
|
||||||
|
if !c.OK {
|
||||||
|
failing++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"checks": checks, "failing": failing, "notes": notes}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orNote(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " (" + s + ")"
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CountConnections reads the bus driver's Debug.Stats answer (`busctl call … GetStats --json=short`):
|
||||||
|
// dbus-broker lists one accounting entry per peer, the reference daemon says ActiveConnections.
|
||||||
|
func CountConnections(out string) (int, bool) {
|
||||||
|
var reply struct {
|
||||||
|
Data []map[string]struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(strings.TrimSpace(out)), &reply) != nil || len(reply.Data) != 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if v, ok := reply.Data[0]["org.bus1.DBus.Debug.Stats.PeerAccounting"]; ok {
|
||||||
|
var peers []json.RawMessage
|
||||||
|
if json.Unmarshal(v.Data, &peers) == nil {
|
||||||
|
return len(peers), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := reply.Data[0]["ActiveConnections"]; ok {
|
||||||
|
var n int
|
||||||
|
if json.Unmarshal(v.Data, &n) == nil {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUniqueNames counts the connections among ListNames' answer (`busctl call … ListNames`).
|
||||||
|
func CountUniqueNames(out string) (int, bool) {
|
||||||
|
var reply struct {
|
||||||
|
Data [][]string `json:"data"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(strings.TrimSpace(out)), &reply) != nil || len(reply.Data) != 1 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, name := range reply.Data[0] {
|
||||||
|
if strings.HasPrefix(name, ":") {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health is the system bus's health now: a ping through the watcher's connection, how many
|
||||||
|
// connections the bus has, and how long it has run.
|
||||||
|
func (m *Machine) Health(ctx context.Context, w *Watcher) map[string]any {
|
||||||
|
h := map[string]any{"bus": "system"}
|
||||||
|
if w != nil {
|
||||||
|
took, err := w.PingNow()
|
||||||
|
if err != nil {
|
||||||
|
h["ping"] = "no answer: " + err.Error()
|
||||||
|
} else {
|
||||||
|
h["ping_ms"] = float64(took.Microseconds()) / 1000
|
||||||
|
}
|
||||||
|
h["watcher"] = w.Snapshot()
|
||||||
|
}
|
||||||
|
// Debug.Stats is root's on the system bus; asked as the account it is refused and logged as a
|
||||||
|
// denial, which the watcher would then publish. So it is asked through sudo -n or not at all.
|
||||||
|
call := []string{"busctl", "--system", "--json=short", "--no-pager", "call", busName, busPath}
|
||||||
|
if out, err := m.privileged(ctx, call[0], append(call[1:], busName+".Debug.Stats", "GetStats")...); err == nil {
|
||||||
|
if n, ok := CountConnections(out); ok {
|
||||||
|
h["connections"], h["connections_from"] = n, "the bus's Debug.Stats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := h["connections"]; !ok {
|
||||||
|
if out, err := m.Run(ctx, call[0], append(call[1:], busName, "ListNames")...); err == nil {
|
||||||
|
if n, ok := CountUniqueNames(out); ok {
|
||||||
|
h["connections"], h["connections_from"] = n, "the unique names on the bus (Debug.Stats needs sudo -n)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u, err := m.SystemBusUnit(ctx); err != nil {
|
||||||
|
h["unit"] = err.Error()
|
||||||
|
} else {
|
||||||
|
h["unit"], h["active"], h["pid"] = u.Unit, u.Active, u.PID
|
||||||
|
if !u.Since.IsZero() {
|
||||||
|
h["started"] = u.Started
|
||||||
|
h["uptime"] = m.Now().Sub(u.Since).Round(time.Second).String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/godbus/dbus/v5/introspect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A bus name and an object path as the specification allows them; anything else is refused before
|
||||||
|
// it reaches busctl, so no argument is ever taken for an option.
|
||||||
|
var (
|
||||||
|
busNameRE = regexp.MustCompile(`^(:[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+|[A-Za-z_-][A-Za-z0-9_-]*(\.[A-Za-z_-][A-Za-z0-9_-]*)+)$`)
|
||||||
|
objectPathRE = regexp.MustCompile(`^/([A-Za-z0-9_]+(/[A-Za-z0-9_]+)*)?$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Member is one method or signal, with its arguments as "name type".
|
||||||
|
type Member struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
In []string `json:"in,omitempty"`
|
||||||
|
Out []string `json:"out,omitempty"`
|
||||||
|
Args []string `json:"args,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property is one property's name, type and access, never its value: a value can be anything a
|
||||||
|
// service holds, and reading it is a call of its own.
|
||||||
|
type Property struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Access string `json:"access"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface is one interface of an object.
|
||||||
|
type Interface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Methods []Member `json:"methods,omitempty"`
|
||||||
|
Properties []Property `json:"properties,omitempty"`
|
||||||
|
Signals []Member `json:"signals,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object is dbus_introspect's answer.
|
||||||
|
type Object struct {
|
||||||
|
Bus string `json:"bus"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Interfaces []Interface `json:"interfaces"`
|
||||||
|
Children []string `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseIntrospection reads `busctl call … Introspect --json=short` ({"type":"s","data":["<xml>"]}).
|
||||||
|
func ParseIntrospection(out string) (introspect.Node, error) {
|
||||||
|
var reply struct {
|
||||||
|
Data []string `json:"data"`
|
||||||
|
}
|
||||||
|
var node introspect.Node
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &reply); err != nil || len(reply.Data) != 1 {
|
||||||
|
return node, fmt.Errorf("the introspection answer is not a single string")
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal([]byte(reply.Data[0]), &node); err != nil {
|
||||||
|
return node, fmt.Errorf("the introspection document does not parse: %w", err)
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shape turns an introspection document into the tool's answer.
|
||||||
|
func Shape(bus, service, path string, node introspect.Node) Object {
|
||||||
|
o := Object{Bus: bus, Service: service, Path: path, Interfaces: []Interface{}}
|
||||||
|
for _, i := range node.Interfaces {
|
||||||
|
iface := Interface{Name: i.Name}
|
||||||
|
for _, m := range i.Methods {
|
||||||
|
mem := Member{Name: m.Name}
|
||||||
|
for _, a := range m.Args {
|
||||||
|
s := strings.TrimSpace(a.Name + " " + a.Type)
|
||||||
|
if a.Direction == "out" {
|
||||||
|
mem.Out = append(mem.Out, s)
|
||||||
|
} else {
|
||||||
|
mem.In = append(mem.In, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iface.Methods = append(iface.Methods, mem)
|
||||||
|
}
|
||||||
|
for _, p := range i.Properties {
|
||||||
|
iface.Properties = append(iface.Properties, Property{Name: p.Name, Type: p.Type, Access: p.Access})
|
||||||
|
}
|
||||||
|
for _, s := range i.Signals {
|
||||||
|
mem := Member{Name: s.Name}
|
||||||
|
for _, a := range s.Args {
|
||||||
|
mem.Args = append(mem.Args, strings.TrimSpace(a.Name+" "+a.Type))
|
||||||
|
}
|
||||||
|
iface.Signals = append(iface.Signals, mem)
|
||||||
|
}
|
||||||
|
o.Interfaces = append(o.Interfaces, iface)
|
||||||
|
}
|
||||||
|
for _, c := range node.Children {
|
||||||
|
if len(o.Children) >= AnswerCap {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
o.Children = append(o.Children, c.Name)
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// Introspect asks one object what it offers, as this account, without starting a service that is not
|
||||||
|
// running (--auto-start=no): looking must not change what runs.
|
||||||
|
func (m *Machine) Introspect(ctx context.Context, bus, service, path string) (Object, error) {
|
||||||
|
if !busNameRE.MatchString(service) {
|
||||||
|
return Object{}, fmt.Errorf("%q is not a bus name", service)
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
if !objectPathRE.MatchString(path) {
|
||||||
|
return Object{}, fmt.Errorf("%q is not an object path", path)
|
||||||
|
}
|
||||||
|
args, err := m.busArgs(bus)
|
||||||
|
if err != nil {
|
||||||
|
return Object{}, err
|
||||||
|
}
|
||||||
|
out, err := m.Run(ctx, "busctl", append(args, "--json=short", "--no-pager", "--auto-start=no", "call",
|
||||||
|
service, path, "org.freedesktop.DBus.Introspectable", "Introspect")...)
|
||||||
|
if err != nil {
|
||||||
|
return Object{}, err
|
||||||
|
}
|
||||||
|
node, err := ParseIntrospection(out)
|
||||||
|
if err != nil {
|
||||||
|
return Object{}, err
|
||||||
|
}
|
||||||
|
return Shape(orWord(bus, "system"), service, path, node), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BusUnits are the system bus's units as the journal knows them: dbus-broker's own name, and the
|
||||||
|
// alias every implementation answers to.
|
||||||
|
var BusUnits = []string{"dbus-broker.service", "dbus.service"}
|
||||||
|
|
||||||
|
// JournalLines is the most journal entries one read takes.
|
||||||
|
const JournalLines = 2000
|
||||||
|
|
||||||
|
// Denial is one policy denial as the bus logged it: the header of the message it refused, never its
|
||||||
|
// body, which the bus does not log either.
|
||||||
|
type Denial struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Sender string `json:"sender,omitempty"`
|
||||||
|
Destination string `json:"destination,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
Member string `json:"member,omitempty"`
|
||||||
|
Policy string `json:"policy,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// key is what makes two denials the same example: who was refused what, ignoring the sender's unique
|
||||||
|
// name, which differs at every connection.
|
||||||
|
func (d Denial) key() string {
|
||||||
|
return d.Action + "|" + d.Type + "|" + d.Destination + "|" + d.Interface + "|" + d.Member
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalEntry is the part of a journal entry the module reads. MESSAGE is read only to recognise a
|
||||||
|
// denial; it is never answered or published.
|
||||||
|
type journalEntry struct {
|
||||||
|
Cursor string `json:"__CURSOR"`
|
||||||
|
Realtime string `json:"__REALTIME_TIMESTAMP"`
|
||||||
|
Message any `json:"MESSAGE"`
|
||||||
|
Action string `json:"DBUS_BROKER_TRANSMIT_ACTION"`
|
||||||
|
Type string `json:"DBUS_BROKER_MESSAGE_TYPE"`
|
||||||
|
Sender string `json:"DBUS_BROKER_SENDER_UNIQUE_NAME"`
|
||||||
|
Destination string `json:"DBUS_BROKER_MESSAGE_DESTINATION"`
|
||||||
|
Path string `json:"DBUS_BROKER_MESSAGE_PATH"`
|
||||||
|
Interface string `json:"DBUS_BROKER_MESSAGE_INTERFACE"`
|
||||||
|
Member string `json:"DBUS_BROKER_MESSAGE_MEMBER"`
|
||||||
|
Policy string `json:"DBUS_BROKER_POLICY_TYPE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDenial is whether a bus's log line is a policy denial: dbus-broker's "A security policy denied",
|
||||||
|
// or the reference daemon's "Rejected send message".
|
||||||
|
func IsDenial(message string) bool {
|
||||||
|
return strings.Contains(message, "security policy denied") || strings.Contains(message, "Rejected send message") ||
|
||||||
|
strings.Contains(message, "Rejected receive message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDenials reads journalctl's JSON lines and answers the denials among them and the last cursor.
|
||||||
|
func ParseDenials(out string) ([]Denial, string) {
|
||||||
|
var got []Denial
|
||||||
|
cursor := ""
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || line[0] != '{' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var e journalEntry
|
||||||
|
if json.Unmarshal([]byte(line), &e) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Cursor != "" {
|
||||||
|
cursor = e.Cursor
|
||||||
|
}
|
||||||
|
msg, _ := e.Message.(string) // a binary MESSAGE comes as an array of bytes and is no denial
|
||||||
|
if !IsDenial(msg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := ""
|
||||||
|
if us, err := strconv.ParseInt(e.Realtime, 10, 64); err == nil {
|
||||||
|
at = time.UnixMicro(us).UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
got = append(got, Denial{At: at, Action: e.Action, Type: e.Type, Sender: e.Sender,
|
||||||
|
Destination: e.Destination, Path: e.Path, Interface: e.Interface, Member: e.Member, Policy: e.Policy})
|
||||||
|
}
|
||||||
|
return got, cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalArgs() []string {
|
||||||
|
args := []string{"--no-pager", "-o", "json", "-n", strconv.Itoa(JournalLines)}
|
||||||
|
for _, u := range BusUnits {
|
||||||
|
args = append(args, "-u", u)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denials is the watcher's Journal on this machine: journalctl as the operator's account, which
|
||||||
|
// reads the system journal through its group.
|
||||||
|
func (m *Machine) Denials(ctx context.Context, after string, since time.Time) ([]Denial, string, error) {
|
||||||
|
args := journalArgs()
|
||||||
|
if after != "" {
|
||||||
|
args = append(args, "--after-cursor", after)
|
||||||
|
} else {
|
||||||
|
args = append(args, "--since", "@"+strconv.FormatInt(since.Unix(), 10))
|
||||||
|
}
|
||||||
|
out, err := m.Run(ctx, "journalctl", args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
d, cursor := ParseDenials(out)
|
||||||
|
return d, cursor, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run on a real machine with MESH_LIVE=1: every tool reads this machine's buses, and the watcher's
|
||||||
|
// connection answers. Nothing is changed.
|
||||||
|
func TestLiveTools(t *testing.T) {
|
||||||
|
if os.Getenv("MESH_LIVE") == "" {
|
||||||
|
t.Skip("set MESH_LIVE=1 on a machine with a system bus")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
m := Here()
|
||||||
|
b, err := DialSystemBus()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
if err := b.Ping(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id, _ := b.ID(ctx)
|
||||||
|
pid, _ := b.PID(ctx, busName)
|
||||||
|
t.Logf("bus %s, driver pid %d in %s", id, pid, m.UnitOf(pid))
|
||||||
|
show := func(name string, v any, err error) {
|
||||||
|
raw, _ := json.Marshal(v)
|
||||||
|
if len(raw) > 1500 {
|
||||||
|
raw = append(raw[:1500], "…"...)
|
||||||
|
}
|
||||||
|
t.Logf("%s: %v\n%s", name, err, raw)
|
||||||
|
}
|
||||||
|
n, err := m.Names(ctx, "system")
|
||||||
|
show("names system", n, err)
|
||||||
|
n, err = m.Names(ctx, "session")
|
||||||
|
show("names session", n, err)
|
||||||
|
o, err := m.Introspect(ctx, "system", "org.freedesktop.login1", "/org/freedesktop/login1")
|
||||||
|
show("introspect", o, err)
|
||||||
|
w, err := m.Monitor(ctx, "system", 2, "", nil)
|
||||||
|
show("monitor", w, err)
|
||||||
|
show("health", m.Health(ctx, nil), nil)
|
||||||
|
show("check", m.Check(ctx, nil), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run with MESH_LIVE=1: the watcher connects, pings and baselines the names, and says nothing.
|
||||||
|
func TestLiveWatcher(t *testing.T) {
|
||||||
|
if os.Getenv("MESH_LIVE") == "" {
|
||||||
|
t.Skip("set MESH_LIVE=1 on a machine with a system bus")
|
||||||
|
}
|
||||||
|
m := Here()
|
||||||
|
var said []string
|
||||||
|
w := NewWatcher(m, func(e string, b any) error { said = append(said, e); return nil }, DialSystemBus, m.Denials)
|
||||||
|
w.state = filepath.Join(t.TempDir(), "bus")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), PingEvery+2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
w.Run(ctx)
|
||||||
|
s := w.Snapshot()
|
||||||
|
t.Logf("%+v said %v", s, said)
|
||||||
|
if s.BusID == "" || s.LastPingAt == "" || s.Stalled || s.Services == 0 {
|
||||||
|
t.Fatalf("%+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommandTimeout bounds every command a tool runs: a bus that hangs must cost a tool call twenty
|
||||||
|
// seconds, never the runtime's thirty.
|
||||||
|
const CommandTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// ReadCap is the most of one command's output the module reads. An introspection document of the
|
||||||
|
// service manager is about 100 KiB; nothing the module asks is near a mebibyte.
|
||||||
|
const ReadCap = 1024 * 1024
|
||||||
|
|
||||||
|
// AnswerCap is the most entries a tool answers in one list (names, messages, denials).
|
||||||
|
const AnswerCap = 500
|
||||||
|
|
||||||
|
// Runner runs one command and answers its standard output. Injected, so every tool is tested against
|
||||||
|
// recorded answers rather than this machine's bus.
|
||||||
|
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||||
|
|
||||||
|
// Streamer runs one command for at most the context's time and hands each line of its output to
|
||||||
|
// line, which says whether it wants more. The end of the time is the normal end, not an error.
|
||||||
|
// Injected, so dbus_monitor is tested without a bus.
|
||||||
|
type Streamer func(ctx context.Context, line func(string) bool, name string, args ...string) error
|
||||||
|
|
||||||
|
// ExecRunner runs a command, bounded by CommandTimeout. A failure carries what it said on stderr.
|
||||||
|
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
out := capped(stdout.String(), ReadCap)
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return out, fmt.Errorf("%s did not answer within %s", name, CommandTimeout)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
said := strings.TrimSpace(stderr.String())
|
||||||
|
if said == "" {
|
||||||
|
said = strings.TrimSpace(stdout.String())
|
||||||
|
}
|
||||||
|
return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, capped(said, 2048))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecStreamer runs a command until the context ends, line by line. A line longer than ReadCap is
|
||||||
|
// skipped, never held: a monitored message's line carries its body, which the module never keeps.
|
||||||
|
func ExecStreamer(ctx context.Context, line func(string) bool, name string, args ...string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0")
|
||||||
|
// A terminate, which sudo passes on to what it runs; a kill would leave a root monitor behind.
|
||||||
|
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
out, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := bufio.NewReaderSize(out, 64*1024)
|
||||||
|
var cur []byte
|
||||||
|
tooLong := false
|
||||||
|
for {
|
||||||
|
chunk, isPrefix, err := r.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !tooLong {
|
||||||
|
cur = append(cur, chunk...)
|
||||||
|
if len(cur) > ReadCap {
|
||||||
|
cur, tooLong = cur[:0], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isPrefix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !tooLong && !line(string(cur)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cur, tooLong = cur[:0], false
|
||||||
|
}
|
||||||
|
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
werr := cmd.Wait()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil // the time ran out: the normal end of a bounded watch
|
||||||
|
}
|
||||||
|
if werr != nil && stderr.Len() > 0 {
|
||||||
|
return fmt.Errorf("%s: %w: %s", name, werr, capped(strings.TrimSpace(stderr.String()), 2048))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capped(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "\n… (cut)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree),
|
||||||
|
// a way to run commands, a way to stream one, and the environment the runtime gave it.
|
||||||
|
type Machine struct {
|
||||||
|
Root string
|
||||||
|
Run Runner
|
||||||
|
Stream Streamer
|
||||||
|
Env func(string) string
|
||||||
|
UID int
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here is the machine this process runs on.
|
||||||
|
func Here() *Machine {
|
||||||
|
return &Machine{Root: "/", Run: ExecRunner, Stream: ExecStreamer, Env: os.Getenv, UID: os.Getuid(), Now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||||
|
|
||||||
|
func (m *Machine) read(p string) string {
|
||||||
|
b, err := os.ReadFile(m.path(p))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) exists(p string) bool {
|
||||||
|
_, err := os.Stat(m.path(p))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) glob(pattern string) []string {
|
||||||
|
got, _ := filepath.Glob(m.path(pattern))
|
||||||
|
out := make([]string, 0, len(got))
|
||||||
|
for _, g := range got {
|
||||||
|
rel, err := filepath.Rel(m.Root, g)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, "/"+rel)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// privileged runs a command as root without asking for a password (sudo -n), as the other modules'
|
||||||
|
// tools do: the runtime runs as the operator's account, and watching the system bus is root's.
|
||||||
|
func (m *Machine) privileged(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
return m.Run(ctx, "sudo", append([]string{"-n", name}, args...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buses are the two buses a tool can be pointed at.
|
||||||
|
var Buses = []string{"system", "session"}
|
||||||
|
|
||||||
|
// ErrNoSession is the answer on a machine where this account has no session bus: the servers.
|
||||||
|
var ErrNoSession = errors.New("no session bus for this account on this machine")
|
||||||
|
|
||||||
|
// SessionAddress is the account's session bus: the runtime's DBUS_SESSION_BUS_ADDRESS, else the
|
||||||
|
// user manager's socket under XDG_RUNTIME_DIR or /run/user/<uid>. Only a socket that exists counts.
|
||||||
|
func (m *Machine) SessionAddress() (string, error) {
|
||||||
|
if a := m.Env("DBUS_SESSION_BUS_ADDRESS"); strings.HasPrefix(a, "unix:path=") {
|
||||||
|
p := strings.TrimPrefix(a, "unix:path=")
|
||||||
|
if i := strings.IndexByte(p, ','); i >= 0 {
|
||||||
|
p = p[:i]
|
||||||
|
}
|
||||||
|
if m.exists(p) {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
} else if a != "" {
|
||||||
|
return a, nil // an abstract or other address: taken as given
|
||||||
|
}
|
||||||
|
dir := m.Env("XDG_RUNTIME_DIR")
|
||||||
|
if dir == "" {
|
||||||
|
dir = "/run/user/" + strconv.Itoa(m.UID)
|
||||||
|
}
|
||||||
|
if p := dir + "/bus"; m.exists(p) {
|
||||||
|
return "unix:path=" + p, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%w (no socket at %s/bus)", ErrNoSession, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// busArgs are busctl's words for one bus.
|
||||||
|
func (m *Machine) busArgs(bus string) ([]string, error) {
|
||||||
|
switch bus {
|
||||||
|
case "", "system":
|
||||||
|
return []string{"--system"}, nil
|
||||||
|
case "session":
|
||||||
|
a, err := m.SessionAddress()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{"--address=" + a}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("bus is system or session, not %q", bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnitOf is the systemd unit a process runs in, from its cgroup: readable for any process.
|
||||||
|
func (m *Machine) UnitOf(pid uint32) string {
|
||||||
|
if pid == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return UnitFromCgroup(m.read(fmt.Sprintf("/proc/%d/cgroup", pid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessName is a process's command name.
|
||||||
|
func (m *Machine) ProcessName(pid uint32) string {
|
||||||
|
if pid == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.read(fmt.Sprintf("/proc/%d/comm", pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnitFromCgroup is the innermost service, socket or scope in a cgroup v2 path.
|
||||||
|
func UnitFromCgroup(cgroup string) string {
|
||||||
|
for _, line := range strings.Split(cgroup, "\n") {
|
||||||
|
if !strings.HasPrefix(line, "0::") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimPrefix(line, "0::"), "/")
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
p := parts[i]
|
||||||
|
if strings.HasSuffix(p, ".service") || strings.HasSuffix(p, ".scope") {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// BootID is this boot's id: a bus that changed across a boot did not restart, the machine did.
|
||||||
|
func (m *Machine) BootID() string { return m.read("/proc/sys/kernel/random/boot_id") }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// The dbus module's Go bundle (novox/hq ADR 0215): the holder of node-message-bus. One process the
|
||||||
|
// node's runtime launches as the operator's account, serving the module's tools over MCP on stdio and
|
||||||
|
// running its watcher beside them, which publishes what matters about the system bus, never its
|
||||||
|
// traffic.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
m := Here()
|
||||||
|
w := NewWatcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) },
|
||||||
|
DialSystemBus, m.Denials)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go w.Run(ctx)
|
||||||
|
if err := stdio.Serve("", Tools(m, w)); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The dbus module's shape (novox/hq ADR 0215): it holds node-message-bus, owns the bus's packages,
|
||||||
|
// declares the bus running and never restarts or reloads it, and publishes only its curated events.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifestShape struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []map[string]any `json:"claims"`
|
||||||
|
Emits []string `json:"emits"`
|
||||||
|
Consumes []string `json:"consumes"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifest(t *testing.T) (manifestShape, string) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifestShape
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return m, string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItHoldsTheMessageBusSeatWithOnlyWhatItNeeds(t *testing.T) {
|
||||||
|
m, _ := manifest(t)
|
||||||
|
if m.Module != "dbus" || len(m.Claims) != 1 || m.Claims[0]["name"] != "node-message-bus" || m.Claims[0]["scope"] != "node" {
|
||||||
|
t.Fatalf("claims: %+v", m.Claims)
|
||||||
|
}
|
||||||
|
if _, serves := m.Claims[0]["serves"]; serves {
|
||||||
|
t.Error("the seat receives nothing yet (ADR 0215 §4) and serves no verbs")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(m.Capabilities, []string{"package-manager", "service-manager"}) {
|
||||||
|
t.Fatalf("capabilities: %v", m.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItOwnsTheBusImplementationsPackages(t *testing.T) {
|
||||||
|
m, _ := manifest(t)
|
||||||
|
var pkgs []string
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" {
|
||||||
|
pkgs = append(pkgs, r["package"].(string))
|
||||||
|
if r["absent"] == true {
|
||||||
|
t.Errorf("%v is declared absent", r["package"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(pkgs)
|
||||||
|
if !reflect.DeepEqual(pkgs, Packages) {
|
||||||
|
t.Fatalf("packages %v, the check reads %v", pkgs, Packages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheBusIsNeverRestartedOrReloaded is ADR 0215 §2: the bus is declared running, with no trigger
|
||||||
|
// that would restart or reload it, and no boot state: dbus.service is an alias and dbus-broker.service
|
||||||
|
// is started at boot through dbus.socket, so enabling would write an alias link the machines do not
|
||||||
|
// have today.
|
||||||
|
func TestTheBusIsNeverRestartedOrReloaded(t *testing.T) {
|
||||||
|
m, raw := manifest(t)
|
||||||
|
if strings.Contains(raw, "restart-on") || strings.Contains(raw, "reload-on") {
|
||||||
|
t.Fatal("the manifest carries a restart or reload trigger")
|
||||||
|
}
|
||||||
|
var services []map[string]any
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
services = append(services, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(services) != 1 {
|
||||||
|
t.Fatalf("services: %v", services)
|
||||||
|
}
|
||||||
|
s := services[0]
|
||||||
|
if s["unit"] != SystemUnit || s["state"] != "running" {
|
||||||
|
t.Fatalf("%v", s)
|
||||||
|
}
|
||||||
|
if _, ok := s["boot"]; ok {
|
||||||
|
t.Fatal("a boot state on the bus would have the host enable it")
|
||||||
|
}
|
||||||
|
for k := range s {
|
||||||
|
if k != "id" && k != "type" && k != "unit" && k != "state" {
|
||||||
|
t.Errorf("the bus's service carries %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItEmitsTheCuratedEventsAndConsumesNothing(t *testing.T) {
|
||||||
|
m, _ := manifest(t)
|
||||||
|
if !reflect.DeepEqual(m.Emits, Emits) {
|
||||||
|
t.Fatalf("emits %v, the watcher publishes %v", m.Emits, Emits)
|
||||||
|
}
|
||||||
|
if len(m.Consumes) != 0 {
|
||||||
|
t.Fatalf("consumes %v", m.Consumes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsAreTheManifests(t *testing.T) {
|
||||||
|
m, _ := manifest(t)
|
||||||
|
served := map[string]bool{}
|
||||||
|
for _, tool := range Tools(testMachine(t, nil), nil) {
|
||||||
|
if served[tool.Name] {
|
||||||
|
t.Errorf("%s is served twice", tool.Name)
|
||||||
|
}
|
||||||
|
served[tool.Name] = true
|
||||||
|
if !strings.HasPrefix(tool.Name, "dbus_") {
|
||||||
|
t.Errorf("%s is not named dbus_<verb>", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range m.Tools {
|
||||||
|
if !served[want] {
|
||||||
|
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
|
||||||
|
}
|
||||||
|
delete(served, want)
|
||||||
|
}
|
||||||
|
for extra := range served {
|
||||||
|
t.Errorf("the bundle serves %s, which the manifest does not list", extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheBundleIsTheBuildersShape(t *testing.T) {
|
||||||
|
m, _ := manifest(t)
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("%v", m.Build.Artifacts)
|
||||||
|
}
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
for k, v := range map[string]string{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/dbus-tools", "binary": "dbus-tools"} {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("%s is %v, want %s", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNothingInstallationSpecific holds the manifest to the catalogue's rule: no node names, domains
|
||||||
|
// or home paths.
|
||||||
|
func TestNothingInstallationSpecific(t *testing.T) {
|
||||||
|
_, raw := manifest(t)
|
||||||
|
for _, re := range []*regexp.Regexp{regexp.MustCompile(`/home/`), regexp.MustCompile(`\.(be|internal|com)\b`)} {
|
||||||
|
if loc := re.FindString(raw); loc != "" {
|
||||||
|
t.Errorf("the manifest carries %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxMonitorSeconds bounds a watch of the bus: long enough to catch an exchange, short enough that
|
||||||
|
// nobody leaves a monitor running.
|
||||||
|
const MaxMonitorSeconds = 15
|
||||||
|
|
||||||
|
// MonitorLimit is the most messages busctl reads in one watch before it stops by itself.
|
||||||
|
const MonitorLimit = 20000
|
||||||
|
|
||||||
|
// Header is what dbus_monitor answers of one message: who sent what to whom. The body is never read
|
||||||
|
// into it: the struct has no field for it, so a payload is dropped as each line is decoded.
|
||||||
|
type Header struct {
|
||||||
|
Time string `json:"time,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Sender string `json:"sender,omitempty"`
|
||||||
|
Destination string `json:"destination,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
Member string `json:"member,omitempty"`
|
||||||
|
ErrorName string `json:"error_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type monitorLine struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
Member string `json:"member"`
|
||||||
|
ErrorName string `json:"error_name"`
|
||||||
|
Realtime int64 `json:"timestamp-realtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseHeader reads one line of `busctl monitor --json=short` into its header alone.
|
||||||
|
func ParseHeader(line string) (Header, bool) {
|
||||||
|
var l monitorLine
|
||||||
|
if json.Unmarshal([]byte(line), &l) != nil || l.Type == "" {
|
||||||
|
return Header{}, false
|
||||||
|
}
|
||||||
|
h := Header{Type: l.Type, Sender: l.Sender, Destination: l.Destination, Path: l.Path,
|
||||||
|
Interface: l.Interface, Member: l.Member, ErrorName: l.ErrorName}
|
||||||
|
if l.Realtime > 0 {
|
||||||
|
h.Time = time.UnixMicro(l.Realtime).UTC().Format("15:04:05.000000")
|
||||||
|
}
|
||||||
|
return h, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch is dbus_monitor's answer.
|
||||||
|
type Watch struct {
|
||||||
|
Bus string `json:"bus"`
|
||||||
|
Seconds int `json:"seconds"`
|
||||||
|
Match string `json:"match,omitempty"`
|
||||||
|
Names []string `json:"names,omitempty"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Messages []Header `json:"messages"`
|
||||||
|
Cut bool `json:"cut,omitempty"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor watches one bus for a few seconds and answers the headers of what passed. The system bus is
|
||||||
|
// watched as root (sudo -n): only root may become a monitor there. The session bus is the account's
|
||||||
|
// own. The bodies, which carry secrets, notification text and the clipboard, are never kept.
|
||||||
|
func (m *Machine) Monitor(ctx context.Context, bus string, seconds int, match string, names []string) (Watch, error) {
|
||||||
|
if seconds < 1 || seconds > MaxMonitorSeconds {
|
||||||
|
return Watch{}, fmt.Errorf("seconds is 1 to %d", MaxMonitorSeconds)
|
||||||
|
}
|
||||||
|
if len(match) > 512 || strings.ContainsAny(match, "\n\x00") {
|
||||||
|
return Watch{}, fmt.Errorf("the match rule is not one line of at most 512 characters")
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if !busNameRE.MatchString(n) {
|
||||||
|
return Watch{}, fmt.Errorf("%q is not a bus name", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args, err := m.busArgs(bus)
|
||||||
|
if err != nil {
|
||||||
|
return Watch{}, err
|
||||||
|
}
|
||||||
|
cmd := append([]string{"timeout", strconv.Itoa(seconds) + "s", "busctl"}, args...)
|
||||||
|
cmd = append(cmd, "--json=short", "--no-pager", "--limit-messages="+strconv.Itoa(MonitorLimit), "monitor")
|
||||||
|
if match != "" {
|
||||||
|
cmd = append(cmd, "--match="+match)
|
||||||
|
}
|
||||||
|
cmd = append(cmd, names...)
|
||||||
|
if orWord(bus, "system") == "system" {
|
||||||
|
cmd = append([]string{"sudo", "-n"}, cmd...)
|
||||||
|
}
|
||||||
|
w := Watch{Bus: orWord(bus, "system"), Seconds: seconds, Match: match, Names: names, Messages: []Header{},
|
||||||
|
Note: "headers only; message bodies are never read into the answer"}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second+5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err = m.Stream(ctx, func(line string) bool {
|
||||||
|
h, ok := ParseHeader(line)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.Total++
|
||||||
|
if len(w.Messages) < AnswerCap {
|
||||||
|
w.Messages = append(w.Messages, h)
|
||||||
|
} else {
|
||||||
|
w.Cut = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, cmd[0], cmd[1:]...)
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// busctlName is one line of `busctl list --json`.
|
||||||
|
type busctlName struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PID *uint32 `json:"pid"`
|
||||||
|
Process *string `json:"process"`
|
||||||
|
User *string `json:"user"`
|
||||||
|
Connection string `json:"connection"`
|
||||||
|
Unit *string `json:"unit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name is one well-known name on a bus, with what holds it.
|
||||||
|
type Name struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PID uint32 `json:"pid,omitempty"`
|
||||||
|
Process string `json:"process,omitempty"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Connection string `json:"connection,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names is dbus_names's answer.
|
||||||
|
type Names struct {
|
||||||
|
Bus string `json:"bus"`
|
||||||
|
Running []Name `json:"running"`
|
||||||
|
ActivatableNotRunning []string `json:"activatable_not_running"`
|
||||||
|
Connections int `json:"connections"`
|
||||||
|
Cut bool `json:"cut,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseNames reads `busctl list --json=short`: the well-known names running, the activatable names
|
||||||
|
// that are not, and how many connections (unique names) the bus has.
|
||||||
|
func ParseNames(bus, out string) (Names, error) {
|
||||||
|
var raw []busctlName
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &raw); err != nil {
|
||||||
|
return Names{}, fmt.Errorf("busctl list answered what is not its JSON: %w", err)
|
||||||
|
}
|
||||||
|
n := Names{Bus: bus, Running: []Name{}, ActivatableNotRunning: []string{}}
|
||||||
|
for _, r := range raw {
|
||||||
|
switch {
|
||||||
|
case r.Name == busName: // the bus itself, which busctl attributes to the service manager
|
||||||
|
case strings.HasPrefix(r.Name, ":"):
|
||||||
|
n.Connections++
|
||||||
|
case r.Connection == "(activatable)" || r.PID == nil:
|
||||||
|
n.ActivatableNotRunning = append(n.ActivatableNotRunning, r.Name)
|
||||||
|
default:
|
||||||
|
n.Running = append(n.Running, Name{Name: r.Name, PID: deref(r.PID), Process: derefS(r.Process),
|
||||||
|
User: derefS(r.User), Unit: derefS(r.Unit), Connection: r.Connection})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(n.Running, func(i, j int) bool { return n.Running[i].Name < n.Running[j].Name })
|
||||||
|
sort.Strings(n.ActivatableNotRunning)
|
||||||
|
if len(n.Running) > AnswerCap {
|
||||||
|
n.Running, n.Cut = n.Running[:AnswerCap], true
|
||||||
|
}
|
||||||
|
if len(n.ActivatableNotRunning) > AnswerCap {
|
||||||
|
n.ActivatableNotRunning, n.Cut = n.ActivatableNotRunning[:AnswerCap], true
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names lists one bus's names as this account sees them.
|
||||||
|
func (m *Machine) Names(ctx context.Context, bus string) (Names, error) {
|
||||||
|
args, err := m.busArgs(bus)
|
||||||
|
if err != nil {
|
||||||
|
return Names{}, err
|
||||||
|
}
|
||||||
|
out, err := m.Run(ctx, "busctl", append(args, "--json=short", "--no-pager", "list")...)
|
||||||
|
if err != nil {
|
||||||
|
return Names{}, err
|
||||||
|
}
|
||||||
|
return ParseNames(orWord(bus, "system"), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(p *uint32) uint32 {
|
||||||
|
if p == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefS(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func orWord(s, word string) string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return word
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recorded answers, trimmed, from a workstation of 2026-10-05.
|
||||||
|
const busctlList = `[{"name":":1.0","pid":456,"process":"systemd-timesyn","user":"systemd-timesync","connection":":1.0","unit":"systemd-timesyncd.service","session":null,"description":null},` +
|
||||||
|
`{"name":"fi.w1.wpa_supplicant1","pid":1442,"process":"wpa_supplicant","user":"root","connection":":1.23","unit":"wpa_supplicant.service","session":null,"description":null},` +
|
||||||
|
`{"name":"org.blueman.Mechanism","pid":null,"process":null,"user":null,"connection":"(activatable)","unit":null,"session":null,"description":null},` +
|
||||||
|
`{"name":":1.11","pid":975,"process":"polkitd","user":"polkitd","connection":":1.11","unit":"polkit.service","session":null,"description":null},` +
|
||||||
|
`{"name":"org.freedesktop.DBus","pid":807,"process":"dbus-broker-lau","user":"root","connection":"org.freedesktop.DBus","unit":"dbus-broker.service","session":null,"description":null}]`
|
||||||
|
|
||||||
|
func TestNamesAreSplitIntoRunningActivatableAndConnections(t *testing.T) {
|
||||||
|
n, err := ParseNames("system", busctlList)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n.Connections != 2 || len(n.Running) != 1 || n.Running[0].Name != "fi.w1.wpa_supplicant1" ||
|
||||||
|
n.Running[0].Unit != "wpa_supplicant.service" || n.Running[0].PID != 1442 {
|
||||||
|
t.Fatalf("%+v", n)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(n.ActivatableNotRunning, []string{"org.blueman.Mechanism"}) {
|
||||||
|
t.Fatalf("%v", n.ActivatableNotRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const introspection = `{"type":"s","data":["<!DOCTYPE node PUBLIC \"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN\" \"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd\">\n<node>\n <interface name=\"org.freedesktop.login1.Manager\">\n <property name=\"IdleHint\" type=\"b\" access=\"read\"></property>\n <method name=\"Inhibit\">\n <arg type=\"s\" name=\"what\" direction=\"in\"/>\n <arg type=\"h\" name=\"pipe_fd\" direction=\"out\"/>\n </method>\n <signal name=\"PrepareForSleep\">\n <arg type=\"b\" name=\"start\"/>\n </signal>\n </interface>\n <node name=\"session\"/>\n <node name=\"seat\"/>\n</node>\n"]}`
|
||||||
|
|
||||||
|
func TestAnIntrospectionIsShapedWithoutValues(t *testing.T) {
|
||||||
|
node, err := ParseIntrospection(introspection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o := Shape("system", "org.freedesktop.login1", "/org/freedesktop/login1", node)
|
||||||
|
if len(o.Interfaces) != 1 || !reflect.DeepEqual(o.Children, []string{"session", "seat"}) {
|
||||||
|
t.Fatalf("%+v", o)
|
||||||
|
}
|
||||||
|
i := o.Interfaces[0]
|
||||||
|
if !reflect.DeepEqual(i.Methods[0], Member{Name: "Inhibit", In: []string{"what s"}, Out: []string{"pipe_fd h"}}) ||
|
||||||
|
!reflect.DeepEqual(i.Properties[0], Property{Name: "IdleHint", Type: "b", Access: "read"}) ||
|
||||||
|
!reflect.DeepEqual(i.Signals[0], Member{Name: "PrepareForSleep", Args: []string{"start b"}}) {
|
||||||
|
t.Fatalf("%+v", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntrospectRefusesWhatIsNotANameOrAPathAndNeverStartsAService(t *testing.T) {
|
||||||
|
var asked []string
|
||||||
|
m := testMachine(t, nil)
|
||||||
|
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
asked = append([]string{name}, args...)
|
||||||
|
return introspection, nil
|
||||||
|
}
|
||||||
|
if _, err := m.Introspect(context.Background(), "system", "--address=x", "/"); err == nil {
|
||||||
|
t.Fatal("an option was taken for a bus name")
|
||||||
|
}
|
||||||
|
if _, err := m.Introspect(context.Background(), "system", "org.freedesktop.login1", "relative"); err == nil {
|
||||||
|
t.Fatal("a relative path was taken")
|
||||||
|
}
|
||||||
|
if _, err := m.Introspect(context.Background(), "system", "org.freedesktop.login1", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(asked, " "), "--auto-start=no") || asked[len(asked)-3] != "/" {
|
||||||
|
t.Fatalf("%v", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monitorLines = `{"type":"method_call","endian":"l","flags":0,"version":1,"cookie":186058,"timestamp-realtime":1791193058632202,"sender":":1.797","destination":"org.freedesktop.UPower","path":"/org/freedesktop/UPower","interface":"org.freedesktop.UPower","member":"GetDisplayDevice","payload":{"type":"","data":[]}}
|
||||||
|
{"type":"signal","endian":"l","flags":1,"version":1,"cookie":7,"timestamp-realtime":1791193058632725,"sender":":1.40","path":"/org/freedesktop/Notifications","interface":"org.freedesktop.Notifications","member":"Notify","payload":{"type":"susssasa{sv}i","data":["app",0,"","the secret notification text","the clipboard's password",[],{},5000]}}
|
||||||
|
not json at all
|
||||||
|
{"type":"error","endian":"l","flags":1,"version":1,"cookie":9,"sender":":1.9","destination":":1.797","error_name":"org.freedesktop.DBus.Error.AccessDenied","payload":{"type":"s","data":["the reason, with a token"]}}`
|
||||||
|
|
||||||
|
func TestAMonitoredMessageIsItsHeaderOnly(t *testing.T) {
|
||||||
|
var asked []string
|
||||||
|
m := testMachine(t, nil)
|
||||||
|
m.Stream = func(ctx context.Context, line func(string) bool, name string, args ...string) error {
|
||||||
|
asked = append([]string{name}, args...)
|
||||||
|
if _, ok := ctx.Deadline(); !ok {
|
||||||
|
t.Error("the watch has no deadline")
|
||||||
|
}
|
||||||
|
for _, l := range strings.Split(monitorLines, "\n") {
|
||||||
|
if !line(l) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w, err := m.Monitor(context.Background(), "system", 3, "type='signal'", []string{"org.freedesktop.Notifications"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(w)
|
||||||
|
for _, secret := range []string{"secret notification", "password", "token", "payload", "data"} {
|
||||||
|
if strings.Contains(string(raw), secret) {
|
||||||
|
t.Errorf("the answer carries %q: %s", secret, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.Total != 3 || w.Messages[1].Member != "Notify" || w.Messages[2].ErrorName != "org.freedesktop.DBus.Error.AccessDenied" {
|
||||||
|
t.Fatalf("%+v", w)
|
||||||
|
}
|
||||||
|
cmd := strings.Join(asked, " ")
|
||||||
|
if !strings.HasPrefix(cmd, "sudo -n timeout 3s busctl --system") || !strings.Contains(cmd, "--match=type='signal'") ||
|
||||||
|
!strings.HasSuffix(cmd, "org.freedesktop.Notifications") {
|
||||||
|
t.Fatalf("%s", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMonitorIsBoundedAndTheSessionBusIsTheAccounts(t *testing.T) {
|
||||||
|
m := testMachine(t, map[string]string{"/run/user/1000/bus": ""})
|
||||||
|
var asked string
|
||||||
|
m.Stream = func(_ context.Context, _ func(string) bool, name string, args ...string) error {
|
||||||
|
asked = name + " " + strings.Join(args, " ")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, s := range []int{0, MaxMonitorSeconds + 1} {
|
||||||
|
if _, err := m.Monitor(context.Background(), "system", s, "", nil); err == nil {
|
||||||
|
t.Errorf("%d seconds were accepted", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := m.Monitor(context.Background(), "session", 2, "", []string{"-x"}); err == nil {
|
||||||
|
t.Error("an option was taken for a name")
|
||||||
|
}
|
||||||
|
if _, err := m.Monitor(context.Background(), "session", 2, "", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(asked, "sudo") || !strings.Contains(asked, "--address=unix:path=/run/user/1000/bus") {
|
||||||
|
t.Fatalf("%s", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASessionBusIsFoundOrSaidMissing(t *testing.T) {
|
||||||
|
m := testMachine(t, nil)
|
||||||
|
if _, err := m.SessionAddress(); !errors.Is(err, ErrNoSession) {
|
||||||
|
t.Fatalf("a server without a session said %v", err)
|
||||||
|
}
|
||||||
|
m = testMachine(t, map[string]string{"/run/user/1000/bus": ""})
|
||||||
|
if a, err := m.SessionAddress(); err != nil || a != "unix:path=/run/user/1000/bus" {
|
||||||
|
t.Fatalf("%q %v", a, err)
|
||||||
|
}
|
||||||
|
m.Env = func(k string) string {
|
||||||
|
if k == "DBUS_SESSION_BUS_ADDRESS" {
|
||||||
|
return "unix:path=/run/user/1000/bus,guid=abc"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if a, _ := m.SessionAddress(); a != "unix:path=/run/user/1000/bus,guid=abc" {
|
||||||
|
t.Fatalf("%q", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalLines = `{"__CURSOR":"s=1;i=1","__REALTIME_TIMESTAMP":"1791192960341045","MESSAGE":"Ready","_PID":"807"}
|
||||||
|
{"__CURSOR":"s=1;i=2","__REALTIME_TIMESTAMP":"1791192960341045","MESSAGE":"A security policy denied :1.1199 to send method call /org/freedesktop/DBus:org.freedesktop.DBus.Debug.Stats.GetStats to org.freedesktop.DBus.","DBUS_BROKER_TRANSMIT_ACTION":"send","DBUS_BROKER_MESSAGE_TYPE":"method_call","DBUS_BROKER_SENDER_UNIQUE_NAME":":1.1199","DBUS_BROKER_MESSAGE_DESTINATION":"org.freedesktop.DBus","DBUS_BROKER_MESSAGE_PATH":"/org/freedesktop/DBus","DBUS_BROKER_MESSAGE_INTERFACE":"org.freedesktop.DBus.Debug.Stats","DBUS_BROKER_MESSAGE_MEMBER":"GetStats","DBUS_BROKER_POLICY_TYPE":"internal"}
|
||||||
|
{"__CURSOR":"s=1;i=3","MESSAGE":[65,66]}`
|
||||||
|
|
||||||
|
func TestDenialsAreReadFromTheJournalByTheirFields(t *testing.T) {
|
||||||
|
d, cursor := ParseDenials(journalLines)
|
||||||
|
if cursor != "s=1;i=3" || len(d) != 1 {
|
||||||
|
t.Fatalf("%q %+v", cursor, d)
|
||||||
|
}
|
||||||
|
want := Denial{At: "2026-10-05T09:36:00Z", Action: "send", Type: "method_call", Sender: ":1.1199",
|
||||||
|
Destination: "org.freedesktop.DBus", Path: "/org/freedesktop/DBus", Interface: "org.freedesktop.DBus.Debug.Stats",
|
||||||
|
Member: "GetStats", Policy: "internal"}
|
||||||
|
if d[0] != want {
|
||||||
|
t.Fatalf("%+v", d[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheJournalIsReadFromTheCursorOn(t *testing.T) {
|
||||||
|
m := testMachine(t, nil)
|
||||||
|
var asked []string
|
||||||
|
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
asked = append([]string{name}, args...)
|
||||||
|
return journalLines, nil
|
||||||
|
}
|
||||||
|
_, next, _ := m.Denials(context.Background(), "", time.Unix(100, 0))
|
||||||
|
if !strings.Contains(strings.Join(asked, " "), "--since @100") {
|
||||||
|
t.Fatalf("%v", asked)
|
||||||
|
}
|
||||||
|
m.Denials(context.Background(), next, time.Time{})
|
||||||
|
if !strings.Contains(strings.Join(asked, " "), "--after-cursor s=1;i=3") || !strings.Contains(strings.Join(asked, " "), "-u dbus-broker.service") {
|
||||||
|
t.Fatalf("%v", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAUnitIsReadFromACgroup(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"0::/system.slice/bluetooth.service\n": "bluetooth.service",
|
||||||
|
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/dunst.service": "dunst.service",
|
||||||
|
"0::/user.slice/user-1000.slice/session-2.scope": "session-2.scope",
|
||||||
|
"0::/init.scope": "init.scope",
|
||||||
|
"": "",
|
||||||
|
} {
|
||||||
|
if got := UnitFromCgroup(in); got != want {
|
||||||
|
t.Errorf("%q: %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowBlocksAreReadInOrder(t *testing.T) {
|
||||||
|
b := ParseShow("Id=dbus-org.freedesktop.resolve1.service\nLoadState=not-found\n\nId=systemd-hostnamed.service\nLoadState=loaded\n")
|
||||||
|
if len(b) != 2 || b[0]["LoadState"] != "not-found" || b[1]["Id"] != "systemd-hostnamed.service" {
|
||||||
|
t.Fatalf("%v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARunningBusOlderThanItsPackageIsSaid(t *testing.T) {
|
||||||
|
p := ParseDesc("%NAME%\ndbus-broker\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1772097880\n")
|
||||||
|
if p.Name != "dbus-broker" || p.Version != "37-3" || p.Installed.Unix() != 1772097880 {
|
||||||
|
t.Fatalf("%+v", p)
|
||||||
|
}
|
||||||
|
if due, _ := RebootDue(time.Unix(1791123478, 0), []Package{p}); due {
|
||||||
|
t.Fatal("a bus started after its package was said to be older")
|
||||||
|
}
|
||||||
|
due, detail := RebootDue(time.Unix(1772000000, 0), []Package{p})
|
||||||
|
if !due || !strings.Contains(detail, "reboot is due") || !strings.Contains(detail, "dbus-broker 37-3") {
|
||||||
|
t.Fatalf("%v %s", due, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectionsAreCountedFromStatsOrNames(t *testing.T) {
|
||||||
|
stats := `{"type":"a{sv}","data":[{"org.bus1.DBus.Debug.Stats.PeerAccounting":{"type":"a(sa{sv}a{su})","data":[[":1.0",{},{}],[":1.1",{},{}],[":1.7",{},{}]]}}]}`
|
||||||
|
if n, ok := CountConnections(stats); !ok || n != 3 {
|
||||||
|
t.Fatalf("%d %v", n, ok)
|
||||||
|
}
|
||||||
|
if n, ok := CountConnections(`{"type":"a{sv}","data":[{"ActiveConnections":{"type":"u","data":12}}]}`); !ok || n != 12 {
|
||||||
|
t.Fatalf("%d %v", n, ok)
|
||||||
|
}
|
||||||
|
if n, ok := CountUniqueNames(`{"type":"as","data":[["org.freedesktop.DBus",":1.0","org.bluez",":1.5"]]}`); !ok || n != 2 {
|
||||||
|
t.Fatalf("%d %v", n, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsAreNeverAskedAsTheAccount(t *testing.T) {
|
||||||
|
m := testMachine(t, nil)
|
||||||
|
var calls []string
|
||||||
|
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
line := name + " " + strings.Join(args, " ")
|
||||||
|
calls = append(calls, line)
|
||||||
|
if name == "sudo" {
|
||||||
|
return "", errors.New("sudo: a password is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "ListNames") {
|
||||||
|
return `{"type":"as","data":[[":1.0",":1.1"]]}`, nil
|
||||||
|
}
|
||||||
|
return "Id=dbus-broker.service\nActiveState=active\nMainPID=807\nActiveEnterTimestamp=@1791123478\n", nil
|
||||||
|
}
|
||||||
|
h := m.Health(context.Background(), nil)
|
||||||
|
for _, c := range calls {
|
||||||
|
if strings.Contains(c, "GetStats") && !strings.HasPrefix(c, "sudo -n ") {
|
||||||
|
t.Fatalf("Debug.Stats asked as the account, which the bus logs as a denial: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h["connections"] != 2 || h["unit"] != "dbus-broker.service" {
|
||||||
|
t.Fatalf("%v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCheckReadsPackagesUnitsServiceFilesAndDenials(t *testing.T) {
|
||||||
|
m := testMachine(t, map[string]string{
|
||||||
|
"/var/lib/pacman/local/dbus-1.16.2-1/desc": "%NAME%\ndbus\n\n%VERSION%\n1.16.2-1\n\n%INSTALLDATE%\n1741340000\n",
|
||||||
|
"/var/lib/pacman/local/dbus-broker-37-3/desc": "%NAME%\ndbus-broker\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1800000000\n",
|
||||||
|
"/var/lib/pacman/local/dbus-broker-units-37-3/desc": "%NAME%\ndbus-broker-units\n\n%VERSION%\n37-3\n\n%INSTALLDATE%\n1772097880\n",
|
||||||
|
"/usr/share/dbus-1/system-services/org.bluez.service": "[D-BUS Service]\nName=org.bluez\nExec=/bin/false\nUser=root\nSystemdService=dbus-org.bluez.service\n",
|
||||||
|
"/usr/share/dbus-1/system-services/org.example.service": "[D-BUS Service]\nName=org.example\nSystemdService=example.service\n",
|
||||||
|
"/usr/share/dbus-1/system-services/org.freedesktop.systemd1.service": "[D-BUS Service]\nName=org.freedesktop.systemd1\nExec=/bin/false\n",
|
||||||
|
})
|
||||||
|
m.Run = func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
line := strings.Join(args, " ")
|
||||||
|
switch {
|
||||||
|
case name == "journalctl":
|
||||||
|
return journalLines, nil
|
||||||
|
case strings.Contains(line, "Id,LoadState"):
|
||||||
|
return "Id=dbus-org.bluez.service\nLoadState=not-found\n\nId=example.service\nLoadState=not-found\n", nil
|
||||||
|
default:
|
||||||
|
return "Id=dbus-broker.service\nActiveState=active\nMainPID=807\nActiveEnterTimestamp=@1791123478\n", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := m.Check(context.Background(), nil)
|
||||||
|
byName := map[string]Check{}
|
||||||
|
for _, c := range got["checks"].([]Check) {
|
||||||
|
byName[c.Name] = c
|
||||||
|
}
|
||||||
|
if !byName["package dbus-broker-units"].OK || !byName["system bus"].OK {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if c := byName["running bus is the installed one"]; c.OK || !strings.Contains(c.Detail, "dbus-broker 37-3") {
|
||||||
|
t.Fatalf("an upgraded broker was not said: %+v", c)
|
||||||
|
}
|
||||||
|
if c := byName["activatable services"]; c.OK || !strings.Contains(c.Detail, "org.example → example.service") || strings.Contains(c.Detail, "bluez") {
|
||||||
|
t.Fatalf("%+v", c)
|
||||||
|
}
|
||||||
|
if c := byName["policy denials in the last hour"]; c.OK || !strings.Contains(c.Detail, "GetStats") {
|
||||||
|
t.Fatalf("%+v", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(got["notes"].([]string), " "), "org.bluez → dbus-org.bluez.service") {
|
||||||
|
t.Fatalf("%v", got["notes"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var busArg = map[string]any{"type": "string", "enum": Buses,
|
||||||
|
"description": "system (the default) or session (this account's login session bus, where one exists)"}
|
||||||
|
|
||||||
|
func busOf(args map[string]any) string {
|
||||||
|
b, _ := args["bus"].(string)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools are the module's tools over MCP (novox/hq ADR 0215): the names on either bus, what an object
|
||||||
|
// offers, a bounded watch of message headers, the module's check, and the bus's health.
|
||||||
|
func Tools(m *Machine, w *Watcher) []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "dbus_names",
|
||||||
|
Description: "Every well-known name on the system or session bus: its owner's pid, process, user and " +
|
||||||
|
"systemd unit, the activatable names that are not running, and how many connections the bus has. " +
|
||||||
|
"Read as the operator's account.",
|
||||||
|
Input: map[string]any{"type": "object", "properties": map[string]any{"bus": busArg}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
return m.Names(context.Background(), busOf(args))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dbus_introspect",
|
||||||
|
Description: "What one object on a bus offers: its interfaces with their methods (arguments in and out), " +
|
||||||
|
"properties (type and access, never their values) and signals, and its child objects. A service " +
|
||||||
|
"that is not running is not started for it.",
|
||||||
|
Input: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"bus": busArg,
|
||||||
|
"service": map[string]any{"type": "string", "description": "the bus name, e.g. org.freedesktop.login1"},
|
||||||
|
"path": map[string]any{"type": "string", "description": "the object path (default /)"},
|
||||||
|
},
|
||||||
|
"required": []string{"service"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
service, _ := args["service"].(string)
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
return m.Introspect(context.Background(), busOf(args), service, path)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dbus_monitor",
|
||||||
|
Description: fmt.Sprintf("Watch a bus for a few seconds (at most %d) and answer the headers of the "+
|
||||||
|
"messages that passed: type, sender, destination, path, interface, member. Never a message's body, "+
|
||||||
|
"which carries secrets, notification text and the clipboard. Optional match rule and names to "+
|
||||||
|
"narrow it. The system bus is watched as root through sudo -n.", MaxMonitorSeconds),
|
||||||
|
Input: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"bus": busArg,
|
||||||
|
"seconds": map[string]any{"type": "integer", "description": fmt.Sprintf("how long (default 5, at most %d)", MaxMonitorSeconds)},
|
||||||
|
"match": map[string]any{"type": "string", "description": "a D-Bus match rule, e.g. type='signal',interface='org.freedesktop.login1.Manager'"},
|
||||||
|
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "only messages to or from these bus names"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
s := 5
|
||||||
|
if v, ok := args["seconds"].(float64); ok {
|
||||||
|
s = int(v)
|
||||||
|
}
|
||||||
|
match, _ := args["match"].(string)
|
||||||
|
var names []string
|
||||||
|
if list, ok := args["names"].([]any); ok {
|
||||||
|
for _, n := range list {
|
||||||
|
if str, ok := n.(string); ok {
|
||||||
|
names = append(names, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.Monitor(context.Background(), busOf(args), s, match, names)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dbus_check",
|
||||||
|
Description: "Whether this machine's message bus is as the mesh expects: the bus's packages installed, " +
|
||||||
|
"dbus-broker running behind dbus.service, whether the running bus is older than its installed " +
|
||||||
|
"package (then a reboot is due: the bus is never restarted live), activatable services whose " +
|
||||||
|
"unit does not exist, policy denials in the last hour, and the watcher's state.",
|
||||||
|
Run: func(map[string]any) (any, error) { return m.Check(context.Background(), w), nil },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dbus_health",
|
||||||
|
Description: "The system bus's health now: a ping's round trip, how many connections it has, its unit, " +
|
||||||
|
"pid and uptime, and what the watcher last saw.",
|
||||||
|
Run: func(map[string]any) (any, error) { return m.Health(context.Background(), w), nil },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The watcher publishes what matters about the machine's system bus as this module's events
|
||||||
|
// (novox/hq ADR 0215 §3): its health, a well-known service appearing or leaving, and a policy
|
||||||
|
// denial. It reads only the bus driver's own answers and signals and the bus unit's journal. It never
|
||||||
|
// becomes a monitor and never sees another peer's messages, so traffic cannot leave the machine
|
||||||
|
// through it: the event bodies are built from names, pids, units and the denial's header fields.
|
||||||
|
|
||||||
|
// Event types, as the module's manifest declares them in `emits`.
|
||||||
|
const (
|
||||||
|
BusStalled = "bus.stalled"
|
||||||
|
BusRecovered = "bus.recovered"
|
||||||
|
BusRestarted = "bus.restarted"
|
||||||
|
ServiceAppeared = "service.appeared"
|
||||||
|
ServiceLeft = "service.left"
|
||||||
|
PolicyDenied = "policy.denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emits is every event the watcher publishes, in the manifest's order.
|
||||||
|
var Emits = []string{BusStalled, BusRecovered, BusRestarted, ServiceAppeared, ServiceLeft, PolicyDenied}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PingEvery is how often the bus is pinged.
|
||||||
|
PingEvery = 10 * time.Second
|
||||||
|
// StallAfter is how long a ping may take before the bus counts as stalled.
|
||||||
|
StallAfter = 3 * time.Second
|
||||||
|
// Debounce is how long a name must stay as it is before its change is said: a service restarted
|
||||||
|
// within it, or one that flaps, says nothing.
|
||||||
|
Debounce = 10 * time.Second
|
||||||
|
// DenialsEvery is how often the journal is read for policy denials.
|
||||||
|
DenialsEvery = 30 * time.Second
|
||||||
|
// DenialEventEvery is the least time between two policy.denied events; denials in between are
|
||||||
|
// counted into the next one.
|
||||||
|
DenialEventEvery = 5 * time.Minute
|
||||||
|
// MaxQueue is the most events kept while the mesh's bus does not take them; the oldest go first.
|
||||||
|
MaxQueue = 1000
|
||||||
|
// DenialExamples is the most distinct denials one policy.denied names.
|
||||||
|
DenialExamples = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// NameChange is the bus driver's NameOwnerChanged: a name, its old owner and its new one.
|
||||||
|
type NameChange struct{ Name, Old, New string }
|
||||||
|
|
||||||
|
// Bus is the system bus as the watcher uses it, behind an interface so it is tested without one.
|
||||||
|
type Bus interface {
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
ID(ctx context.Context) (string, error)
|
||||||
|
PID(ctx context.Context, name string) (uint32, error)
|
||||||
|
Names(ctx context.Context) ([]string, error)
|
||||||
|
Activatable(ctx context.Context) ([]string, error)
|
||||||
|
// Changes closes when the connection is lost.
|
||||||
|
Changes() <-chan NameChange
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emitter publishes one event and returns once the mesh's bus has it.
|
||||||
|
type Emitter func(eventType string, body any) error
|
||||||
|
|
||||||
|
// Journal answers the policy denials in the bus unit's journal after a cursor (or, with none, since
|
||||||
|
// a time), and the cursor to continue from.
|
||||||
|
type Journal func(ctx context.Context, after string, since time.Time) ([]Denial, string, error)
|
||||||
|
|
||||||
|
type queued struct {
|
||||||
|
Type string
|
||||||
|
Body map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type owner struct {
|
||||||
|
PID uint32
|
||||||
|
Process string
|
||||||
|
Unit string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher is the long-running half of the module.
|
||||||
|
type Watcher struct {
|
||||||
|
m *Machine
|
||||||
|
emit Emitter
|
||||||
|
dial func() (Bus, error)
|
||||||
|
journal Journal
|
||||||
|
now func() time.Time
|
||||||
|
state string // where the last seen bus identity is kept, across restarts of the runtime
|
||||||
|
retry time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
bus Bus
|
||||||
|
queue []queued
|
||||||
|
dropped int
|
||||||
|
issue string
|
||||||
|
|
||||||
|
connected bool
|
||||||
|
stalled bool
|
||||||
|
stallSince time.Time
|
||||||
|
stallReason string
|
||||||
|
lastPing time.Duration
|
||||||
|
lastPingAt time.Time
|
||||||
|
busID string
|
||||||
|
brokerPID uint32
|
||||||
|
|
||||||
|
baselined bool
|
||||||
|
current map[string]bool
|
||||||
|
published map[string]owner
|
||||||
|
dirty map[string]time.Time
|
||||||
|
activatable map[string]bool
|
||||||
|
|
||||||
|
cursor string
|
||||||
|
denials int
|
||||||
|
denialSince time.Time
|
||||||
|
examples []Denial
|
||||||
|
lastDenial time.Time
|
||||||
|
|
||||||
|
kick chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWatcher is a watcher for this machine, emitting through emit, reaching the bus through dial and
|
||||||
|
// the journal through journal.
|
||||||
|
func NewWatcher(m *Machine, emit Emitter, dial func() (Bus, error), journal Journal) *Watcher {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
now := time.Now
|
||||||
|
if m != nil && m.Now != nil {
|
||||||
|
now = m.Now
|
||||||
|
}
|
||||||
|
return &Watcher{m: m, emit: emit, dial: dial, journal: journal, now: now,
|
||||||
|
state: filepath.Join(home, ".local", "state", "mesh-dbus", "bus"), retry: 5 * time.Second,
|
||||||
|
current: map[string]bool{}, published: map[string]owner{}, dirty: map[string]time.Time{},
|
||||||
|
activatable: map[string]bool{}, kick: make(chan struct{}, 1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is what dbus_check and dbus_health show of the watcher.
|
||||||
|
type Snapshot struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Stalled bool `json:"stalled"`
|
||||||
|
StalledSince string `json:"stalled_since,omitempty"`
|
||||||
|
StallReason string `json:"stall_reason,omitempty"`
|
||||||
|
LastPingMS float64 `json:"last_ping_ms"`
|
||||||
|
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||||
|
BusID string `json:"bus_id,omitempty"`
|
||||||
|
BrokerPID uint32 `json:"broker_pid,omitempty"`
|
||||||
|
Services int `json:"well_known_names"`
|
||||||
|
Pending int `json:"pending_events"`
|
||||||
|
Dropped int `json:"dropped_events,omitempty"`
|
||||||
|
Problem string `json:"problem,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) Snapshot() Snapshot {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
s := Snapshot{Connected: w.connected, Stalled: w.stalled, StallReason: w.stallReason,
|
||||||
|
LastPingMS: float64(w.lastPing.Microseconds()) / 1000, BusID: w.busID, BrokerPID: w.brokerPID,
|
||||||
|
Services: len(w.published), Pending: len(w.queue), Dropped: w.dropped, Problem: w.issue}
|
||||||
|
if w.stalled {
|
||||||
|
s.StalledSince = w.stallSince.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if !w.lastPingAt.IsZero() {
|
||||||
|
s.LastPingAt = w.lastPingAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) problem(s string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.issue = s
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue adds an event in order, stamped with when it happened. A full queue lets the oldest go and
|
||||||
|
// counts it: a mesh bus gone for a day must not grow the process without bound.
|
||||||
|
func (w *Watcher) enqueue(eventType string, body map[string]any) {
|
||||||
|
if body == nil {
|
||||||
|
body = map[string]any{}
|
||||||
|
}
|
||||||
|
body["at"] = w.now().UTC().Format(time.RFC3339)
|
||||||
|
w.mu.Lock()
|
||||||
|
w.queue = append(w.queue, queued{eventType, body})
|
||||||
|
if len(w.queue) > MaxQueue {
|
||||||
|
w.dropped += len(w.queue) - MaxQueue
|
||||||
|
w.queue = w.queue[len(w.queue)-MaxQueue:]
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case w.kick <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush publishes what waits, in order, and stops at the first the mesh's bus does not take.
|
||||||
|
func (w *Watcher) flush() {
|
||||||
|
for {
|
||||||
|
w.mu.Lock()
|
||||||
|
if len(w.queue) == 0 {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := w.queue[0]
|
||||||
|
w.mu.Unlock()
|
||||||
|
if err := w.emit(next.Type, next.Body); err != nil {
|
||||||
|
w.problem("the mesh's bus did not take " + next.Type + ": " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
if len(w.queue) > 0 {
|
||||||
|
w.queue = w.queue[1:]
|
||||||
|
}
|
||||||
|
if len(w.queue) == 0 && strings.HasPrefix(w.issue, "the mesh's bus") {
|
||||||
|
w.issue = ""
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flusher publishes on its own, so an emit waiting on the runtime never delays a ping.
|
||||||
|
func (w *Watcher) flusher(ctx context.Context) {
|
||||||
|
tick := time.NewTicker(5 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-w.kick:
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
w.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markStalled says bus.stalled once, until the bus answers again.
|
||||||
|
func (w *Watcher) markStalled(reason string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.stalled {
|
||||||
|
w.stallReason = reason
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.stalled, w.stallSince, w.stallReason = true, w.now(), reason
|
||||||
|
w.mu.Unlock()
|
||||||
|
w.enqueue(BusStalled, map[string]any{"reason": reason})
|
||||||
|
}
|
||||||
|
|
||||||
|
// answered says bus.recovered when a stalled bus answers again.
|
||||||
|
func (w *Watcher) answered() {
|
||||||
|
w.mu.Lock()
|
||||||
|
if !w.stalled {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
since := w.stallSince
|
||||||
|
w.stalled, w.stallReason = false, ""
|
||||||
|
w.mu.Unlock()
|
||||||
|
w.enqueue(BusRecovered, map[string]any{"stalled_since": since.UTC().Format(time.RFC3339),
|
||||||
|
"stalled_seconds": int(w.now().Sub(since).Seconds())})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ping asks the bus driver to answer within StallAfter.
|
||||||
|
func (w *Watcher) ping(b Bus) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
err := b.Ping(ctx)
|
||||||
|
took := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
w.markStalled("the bus did not answer a ping within " + StallAfter.String())
|
||||||
|
} else {
|
||||||
|
w.markStalled("the bus answered a ping with an error: " + err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.lastPing, w.lastPingAt = took, w.now()
|
||||||
|
w.mu.Unlock()
|
||||||
|
w.answered()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingNow pings the bus on the watcher's connection, for dbus_health.
|
||||||
|
func (w *Watcher) PingNow() (time.Duration, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
b := w.bus
|
||||||
|
w.mu.Unlock()
|
||||||
|
if b == nil {
|
||||||
|
return 0, errNotConnected
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
err := b.Ping(ctx)
|
||||||
|
return time.Since(start), err
|
||||||
|
}
|
||||||
|
|
||||||
|
type watcherError string
|
||||||
|
|
||||||
|
func (e watcherError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
const errNotConnected = watcherError("the watcher is not connected to the system bus")
|
||||||
|
|
||||||
|
// identity notes the bus's id and the bus driver's pid, and says bus.restarted when either changed
|
||||||
|
// within one boot: after a boot both change, and that is the machine's news, not the bus's.
|
||||||
|
func (w *Watcher) identity(id string, pid uint32) {
|
||||||
|
boot := w.m.BootID()
|
||||||
|
w.mu.Lock()
|
||||||
|
prevID, prevPID := w.busID, w.brokerPID
|
||||||
|
w.busID, w.brokerPID = id, pid
|
||||||
|
w.mu.Unlock()
|
||||||
|
prevBoot := boot
|
||||||
|
if prevID == "" {
|
||||||
|
if b, err := os.ReadFile(w.state); err == nil {
|
||||||
|
f := strings.Fields(string(b))
|
||||||
|
if len(f) == 3 {
|
||||||
|
prevBoot, prevID = f[0], f[1]
|
||||||
|
prevPID = parsePID(f[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prevID != "" && prevBoot == boot && (prevID != id || prevPID != pid) {
|
||||||
|
w.enqueue(BusRestarted, map[string]any{"previous_bus_id": prevID, "bus_id": id,
|
||||||
|
"previous_pid": prevPID, "pid": pid, "unit": w.m.UnitOf(pid)})
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(w.state), 0o755); err == nil {
|
||||||
|
_ = os.WriteFile(w.state, []byte(boot+" "+id+" "+itoa(pid)+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWellKnown is whether a name is a service's name rather than a connection's: unique names (":1.42")
|
||||||
|
// come and go with every client and are never said, nor is the bus driver's own.
|
||||||
|
func IsWellKnown(name string) bool {
|
||||||
|
return name != "" && !strings.HasPrefix(name, ":") && name != busName
|
||||||
|
}
|
||||||
|
|
||||||
|
// connected baselines the names after a (re)connect. The first time it says nothing; after a lost
|
||||||
|
// connection the difference with what was said is debounced like any other change, so a service that
|
||||||
|
// did not come back with a restarted bus is said to have left.
|
||||||
|
func (w *Watcher) connectedTo(b Bus) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
|
||||||
|
defer cancel()
|
||||||
|
names, err := b.Names(ctx)
|
||||||
|
if err != nil {
|
||||||
|
w.problem("listing the bus's names: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
act, _ := b.Activatable(ctx)
|
||||||
|
now := w.now()
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
w.activatable = map[string]bool{}
|
||||||
|
for _, n := range act {
|
||||||
|
w.activatable[n] = true
|
||||||
|
}
|
||||||
|
cur := map[string]bool{}
|
||||||
|
for _, n := range names {
|
||||||
|
if IsWellKnown(n) {
|
||||||
|
cur[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !w.baselined {
|
||||||
|
w.baselined = true
|
||||||
|
w.current = cur
|
||||||
|
for n := range cur {
|
||||||
|
w.published[n] = owner{}
|
||||||
|
w.dirty[n] = time.Time{} // resolved silently at the next settle
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for n := range cur {
|
||||||
|
if _, said := w.published[n]; !said {
|
||||||
|
w.dirty[n] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for n := range w.published {
|
||||||
|
if !cur[n] {
|
||||||
|
w.dirty[n] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.current = cur
|
||||||
|
}
|
||||||
|
|
||||||
|
// observe takes one NameOwnerChanged. Only well-known names count.
|
||||||
|
func (w *Watcher) observe(c NameChange) {
|
||||||
|
if !IsWellKnown(c.Name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if c.New != "" {
|
||||||
|
w.current[c.Name] = true
|
||||||
|
} else {
|
||||||
|
delete(w.current, c.Name)
|
||||||
|
}
|
||||||
|
w.dirty[c.Name] = w.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle says what changed and stayed changed for Debounce. A name's owner is resolved when it is
|
||||||
|
// said, so the event names the process and the unit that holds it.
|
||||||
|
func (w *Watcher) settle(b Bus) {
|
||||||
|
now := w.now()
|
||||||
|
w.mu.Lock()
|
||||||
|
var due []string
|
||||||
|
for n, at := range w.dirty {
|
||||||
|
if now.Sub(at) >= Debounce {
|
||||||
|
due = append(due, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(due)
|
||||||
|
w.mu.Unlock()
|
||||||
|
for _, n := range due {
|
||||||
|
w.mu.Lock()
|
||||||
|
present := w.current[n]
|
||||||
|
was, said := w.published[n]
|
||||||
|
silent := w.dirty[n].IsZero()
|
||||||
|
delete(w.dirty, n)
|
||||||
|
activatable := w.activatable[n]
|
||||||
|
w.mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case present && (!said || silent):
|
||||||
|
o := w.resolve(b, n)
|
||||||
|
w.mu.Lock()
|
||||||
|
w.published[n] = o
|
||||||
|
w.mu.Unlock()
|
||||||
|
if !silent {
|
||||||
|
w.enqueue(ServiceAppeared, o.body(n, activatable))
|
||||||
|
}
|
||||||
|
case !present && said:
|
||||||
|
w.mu.Lock()
|
||||||
|
delete(w.published, n)
|
||||||
|
w.mu.Unlock()
|
||||||
|
w.enqueue(ServiceLeft, was.body(n, activatable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) resolve(b Bus, name string) owner {
|
||||||
|
if b == nil {
|
||||||
|
return owner{}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), StallAfter)
|
||||||
|
defer cancel()
|
||||||
|
pid, err := b.PID(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return owner{}
|
||||||
|
}
|
||||||
|
return owner{PID: pid, Process: w.m.ProcessName(pid), Unit: w.m.UnitOf(pid)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o owner) body(name string, activatable bool) map[string]any {
|
||||||
|
body := map[string]any{"name": name, "activatable": activatable}
|
||||||
|
if o.PID != 0 {
|
||||||
|
body["pid"] = o.PID
|
||||||
|
}
|
||||||
|
if o.Process != "" {
|
||||||
|
body["process"] = o.Process
|
||||||
|
}
|
||||||
|
if o.Unit != "" {
|
||||||
|
body["unit"] = o.Unit
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDenials takes the denials logged since the last read, and says policy.denied at most once per
|
||||||
|
// DenialEventEvery, with the count and a few distinct examples: a client denied in a loop must not
|
||||||
|
// flood the mesh's bus.
|
||||||
|
func (w *Watcher) readDenials(ctx context.Context, start time.Time) {
|
||||||
|
if w.journal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
cursor := w.cursor
|
||||||
|
w.mu.Unlock()
|
||||||
|
got, next, err := w.journal(ctx, cursor, start)
|
||||||
|
if err != nil {
|
||||||
|
w.problem("reading the bus's journal: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := w.now()
|
||||||
|
w.mu.Lock()
|
||||||
|
if next != "" {
|
||||||
|
w.cursor = next
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(w.issue, "reading the bus's journal") {
|
||||||
|
w.issue = ""
|
||||||
|
}
|
||||||
|
for _, d := range got {
|
||||||
|
if w.denials == 0 {
|
||||||
|
w.denialSince = now
|
||||||
|
}
|
||||||
|
w.denials++
|
||||||
|
if len(w.examples) < DenialExamples && !containsDenial(w.examples, d) {
|
||||||
|
w.examples = append(w.examples, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
due := w.denials > 0 && (w.lastDenial.IsZero() || now.Sub(w.lastDenial) >= DenialEventEvery)
|
||||||
|
var body map[string]any
|
||||||
|
if due {
|
||||||
|
body = map[string]any{"count": w.denials, "since": w.denialSince.UTC().Format(time.RFC3339),
|
||||||
|
"examples": w.examples}
|
||||||
|
w.denials, w.examples, w.lastDenial = 0, nil, now
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
if due {
|
||||||
|
w.enqueue(PolicyDenied, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run watches until ctx ends. Without the system bus it says the bus stalled, and tries again every
|
||||||
|
// few seconds; a lost connection is followed at once by a new one.
|
||||||
|
func (w *Watcher) Run(ctx context.Context) {
|
||||||
|
go w.flusher(ctx)
|
||||||
|
start := w.now()
|
||||||
|
tick := time.NewTicker(PingEvery)
|
||||||
|
defer tick.Stop()
|
||||||
|
denials := time.NewTicker(DenialsEvery)
|
||||||
|
defer denials.Stop()
|
||||||
|
retry := time.NewTimer(0)
|
||||||
|
defer retry.Stop()
|
||||||
|
var changes <-chan NameChange
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
w.mu.Lock()
|
||||||
|
b := w.bus
|
||||||
|
w.bus, w.connected = nil, false
|
||||||
|
w.mu.Unlock()
|
||||||
|
if b != nil {
|
||||||
|
b.Close()
|
||||||
|
}
|
||||||
|
w.flush()
|
||||||
|
return
|
||||||
|
case <-retry.C:
|
||||||
|
b, err := w.dial()
|
||||||
|
if err != nil {
|
||||||
|
w.markStalled("the system bus is not reachable: " + err.Error())
|
||||||
|
retry.Reset(w.retry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idCtx, cancel := context.WithTimeout(ctx, StallAfter)
|
||||||
|
id, idErr := b.ID(idCtx)
|
||||||
|
pid, _ := b.PID(idCtx, busName)
|
||||||
|
cancel()
|
||||||
|
if idErr != nil {
|
||||||
|
b.Close()
|
||||||
|
w.markStalled("the system bus did not say its id: " + idErr.Error())
|
||||||
|
retry.Reset(w.retry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.mu.Lock()
|
||||||
|
w.bus, w.connected = b, true
|
||||||
|
w.mu.Unlock()
|
||||||
|
changes = b.Changes()
|
||||||
|
w.identity(id, pid)
|
||||||
|
w.connectedTo(b)
|
||||||
|
w.answered()
|
||||||
|
w.settle(b)
|
||||||
|
case c, open := <-changes:
|
||||||
|
if !open {
|
||||||
|
w.mu.Lock()
|
||||||
|
b := w.bus
|
||||||
|
w.bus, w.connected = nil, false
|
||||||
|
w.mu.Unlock()
|
||||||
|
if b != nil {
|
||||||
|
b.Close()
|
||||||
|
}
|
||||||
|
changes = nil
|
||||||
|
retry.Reset(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.observe(c)
|
||||||
|
case <-tick.C:
|
||||||
|
w.mu.Lock()
|
||||||
|
b := w.bus
|
||||||
|
w.mu.Unlock()
|
||||||
|
if b != nil {
|
||||||
|
w.ping(b)
|
||||||
|
w.settle(b)
|
||||||
|
}
|
||||||
|
case <-denials.C:
|
||||||
|
w.readDenials(ctx, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsDenial(list []Denial, d Denial) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x.key() == d.key() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePID(s string) uint32 {
|
||||||
|
var n uint32
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n = n*10 + uint32(c-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n uint32) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b [10]byte
|
||||||
|
i := len(b)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
b[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBus is a system bus in memory: names with their owners' pids, a ping that can hang, and the
|
||||||
|
// NameOwnerChanged stream.
|
||||||
|
type fakeBus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
id string
|
||||||
|
pid uint32
|
||||||
|
names map[string]uint32
|
||||||
|
hang bool
|
||||||
|
changes chan NameChange
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeBus(id string, pid uint32, names map[string]uint32) *fakeBus {
|
||||||
|
return &fakeBus{id: id, pid: pid, names: names, changes: make(chan NameChange, 64)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBus) Ping(ctx context.Context) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
hang := f.hang
|
||||||
|
f.mu.Unlock()
|
||||||
|
if hang {
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeBus) ID(context.Context) (string, error) { return f.id, nil }
|
||||||
|
func (f *fakeBus) PID(_ context.Context, name string) (uint32, error) {
|
||||||
|
if name == busName {
|
||||||
|
return f.pid, nil
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if p, ok := f.names[name]; ok {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
return 0, errors.New("no such name")
|
||||||
|
}
|
||||||
|
func (f *fakeBus) Names(context.Context) ([]string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := []string{busName, ":1.0", ":1.1"}
|
||||||
|
for n := range f.names {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (f *fakeBus) Activatable(context.Context) ([]string, error) {
|
||||||
|
return []string{"org.freedesktop.hostname1"}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeBus) Changes() <-chan NameChange { return f.changes }
|
||||||
|
func (f *fakeBus) Close() {}
|
||||||
|
|
||||||
|
// meshBus records what was published, and can refuse.
|
||||||
|
type meshBus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
down bool
|
||||||
|
types []string
|
||||||
|
bodies []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *meshBus) emit(t string, body any) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.down {
|
||||||
|
return errors.New("no bus")
|
||||||
|
}
|
||||||
|
b.types = append(b.types, t)
|
||||||
|
m, _ := body.(map[string]any)
|
||||||
|
b.bodies = append(b.bodies, m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *meshBus) seen() []string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return append([]string(nil), b.types...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clock is a time the test moves.
|
||||||
|
type clock struct{ t time.Time }
|
||||||
|
|
||||||
|
func (c *clock) now() time.Time { return c.t }
|
||||||
|
func (c *clock) advance(d time.Duration) { c.t = c.t.Add(d) }
|
||||||
|
|
||||||
|
func testMachine(t *testing.T, files map[string]string) *Machine {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for p, c := range files {
|
||||||
|
full := filepath.Join(root, p)
|
||||||
|
os.MkdirAll(filepath.Dir(full), 0o755)
|
||||||
|
os.WriteFile(full, []byte(c), 0o644)
|
||||||
|
}
|
||||||
|
return &Machine{Root: root, Env: func(string) string { return "" }, UID: 1000, Now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWatcher(t *testing.T, b *meshBus, c *clock) *Watcher {
|
||||||
|
m := testMachine(t, map[string]string{
|
||||||
|
"/proc/sys/kernel/random/boot_id": "boot-1\n",
|
||||||
|
"/proc/700/comm": "systemd-logind\n",
|
||||||
|
"/proc/700/cgroup": "0::/system.slice/systemd-logind.service\n",
|
||||||
|
"/proc/900/comm": "bluetoothd\n",
|
||||||
|
"/proc/900/cgroup": "0::/system.slice/bluetooth.service\n",
|
||||||
|
})
|
||||||
|
w := NewWatcher(m, b.emit, nil, nil)
|
||||||
|
w.now = c.now
|
||||||
|
w.state = filepath.Join(t.TempDir(), "bus")
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() *clock { return &clock{t: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)} }
|
||||||
|
|
||||||
|
func TestTheFirstConnectionSaysNothingAboutNames(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
|
||||||
|
w.identity("id-1", 500)
|
||||||
|
w.connectedTo(fb)
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); len(got) != 0 {
|
||||||
|
t.Fatalf("the baseline was announced: %v", got)
|
||||||
|
}
|
||||||
|
if w.Snapshot().Services != 1 {
|
||||||
|
t.Fatalf("%+v", w.Snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAServiceAppearingIsSaidOnceItStaysWithItsUnit(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
fb := newFakeBus("id-1", 500, map[string]uint32{})
|
||||||
|
w.connectedTo(fb)
|
||||||
|
fb.names["org.bluez"] = 900
|
||||||
|
w.observe(NameChange{Name: "org.bluez", New: ":1.9"})
|
||||||
|
c.advance(Debounce / 2)
|
||||||
|
w.settle(fb)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); len(got) != 0 {
|
||||||
|
t.Fatalf("said before the debounce: %v", got)
|
||||||
|
}
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceAppeared}) {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
body := mb.bodies[0]
|
||||||
|
if body["name"] != "org.bluez" || body["unit"] != "bluetooth.service" || body["process"] != "bluetoothd" || body["pid"] != uint32(900) {
|
||||||
|
t.Fatalf("%v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFlapIsDebouncedAway(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
|
||||||
|
w.connectedTo(fb)
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
// logind restarted: left and back within the debounce, and a newcomer that came and went.
|
||||||
|
w.observe(NameChange{Name: "org.freedesktop.login1", Old: ":1.5"})
|
||||||
|
c.advance(time.Second)
|
||||||
|
w.observe(NameChange{Name: "org.freedesktop.login1", New: ":1.80"})
|
||||||
|
w.observe(NameChange{Name: "org.example.Brief", New: ":1.81"})
|
||||||
|
w.observe(NameChange{Name: "org.example.Brief", Old: ":1.81"})
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); len(got) != 0 {
|
||||||
|
t.Fatalf("a flap was said: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAServiceLeavingIsSaidWithTheUnitItHad(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700})
|
||||||
|
w.connectedTo(fb)
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
delete(fb.names, "org.freedesktop.login1")
|
||||||
|
w.observe(NameChange{Name: "org.freedesktop.login1", Old: ":1.5"})
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceLeft}) {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
if mb.bodies[0]["unit"] != "systemd-logind.service" {
|
||||||
|
t.Fatalf("%v", mb.bodies[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniqueNamesAndTheDriverAreNeverSaid(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
fb := newFakeBus("id-1", 500, map[string]uint32{})
|
||||||
|
w.connectedTo(fb)
|
||||||
|
w.observe(NameChange{Name: ":1.42", New: ":1.42"})
|
||||||
|
w.observe(NameChange{Name: ":1.42", Old: ":1.42"})
|
||||||
|
w.observe(NameChange{Name: busName, New: busName})
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); len(got) != 0 {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
for _, n := range []string{":1.1", busName, ""} {
|
||||||
|
if IsWellKnown(n) {
|
||||||
|
t.Errorf("%q counted as a service", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPingThatHangsIsAStallAndAnAnswerARecovery(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
fb := newFakeBus("id-1", 500, nil)
|
||||||
|
fb.hang = true
|
||||||
|
begun := time.Now()
|
||||||
|
w.ping(fb)
|
||||||
|
w.ping(fb)
|
||||||
|
if time.Since(begun) > 2*StallAfter+time.Second {
|
||||||
|
t.Fatal("a ping waited longer than its bound")
|
||||||
|
}
|
||||||
|
c.advance(42 * time.Second)
|
||||||
|
fb.hang = false
|
||||||
|
w.ping(fb)
|
||||||
|
w.ping(fb)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusStalled, BusRecovered}) {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
if mb.bodies[1]["stalled_seconds"] != 42 || w.Snapshot().Stalled {
|
||||||
|
t.Fatalf("%v %+v", mb.bodies[1], w.Snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARestartWithinABootIsSaidAndABootIsNot(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
w.identity("id-1", 500)
|
||||||
|
w.identity("id-1", 500) // a reconnect to the same bus
|
||||||
|
w.identity("id-2", 501) // the bus came back as another
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusRestarted}) {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
if mb.bodies[0]["previous_bus_id"] != "id-1" || mb.bodies[0]["pid"] != uint32(501) {
|
||||||
|
t.Fatalf("%v", mb.bodies[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The runtime restarts: the bus it remembers is the one still running, so nothing is said.
|
||||||
|
again := NewWatcher(w.m, mb.emit, nil, nil)
|
||||||
|
again.state, again.now = w.state, c.now
|
||||||
|
again.identity("id-2", 501)
|
||||||
|
// After a boot both change, and that is not the bus's restart.
|
||||||
|
os.WriteFile(filepath.Join(w.m.Root, "/proc/sys/kernel/random/boot_id"), []byte("boot-2\n"), 0o644)
|
||||||
|
third := NewWatcher(w.m, mb.emit, nil, nil)
|
||||||
|
third.state, third.now = w.state, c.now
|
||||||
|
third.identity("id-3", 400)
|
||||||
|
again.flush()
|
||||||
|
third.flush()
|
||||||
|
if got := mb.seen(); len(got) != 1 {
|
||||||
|
t.Fatalf("a runtime restart or a boot was taken for the bus's restart: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAServiceThatDidNotComeBackAfterAReconnectHasLeft(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
fb := newFakeBus("id-1", 500, map[string]uint32{"org.freedesktop.login1": 700, "org.bluez": 900})
|
||||||
|
w.connectedTo(fb)
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
back := newFakeBus("id-2", 501, map[string]uint32{"org.freedesktop.login1": 700})
|
||||||
|
w.connectedTo(back)
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(back)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); !reflect.DeepEqual(got, []string{ServiceLeft}) || mb.bodies[0]["name"] != "org.bluez" {
|
||||||
|
t.Fatalf("%v %v", got, mb.bodies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventsWaitInOrderWhileTheMeshBusIsGone(t *testing.T) {
|
||||||
|
mb, c := &meshBus{down: true}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
w.markStalled("test")
|
||||||
|
c.advance(time.Minute)
|
||||||
|
w.answered()
|
||||||
|
w.flush()
|
||||||
|
if s := w.Snapshot(); s.Pending != 2 || s.Problem == "" {
|
||||||
|
t.Fatalf("what the mesh's bus did not take is not kept and said: %+v", s)
|
||||||
|
}
|
||||||
|
mb.mu.Lock()
|
||||||
|
mb.down = false
|
||||||
|
mb.mu.Unlock()
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); !reflect.DeepEqual(got, []string{BusStalled, BusRecovered}) {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
if s := w.Snapshot(); s.Pending != 0 || s.Problem != "" {
|
||||||
|
t.Fatalf("%+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFullQueueLetsTheOldestGo(t *testing.T) {
|
||||||
|
mb, c := &meshBus{down: true}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
for i := 0; i < MaxQueue+5; i++ {
|
||||||
|
w.enqueue(ServiceAppeared, map[string]any{"i": i})
|
||||||
|
}
|
||||||
|
s := w.Snapshot()
|
||||||
|
if s.Pending != MaxQueue || s.Dropped != 5 || w.queue[0].Body["i"] != 5 {
|
||||||
|
t.Fatalf("%+v first %v", s, w.queue[0].Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDenialsAreSaidAtMostOncePerWindowWithACount(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
batches := [][]Denial{
|
||||||
|
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"}},
|
||||||
|
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"},
|
||||||
|
{Type: "method_call", Interface: "org.example.B", Member: "Other", Destination: "org.example"}},
|
||||||
|
{{Type: "method_call", Interface: "org.example.A", Member: "Do", Destination: "org.example"}},
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
w.journal = func(ctx context.Context, after string, since time.Time) ([]Denial, string, error) {
|
||||||
|
if n > 0 && after != "c"+string(rune('0'+n-1)) {
|
||||||
|
t.Errorf("read %d did not continue from the cursor: %q", n, after)
|
||||||
|
}
|
||||||
|
d := batches[n]
|
||||||
|
n++
|
||||||
|
return d, "c" + string(rune('0'+n-1)), nil
|
||||||
|
}
|
||||||
|
w.readDenials(context.Background(), c.t)
|
||||||
|
c.advance(DenialsEvery)
|
||||||
|
w.readDenials(context.Background(), c.t)
|
||||||
|
c.advance(DenialEventEvery)
|
||||||
|
w.readDenials(context.Background(), c.t)
|
||||||
|
w.flush()
|
||||||
|
if got := mb.seen(); !reflect.DeepEqual(got, []string{PolicyDenied, PolicyDenied}) {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
if mb.bodies[0]["count"] != 1 || mb.bodies[1]["count"] != 3 {
|
||||||
|
t.Fatalf("%v", mb.bodies)
|
||||||
|
}
|
||||||
|
if ex := mb.bodies[1]["examples"].([]Denial); len(ex) != 2 {
|
||||||
|
t.Fatalf("the same denial is one example: %v", ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoEventCarriesTraffic holds every event body to names, pids, units, times, counts and the
|
||||||
|
// header fields of a denial: nothing in the watcher can carry a message's body.
|
||||||
|
func TestNoEventCarriesTraffic(t *testing.T) {
|
||||||
|
mb, c := &meshBus{}, start()
|
||||||
|
w := testWatcher(t, mb, c)
|
||||||
|
fb := newFakeBus("id-1", 500, map[string]uint32{})
|
||||||
|
w.connectedTo(fb)
|
||||||
|
fb.names["org.bluez"] = 900
|
||||||
|
w.observe(NameChange{Name: "org.bluez", New: ":1.9"})
|
||||||
|
c.advance(Debounce)
|
||||||
|
w.settle(fb)
|
||||||
|
w.markStalled("x")
|
||||||
|
w.answered()
|
||||||
|
w.identity("a", 1)
|
||||||
|
w.identity("b", 2)
|
||||||
|
w.journal = func(context.Context, string, time.Time) ([]Denial, string, error) {
|
||||||
|
d, cur := ParseDenials(`{"__CURSOR":"c","MESSAGE":"A security policy denied :1.9 to send method call /p:i.m to d.","DBUS_BROKER_MESSAGE_MEMBER":"m","SECRET_BODY":"hunter2"}`)
|
||||||
|
return d, cur, nil
|
||||||
|
}
|
||||||
|
w.readDenials(context.Background(), c.t)
|
||||||
|
w.flush()
|
||||||
|
allowed := map[string]bool{"at": true, "name": true, "pid": true, "process": true, "unit": true, "activatable": true,
|
||||||
|
"reason": true, "stalled_since": true, "stalled_seconds": true, "previous_bus_id": true, "bus_id": true,
|
||||||
|
"previous_pid": true, "count": true, "since": true, "examples": true}
|
||||||
|
if len(mb.types) != 5 {
|
||||||
|
t.Fatalf("%v", mb.types)
|
||||||
|
}
|
||||||
|
for i, b := range mb.bodies {
|
||||||
|
for k := range b {
|
||||||
|
if !allowed[k] {
|
||||||
|
t.Errorf("%s carries %q", mb.types[i], k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(b)
|
||||||
|
if strings.Contains(string(raw), "hunter2") || strings.Contains(string(raw), "security policy") {
|
||||||
|
t.Errorf("%s carries what the bus logged verbatim: %s", mb.types[i], raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithoutABusSaysItStalledAndReconnects(t *testing.T) {
|
||||||
|
mb := &meshBus{}
|
||||||
|
m := testMachine(t, map[string]string{"/proc/sys/kernel/random/boot_id": "b\n"})
|
||||||
|
fb := newFakeBus("id-1", 500, map[string]uint32{})
|
||||||
|
var dials int
|
||||||
|
w := NewWatcher(m, mb.emit, func() (Bus, error) {
|
||||||
|
dials++
|
||||||
|
if dials == 1 {
|
||||||
|
return nil, errors.New("no socket")
|
||||||
|
}
|
||||||
|
return fb, nil
|
||||||
|
}, nil)
|
||||||
|
w.state = filepath.Join(t.TempDir(), "bus")
|
||||||
|
w.retry = 20 * time.Millisecond
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { w.Run(ctx); close(done) }()
|
||||||
|
deadline := time.Now().Add(6 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && !w.Snapshot().Connected {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !w.Snapshot().Connected {
|
||||||
|
t.Fatal("the watcher did not reconnect")
|
||||||
|
}
|
||||||
|
close(fb.changes) // the bus goes away
|
||||||
|
for time.Now().Before(deadline) && w.Snapshot().Connected {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
got := mb.seen()
|
||||||
|
if len(got) < 2 || got[0] != BusStalled || got[1] != BusRecovered {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module dbus
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"module": "dbus",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager",
|
||||||
|
"service-manager"
|
||||||
|
],
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"name": "node-message-bus",
|
||||||
|
"scope": "node"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"emits": [
|
||||||
|
"bus.stalled",
|
||||||
|
"bus.recovered",
|
||||||
|
"bus.restarted",
|
||||||
|
"service.appeared",
|
||||||
|
"service.left",
|
||||||
|
"policy.denied"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"dbus_names",
|
||||||
|
"dbus_introspect",
|
||||||
|
"dbus_monitor",
|
||||||
|
"dbus_check",
|
||||||
|
"dbus_health"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "dbus",
|
||||||
|
"type": "package",
|
||||||
|
"package": "dbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "broker",
|
||||||
|
"type": "package",
|
||||||
|
"package": "dbus-broker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "broker-units",
|
||||||
|
"type": "package",
|
||||||
|
"package": "dbus-broker-units"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "system-bus",
|
||||||
|
"type": "service",
|
||||||
|
"unit": "dbus-broker.service",
|
||||||
|
"state": "running"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools-go",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/dbus-tools",
|
||||||
|
"binary": "dbus-tools",
|
||||||
|
"loads": [
|
||||||
|
"dbus-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,3 +45,10 @@ setting once issue 168 closes, not a file the next push would overwrite.
|
|||||||
|
|
||||||
- `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads
|
- `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,7 +22,9 @@ 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"`
|
||||||
Build struct {
|
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
|
||||||
|
Contributions []contribution `json:"contributions"`
|
||||||
|
Build struct {
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
} `json:"build"`
|
} `json:"build"`
|
||||||
}
|
}
|
||||||
@@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contribution struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3Lines is what the module contributes to the window manager.
|
||||||
|
func (m manifest) i3Lines() string {
|
||||||
|
var out string
|
||||||
|
for _, c := range m.Contributions {
|
||||||
|
if c.Seat == "node-display-session" && c.Kind == "config" {
|
||||||
|
out += c.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
|
||||||
|
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
|
||||||
|
t.Helper()
|
||||||
|
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.i3Lines() != string(want) {
|
||||||
|
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.sameAsSource(t, "i3-bindings", "files/i3/50-feh.conf")
|
m.i3LinesAreSource(t, "files/i3/50-feh.conf")
|
||||||
if p := m.resource(t, "i3-bindings")["path"]; p != "${machine:account-home}/.config/i3/config.d/50-feh.conf" {
|
if p := m.i3Lines(); p == "" {
|
||||||
t.Fatalf("path: %v", p)
|
t.Fatalf("path: %v", p)
|
||||||
}
|
}
|
||||||
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") {
|
if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") {
|
||||||
t.Fatalf("%s", c)
|
t.Fatalf("%s", c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,6 @@
|
|||||||
"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": {
|
||||||
@@ -67,5 +59,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"seat": "node-display-session",
|
||||||
|
"kind": "config",
|
||||||
|
"content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,6 +564,16 @@ 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> {
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ runProvisioner("npm-package-registry", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async remove(p: { as: string }): Promise<void> {
|
async remove(p: { as: string }): Promise<void> {
|
||||||
await gitea.deleteUser(p.as);
|
// Login prohibited, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): deleting purges every repository the user owns.
|
||||||
|
await gitea.prohibitLogin(p.as);
|
||||||
},
|
},
|
||||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
// 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).
|
||||||
|
|||||||
@@ -150,3 +150,11 @@ about to end, or accept it: a reload keeps every window and workspace.
|
|||||||
written the files. So at its start the watcher compares the files on disk with what the running i3
|
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`.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -138,7 +139,12 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin
|
|||||||
t.Fatal("the lock key goes through logind, which the lock screen's module relies on")
|
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 {
|
||||||
out, err := exec.Command(i3, "-C", "-c", filepath.Join("..", "..", "config", "config")).CombinedOutput()
|
// As a node with no contribution and no file of the operator's would hold it.
|
||||||
|
alone := strings.Replace(strings.Replace(c, "${contribution:node-display-session:config}", "", 1),
|
||||||
|
"\ninclude ~/.config/i3/config.d/*.conf", "\n", 1)
|
||||||
|
path := filepath.Join(t.TempDir(), "config")
|
||||||
|
os.WriteFile(path, []byte(alone), 0o644)
|
||||||
|
out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput()
|
||||||
if err != nil || len(ParseCheck(string(out))) > 0 {
|
if err != nil || len(ParseCheck(string(out))) > 0 {
|
||||||
t.Fatalf("i3 -C: %v %s", err, out)
|
t.Fatalf("i3 -C: %v %s", err, out)
|
||||||
}
|
}
|
||||||
@@ -169,37 +175,56 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every module of the catalogue that drops a file into i3's config.d is loaded with the main file, as
|
// Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in
|
||||||
// i3 would load them on a machine with all of them assigned: no two bind one key, and every line parses.
|
// the main file as the controller places them — in module order, each module's under a line naming it
|
||||||
func TestTheMainFileAndEveryModulesDropInLoadTogether(t *testing.T) {
|
// — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and
|
||||||
|
// every line parses.
|
||||||
|
func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) {
|
||||||
i3, err := exec.LookPath("i3")
|
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 {
|
||||||
Resources []map[string]any `json:"resources"`
|
Module string `json:"module"`
|
||||||
|
Contributions []struct {
|
||||||
|
Seat, Kind, Content string
|
||||||
|
} `json:"contributions"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(raw, &m)
|
json.Unmarshal(raw, &m)
|
||||||
for _, r := range m.Resources {
|
named := false
|
||||||
path, _ := r["path"].(string)
|
for _, c := range m.Contributions {
|
||||||
if r["type"] == "file" && strings.Contains(path, "/.config/i3/config.d/") {
|
if c.Seat != "node-display-session" || c.Kind != "config" {
|
||||||
os.WriteFile(filepath.Join(drop, filepath.Base(path)), []byte(r["content"].(string)), 0o644)
|
continue
|
||||||
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), "include ~/.config/i3/config.d/*.conf", "include "+drop+"/*.conf", 1)
|
text := strings.Replace(string(main), "${contribution:node-display-session:config}", placed.String(), 1)
|
||||||
|
// The operator's own files are not the catalogue's: the include line itself goes (a contribution may
|
||||||
|
// name it in a comment, so only the line that is exactly it).
|
||||||
|
text = strings.Replace(text, "\ninclude ~/.config/i3/config.d/*.conf", "\n", 1)
|
||||||
os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644)
|
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 drop-ins %v: %v\n%s", found, err, out)
|
t.Fatalf("with the contributions of %v: %v\n%s", found, err, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# 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 with files of their own in ~/.config/i3/config.d/, named
|
# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR
|
||||||
# <NN>-<module>.conf and read in name order by the include at the end, where every variable set here
|
# 0212): the mesh places their lines near the end, each module's under a line naming it, where every
|
||||||
# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours.
|
# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read
|
||||||
|
# after them, by the include at the very end, and is yours.
|
||||||
#
|
#
|
||||||
# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the
|
# 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.
|
||||||
@@ -172,9 +173,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 as its own drop-in in config.d, and the line goes from here in the same
|
# it contributes the line to node-display-session, and the line goes from here in the same change.
|
||||||
# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs
|
# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute
|
||||||
# (rofi, clipmenu, feh, i3status-rust, gnome-keyring).
|
# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14).
|
||||||
|
|
||||||
# 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
|
||||||
@@ -190,6 +191,12 @@ bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.s
|
|||||||
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
||||||
|
|
||||||
#########################################
|
#########################################
|
||||||
###### Other modules' drop-ins ####
|
###### Other modules' lines ####
|
||||||
|
#########################################
|
||||||
|
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
|
||||||
|
# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.
|
||||||
|
${contribution:node-display-session:config}
|
||||||
|
#########################################
|
||||||
|
###### Your own files ####
|
||||||
#########################################
|
#########################################
|
||||||
include ~/.config/i3/config.d/*.conf
|
include ~/.config/i3/config.d/*.conf
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -84,3 +84,10 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod
|
|||||||
runs per session and ends with the session, as a session-start line already does.
|
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,7 +22,9 @@ 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"`
|
||||||
Build struct {
|
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
|
||||||
|
Contributions []contribution `json:"contributions"`
|
||||||
|
Build struct {
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
} `json:"build"`
|
} `json:"build"`
|
||||||
}
|
}
|
||||||
@@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contribution struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3Lines is what the module contributes to the window manager.
|
||||||
|
func (m manifest) i3Lines() string {
|
||||||
|
var out string
|
||||||
|
for _, c := range m.Contributions {
|
||||||
|
if c.Seat == "node-display-session" && c.Kind == "config" {
|
||||||
|
out += c.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
|
||||||
|
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
|
||||||
|
t.Helper()
|
||||||
|
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.i3Lines() != string(want) {
|
||||||
|
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
|
|||||||
m := readManifest(t)
|
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", "i3-bars": "files/i3/60-i3status-rust.conf",
|
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog",
|
||||||
} {
|
} {
|
||||||
m.sameAsSource(t, id, src)
|
m.sameAsSource(t, id, src)
|
||||||
}
|
}
|
||||||
@@ -47,13 +47,10 @@ func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheBarsAreAnI3DropInAndTheWatchdogRunsOncePerSession(t *testing.T) {
|
func TestTheBarsAreAContributionToTheWindowManagerAndTheWatchdogRunsOncePerSession(t *testing.T) {
|
||||||
m := readManifest(t)
|
m := readManifest(t)
|
||||||
bars := m.resource(t, "i3-bars")
|
m.i3LinesAreSource(t, "files/i3/60-i3status-rust.conf")
|
||||||
if bars["path"] != "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf" {
|
c := m.i3Lines()
|
||||||
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)
|
||||||
|
|||||||
@@ -85,14 +85,6 @@
|
|||||||
"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": {
|
||||||
@@ -109,5 +101,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"seat": "node-display-session",
|
||||||
|
"kind": "config",
|
||||||
|
"content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ 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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ 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.
|
||||||
await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
|
// Disabled, never deleted (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once): a mailbox holding mail is the one thing a background loop must
|
||||||
|
// not destroy. applyProvisioned enables it again when the consumer returns.
|
||||||
|
await mailu.disableUser(`${p.as}@${domain()}`).catch(() => {});
|
||||||
},
|
},
|
||||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
// 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).
|
||||||
|
|||||||
@@ -47,15 +47,10 @@ 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. The bucket is emptied-then-dropped only if
|
// Revoking the key is what cuts the consumer's access, and the bucket is kept, empty or not
|
||||||
// empty; a bucket that still holds objects is left for an operator rather than erroring on every
|
// (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.
|
||||||
// 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 */ }
|
||||||
try {
|
console.error(`[minio] ${p.as} withdrawn: access key revoked, bucket ${bucket} kept`);
|
||||||
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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -125,6 +125,18 @@ 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);
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ runProvisioner("mongodb-database", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async remove(p: { as: string }): Promise<void> {
|
async remove(p: { as: string }): Promise<void> {
|
||||||
await mongo.dropDatabaseAndUser(p.as, p.as);
|
// Locked, never dropped (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). create gives the roles back.
|
||||||
await announce("database.deprovisioned", { database: p.as });
|
await mongo.lockUser(p.as, p.as);
|
||||||
|
await announce("database.deprovisioned", { database: p.as, kept: "true" });
|
||||||
},
|
},
|
||||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
// 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).
|
||||||
|
|||||||
@@ -256,6 +256,14 @@ 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)}`,
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ runProvisioner("mssql-database", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async remove(p: { as: string }): Promise<void> {
|
async remove(p: { as: string }): Promise<void> {
|
||||||
await mssql.dropDatabaseAndLogin(p.as, p.as);
|
// Disabled, never dropped (novox/hq issue 241: a withdrawal never destroys a consumer's data — on 2026-10-04 a misread grants file withdrew every consumer at once). create enables the login again.
|
||||||
await announce("database.deprovisioned", { database: p.as });
|
await mssql.disableLogin(p.as);
|
||||||
|
await announce("database.deprovisioned", { database: p.as, kept: "true" });
|
||||||
},
|
},
|
||||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
// 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).
|
||||||
|
|||||||
@@ -145,14 +145,43 @@ export class PostgresClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop a database and its owning role, idempotently, after evicting live connections. */
|
/**
|
||||||
async dropDatabaseAndRole(database: string, role: string): Promise<void> {
|
* Withdraw a consumer without destroying anything (novox/hq issue 241): its login can no longer log
|
||||||
|
* 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(`DROP DATABASE IF EXISTS ${ident(database)}`);
|
await this.query(`ALTER DATABASE ${ident(database)} RENAME TO ${ident(aside)}`);
|
||||||
await this.query(`DROP ROLE IF EXISTS ${ident(role)}`);
|
return aside;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -301,3 +330,10 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,9 +41,13 @@ 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.dropDatabaseAndRole(p.as, p.as);
|
await postgres.lockRole(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).
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -30,6 +30,23 @@ 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 };
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,7 @@ 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.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
"github.com/godbus/dbus/v5"
|
||||||
)
|
)
|
||||||
@@ -56,9 +58,29 @@ 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() }
|
||||||
|
|||||||
@@ -164,6 +164,27 @@ 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() {
|
||||||
@@ -264,6 +285,9 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,36 @@ 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()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"module": "power",
|
"module": "power",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
|
"package-manager",
|
||||||
"service-manager"
|
"service-manager"
|
||||||
],
|
],
|
||||||
"claims": [
|
"claims": [
|
||||||
@@ -27,6 +28,11 @@
|
|||||||
"power_check"
|
"power_check"
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "polkit",
|
||||||
|
"type": "package",
|
||||||
|
"package": "polkit"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "scripts",
|
"id": "scripts",
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
@@ -213,7 +219,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# 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"
|
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "after-boot",
|
"id": "after-boot",
|
||||||
|
|||||||
@@ -86,3 +86,10 @@ 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,7 +22,9 @@ 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"`
|
||||||
Build struct {
|
// Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here.
|
||||||
|
Contributions []contribution `json:"contributions"`
|
||||||
|
Build struct {
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
} `json:"build"`
|
} `json:"build"`
|
||||||
}
|
}
|
||||||
@@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contribution struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3Lines is what the module contributes to the window manager.
|
||||||
|
func (m manifest) i3Lines() string {
|
||||||
|
var out string
|
||||||
|
for _, c := range m.Contributions {
|
||||||
|
if c.Seat == "node-display-session" && c.Kind == "config" {
|
||||||
|
out += c.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3LinesAreSource checks the window-manager contribution is the source file it is written from.
|
||||||
|
func (m manifest) i3LinesAreSource(t *testing.T, source string) {
|
||||||
|
t.Helper()
|
||||||
|
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.i3Lines() != string(want) {
|
||||||
|
t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -85,7 +84,8 @@ func TestThePowerMenuNamesNoWindowManagerAndNoLocker(t *testing.T) {
|
|||||||
|
|
||||||
func TestTheKeyBindingsAreAnI3DropInRunningTheModulesOwnCommands(t *testing.T) {
|
func TestTheKeyBindingsAreAnI3DropInRunningTheModulesOwnCommands(t *testing.T) {
|
||||||
m := readManifest(t)
|
m := readManifest(t)
|
||||||
bindings := m.resource(t, "i3-bindings")["content"].(string)
|
m.i3LinesAreSource(t, "files/i3/50-rofi.conf")
|
||||||
|
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
|
||||||
|
|||||||
@@ -88,14 +88,6 @@
|
|||||||
"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": {
|
||||||
@@ -112,5 +104,12 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ runProvisioner("analytics", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async remove(p: { as: string }): Promise<void> {
|
async remove(p: { as: string }): Promise<void> {
|
||||||
const token = await umami.getToken();
|
// 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.
|
||||||
// Keyed on the mesh-derived login, the one identity the harness carries into removal.
|
console.error(`[umami] ${p.as} withdrawn: website and its analytics kept`);
|
||||||
const site = await umami.findWebsite(token, p.as);
|
|
||||||
if (site) await umami.deleteWebsite(token, site.id);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user