Compare commits
83
Commits
feat/dbus-module
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
495bb88111 | ||
|
|
b87dc29706 | ||
|
|
e5cb7b071c | ||
|
|
7fad19a764 | ||
|
|
4769dadf63 | ||
|
|
bbb67e41a0 | ||
|
|
f9f27d4878 | ||
|
|
cc2f19123a | ||
|
|
0275c2eeac | ||
|
|
78328d4ab2 | ||
|
|
48d4188927 | ||
|
|
5c2157b81c | ||
|
|
77fb1ecfb2 | ||
|
|
6f1e2f5a0d | ||
|
|
ed6384feb0 | ||
|
|
0e072b05c0 | ||
|
|
60604fcd11 | ||
|
|
ff61578e3e | ||
|
|
138d9afd7b | ||
|
|
dd124966ad | ||
|
|
73d6a51325 | ||
|
|
7b0b80ee05 | ||
|
|
d8b4d20886 | ||
|
|
0515db043a | ||
|
|
7f491fd6bc | ||
|
|
b4b86c1452 | ||
|
|
737f42deb4 | ||
|
|
5c89eaf9a6 | ||
|
|
964a4fdfbc | ||
|
|
20603b63e6 | ||
|
|
4bf5eef2fd | ||
|
|
4cac44face | ||
|
|
875d2a0554 | ||
|
|
76707bbe0e | ||
|
|
076455ec78 | ||
|
|
f20c4b749b | ||
|
|
69d6b9066f | ||
|
|
2cc27e2a74 | ||
|
|
0581256905 | ||
|
|
082f8de32f | ||
|
|
428f5b8864 | ||
|
|
579d21a209 | ||
|
|
52b81d524c | ||
|
|
b651137d95 | ||
|
|
c42f1ce45b | ||
|
|
6a42bafb1c | ||
|
|
053eba6950 | ||
|
|
45507c3d5c | ||
|
|
63e53f4622 | ||
|
|
ebacf79c91 | ||
|
|
a66a23582f | ||
|
|
db9a5bff0c | ||
|
|
85352c8d6f | ||
|
|
f74e1f0309 | ||
|
|
97b1b2b36b | ||
|
|
d0d5546088 | ||
|
|
2a99b3c3e9 | ||
|
|
ade4901d7f | ||
|
|
f2b5168647 | ||
|
|
75f25fca21 | ||
|
|
1fc10b0323 | ||
|
|
4f3e52b47a | ||
|
|
1a03caf8a5 | ||
|
|
54ba97f901 | ||
|
|
92f78db970 | ||
|
|
1d8f1ceff8 | ||
|
|
83bd2afe66 | ||
|
|
46e3a5a6b7 | ||
|
|
d67e786bf9 | ||
|
|
7786507d45 | ||
|
|
a70227a40a | ||
|
|
08c7a79f79 | ||
|
|
d9120c6848 | ||
|
|
96ee0d7ad6 | ||
|
|
d43de93e49 | ||
|
|
3a88bca21a | ||
|
|
dd1035a27d | ||
|
|
37bb53ab95 | ||
|
|
45f27eb300 | ||
|
|
785d32407b | ||
|
|
170b3296e9 | ||
|
|
32cd92baeb | ||
|
|
9582208984 |
@@ -1,2 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
|
||||||
|
modules/slack/cmd/slack-tools/slack-tools
|
||||||
|
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# blueman
|
||||||
|
|
||||||
|
The Bluetooth tray applet on the workstations, as a module (novox/hq ADR 0208). It requires
|
||||||
|
`x11-display`, so it is assigned only where a display server is held on the same machine.
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| the applet, the manager window, send-to | package `blueman`, from the official repositories |
|
||||||
|
|
||||||
|
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||||
|
|
||||||
|
- **No AUR.** Both workstations run the official package (`extra`), installed explicitly.
|
||||||
|
- **The Bluetooth stack is not this module's.** `bluez`, `bluez-utils` and `bluetooth.service` belong
|
||||||
|
to the `bluetooth` module. This module declares none of them (ADR 0210 §4: one package, one module).
|
||||||
|
The devices, pairing and power are that module's tools (`bluetooth_*`). This module's tools are
|
||||||
|
about the applet.
|
||||||
|
- **The operator's applet settings are found.** blueman keeps them in dconf (`org.blueman.*`): the
|
||||||
|
plugin switches, the recent connections, auto-connect and auto-power-on. The module neither sets nor
|
||||||
|
resets them. `blueman_status` shows the plugin switches.
|
||||||
|
|
||||||
|
## How it starts: the package's autostart entry, and nothing else
|
||||||
|
|
||||||
|
One process has one starter (the rule `picom` states for the desktop modules). The package ships
|
||||||
|
`/etc/xdg/autostart/blueman.desktop` (`blueman-applet`). The session runs it once at login through
|
||||||
|
the `i3` module's `dex --autostart --environment i3`. **That entry is the applet's one start.** The
|
||||||
|
module adds no `xinitrc` slot and no `node-display-session` exec, because either would start it a
|
||||||
|
second time.
|
||||||
|
|
||||||
|
- **Excluded:** the window manager's `exec … blueman-applet`, which the `i3` module's configuration
|
||||||
|
dropped.
|
||||||
|
- **Not a start:** the package's user unit `blueman-applet.service` is static. It exists for D-Bus
|
||||||
|
activation of `org.blueman.Applet`. A program that calls the applet's bus name while no applet runs
|
||||||
|
starts one through it. The applet is single-instance on that name, so this never makes a second
|
||||||
|
one. The tools always ask the bus with `--auto-start=no`, so asking never starts it.
|
||||||
|
- The applet starts its tray icon, `blueman-tray`, itself.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
They are served by the node's runtime as the operator account (ADR 0175).
|
||||||
|
|
||||||
|
| tool | does |
|
||||||
|
|---|---|
|
||||||
|
| `blueman_status` (r) | <ul><li>whether the applet and its tray icon run: pid, since, and the scope or unit they run in</li><li>the installed version, and what starts it at login</li><li>the plugins the running applet has loaded and those it has not (asked of the applet on the session bus)</li><li>the plugin switches in `org.blueman.general plugin-list`</li><li>whether the applet sees Bluetooth on</li></ul> |
|
||||||
|
| `blueman_restart` (a) | asks the applet and the tray icon to end (SIGTERM), forces them after 5 s, and starts `blueman-applet` in the operator's session. The start is a transient user unit `mesh-blueman-applet`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop |
|
||||||
|
| `blueman_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one applet runs in a desktop session</li><li>`bluetooth.service` is active</li></ul>Each finding says what to do |
|
||||||
|
|
||||||
|
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment,
|
||||||
|
as `clipmenu` and `screen-lock` do. Every command has a timeout and capped output. Everything runs
|
||||||
|
through an injected runner and a fake root in the tests.
|
||||||
|
|
||||||
|
## What changes when it is assigned
|
||||||
|
|
||||||
|
| | g14 | shanks |
|
||||||
|
|---|---|---|
|
||||||
|
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
|
||||||
|
| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
|
||||||
|
| settings (dconf) | defaults, no plugin switched; recent connections only | no plugin switched; auto-connect for one headset, auto-power-on set |
|
||||||
|
|
||||||
|
The workstations are already in the state this module describes.
|
||||||
|
|
||||||
|
## Migration (ADR 0182)
|
||||||
|
|
||||||
|
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
|
||||||
|
the applet runs from its one start. `blueman_check` then answers `ok`.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
- The applet's dconf settings (`/org/blueman/`).
|
||||||
|
- `/etc/xdg/autostart/blueman.desktop`, the package's own file.
|
||||||
|
|
||||||
|
## Relies on
|
||||||
|
|
||||||
|
- **The `bluetooth` module, for bluez and its daemon.** Without them the applet has nothing to
|
||||||
|
manage. There is no dependency mechanism between two modules that hold no seat. So nothing refuses
|
||||||
|
`blueman` on a node without `bluetooth`. `blueman_check` reports it instead, from
|
||||||
|
`bluetooth.service`. **Assign both.** When the stack becomes a seat (`node-bluetooth`), this module
|
||||||
|
depends on the seat.
|
||||||
|
- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat.
|
||||||
|
Assigned without `i3`, the applet is installed and does not start. `blueman_check` says so.
|
||||||
|
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||||
|
// The same in every desktop module that carries it.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// text is a string argument, trimmed; required says an empty one is refused.
|
||||||
|
func text(args map[string]any, key string, required bool) (string, error) {
|
||||||
|
v, present := args[key]
|
||||||
|
if !present || v == nil {
|
||||||
|
if required {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" && required {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// whole is a whole-number argument within [least, most], or def when absent.
|
||||||
|
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, present := args[key]
|
||||||
|
if !present || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s is a number, not %T", key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f != math.Trunc(f) {
|
||||||
|
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||||
|
}
|
||||||
|
n := int(f)
|
||||||
|
if n < least || n > most {
|
||||||
|
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flag is a boolean argument, or def when absent.
|
||||||
|
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, present := args[key]
|
||||||
|
if !present || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// texts is a list-of-strings argument.
|
||||||
|
func texts(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, present := args[key]
|
||||||
|
if !present || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
list, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
if ss, isStrings := v.([]string); isStrings {
|
||||||
|
return ss, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(list))
|
||||||
|
for i, item := range list {
|
||||||
|
s, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||||
|
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||||
|
n, err := whole(args, key, def, 1, most)
|
||||||
|
return time.Duration(n) * time.Second, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// blueman's applet as the tools see it: its processes, its XDG autostart entry (the package's), the
|
||||||
|
// applet's own answers on the session bus, and its settings in gsettings. Every bus call is made with
|
||||||
|
// --auto-start=no: the applet is D-Bus activatable, and a question must never start it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appletComm = "blueman-applet"
|
||||||
|
trayComm = "blueman-tray"
|
||||||
|
appletBin = "/usr/bin/blueman-applet"
|
||||||
|
entryName = "blueman.desktop"
|
||||||
|
restartAs = "mesh-blueman-applet"
|
||||||
|
packageFor = "blueman"
|
||||||
|
busName = "org.blueman.Applet"
|
||||||
|
busPath = "/org/blueman/Applet"
|
||||||
|
stackUnit = "bluetooth.service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// appletCall asks the running applet one question over the session bus and answers busctl's JSON.
|
||||||
|
func (m *Machine) appletCall(method string) (json.RawMessage, error) {
|
||||||
|
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, busPath, busName, method}
|
||||||
|
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
|
||||||
|
if err := failed(o, "busctl", args...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Data []json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
|
||||||
|
return nil, fmt.Errorf("the applet's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
|
||||||
|
}
|
||||||
|
return doc.Data[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) appletStrings(method string) ([]string, error) {
|
||||||
|
raw, err := m.appletCall(method)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("the applet's %s is not a list of names: %w", method, err)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gvariantStrings reads gsettings' printed array of strings: "@as []" or "['a', '!b']".
|
||||||
|
func gvariantStrings(s string) []string {
|
||||||
|
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "@as"))
|
||||||
|
s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]")
|
||||||
|
out := []string{}
|
||||||
|
for _, item := range strings.Split(s, ",") {
|
||||||
|
item = strings.Trim(strings.TrimSpace(item), `'"`)
|
||||||
|
if item != "" {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugins is which applet plugins run, and what the operator's settings say about them.
|
||||||
|
type Plugins struct {
|
||||||
|
// Loaded are the plugins the running applet answers it has loaded.
|
||||||
|
Loaded []string `json:"loaded"`
|
||||||
|
// NotLoaded are the plugins it knows but did not load: switched off, or not fit for this machine.
|
||||||
|
NotLoaded []string `json:"not_loaded"`
|
||||||
|
// Switched is the operator's plugin-list setting: a name to load it, !name to keep it off. Empty
|
||||||
|
// is blueman's defaults.
|
||||||
|
Switched []string `json:"switched"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppletStatus is what blueman_status answers.
|
||||||
|
type AppletStatus struct {
|
||||||
|
Installed string `json:"installed,omitempty"`
|
||||||
|
Applet []Proc `json:"applet"`
|
||||||
|
Tray []Proc `json:"tray"`
|
||||||
|
StartedBy Autostart `json:"started_by"`
|
||||||
|
// Bluetooth is the applet's own view of the adapter's power, when it runs.
|
||||||
|
Bluetooth *bool `json:"bluetooth_on,omitempty"`
|
||||||
|
Plugins Plugins `json:"plugins"`
|
||||||
|
// Unanswered says why the running applet's view is missing.
|
||||||
|
Unanswered string `json:"unanswered,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reads the applet: running or not, how it starts, and its plugins.
|
||||||
|
func (m *Machine) Status() (AppletStatus, error) {
|
||||||
|
s := AppletStatus{Applet: m.procs(appletComm), Tray: m.procs(trayComm), StartedBy: m.autostart(entryName),
|
||||||
|
Plugins: Plugins{Loaded: []string{}, NotLoaded: []string{}, Switched: []string{}}}
|
||||||
|
if s.Applet == nil {
|
||||||
|
s.Applet = []Proc{}
|
||||||
|
}
|
||||||
|
if s.Tray == nil {
|
||||||
|
s.Tray = []Proc{}
|
||||||
|
}
|
||||||
|
if v, err := m.installed(packageFor); err == nil {
|
||||||
|
s.Installed = v
|
||||||
|
}
|
||||||
|
o := m.cmd(5*time.Second, m.bus(), "gsettings", "get", "org.blueman.general", "plugin-list")
|
||||||
|
if o.Err == nil && o.Code == 0 {
|
||||||
|
s.Plugins.Switched = gvariantStrings(o.Stdout)
|
||||||
|
}
|
||||||
|
if len(s.Applet) == 0 {
|
||||||
|
s.Unanswered = "the applet is not running"
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
loaded, err := m.appletStrings("QueryPlugins")
|
||||||
|
if err != nil {
|
||||||
|
s.Unanswered = err.Error()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
s.Plugins.Loaded = loaded
|
||||||
|
if all, err := m.appletStrings("QueryAvailablePlugins"); err == nil {
|
||||||
|
in := map[string]bool{}
|
||||||
|
for _, p := range loaded {
|
||||||
|
in[p] = true
|
||||||
|
}
|
||||||
|
for _, p := range all {
|
||||||
|
if !in[p] {
|
||||||
|
s.Plugins.NotLoaded = append(s.Plugins.NotLoaded, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, err := m.appletCall("GetBluetoothStatus"); err == nil {
|
||||||
|
var on bool
|
||||||
|
if json.Unmarshal(raw, &on) == nil {
|
||||||
|
s.Bluetooth = &on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartAnswer is what blueman_restart answers.
|
||||||
|
type RestartAnswer struct {
|
||||||
|
Ended []int `json:"ended"`
|
||||||
|
Killed []int `json:"killed,omitempty"`
|
||||||
|
Running []Proc `json:"running"`
|
||||||
|
Session Session `json:"session"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart ends the applet and its tray icon, and starts the applet again in the operator's session
|
||||||
|
// under the account's service manager. The applet starts its tray icon itself.
|
||||||
|
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||||
|
s, err := m.session()
|
||||||
|
if err != nil {
|
||||||
|
return RestartAnswer{}, err
|
||||||
|
}
|
||||||
|
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||||
|
a.Ended, a.Killed = m.stop(5*time.Second, appletComm, trayComm)
|
||||||
|
if err := m.detach(s, restartAs, appletBin); err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
a.Running = m.waitFor(appletComm, 4*time.Second)
|
||||||
|
if len(a.Running) == 0 {
|
||||||
|
return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+
|
||||||
|
"see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAnswer is what blueman_check answers.
|
||||||
|
type CheckAnswer struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Findings []Finding `json:"findings"`
|
||||||
|
Starts []string `json:"starts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check verifies what the module promises and relies on: the package; one start (the package's
|
||||||
|
// autostart entry, which the session's dex runs); the applet running once in a session; and the
|
||||||
|
// Bluetooth daemon it manages, which is the bluetooth module's.
|
||||||
|
func (m *Machine) Check() (CheckAnswer, error) {
|
||||||
|
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||||
|
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||||
|
v, err := m.installed(packageFor)
|
||||||
|
if err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
add("the package blueman is not installed", "push the module to the node")
|
||||||
|
}
|
||||||
|
entry := m.autostart(entryName)
|
||||||
|
if entry.Starts {
|
||||||
|
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
||||||
|
if entry.From != "/etc/xdg/autostart/"+entryName {
|
||||||
|
add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry")
|
||||||
|
}
|
||||||
|
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
|
||||||
|
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
|
||||||
|
}
|
||||||
|
for _, l := range m.i3Starts(appletComm) {
|
||||||
|
a.Starts = append(a.Starts, "window manager: "+l)
|
||||||
|
add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start")
|
||||||
|
}
|
||||||
|
if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" {
|
||||||
|
add("the Bluetooth daemon ("+stackUnit+") is not running: the applet has nothing to manage",
|
||||||
|
"assign the bluetooth module, which owns bluez and its daemon")
|
||||||
|
}
|
||||||
|
running := m.procs(appletComm)
|
||||||
|
if _, err := m.session(); err == nil {
|
||||||
|
switch {
|
||||||
|
case len(running) == 0:
|
||||||
|
add("no applet runs in the desktop session", "blueman_restart")
|
||||||
|
case len(running) > 1:
|
||||||
|
add(fmt.Sprintf("%d applets run", len(running)), "blueman_restart ends them all and starts one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.OK = len(a.Findings) == 0
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newApplet(t *testing.T, running bool) *fake {
|
||||||
|
f := newFake(t)
|
||||||
|
f.write("/etc/xdg/autostart/blueman.desktop", "[Desktop Entry]\nName=Blueman Applet\nExec=blueman-applet\nType=Application\n")
|
||||||
|
if running {
|
||||||
|
f.proc(3859, 1000, appletComm, []string{"/usr/bin/python", "/usr/bin/blueman-applet"}, "session-c1.scope")
|
||||||
|
f.proc(4084, 1000, trayComm, []string{"/usr/bin/python", "/usr/bin/blueman-tray"}, "session-c1.scope")
|
||||||
|
}
|
||||||
|
f.answer = func(name string, args []string) Output {
|
||||||
|
call := name + " " + strings.Join(args, " ")
|
||||||
|
switch {
|
||||||
|
case name == "pacman":
|
||||||
|
return Output{Stdout: "blueman 2.4.6-2\n"}
|
||||||
|
case name == "gsettings":
|
||||||
|
return Output{Stdout: "['!NetUsage', 'DhcpClient']\n"}
|
||||||
|
case strings.HasSuffix(call, " QueryPlugins"):
|
||||||
|
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager"]]}`}
|
||||||
|
case strings.HasSuffix(call, " QueryAvailablePlugins"):
|
||||||
|
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager","NetUsage"]]}`}
|
||||||
|
case strings.HasSuffix(call, " GetBluetoothStatus"):
|
||||||
|
return Output{Stdout: `{"type":"b","data":[true]}`}
|
||||||
|
case name == "systemctl" && len(args) > 1 && args[0] == "is-active":
|
||||||
|
return Output{Stdout: "active\n"}
|
||||||
|
}
|
||||||
|
return Output{}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusAsksTheRunningAppletAndNeverStartsIt(t *testing.T) {
|
||||||
|
f := newApplet(t, true)
|
||||||
|
s, err := f.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Installed != "2.4.6-2" || len(s.Applet) != 1 || len(s.Tray) != 1 || !s.StartedBy.Starts || s.Bluetooth == nil || !*s.Bluetooth {
|
||||||
|
t.Fatalf("%+v", s)
|
||||||
|
}
|
||||||
|
if strings.Join(s.Plugins.Loaded, ",") != "AuthAgent,PowerManager,StatusIcon" || strings.Join(s.Plugins.NotLoaded, ",") != "NetUsage" ||
|
||||||
|
strings.Join(s.Plugins.Switched, ",") != "!NetUsage,DhcpClient" {
|
||||||
|
t.Fatalf("%+v", s.Plugins)
|
||||||
|
}
|
||||||
|
for _, c := range f.calls {
|
||||||
|
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
|
||||||
|
t.Fatalf("a bus call that could start the applet: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped := newApplet(t, false)
|
||||||
|
s, err = stopped.Status()
|
||||||
|
if err != nil || s.Unanswered != "the applet is not running" || len(s.Applet) != 0 || s.Bluetooth != nil {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
if stopped.called("busctl") {
|
||||||
|
t.Fatalf("asked the bus with no applet running: %q", stopped.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsListsAreReadAsGSettingsPrintsThem(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{"@as []\n": "", "['a']": "a", "['!a', 'b']\n": "!a,b"} {
|
||||||
|
if got := strings.Join(gvariantStrings(in), ","); got != want {
|
||||||
|
t.Errorf("%q: %q, not %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartEndsAppletAndTrayAndStartsTheApplet(t *testing.T) {
|
||||||
|
f := newApplet(t, true)
|
||||||
|
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||||
|
t.Fatalf("without a desktop: %v", err)
|
||||||
|
}
|
||||||
|
f.desktopSession()
|
||||||
|
f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") }
|
||||||
|
a, err := f.Restart()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(a.Ended) != 2 || len(a.Running) != 1 || a.Running[0].PID != 9100 || a.Running[0].Command != appletBin {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
|
||||||
|
t.Fatalf("%q", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) {
|
||||||
|
f := newApplet(t, true)
|
||||||
|
f.desktopSession()
|
||||||
|
c, err := f.Check()
|
||||||
|
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/blueman.desktop" {
|
||||||
|
t.Fatalf("%+v %v", c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.write(testHome+"/.config/i3/config", "exec --no-startup-id blueman-applet\n")
|
||||||
|
f.write(testHome+"/.config/autostart/blueman.desktop", "[Desktop Entry]\nExec=blueman-applet\nHidden=true\n")
|
||||||
|
f.proc(3860, 1000, appletComm, []string{"blueman-applet"}, "session-c1.scope")
|
||||||
|
f.answer = func(name string, args []string) Output {
|
||||||
|
switch name {
|
||||||
|
case "pacman":
|
||||||
|
return Output{Code: 1}
|
||||||
|
case "systemctl":
|
||||||
|
return Output{Stdout: "inactive\n", Code: 3}
|
||||||
|
case "dex":
|
||||||
|
return Output{Code: 127, Err: ErrNotInstalled}
|
||||||
|
}
|
||||||
|
return Output{}
|
||||||
|
}
|
||||||
|
c, _ = f.Check()
|
||||||
|
var all []string
|
||||||
|
for _, x := range c.Findings {
|
||||||
|
all = append(all, x.What)
|
||||||
|
}
|
||||||
|
got := strings.Join(all, "\n")
|
||||||
|
for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1",
|
||||||
|
"bluetooth.service", "2 applets run"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("no finding %q in\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||||
|
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||||
|
|
||||||
|
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||||
|
compared := 0
|
||||||
|
for _, module := range carriers {
|
||||||
|
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||||
|
mine, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||||
|
if err != nil || !bytes.Equal(mine, theirs) {
|
||||||
|
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compared++
|
||||||
|
}
|
||||||
|
if compared == 0 {
|
||||||
|
t.Log("no sibling copies beside this module")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||||
|
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||||
|
// node's tool runtime (novox/hq ADR 0208).
|
||||||
|
//
|
||||||
|
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||||
|
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||||
|
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||||
|
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||||
|
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||||
|
//
|
||||||
|
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||||
|
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||||
|
//
|
||||||
|
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||||
|
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||||
|
// to at most MostRead.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command and read is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 10 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
MostRead = 16 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Output is what a command did.
|
||||||
|
type Output struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Code int
|
||||||
|
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||||
|
Err error
|
||||||
|
Cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||||
|
var (
|
||||||
|
ErrNotInstalled = errors.New("not installed")
|
||||||
|
ErrTimedOut = errors.New("timed out")
|
||||||
|
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||||
|
ErrNoSession = errors.New("no graphical session")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||||
|
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||||
|
|
||||||
|
// Machine is what the tools read and act on.
|
||||||
|
type Machine struct {
|
||||||
|
Root string // "" on the machine; a fake root in tests
|
||||||
|
Home string // the operator's home, as the machine names it
|
||||||
|
UID int
|
||||||
|
Run Runner
|
||||||
|
Kill func(pid int, sig syscall.Signal) error
|
||||||
|
Sleep func(time.Duration)
|
||||||
|
Now func() time.Time
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMachine is the machine the bundle runs on.
|
||||||
|
func NewMachine() *Machine {
|
||||||
|
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||||
|
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||||
|
func operatorHome() string {
|
||||||
|
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
h, _ := os.UserHomeDir()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||||
|
|
||||||
|
// home is a path under the operator's home, on this machine's filesystem.
|
||||||
|
func (m *Machine) home(rel ...string) string {
|
||||||
|
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||||
|
func (m *Machine) tilde(p string) string {
|
||||||
|
if m.Home != "" && m.Home != "/" {
|
||||||
|
h := strings.TrimSuffix(m.Home, "/")
|
||||||
|
if p == h {
|
||||||
|
return "~"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, h+"/") {
|
||||||
|
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||||
|
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||||
|
if timeout <= 0 || timeout > m.Timeout {
|
||||||
|
timeout = m.Timeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
return m.Run(ctx, env, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||||
|
func failed(o Output, name string, args ...string) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(o.Err, ErrNotInstalled):
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", name)
|
||||||
|
case errors.Is(o.Err, ErrTimedOut):
|
||||||
|
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||||
|
case o.Err != nil:
|
||||||
|
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||||
|
case o.Code != 0:
|
||||||
|
said := strings.TrimSpace(o.Stderr)
|
||||||
|
if said == "" {
|
||||||
|
said = strings.TrimSpace(o.Stdout)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
type capped struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capped) Write(p []byte) (int, error) {
|
||||||
|
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||||
|
if room > 0 {
|
||||||
|
c.b.Write(p[:room])
|
||||||
|
}
|
||||||
|
c.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return c.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||||
|
path, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
return Output{Code: 127, Err: ErrNotInstalled}
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, path, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
var out, errs capped
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
err = cmd.Run()
|
||||||
|
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
o.Code, o.Err = 124, ErrTimedOut
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
o.Code = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
o.Code, o.Err = 127, err
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBounded reads a file to at most MostRead bytes.
|
||||||
|
func readBounded(path string) ([]byte, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proc is one process of the account.
|
||||||
|
type Proc struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||||
|
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||||
|
StartedIn string `json:"started_in,omitempty"`
|
||||||
|
Since string `json:"since,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// procs are this account's processes named comm, oldest first.
|
||||||
|
func (m *Machine) procs(comm string) []Proc {
|
||||||
|
entries, err := os.ReadDir(m.path("/proc"))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
boot := m.bootTime()
|
||||||
|
var out []Proc
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||||
|
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||||
|
if p.Command == "" {
|
||||||
|
p.Command = comm
|
||||||
|
}
|
||||||
|
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||||
|
line := strings.Split(cg, "\n")[0]
|
||||||
|
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||||
|
}
|
||||||
|
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||||
|
p.Since = t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||||
|
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||||
|
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||||
|
// The program is the first word of the command line, or the second for a script run by its
|
||||||
|
// interpreter. An empty word keeps every process named comm.
|
||||||
|
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||||
|
var out []Proc
|
||||||
|
for _, p := range m.procs(comm) {
|
||||||
|
f := strings.Fields(p.Command)
|
||||||
|
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||||
|
func (m *Machine) uidOf(dir string) int {
|
||||||
|
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||||
|
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||||
|
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) bootTime() int64 {
|
||||||
|
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||||
|
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||||
|
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||||
|
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||||
|
i := strings.LastIndexByte(stat, ')')
|
||||||
|
if i < 0 || boot == 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
f := strings.Fields(stat[i+1:])
|
||||||
|
if len(f) < 20 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return time.Unix(boot+ticks/100, 0), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTrimmed(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is what a tool needs to start something on the operator's desktop.
|
||||||
|
type Session struct {
|
||||||
|
Display string `json:"display"`
|
||||||
|
XAuthority string `json:"xauthority,omitempty"`
|
||||||
|
Bus string `json:"bus,omitempty"`
|
||||||
|
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||||
|
From string `json:"found_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionHolders are the processes whose environment is the session's, best first.
|
||||||
|
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||||
|
|
||||||
|
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||||
|
func (m *Machine) session() (Session, error) {
|
||||||
|
entries, _ := os.ReadDir(m.path("/proc"))
|
||||||
|
best, bestRank := -1, len(sessionHolders)+1
|
||||||
|
var env map[string]string
|
||||||
|
var from string
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||||
|
if m.uidOf(dir) != m.UID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vars := parseEnviron(raw)
|
||||||
|
if vars["DISPLAY"] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||||
|
rank := len(sessionHolders)
|
||||||
|
for i, h := range sessionHolders {
|
||||||
|
if h == comm {
|
||||||
|
rank = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||||
|
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env == nil {
|
||||||
|
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||||
|
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||||
|
}
|
||||||
|
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||||
|
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||||
|
if s.RuntimeDir == "" {
|
||||||
|
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||||
|
}
|
||||||
|
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||||
|
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||||
|
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||||
|
func (m *Machine) bus() []string {
|
||||||
|
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||||
|
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||||
|
func (s Session) Env() []string {
|
||||||
|
var env []string
|
||||||
|
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||||
|
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||||
|
if kv[1] != "" {
|
||||||
|
env = append(env, kv[0]+"="+kv[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnviron(raw []byte) map[string]string {
|
||||||
|
env := map[string]string{}
|
||||||
|
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||||
|
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||||
|
env[string(kv[:i])] = string(kv[i+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||||
|
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||||
|
// first, so the fixed name means at most one.
|
||||||
|
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||||
|
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||||
|
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||||
|
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||||
|
if kv[1] != "" {
|
||||||
|
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call = append(append(call, "--"), argv...)
|
||||||
|
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||||
|
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||||
|
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||||
|
var ps []Proc
|
||||||
|
for _, c := range comms {
|
||||||
|
ps = append(ps, m.procs(c)...)
|
||||||
|
}
|
||||||
|
return m.stopProcs(grace, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopProcs ends the processes given, as stop does.
|
||||||
|
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||||
|
var pids []int
|
||||||
|
for _, p := range ps {
|
||||||
|
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||||
|
pids = append(pids, p.PID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alive := func() []int {
|
||||||
|
var left []int
|
||||||
|
for _, pid := range pids {
|
||||||
|
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||||
|
left = append(left, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
step := 200 * time.Millisecond
|
||||||
|
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||||
|
m.Sleep(step)
|
||||||
|
}
|
||||||
|
left := alive()
|
||||||
|
for _, pid := range left {
|
||||||
|
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||||
|
killed = append(killed, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gone := map[int]bool{}
|
||||||
|
for _, pid := range left {
|
||||||
|
gone[pid] = true
|
||||||
|
}
|
||||||
|
for _, pid := range pids {
|
||||||
|
if !gone[pid] {
|
||||||
|
ended = append(ended, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ended, killed
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||||
|
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||||
|
|
||||||
|
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||||
|
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||||
|
step := 250 * time.Millisecond
|
||||||
|
for waited := time.Duration(0); ; waited += step {
|
||||||
|
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
m.Sleep(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||||
|
func desktopEntry(path string) map[string]string {
|
||||||
|
raw, err := readBounded(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
in := false
|
||||||
|
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||||
|
for s.Scan() {
|
||||||
|
l := strings.TrimSpace(s.Text())
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(l, "["):
|
||||||
|
in = l == "[Desktop Entry]"
|
||||||
|
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||||
|
if i := strings.IndexByte(l, '='); i > 0 {
|
||||||
|
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||||
|
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||||
|
type Autostart struct {
|
||||||
|
Entry string `json:"entry"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Exec string `json:"exec,omitempty"`
|
||||||
|
Starts bool `json:"starts"`
|
||||||
|
Because string `json:"because,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||||
|
func (m *Machine) autostart(name string) Autostart {
|
||||||
|
a := Autostart{Entry: name}
|
||||||
|
user := m.home(".config", "autostart", name)
|
||||||
|
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||||
|
var e map[string]string
|
||||||
|
switch {
|
||||||
|
case exists(user):
|
||||||
|
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||||
|
case exists(system):
|
||||||
|
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||||
|
default:
|
||||||
|
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
a.Exec = e["Exec"]
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(e["Hidden"], "true"):
|
||||||
|
a.Because = "Hidden=true"
|
||||||
|
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||||
|
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||||
|
case a.Exec == "":
|
||||||
|
a.Because = "the entry has no Exec"
|
||||||
|
default:
|
||||||
|
a.Starts = true
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||||
|
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||||
|
func (m *Machine) i3Starts(word string) []string {
|
||||||
|
files := []string{m.home(".config", "i3", "config")}
|
||||||
|
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||||
|
files = append(files, more...)
|
||||||
|
var out []string
|
||||||
|
for _, f := range files {
|
||||||
|
raw, err := readBounded(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for n, l := range strings.Split(string(raw), "\n") {
|
||||||
|
t := strings.TrimSpace(l)
|
||||||
|
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range strings.Fields(t)[1:] {
|
||||||
|
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||||
|
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||||
|
func (m *Machine) installed(pkg string) (string, error) {
|
||||||
|
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||||
|
if o.Err != nil {
|
||||||
|
return "", failed(o, "pacman", "-Q", pkg)
|
||||||
|
}
|
||||||
|
if o.Code != 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
f := strings.Fields(o.Stdout)
|
||||||
|
if len(f) < 2 {
|
||||||
|
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||||
|
}
|
||||||
|
return f[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finding is one thing a check found wrong, and what to do about it.
|
||||||
|
type Finding struct {
|
||||||
|
What string `json:"what"`
|
||||||
|
Do string `json:"do,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||||
|
// application's bundle (copies_test.go).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testHome = "/home/operator"
|
||||||
|
|
||||||
|
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||||
|
type fake struct {
|
||||||
|
*Machine
|
||||||
|
t *testing.T
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []string
|
||||||
|
answer func(name string, args []string) Output
|
||||||
|
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||||
|
onStart func(argv []string)
|
||||||
|
// stubborn pids ignore SIGTERM.
|
||||||
|
stubborn map[int]bool
|
||||||
|
signals []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFake(t *testing.T) *fake {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||||
|
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||||
|
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||||
|
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
f.mu.Unlock()
|
||||||
|
if name == "systemd-run" && f.onStart != nil {
|
||||||
|
for i, a := range args {
|
||||||
|
if a == "--" {
|
||||||
|
f.onStart(args[i+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.answer != nil {
|
||||||
|
return f.answer(name, args)
|
||||||
|
}
|
||||||
|
return Output{}
|
||||||
|
}
|
||||||
|
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||||
|
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||||
|
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||||
|
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) write(path, content string) {
|
||||||
|
f.t.Helper()
|
||||||
|
p := filepath.Join(f.Root, path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||||
|
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||||
|
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||||
|
f.write(d+"comm", comm+"\n")
|
||||||
|
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||||
|
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||||
|
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||||
|
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||||
|
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||||
|
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) desktopSession() {
|
||||||
|
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||||
|
f.write("/run/user/1000/bus", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) called(prefix string) bool {
|
||||||
|
for _, c := range f.calls {
|
||||||
|
if strings.HasPrefix(c, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||||
|
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||||
|
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||||
|
got := f.procs("worker")
|
||||||
|
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||||
|
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||||
|
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||||
|
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||||
|
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||||
|
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||||
|
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||||
|
f.desktopSession()
|
||||||
|
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||||
|
s, err := f.session()
|
||||||
|
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||||
|
!strings.Contains(s.From, "i3") {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.desktopSession()
|
||||||
|
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||||
|
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||||
|
f.stubborn[21] = true
|
||||||
|
ended, killed := f.stop(time.Second, "app")
|
||||||
|
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||||
|
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||||
|
}
|
||||||
|
s, _ := f.session()
|
||||||
|
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||||
|
"/.Xauthority -- /usr/bin/app --background"
|
||||||
|
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||||
|
t.Fatalf("%q", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||||
|
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||||
|
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||||
|
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||||
|
got := f.i3Starts("app")
|
||||||
|
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||||
|
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := failed(Output{}, "true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||||
|
t.Fatalf("%+v", o)
|
||||||
|
}
|
||||||
|
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||||
|
t.Fatalf("%+v", o)
|
||||||
|
}
|
||||||
|
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||||
|
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||||
|
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// The blueman module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Bluetooth tray
|
||||||
|
// applet in the operator's session, served by the node's runtime as the operator account. The module
|
||||||
|
// holds no seat, so every tool is its own. The devices themselves are the bluetooth module's tools.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var machine = NewMachine()
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "blueman_status",
|
||||||
|
Description: "The Bluetooth applet: whether it and its tray icon run (pid, since, and the unit or " +
|
||||||
|
"session scope they run in), the installed version, what starts it at login, the plugins the " +
|
||||||
|
"running applet has loaded and those it has not, the plugin switches in the operator's settings, " +
|
||||||
|
"and whether the applet sees Bluetooth on. Never starts the applet. (r)",
|
||||||
|
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "blueman_restart",
|
||||||
|
Description: "End the applet and its tray icon (asked first, then forced after 5 s) and start the " +
|
||||||
|
"applet again in the operator's desktop session, under the account's service manager. Answers " +
|
||||||
|
"the pids ended and the new one. Needs someone logged in to the desktop. (a)",
|
||||||
|
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "blueman_check",
|
||||||
|
Description: "Check what the module promises and relies on: the package is installed; the applet has " +
|
||||||
|
"exactly one start (the package's XDG autostart entry, which the session's dex runs; no " +
|
||||||
|
"window-manager exec); it runs once in a desktop session; and the Bluetooth daemon is running " +
|
||||||
|
"(the bluetooth module's). Answers ok and each finding with what to do. (r)",
|
||||||
|
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blueman's shape (novox/hq ADR 0208, ADR 0210): one official package, no seat, the X display on its
|
||||||
|
// own machine, no start of its own (the package's autostart entry is the one start), nothing of the
|
||||||
|
// bluetooth module's (bluez, its utilities, its daemon), and the Go bundle serving exactly the listed
|
||||||
|
// blueman_ tools.
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Requires []string `json:"requires"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Shell []any `json:"shell"`
|
||||||
|
Contributions []any `json:"contributions"`
|
||||||
|
Environment any `json:"environment"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) (manifest, string) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
var m manifest
|
||||||
|
if err := dec.Decode(&m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m, string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItInstallsTheAppletAndNothingElse(t *testing.T) {
|
||||||
|
m, _ := readManifest(t)
|
||||||
|
if m.Module != "blueman" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||||
|
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
|
||||||
|
t.Fatalf("%+v", m)
|
||||||
|
}
|
||||||
|
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
|
||||||
|
t.Fatalf("resources: %v", m.Resources)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil || m.Environment != nil {
|
||||||
|
t.Fatal("it holds no seat and sets no environment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItAddsNoSecondStartAndDeclaresNothingOfTheBluetoothModule(t *testing.T) {
|
||||||
|
m, raw := readManifest(t)
|
||||||
|
// The package ships its XDG autostart entry, which the session's dex runs: an xinitrc slot or a
|
||||||
|
// window-manager exec would start it twice.
|
||||||
|
if m.Shell != nil || m.Contributions != nil {
|
||||||
|
t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions)
|
||||||
|
}
|
||||||
|
for _, never := range []string{"autostart", "service", "bluez", "/etc/bluetooth"} {
|
||||||
|
if strings.Contains(raw, never) {
|
||||||
|
t.Errorf("module.json names %q: the start is the package's, the stack the bluetooth module's", never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||||
|
m, raw := readManifest(t)
|
||||||
|
served := map[string]bool{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
served[tool.Name] = true
|
||||||
|
if !strings.HasPrefix(tool.Name, "blueman_") || strings.TrimSpace(tool.Description) == "" {
|
||||||
|
t.Errorf("%s: prefixed blueman_ and described", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range m.Tools {
|
||||||
|
if !served[name] {
|
||||||
|
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||||
|
}
|
||||||
|
delete(served, name)
|
||||||
|
}
|
||||||
|
for name := range served {
|
||||||
|
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("%v", m.Build.Artifacts)
|
||||||
|
}
|
||||||
|
b := m.Build.Artifacts[0]
|
||||||
|
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||||
|
b["from"] != "cmd/blueman-tools" || b["binary"] != "blueman-tools" {
|
||||||
|
t.Errorf("the Go tools bundle: %v", b)
|
||||||
|
}
|
||||||
|
s := strings.ToLower(raw)
|
||||||
|
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||||
|
if strings.Contains(s, never) {
|
||||||
|
t.Errorf("module.json names %q", never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module blueman
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"module": "blueman",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager"
|
||||||
|
],
|
||||||
|
"requires": [
|
||||||
|
"x11-display"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"blueman_status",
|
||||||
|
"blueman_restart",
|
||||||
|
"blueman_check"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "package",
|
||||||
|
"type": "package",
|
||||||
|
"package": "blueman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/blueman-tools",
|
||||||
|
"binary": "blueman-tools",
|
||||||
|
"loads": [
|
||||||
|
"blueman-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,6 @@ running. `bluez` becomes explicitly the mesh's.
|
|||||||
## Leaves as found
|
## Leaves as found
|
||||||
|
|
||||||
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
|
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
|
||||||
- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
|
- `blueman` and its applet: the `blueman` module's, which relies on this one for the stack and
|
||||||
That line is the `i3` module's to keep or drop.
|
declares none of its packages. The applet starts from the package's XDG autostart entry.
|
||||||
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
|
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"claims": [
|
"claims": [
|
||||||
{
|
{
|
||||||
"name": "node-build-agent",
|
"name": "node-build-agent",
|
||||||
"scope": "node"
|
"scope": "node",
|
||||||
|
"serves": ["current", "kill", "pause", "resume"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requires": [
|
"requires": [
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ whenever the node's tool runtime collects the module's tools:
|
|||||||
| file | holds |
|
| file | holds |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
|
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, then the settings registered through this module (the mesh's, then this node's), under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
|
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions — then the instruction sections registered for every node and for this one |
|
||||||
|
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>`. Replaced whole, staged beside and swapped in |
|
||||||
|
|
||||||
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
|
Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
|
||||||
this node a subscription token. Nothing else under the home is read or written.
|
subscription token; and what is registered at the **home** scope for this node — a skill, subagent,
|
||||||
|
command, output style, or instructions as a rule file — each path recorded in the module's state
|
||||||
|
(`home-placed.json`). It writes, changes and removes only those — never a path the person made, even one with the
|
||||||
|
same content, and never through a directory that is a symbolic link. A placed file changed by hand is left
|
||||||
|
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
|
||||||
|
items to report them; nothing else is read or written.
|
||||||
|
|
||||||
## Over NATS
|
## Over NATS
|
||||||
|
|
||||||
@@ -41,6 +47,7 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
|||||||
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
||||||
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
||||||
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
|
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
|
||||||
|
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name |
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
@@ -49,6 +56,24 @@ manager), `claude_code_mcp_list`,
|
|||||||
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
|
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
|
||||||
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
||||||
|
|
||||||
|
The agent's configuration (hq ADR 0216), each registered at a **scope** — `mesh` (the default), `node`
|
||||||
|
(`nodes`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the
|
||||||
|
operator account's own `~/.claude` on those nodes):
|
||||||
|
|
||||||
|
- for each kind — `skill`, `agent`, `command`, `hook`, `output_style`, `instructions` —
|
||||||
|
`claude_code_<kind>_list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a
|
||||||
|
lone SKILL.md); a hook is an `event`, a `matcher`, a `command` and its scripts as `files`, with
|
||||||
|
`${HOOK_DIR}` in the command naming their directory. Hooks and settings take no home scope;
|
||||||
|
- `claude_code_settings_get`, `_set` (merged into the scope, or `replace`), `_clear`;
|
||||||
|
`claude_code_permission_add` and `_remove` for one allow, ask or deny rule. The agent refuses to loosen
|
||||||
|
its own settings: these are the operator's to call;
|
||||||
|
- `claude_code_config_list`, `_show` (one registration in full), `_status` (what applies here, the plugin
|
||||||
|
as written, and the home's own items — which the mesh placed, which share a name with a mesh item, which
|
||||||
|
call a tool server not loaded here) and `_import` (an item of this node's home, registered at a scope;
|
||||||
|
the original stays).
|
||||||
|
|
||||||
|
A new session takes a change; a running one at `/reload-plugins`.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
|
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
|
||||||
|
|||||||
@@ -69,19 +69,25 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
|||||||
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
|
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
|
||||||
var a, b any
|
var a, b any
|
||||||
_ = json.Unmarshal([]byte(got[file]), &a)
|
_ = json.Unmarshal([]byte(got[file]), &a)
|
||||||
|
// The plugin's two keys are new since the TypeScript (ADR 0216); everything else means the same.
|
||||||
|
if m, ok := a.(map[string]any); ok && file == "managed-settings.json" {
|
||||||
|
for k := range MarketplaceKeys() {
|
||||||
|
delete(m, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||||
if !reflect.DeepEqual(a, b) {
|
if !reflect.DeepEqual(a, b) {
|
||||||
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
|
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered), f.WithKey)
|
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered, Config{}), f.WithKey)
|
||||||
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain)
|
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}, Config{}), f.Plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
|
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
|
||||||
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
|
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
|
||||||
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil)
|
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil, Config{})
|
||||||
var mcp struct {
|
var mcp struct {
|
||||||
MCPServers map[string]map[string]any `json:"mcpServers"`
|
MCPServers map[string]map[string]any `json:"mcpServers"`
|
||||||
}
|
}
|
||||||
@@ -89,7 +95,7 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
|
|||||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||||
t.Fatalf("%v", mcp.MCPServers)
|
t.Fatalf("%v", mcp.MCPServers)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) {
|
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
||||||
t.Fatal("rendering is not deterministic")
|
t.Fatal("rendering is not deterministic")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +110,7 @@ func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
read := func(binding *Binding) map[string]any {
|
read := func(binding *Binding) map[string]any {
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil)
|
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil, Config{})
|
||||||
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
|
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,944 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The agent's configuration, registered through this module at three scopes (novox/hq ADR 0216, to-be 36 §8).
|
||||||
|
//
|
||||||
|
// Every registration is one key in the module's `config` state (ADR 0201):
|
||||||
|
//
|
||||||
|
// mesh.<kind>.<name> every node running the agent
|
||||||
|
// node.<node>.<kind>.<name> one node — a list of nodes is one key each
|
||||||
|
// home.<node>.<kind>.<name> the operator account's own agent directory on one node
|
||||||
|
//
|
||||||
|
// Every instance watches the state and takes what applies to it. What it takes lands in one place per kind,
|
||||||
|
// the one place the vendor honours for it:
|
||||||
|
//
|
||||||
|
// skill, agent, command, hook, output-style the plugin `nox-mesh`, in a marketplace in the managed directory
|
||||||
|
// (at the home scope: the home's own directories)
|
||||||
|
// instructions sections of the managed instruction file (home: a rule file)
|
||||||
|
// settings the managed settings file, mesh then node, under the mesh's keys
|
||||||
|
//
|
||||||
|
// Tool servers keep their own state and file (`servers`, the managed tool-server file): the exclusive file
|
||||||
|
// would block a plugin's.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plugin is the plugin's name, and its marketplace's: what its items are called in a session, `nox-mesh:<name>`.
|
||||||
|
const Plugin = "nox-mesh"
|
||||||
|
|
||||||
|
// MarketplaceDir is where the marketplace is written, inside the managed directory.
|
||||||
|
const MarketplaceDir = "marketplace"
|
||||||
|
|
||||||
|
// MaxItemBytes is the most one registration may carry, its files included: well under the bus's message limit.
|
||||||
|
const MaxItemBytes = 256 << 10
|
||||||
|
|
||||||
|
// The kinds a registration may be.
|
||||||
|
const (
|
||||||
|
KindSkill = "skill"
|
||||||
|
KindAgent = "agent"
|
||||||
|
KindCommand = "command"
|
||||||
|
KindHook = "hook"
|
||||||
|
KindOutputStyle = "output-style"
|
||||||
|
KindInstructions = "instructions"
|
||||||
|
KindSettings = "settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kinds is every kind, in the order a list shows them.
|
||||||
|
var Kinds = []string{KindSkill, KindAgent, KindCommand, KindHook, KindOutputStyle, KindInstructions, KindSettings}
|
||||||
|
|
||||||
|
// The scopes.
|
||||||
|
const (
|
||||||
|
ScopeMesh = "mesh"
|
||||||
|
ScopeNode = "node"
|
||||||
|
ScopeHome = "home"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsName is the one name a settings registration has: a scope holds one set of settings.
|
||||||
|
const SettingsName = "settings"
|
||||||
|
|
||||||
|
var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
||||||
|
|
||||||
|
// nodeName is what a node may be called in a key.
|
||||||
|
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||||
|
|
||||||
|
// meshOwnedKeys are the settings a registration may not set: the mesh's own, and those that would deny
|
||||||
|
// the mesh's console or its marketplace by another way.
|
||||||
|
var meshOwnedKeys = []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins",
|
||||||
|
"allowedMcpServers", "deniedMcpServers", "allowManagedMcpServersOnly", "strictKnownMarketplaces", "blockedMarketplaces"}
|
||||||
|
|
||||||
|
// hookEvents are the events a hook may be registered for.
|
||||||
|
var hookEvents = map[string]bool{"PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, "Notification": true,
|
||||||
|
"Stop": true, "SubagentStop": true, "SessionStart": true, "SessionEnd": true, "PreCompact": true}
|
||||||
|
|
||||||
|
// Item is one registration, as the state keeps it.
|
||||||
|
type Item struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
// Files are the item's files by path relative to the item: a skill's SKILL.md and whatever sits beside
|
||||||
|
// it; for the one-file kinds, `<name>.md`; a hook's scripts.
|
||||||
|
Files map[string]string `json:"files,omitempty"`
|
||||||
|
// A hook's event, matcher and command. In the command, ${HOOK_DIR} is the directory its files are in.
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Matcher string `json:"matcher,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
// Settings, for the settings kind: keys of the vendor's settings file.
|
||||||
|
Settings map[string]any `json:"settings,omitempty"`
|
||||||
|
// Where it was registered from, and when.
|
||||||
|
By string `json:"by,omitempty"`
|
||||||
|
At string `json:"at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key is where an item lives in the state, for one node (ignored at the mesh scope).
|
||||||
|
func (it Item) Key(node string) string {
|
||||||
|
if it.Scope == ScopeMesh {
|
||||||
|
return ScopeMesh + "." + it.Kind + "." + it.Name
|
||||||
|
}
|
||||||
|
return it.Scope + "." + node + "." + it.Kind + "." + it.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseKey reads a key back: its scope, the node it is for ("" at the mesh scope), the kind and the name.
|
||||||
|
func ParseKey(key string) (scope, node, kind, name string, ok bool) {
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
switch {
|
||||||
|
case len(parts) == 3 && parts[0] == ScopeMesh:
|
||||||
|
return ScopeMesh, "", parts[1], parts[2], true
|
||||||
|
case len(parts) == 4 && (parts[0] == ScopeNode || parts[0] == ScopeHome):
|
||||||
|
return parts[0], parts[1], parts[2], parts[3], true
|
||||||
|
}
|
||||||
|
return "", "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func knownKind(k string) bool {
|
||||||
|
for _, x := range Kinds {
|
||||||
|
if x == k {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problem says why an item cannot be registered, or "".
|
||||||
|
func (it Item) Problem() string {
|
||||||
|
if !knownKind(it.Kind) {
|
||||||
|
return fmt.Sprintf("%q is not a kind: %s", it.Kind, strings.Join(Kinds, ", "))
|
||||||
|
}
|
||||||
|
if it.Scope != ScopeMesh && it.Scope != ScopeNode && it.Scope != ScopeHome {
|
||||||
|
return fmt.Sprintf("%q is not a scope: mesh, node or home", it.Scope)
|
||||||
|
}
|
||||||
|
if it.Kind == KindSettings {
|
||||||
|
if it.Scope == ScopeHome {
|
||||||
|
return "settings take the mesh and node scopes only: the home's settings file is the person's"
|
||||||
|
}
|
||||||
|
if it.Name != SettingsName {
|
||||||
|
return "a scope holds one set of settings, named " + SettingsName
|
||||||
|
}
|
||||||
|
if len(it.Settings) == 0 {
|
||||||
|
return "no settings given"
|
||||||
|
}
|
||||||
|
for _, key := range meshOwnedKeys {
|
||||||
|
if _, ok := it.Settings[key]; ok {
|
||||||
|
return fmt.Sprintf("%q is one of the mesh's own keys, or would turn off the mesh's console or plugin; a registration cannot set it", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !itemName.MatchString(it.Name) {
|
||||||
|
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
|
||||||
|
}
|
||||||
|
if it.Kind == KindHook {
|
||||||
|
if it.Scope == ScopeHome {
|
||||||
|
return "a hook takes the mesh and node scopes only: at home it would live in the person's settings file"
|
||||||
|
}
|
||||||
|
if !hookEvents[it.Event] {
|
||||||
|
return fmt.Sprintf("%q is not a hook event the agent knows", it.Event)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(it.Command) == "" {
|
||||||
|
return "a hook needs a command"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rel := range it.Files {
|
||||||
|
if problem := pathProblem(rel); problem != "" {
|
||||||
|
return problem
|
||||||
|
}
|
||||||
|
for other := range it.Files {
|
||||||
|
if strings.HasPrefix(other, rel+"/") {
|
||||||
|
return fmt.Sprintf("%q is a file and also the directory of %q", rel, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch it.Kind {
|
||||||
|
case KindSkill:
|
||||||
|
if _, ok := it.Files["SKILL.md"]; !ok {
|
||||||
|
return "a skill needs a SKILL.md"
|
||||||
|
}
|
||||||
|
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||||
|
if len(it.Files) != 1 || strings.TrimSpace(it.Files[it.Name+".md"]) == "" {
|
||||||
|
return "this kind is one file, " + it.Name + ".md, with content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := it.size(); n > MaxItemBytes {
|
||||||
|
return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
|
||||||
|
// segment a real name — never empty, `.` or `..`.
|
||||||
|
func pathProblem(rel string) string {
|
||||||
|
if rel == "" || path.IsAbs(rel) || strings.ContainsAny(rel, "\\\x00") {
|
||||||
|
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(rel, "/") {
|
||||||
|
if seg == "" || seg == "." || seg == ".." {
|
||||||
|
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it Item) size() int {
|
||||||
|
raw, _ := json.Marshal(it)
|
||||||
|
return len(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the view -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ConfigView is what this node takes from the `config` state: every mesh item, and the node and home items
|
||||||
|
// for this node — kept in memory from the watch and written through to the module's own file, so the
|
||||||
|
// managed directory renders without the bus.
|
||||||
|
type ConfigView struct {
|
||||||
|
p Paths
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[string]Item
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfigView is the view as last written through, or empty.
|
||||||
|
func NewConfigView(p Paths) *ConfigView {
|
||||||
|
v := &ConfigView{p: p, items: map[string]Item{}}
|
||||||
|
_ = readJSON(p.config(), &v.items)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies says whether a key is this node's to take.
|
||||||
|
func (v *ConfigView) Applies(key string) bool {
|
||||||
|
scope, node, _, _, ok := ParseKey(key)
|
||||||
|
return ok && (scope == ScopeMesh || node == v.p.Node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take takes one change, and answers whether what applies to this node changed.
|
||||||
|
func (v *ConfigView) Take(key, op string, item *Item) bool {
|
||||||
|
if !v.Applies(key) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, node, _, _, _ := ParseKey(key)
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
// Only an item that is what its key says: a key decides which nodes take an item, the item where it
|
||||||
|
// lands, and the two must agree — a mesh key holding a home item would land in every home.
|
||||||
|
if op == "put" && item != nil && item.Problem() == "" && item.Key(node) == key {
|
||||||
|
v.items[key] = *item
|
||||||
|
} else {
|
||||||
|
delete(v.items, key)
|
||||||
|
}
|
||||||
|
return v.writeThroughLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune drops what the view holds and the state no longer does: a registration removed while this node
|
||||||
|
// was away is never handed over by the watch, which hands over what is there, not what went.
|
||||||
|
func (v *ConfigView) Prune(present []string) bool {
|
||||||
|
keep := map[string]bool{}
|
||||||
|
for _, k := range present {
|
||||||
|
keep[k] = true
|
||||||
|
}
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
for k := range v.items {
|
||||||
|
if !keep[k] {
|
||||||
|
delete(v.items, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v.writeThroughLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items is what applies here, by key.
|
||||||
|
func (v *ConfigView) Items() map[string]Item {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
out := make(map[string]Item, len(v.items))
|
||||||
|
for k, it := range v.items {
|
||||||
|
out[k] = it
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeThroughLocked writes the view to its file, with v.mu held: the snapshot and the write are one step, so
|
||||||
|
// an older snapshot never lands after a newer one.
|
||||||
|
func (v *ConfigView) writeThroughLocked() bool {
|
||||||
|
now, _ := indented(v.items)
|
||||||
|
before, _ := os.ReadFile(v.p.config())
|
||||||
|
if string(before) == string(now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(v.p.config(), now, 0o600)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is what applies to this node, sorted for rendering.
|
||||||
|
type Config struct {
|
||||||
|
Mesh, Node, Home []Item
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigOf sorts the items that apply here by scope, each scope by kind and name.
|
||||||
|
func ConfigOf(items map[string]Item) Config {
|
||||||
|
var c Config
|
||||||
|
for _, it := range items {
|
||||||
|
switch it.Scope {
|
||||||
|
case ScopeMesh:
|
||||||
|
c.Mesh = append(c.Mesh, it)
|
||||||
|
case ScopeNode:
|
||||||
|
c.Node = append(c.Node, it)
|
||||||
|
case ScopeHome:
|
||||||
|
c.Home = append(c.Home, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, list := range [][]Item{c.Mesh, c.Node, c.Home} {
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
if list[i].Kind != list[j].Kind {
|
||||||
|
return list[i].Kind < list[j].Kind
|
||||||
|
}
|
||||||
|
return list[i].Name < list[j].Name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rendering --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// PluginFile is one file of the marketplace: its content and whether it is run.
|
||||||
|
type PluginFile struct {
|
||||||
|
Content string
|
||||||
|
Executable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
|
||||||
|
// over a mesh item of the same kind and name: the node's wins.
|
||||||
|
func Marketplace(c Config) map[string]PluginFile {
|
||||||
|
root := "plugins/" + Plugin + "/"
|
||||||
|
out := map[string]PluginFile{
|
||||||
|
".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{
|
||||||
|
"name": Plugin,
|
||||||
|
"owner": map[string]any{"name": "the mesh"},
|
||||||
|
"plugins": []any{map[string]any{"name": Plugin, "source": "./plugins/" + Plugin,
|
||||||
|
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand."}},
|
||||||
|
})},
|
||||||
|
root + ".claude-plugin/plugin.json": {Content: jsonFile(map[string]any{
|
||||||
|
"name": Plugin, "version": "1.0.0",
|
||||||
|
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand.",
|
||||||
|
"author": map[string]any{"name": "the mesh"},
|
||||||
|
})},
|
||||||
|
}
|
||||||
|
chosen := map[string]Item{}
|
||||||
|
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||||
|
for _, it := range layer {
|
||||||
|
chosen[it.Kind+"/"+it.Name] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(chosen))
|
||||||
|
for k := range chosen {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
hooks := map[string][]any{}
|
||||||
|
for _, k := range keys {
|
||||||
|
it := chosen[k]
|
||||||
|
switch it.Kind {
|
||||||
|
case KindSkill:
|
||||||
|
for rel, content := range it.Files {
|
||||||
|
out[root+"skills/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: isScript(rel, content)}
|
||||||
|
}
|
||||||
|
case KindAgent:
|
||||||
|
out[root+"agents/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||||
|
case KindCommand:
|
||||||
|
out[root+"commands/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||||
|
case KindOutputStyle:
|
||||||
|
out[root+"output-styles/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||||
|
case KindHook:
|
||||||
|
for rel, content := range it.Files {
|
||||||
|
out[root+"hooks/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: true}
|
||||||
|
}
|
||||||
|
command := strings.ReplaceAll(it.Command, "${HOOK_DIR}", `"${CLAUDE_PLUGIN_ROOT}/hooks/`+it.Name+`"`)
|
||||||
|
entry := map[string]any{"hooks": []any{map[string]any{"type": "command", "command": command}}}
|
||||||
|
if it.Matcher != "" {
|
||||||
|
entry["matcher"] = it.Matcher
|
||||||
|
}
|
||||||
|
hooks[it.Event] = append(hooks[it.Event], entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hooks) > 0 {
|
||||||
|
out[root+"hooks/hooks.json"] = PluginFile{Content: jsonFile(map[string]any{"hooks": hooks})}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isScript(rel, content string) bool {
|
||||||
|
return strings.HasPrefix(content, "#!") || strings.HasSuffix(rel, ".sh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarketplaceKeys are the two managed settings keys that name the marketplace and enable the plugin.
|
||||||
|
func MarketplaceKeys() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"extraKnownMarketplaces": map[string]any{Plugin: map[string]any{
|
||||||
|
"source": map[string]any{"source": "directory", "path": filepath.Join(ManagedDir, MarketplaceDir)}}},
|
||||||
|
"enabledPlugins": map[string]any{Plugin + "@" + Plugin: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredSettings is the settings registered for the mesh, with those registered for this node laid over.
|
||||||
|
func RegisteredSettings(c Config) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||||
|
for _, it := range layer {
|
||||||
|
if it.Kind == KindSettings {
|
||||||
|
out = mergeSettings(out, it.Settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeSettings lays b over a: objects are merged key by key, lists are joined without repeats (a
|
||||||
|
// permission rule or an auto-mode rule added for a node adds to the mesh's), and anything else is b's.
|
||||||
|
func mergeSettings(a, b map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for k, v := range a {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range b {
|
||||||
|
switch nb := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if na, ok := out[k].(map[string]any); ok {
|
||||||
|
out[k] = mergeSettings(na, nb)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
if la, ok := out[k].([]any); ok {
|
||||||
|
joined := append([]any{}, la...)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, x := range la {
|
||||||
|
raw, _ := json.Marshal(x)
|
||||||
|
seen[string(raw)] = true
|
||||||
|
}
|
||||||
|
for _, x := range nb {
|
||||||
|
raw, _ := json.Marshal(x)
|
||||||
|
if !seen[string(raw)] {
|
||||||
|
seen[string(raw)] = true
|
||||||
|
joined = append(joined, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[k] = joined
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstructionSections is what the managed instruction file adds after the mesh's own text: the sections
|
||||||
|
// registered for the mesh, then those for this node.
|
||||||
|
func InstructionSections(c Config) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, part := range []struct {
|
||||||
|
title string
|
||||||
|
items []Item
|
||||||
|
}{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} {
|
||||||
|
var sections []Item
|
||||||
|
for _, it := range part.items {
|
||||||
|
if it.Kind == KindInstructions {
|
||||||
|
sections = append(sections, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sections) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title)
|
||||||
|
for _, it := range sections {
|
||||||
|
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomeFiles is what the home scope places in the operator account's agent directory, by path relative to
|
||||||
|
// that directory.
|
||||||
|
func HomeFiles(c Config) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, it := range c.Home {
|
||||||
|
switch it.Kind {
|
||||||
|
case KindSkill:
|
||||||
|
for rel, content := range it.Files {
|
||||||
|
out["skills/"+it.Name+"/"+rel] = content
|
||||||
|
}
|
||||||
|
case KindAgent:
|
||||||
|
out["agents/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||||
|
case KindCommand:
|
||||||
|
out["commands/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||||
|
case KindOutputStyle:
|
||||||
|
out["output-styles/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||||
|
case KindInstructions:
|
||||||
|
out["rules/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the home ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Placed is what this module placed in the home, by path relative to the agent directory, with the digest
|
||||||
|
// of what it wrote (ADR 0182: the mesh owns what it places, and only that).
|
||||||
|
type Placed map[string]string
|
||||||
|
|
||||||
|
func digest(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletedByHand marks, in the record, a path the mesh placed and the person then deleted: their choice,
|
||||||
|
// kept until the item is unregistered.
|
||||||
|
const deletedByHand = "deleted-by-hand"
|
||||||
|
|
||||||
|
// PlaceHome brings the home in line with what the home scope wants (ADR 0182): it writes what is wanted and
|
||||||
|
// absent, or its own; it never writes a path the person made, nor through a directory that is a symbolic
|
||||||
|
// link; it removes what it placed and is no longer wanted, unless the person changed it since; and a file it
|
||||||
|
// placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what
|
||||||
|
// it left alone, and why.
|
||||||
|
func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||||
|
dir := filepath.Join(p.Home, ".claude")
|
||||||
|
var placed Placed
|
||||||
|
if !readJSON(p.placed(), &placed) {
|
||||||
|
placed = Placed{}
|
||||||
|
}
|
||||||
|
paths := make([]string, 0, len(want))
|
||||||
|
for rel := range want {
|
||||||
|
paths = append(paths, rel)
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
for _, rel := range paths {
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||||
|
content := want[rel]
|
||||||
|
if why := linkedParent(dir, rel); why != "" {
|
||||||
|
left = append(left, rel+": "+why+", left alone")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current, err := os.ReadFile(full)
|
||||||
|
exists := err == nil
|
||||||
|
ours, wasPlaced := placed[rel]
|
||||||
|
switch {
|
||||||
|
case !exists && wasPlaced && ours == deletedByHand:
|
||||||
|
continue
|
||||||
|
case !exists && wasPlaced:
|
||||||
|
placed[rel] = deletedByHand
|
||||||
|
left = append(left, rel+": deleted by hand, left deleted until the item is unregistered")
|
||||||
|
continue
|
||||||
|
case exists && !wasPlaced:
|
||||||
|
left = append(left, rel+": the person's own, left alone")
|
||||||
|
continue
|
||||||
|
case exists && ours == deletedByHand:
|
||||||
|
left = append(left, rel+": made again by hand after the mesh's was deleted, left alone")
|
||||||
|
continue
|
||||||
|
case exists && string(current) == content:
|
||||||
|
continue
|
||||||
|
case exists && digest(string(current)) != ours:
|
||||||
|
left = append(left, rel+": changed by hand since it was placed, left alone")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
left = append(left, rel+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
if isScript(rel, content) {
|
||||||
|
mode = 0o755
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||||
|
left = append(left, rel+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placed[rel] = digest(content)
|
||||||
|
done = append(done, rel+": placed")
|
||||||
|
}
|
||||||
|
for rel, ours := range placed {
|
||||||
|
if _, wanted := want[rel]; wanted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||||
|
if ours == deletedByHand {
|
||||||
|
delete(placed, rel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if why := linkedParent(dir, rel); why != "" {
|
||||||
|
left = append(left, rel+": no longer registered, but "+why+", left alone")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current, err := os.ReadFile(full)
|
||||||
|
if err == nil && digest(string(current)) != ours {
|
||||||
|
left = append(left, rel+": no longer registered, but changed by hand, left alone")
|
||||||
|
delete(placed, rel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||||
|
left = append(left, rel+": no longer registered, and could not be removed: "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Up to the kind's own directory, never it: `skills/<name>` goes when empty, `skills` stays.
|
||||||
|
removeEmptyParents(filepath.Join(dir, strings.SplitN(rel, "/", 2)[0]), filepath.Dir(full))
|
||||||
|
delete(placed, rel)
|
||||||
|
done = append(done, rel+": removed")
|
||||||
|
}
|
||||||
|
raw, _ := indented(placed)
|
||||||
|
if err := os.WriteFile(p.placed(), raw, 0o600); err != nil {
|
||||||
|
left = append(left, "the record of what was placed could not be saved: "+err.Error())
|
||||||
|
}
|
||||||
|
return done, left
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkedParent says, when a directory between the agent directory and a file is a symbolic link, which one:
|
||||||
|
// writing through it would write wherever it points.
|
||||||
|
func linkedParent(dir, rel string) string {
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
at := dir
|
||||||
|
for _, seg := range parts[:len(parts)-1] {
|
||||||
|
at = filepath.Join(at, seg)
|
||||||
|
if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return at + " is a symbolic link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeEmptyParents removes empty directories from dir up to, not including, root.
|
||||||
|
func removeEmptyParents(root, dir string) {
|
||||||
|
for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) {
|
||||||
|
if err := os.Remove(dir); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir = filepath.Dir(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HomeConflict says whether registering an item at the home scope here would collide with something the
|
||||||
|
// person made: "" when it would not.
|
||||||
|
func HomeConflict(p Paths, it Item) string {
|
||||||
|
var placed Placed
|
||||||
|
_ = readJSON(p.placed(), &placed)
|
||||||
|
for rel := range HomeFiles(Config{Home: []Item{it}}) {
|
||||||
|
if _, ours := placed[rel]; ours {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil {
|
||||||
|
return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- what the module did not place ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// HomeItem is one item found in the home.
|
||||||
|
type HomeItem struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Placed bool `json:"placedByTheMesh"`
|
||||||
|
Notes []string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`)
|
||||||
|
|
||||||
|
// HomeItems lists the home's skills, subagents, commands, output styles and rule files, says which the mesh
|
||||||
|
// placed, and notes those that share a name with a mesh item or call a tool server that is not loaded here.
|
||||||
|
func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||||
|
dir := filepath.Join(p.Home, ".claude")
|
||||||
|
var placed Placed
|
||||||
|
_ = readJSON(p.placed(), &placed)
|
||||||
|
inPlugin := map[string]bool{}
|
||||||
|
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||||
|
for _, it := range layer {
|
||||||
|
inPlugin[it.Kind+"/"+it.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []HomeItem
|
||||||
|
add := func(kind, name, rel, body string) {
|
||||||
|
h := HomeItem{Kind: kind, Name: name, Path: filepath.Join(dir, filepath.FromSlash(rel))}
|
||||||
|
_, h.Placed = placed[rel]
|
||||||
|
if inPlugin[kind+"/"+name] {
|
||||||
|
h.Notes = append(h.Notes, "the mesh also registers a "+kind+" of this name, offered as "+Plugin+":"+name)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range mcpToolRef.FindAllStringSubmatch(body, -1) {
|
||||||
|
if server := m[1]; !seen[server] && server != meshEntry && loaded[server] == nil {
|
||||||
|
seen[server] = true
|
||||||
|
h.Notes = append(h.Notes, "calls tools of `"+server+"`, a tool server not loaded on this node")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md"))
|
||||||
|
if err != nil {
|
||||||
|
continue // the vendor's own synced folders carry none at their top
|
||||||
|
}
|
||||||
|
add(KindSkill, e.Name(), "skills/"+e.Name()+"/SKILL.md", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name()))
|
||||||
|
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Kind != out[j].Kind {
|
||||||
|
return out[i].Kind < out[j].Kind
|
||||||
|
}
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFromHome reads one item of this node's home as an item to register. A rule file becomes instructions.
|
||||||
|
func ImportFromHome(p Paths, kind, name string) (Item, error) {
|
||||||
|
dir := filepath.Join(p.Home, ".claude")
|
||||||
|
it := Item{Kind: kind, Name: name, Files: map[string]string{}}
|
||||||
|
switch kind {
|
||||||
|
case KindSkill:
|
||||||
|
root := filepath.Join(dir, "skills", name)
|
||||||
|
err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(root, full)
|
||||||
|
raw, err := os.ReadFile(full)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
it.Files[filepath.ToSlash(rel)] = string(raw)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Item{}, fmt.Errorf("the skill %s in this home: %w", name, err)
|
||||||
|
}
|
||||||
|
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||||
|
sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind]
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md"))
|
||||||
|
if err != nil {
|
||||||
|
return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err)
|
||||||
|
}
|
||||||
|
it.Files[name+".md"] = string(raw)
|
||||||
|
default:
|
||||||
|
return Item{}, fmt.Errorf("a %s is not imported from a home", kind)
|
||||||
|
}
|
||||||
|
return it, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- registering ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ConfigState is the `config` state as this module reaches it through the runtime.
|
||||||
|
type ConfigState interface {
|
||||||
|
Put(key string, value any) error
|
||||||
|
Delete(key string) error
|
||||||
|
Keys() ([]string, error)
|
||||||
|
Get(key string) (json.RawMessage, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the
|
||||||
|
// node and home scopes one key per node — this node when none is given. Taken into this node's view at once,
|
||||||
|
// so the answer says what it did here.
|
||||||
|
func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) {
|
||||||
|
if !unregister {
|
||||||
|
it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if problem := it.Problem(); problem != "" {
|
||||||
|
return map[string]any{"registered": false, "reason": problem}, nil
|
||||||
|
}
|
||||||
|
} else if !knownKind(it.Kind) {
|
||||||
|
return map[string]any{"unregistered": false, "reason": fmt.Sprintf("%q is not a kind", it.Kind)}, nil
|
||||||
|
}
|
||||||
|
if it.Scope == ScopeMesh {
|
||||||
|
nodes = []string{""}
|
||||||
|
} else if len(nodes) == 0 {
|
||||||
|
nodes = []string{p.Node}
|
||||||
|
} else if problem := nodesProblem(nodes); problem != "" {
|
||||||
|
return map[string]any{verbOf(unregister): false, "reason": problem}, nil
|
||||||
|
}
|
||||||
|
if !unregister && it.Scope == ScopeHome {
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n == p.Node {
|
||||||
|
if conflict := HomeConflict(p, it); conflict != "" {
|
||||||
|
return map[string]any{"registered": false, "reason": conflict}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Compared before and after rather than read from Take: this node's own watch may take the same change
|
||||||
|
// first, and then Take here finds nothing new although this call made it.
|
||||||
|
before, _ := json.Marshal(v.Items())
|
||||||
|
var keys []string
|
||||||
|
for _, n := range nodes {
|
||||||
|
key := it.Key(n)
|
||||||
|
keys = append(keys, key)
|
||||||
|
var err error
|
||||||
|
if unregister {
|
||||||
|
err = state.Delete(key)
|
||||||
|
} else {
|
||||||
|
err = state.Put(key, it)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
op := "put"
|
||||||
|
if unregister {
|
||||||
|
op = "delete"
|
||||||
|
}
|
||||||
|
item := it
|
||||||
|
v.Take(key, op, &item)
|
||||||
|
}
|
||||||
|
after, _ := json.Marshal(v.Items())
|
||||||
|
here := string(before) != string(after)
|
||||||
|
for _, k := range keys {
|
||||||
|
here = here || v.Applies(k)
|
||||||
|
}
|
||||||
|
verb := map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||||
|
answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys}
|
||||||
|
if !unregister {
|
||||||
|
switch {
|
||||||
|
case it.Scope == ScopeHome && it.Kind == KindCommand:
|
||||||
|
answer["offered as"] = "/" + it.Name
|
||||||
|
case it.Scope == ScopeHome:
|
||||||
|
answer["offered as"] = it.Name + ", from the home"
|
||||||
|
case it.Kind == KindSkill || it.Kind == KindAgent || it.Kind == KindOutputStyle:
|
||||||
|
answer["offered as"] = Plugin + ":" + it.Name
|
||||||
|
case it.Kind == KindCommand:
|
||||||
|
answer["offered as"] = "/" + Plugin + ":" + it.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if here {
|
||||||
|
// Rendered whenever it applies here, so the answer says what this node wrote, whichever of the
|
||||||
|
// watch and this call took the change.
|
||||||
|
rendered, err := RenderNow(p, write)
|
||||||
|
answer["rendered here"] = rendered
|
||||||
|
if err != nil {
|
||||||
|
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
|
||||||
|
}
|
||||||
|
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
|
||||||
|
} else {
|
||||||
|
answer["here"] = "not this node: each node it is for takes it from the bus"
|
||||||
|
}
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verbOf(unregister bool) string {
|
||||||
|
return map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodesProblem says why a list of nodes cannot name keys, or "". "all" has been expanded before this.
|
||||||
|
func nodesProblem(nodes []string) string {
|
||||||
|
for _, n := range nodes {
|
||||||
|
if !nodeName.MatchString(n) {
|
||||||
|
return fmt.Sprintf("%q is not a node's name", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandNodes turns `all` into every node running the module; anything else is kept.
|
||||||
|
func ExpandNodes(nodes []string, running func() ([]string, error)) ([]string, error) {
|
||||||
|
if len(nodes) != 1 || nodes[0] != "all" {
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
all, err := running()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("which nodes run claude-code: %w", err)
|
||||||
|
}
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, fmt.Errorf("no node is known to run claude-code")
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state.
|
||||||
|
func List(state ConfigState, kind string) (map[string]any, error) {
|
||||||
|
keys, err := state.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
out := map[string]any{}
|
||||||
|
for _, key := range keys {
|
||||||
|
_, _, k, _, ok := ParseKey(key)
|
||||||
|
if !ok || (kind != "" && k != kind) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, found, err := state.Get(key)
|
||||||
|
if err != nil || !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var it Item
|
||||||
|
if json.Unmarshal(raw, &it) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files := make([]string, 0, len(it.Files))
|
||||||
|
for f := range it.Files {
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
summary := map[string]any{"by": it.By, "at": it.At}
|
||||||
|
if len(files) > 0 {
|
||||||
|
summary["files"] = files
|
||||||
|
}
|
||||||
|
if it.Kind == KindHook {
|
||||||
|
summary["event"], summary["matcher"], summary["command"] = it.Event, it.Matcher, it.Command
|
||||||
|
}
|
||||||
|
if it.Kind == KindSettings {
|
||||||
|
summary["settings"] = it.Settings
|
||||||
|
}
|
||||||
|
out[key] = summary
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show is one registration in full: its files' content included.
|
||||||
|
func Show(state ConfigState, key string) (any, error) {
|
||||||
|
raw, found, err := state.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("nothing is registered at %s", key)
|
||||||
|
}
|
||||||
|
var it Item
|
||||||
|
if err := json.Unmarshal(raw, &it); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return it, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The agent's configuration registered at three scopes (novox/hq ADR 0216): each test is one row of the
|
||||||
|
// record's "How it is checked".
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memConfig is the `config` state as a map, shared by the nodes of a test the way the bus shares it.
|
||||||
|
type memConfig map[string]json.RawMessage
|
||||||
|
|
||||||
|
func (m memConfig) Put(key string, value any) error {
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
m[key] = raw
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (m memConfig) Delete(key string) error { delete(m, key); return nil }
|
||||||
|
func (m memConfig) Keys() ([]string, error) {
|
||||||
|
out := []string{}
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (m memConfig) Get(key string) (json.RawMessage, bool, error) {
|
||||||
|
raw, ok := m[key]
|
||||||
|
return raw, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver hands every key of the state to a node's view, as its watch would.
|
||||||
|
func deliver(m memConfig, v *ConfigView) {
|
||||||
|
for key, raw := range m {
|
||||||
|
var it Item
|
||||||
|
_ = json.Unmarshal(raw, &it)
|
||||||
|
v.Take(key, "put", &it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func one(name, content string) map[string]string { return map[string]string{name + ".md": content} }
|
||||||
|
|
||||||
|
func pluginOf(t *testing.T, w map[string]string) map[string]PluginFile {
|
||||||
|
t.Helper()
|
||||||
|
var files map[string]PluginFile
|
||||||
|
if err := json.Unmarshal([]byte(w[MarketplaceDir+"/"]), &files); err != nil {
|
||||||
|
t.Fatalf("no marketplace written: %v", err)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachKindLandsInItsOnePlace(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
state, view := memConfig{}, NewConfigView(p)
|
||||||
|
register := func(it Item) {
|
||||||
|
t.Helper()
|
||||||
|
answer, err := Register(p, it, nil, false, state, view, writer(w))
|
||||||
|
if err != nil || answer["registered"] == false {
|
||||||
|
t.Fatalf("%s %s: %v %v", it.Kind, it.Name, answer, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
register(Item{Kind: KindSkill, Name: "review", Scope: ScopeMesh,
|
||||||
|
Files: map[string]string{"SKILL.md": "---\nname: review\ndescription: d\n---\nbody", "scripts/run.sh": "#!/bin/sh\necho hi\n"}})
|
||||||
|
register(Item{Kind: KindAgent, Name: "reviewer", Scope: ScopeMesh, Files: one("reviewer", "---\nname: reviewer\n---\nx")})
|
||||||
|
register(Item{Kind: KindCommand, Name: "ship", Scope: ScopeMesh, Files: one("ship", "ship it")})
|
||||||
|
register(Item{Kind: KindOutputStyle, Name: "terse", Scope: ScopeMesh, Files: one("terse", "---\nname: terse\n---\nshort")})
|
||||||
|
register(Item{Kind: KindHook, Name: "guard", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
|
||||||
|
Command: "${HOOK_DIR}/guard.sh", Files: map[string]string{"guard.sh": "#!/bin/sh\nexit 0\n"}})
|
||||||
|
register(Item{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: one("conventions", "Commit in the imperative.")})
|
||||||
|
register(Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh,
|
||||||
|
Settings: map[string]any{"permissions": map[string]any{"deny": []any{"Bash(rm -rf:*)"}}}})
|
||||||
|
|
||||||
|
plugin := pluginOf(t, w)
|
||||||
|
root := "plugins/" + Plugin + "/"
|
||||||
|
for _, want := range []string{".claude-plugin/marketplace.json", root + ".claude-plugin/plugin.json",
|
||||||
|
root + "skills/review/SKILL.md", root + "skills/review/scripts/run.sh", root + "agents/reviewer.md",
|
||||||
|
root + "commands/ship.md", root + "output-styles/terse.md", root + "hooks/hooks.json", root + "hooks/guard/guard.sh"} {
|
||||||
|
if _, ok := plugin[want]; !ok {
|
||||||
|
t.Errorf("the plugin lacks %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !plugin[root+"skills/review/scripts/run.sh"].Executable || !plugin[root+"hooks/guard/guard.sh"].Executable {
|
||||||
|
t.Error("a script is not executable")
|
||||||
|
}
|
||||||
|
var hooks struct {
|
||||||
|
Hooks map[string][]struct {
|
||||||
|
Matcher string `json:"matcher"`
|
||||||
|
Hooks []struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
} `json:"hooks"`
|
||||||
|
} `json:"hooks"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks)
|
||||||
|
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 1 || pre[0].Matcher != "Bash" ||
|
||||||
|
pre[0].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
|
||||||
|
t.Errorf("the hook's command does not name its directory, quoted: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.") {
|
||||||
|
t.Errorf("the instruction section is not in the managed instruction file:\n%s", w["CLAUDE.md"])
|
||||||
|
}
|
||||||
|
var managed map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(w["managed-settings.json"]), &managed)
|
||||||
|
if managed["permissions"] == nil || managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
|
||||||
|
t.Errorf("managed settings: %v", managed)
|
||||||
|
}
|
||||||
|
if _, inPlugin := plugin[root+"settings.json"]; inPlugin {
|
||||||
|
t.Error("settings were put in the plugin, which drops them")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T) {
|
||||||
|
a, wa := node(t, "laptop")
|
||||||
|
b, wb := node(t, "server")
|
||||||
|
state := memConfig{}
|
||||||
|
va, vb := NewConfigView(a), NewConfigView(b)
|
||||||
|
for _, r := range []struct {
|
||||||
|
it Item
|
||||||
|
nodes []string
|
||||||
|
}{
|
||||||
|
{Item{Kind: KindCommand, Name: "everywhere", Scope: ScopeMesh, Files: one("everywhere", "x")}, nil},
|
||||||
|
{Item{Kind: KindCommand, Name: "server-only", Scope: ScopeNode, Files: one("server-only", "x")}, []string{"server"}},
|
||||||
|
{Item{Kind: KindCommand, Name: "at-home", Scope: ScopeHome, Files: one("at-home", "x")}, []string{"laptop"}},
|
||||||
|
} {
|
||||||
|
if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deliver(state, vb)
|
||||||
|
if _, err := RenderNow(b, writer(wb)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root := "plugins/" + Plugin + "/commands/"
|
||||||
|
pa, pb := pluginOf(t, wa), pluginOf(t, wb)
|
||||||
|
if _, ok := pa[root+"everywhere.md"]; !ok {
|
||||||
|
t.Error("the mesh item is missing on the laptop")
|
||||||
|
}
|
||||||
|
if _, ok := pb[root+"everywhere.md"]; !ok {
|
||||||
|
t.Error("the mesh item is missing on the server")
|
||||||
|
}
|
||||||
|
if _, ok := pa[root+"server-only.md"]; ok {
|
||||||
|
t.Error("the server's item reached the laptop")
|
||||||
|
}
|
||||||
|
if _, ok := pb[root+"server-only.md"]; !ok {
|
||||||
|
t.Error("the server's item is missing on the server")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(a.Home, ".claude", "commands", "at-home.md")); err != nil {
|
||||||
|
t.Error("the home item was not placed in the laptop's home")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(b.Home, ".claude", "commands", "at-home.md")); err == nil {
|
||||||
|
t.Error("the laptop's home item reached the server's home")
|
||||||
|
}
|
||||||
|
if _, ok := pa[root+"at-home.md"]; ok {
|
||||||
|
t.Error("a home item went into the plugin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMeshsOwnKeysCannotBeSetOrReplaced(t *testing.T) {
|
||||||
|
for _, key := range []string{"extraKnownMarketplaces", "enabledPlugins", "attribution", "apiKeyHelper"} {
|
||||||
|
it := Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{key: true}}
|
||||||
|
if it.Problem() == "" {
|
||||||
|
t.Errorf("a registration could set %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"enabledPlugins": map[string]any{Plugin + "@" + Plugin: false}}},
|
||||||
|
nil, "/h", nil, Config{})
|
||||||
|
var managed map[string]any
|
||||||
|
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||||
|
if managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
|
||||||
|
t.Error("the operator's setting turned the mesh's plugin off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsLayMeshThenNodeAndJoinTheirLists(t *testing.T) {
|
||||||
|
c := Config{
|
||||||
|
Mesh: []Item{{Kind: KindSettings, Scope: ScopeMesh, Settings: map[string]any{
|
||||||
|
"permissions": map[string]any{"deny": []any{"A"}}, "model": "mesh-model"}}},
|
||||||
|
Node: []Item{{Kind: KindSettings, Scope: ScopeNode, Settings: map[string]any{
|
||||||
|
"permissions": map[string]any{"deny": []any{"A", "B"}}, "model": "node-model"}}},
|
||||||
|
}
|
||||||
|
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"permissions": map[string]any{"allow": []any{"C"}}}},
|
||||||
|
nil, "/h", nil, c)
|
||||||
|
var managed struct {
|
||||||
|
Permissions map[string][]string `json:"permissions"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||||
|
if strings.Join(managed.Permissions["deny"], ",") != "A,B" || strings.Join(managed.Permissions["allow"], ",") != "C" || managed.Model != "node-model" {
|
||||||
|
t.Fatalf("%+v", managed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHomeScopeOwnsOnlyWhatItPlaced(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
state, view := memConfig{}, NewConfigView(p)
|
||||||
|
mine := filepath.Join(p.Home, ".claude", "agents", "mine.md")
|
||||||
|
_ = os.MkdirAll(filepath.Dir(mine), 0o755)
|
||||||
|
writeFile(t, mine, "the person's own")
|
||||||
|
|
||||||
|
answer, _ := Register(p, Item{Kind: KindAgent, Name: "mine", Scope: ScopeHome, Files: one("mine", "the mesh's")}, nil, false, state, view, writer(w))
|
||||||
|
if answer["registered"] != false {
|
||||||
|
t.Fatalf("a name the person uses was taken: %v", answer)
|
||||||
|
}
|
||||||
|
if raw, _ := os.ReadFile(mine); string(raw) != "the person's own" {
|
||||||
|
t.Fatal("the person's file was overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
placed := filepath.Join(p.Home, ".claude", "agents", "placed.md")
|
||||||
|
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if raw, _ := os.ReadFile(placed); string(raw) != "v1" {
|
||||||
|
t.Fatal("the home item was not placed")
|
||||||
|
}
|
||||||
|
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(placed); err == nil {
|
||||||
|
t.Fatal("unregistering did not remove what the mesh placed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(mine); err != nil {
|
||||||
|
t.Fatal("unregistering removed the person's file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed by hand after it was placed: left alone when unregistered.
|
||||||
|
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome, Files: one("edited", "v1")}, nil, false, state, view, writer(w)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
edited := filepath.Join(p.Home, ".claude", "agents", "edited.md")
|
||||||
|
writeFile(t, edited, "changed by hand")
|
||||||
|
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if raw, _ := os.ReadFile(edited); string(raw) != "changed by hand" {
|
||||||
|
t.Fatal("a placed file changed by hand was removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnItemAboveTheLimitOrMisshapenIsRefused(t *testing.T) {
|
||||||
|
big := Item{Kind: KindSkill, Name: "big", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": strings.Repeat("x", MaxItemBytes+1)}}
|
||||||
|
cases := map[string]Item{
|
||||||
|
"too big": big,
|
||||||
|
"a bad name": {Kind: KindAgent, Name: "Bad Name", Scope: ScopeMesh, Files: one("Bad Name", "x")},
|
||||||
|
"a skill without one": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"other.md": "x"}},
|
||||||
|
"a path outside": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": "x", "../escape": "x"}},
|
||||||
|
"a hook at home": {Kind: KindHook, Name: "h", Scope: ScopeHome, Event: "Stop", Command: "true"},
|
||||||
|
"settings at home": {Kind: KindSettings, Name: SettingsName, Scope: ScopeHome, Settings: map[string]any{"model": "x"}},
|
||||||
|
"an unknown event": {Kind: KindHook, Name: "h", Scope: ScopeMesh, Event: "Whenever", Command: "true"},
|
||||||
|
}
|
||||||
|
for label, it := range cases {
|
||||||
|
if it.Problem() == "" {
|
||||||
|
t.Errorf("%s was accepted", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) {
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
dir := filepath.Join(p.Home, ".claude")
|
||||||
|
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||||
|
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\nuse mcp__gone__do_it")
|
||||||
|
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||||
|
it, err := ImportFromHome(p, KindSkill, "old")
|
||||||
|
if err != nil || len(it.Files) != 2 || it.Files["scripts/a.sh"] == "" {
|
||||||
|
t.Fatalf("%+v %v", it, err)
|
||||||
|
}
|
||||||
|
items := HomeItems(p, Config{Mesh: []Item{{Kind: KindSkill, Name: "old"}}}, Servers{})
|
||||||
|
if len(items) != 1 || len(items[0].Notes) != 2 {
|
||||||
|
t.Fatalf("%+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced.
|
||||||
|
func TestTheManifestListsEveryToolServed(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &m)
|
||||||
|
listed := map[string]bool{}
|
||||||
|
for _, n := range m.Tools {
|
||||||
|
listed[n] = true
|
||||||
|
}
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p))
|
||||||
|
for _, tool := range served {
|
||||||
|
if !listed[tool.Name] {
|
||||||
|
t.Errorf("%s is served and not in the manifest", tool.Name)
|
||||||
|
}
|
||||||
|
delete(listed, tool.Name)
|
||||||
|
}
|
||||||
|
for n := range listed {
|
||||||
|
t.Errorf("%s is in the manifest and not served", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tree writer's comparison: a directory holding exactly the files given, executable bits included.
|
||||||
|
func TestATreeIsTheSameOnlyWhenEveryFileAndModeIs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
files := map[string]PluginFile{"a.md": {Content: "a"}, "s/run.sh": {Content: "#!/bin/sh\n", Executable: true}}
|
||||||
|
_ = os.MkdirAll(filepath.Join(dir, "s"), 0o755)
|
||||||
|
writeFile(t, filepath.Join(dir, "a.md"), "a")
|
||||||
|
writeFile(t, filepath.Join(dir, "s", "run.sh"), "#!/bin/sh\n")
|
||||||
|
if sameTree(dir, files) {
|
||||||
|
t.Fatal("a script without its executable bit counted as the same")
|
||||||
|
}
|
||||||
|
_ = os.Chmod(filepath.Join(dir, "s", "run.sh"), 0o755)
|
||||||
|
if !sameTree(dir, files) {
|
||||||
|
t.Fatal("the same tree counted as different")
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "extra.md"), "x")
|
||||||
|
if sameTree(dir, files) {
|
||||||
|
t.Fatal("an extra file counted as the same")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A registration removed while a node was away is dropped when it is back: the watch hands over only what is
|
||||||
|
// there, so the view is pruned to the keys the state still holds.
|
||||||
|
func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
state, view := memConfig{}, NewConfigView(p)
|
||||||
|
for _, name := range []string{"kept", "gone"} {
|
||||||
|
if _, err := Register(p, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}, nil, false, state, view, writer(w)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(state, "mesh.command.gone") // removed from another node while this one was off
|
||||||
|
back := NewConfigView(p) // the node starts again from what it last wrote
|
||||||
|
if len(back.Items()) != 2 {
|
||||||
|
t.Fatalf("the view did not start from what was last written: %v", back.Items())
|
||||||
|
}
|
||||||
|
keys, _ := state.Keys()
|
||||||
|
if !back.Prune(keys) || len(back.Items()) != 1 {
|
||||||
|
t.Fatalf("after pruning: %v", back.Items())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The review's findings, each held by a test.
|
||||||
|
|
||||||
|
func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
|
||||||
|
for label, files := range map[string]map[string]string{
|
||||||
|
"a dot": {"SKILL.md": "x", ".": "x"},
|
||||||
|
"an empty segment": {"SKILL.md": "x", "a//b": "x"},
|
||||||
|
"a parent segment": {"SKILL.md": "x", "a/../b": "x"},
|
||||||
|
"a file and directory": {"SKILL.md": "x", "a": "x", "a/b": "x"},
|
||||||
|
} {
|
||||||
|
if (Item{Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: files}).Problem() == "" {
|
||||||
|
t.Errorf("%s was accepted", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
v := NewConfigView(p)
|
||||||
|
home := Item{Kind: KindCommand, Name: "x", Scope: ScopeHome, Files: one("x", "y")}
|
||||||
|
if v.Take("mesh.command.x", "put", &home); len(v.Items()) != 0 {
|
||||||
|
t.Fatal("a mesh key holding a home item was taken")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"mesh.command.x", "mesh.agent.x"} {
|
||||||
|
mesh := Item{Kind: KindCommand, Name: "x", Scope: ScopeMesh, Files: one("x", "y")}
|
||||||
|
v.Take(key, "put", &mesh)
|
||||||
|
}
|
||||||
|
if len(v.Items()) != 1 {
|
||||||
|
t.Fatalf("an item under a key of another kind was taken: %v", v.Items())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
|
||||||
|
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
|
||||||
|
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
|
||||||
|
t.Fatalf("%v %v", nodes, err)
|
||||||
|
}
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
|
||||||
|
if answer["registered"] != false {
|
||||||
|
t.Fatalf("a node name with a dot was used in a key: %v", answer)
|
||||||
|
}
|
||||||
|
if (Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{"deniedMcpServers": []any{}}}).Problem() == "" {
|
||||||
|
t.Fatal("a registration could deny the mesh's console")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheOperatorsOwnPluginsAndMarketplacesAreKept(t *testing.T) {
|
||||||
|
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{
|
||||||
|
"enabledPlugins": map[string]any{"theirs@market": true},
|
||||||
|
"extraKnownMarketplaces": map[string]any{"market": map[string]any{"source": map[string]any{"source": "github", "repo": "o/r"}}},
|
||||||
|
}}, nil, "/h", nil, Config{})
|
||||||
|
var managed struct {
|
||||||
|
Enabled map[string]any `json:"enabledPlugins"`
|
||||||
|
Known map[string]any `json:"extraKnownMarketplaces"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||||
|
if managed.Enabled["theirs@market"] != true || managed.Enabled[Plugin+"@"+Plugin] != true || managed.Known["market"] == nil || managed.Known[Plugin] == nil {
|
||||||
|
t.Fatalf("%+v", managed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHomeLeavesThePersonsChoicesAlone(t *testing.T) {
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
dir := filepath.Join(p.Home, ".claude")
|
||||||
|
// An identical file the person made is not taken over, so unregistering never removes it.
|
||||||
|
_ = os.MkdirAll(filepath.Join(dir, "agents"), 0o755)
|
||||||
|
writeFile(t, filepath.Join(dir, "agents", "same.md"), "x")
|
||||||
|
if _, left := PlaceHome(p, map[string]string{"agents/same.md": "x"}); len(left) != 1 {
|
||||||
|
t.Fatalf("an identical file of the person's was taken over: %v", left)
|
||||||
|
}
|
||||||
|
PlaceHome(p, map[string]string{})
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "agents", "same.md")); err != nil {
|
||||||
|
t.Fatal("the person's file was removed")
|
||||||
|
}
|
||||||
|
// A placed file the person deleted stays deleted while it is registered.
|
||||||
|
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||||
|
_ = os.Remove(filepath.Join(dir, "commands", "c.md"))
|
||||||
|
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "commands", "c.md")); err == nil {
|
||||||
|
t.Fatal("a file the person deleted was placed again")
|
||||||
|
}
|
||||||
|
// The kind's own directory stays when the mesh's last item in it goes.
|
||||||
|
PlaceHome(p, map[string]string{"skills/s/SKILL.md": "x"})
|
||||||
|
PlaceHome(p, map[string]string{})
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); err == nil {
|
||||||
|
t.Fatal("the item's own directory was left")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "skills")); err != nil {
|
||||||
|
t.Fatal("the kind's directory was removed")
|
||||||
|
}
|
||||||
|
// Never through a symbolic link.
|
||||||
|
elsewhere := t.TempDir()
|
||||||
|
if err := os.Symlink(elsewhere, filepath.Join(dir, "output-styles")); err != nil {
|
||||||
|
t.Skip("no symbolic links here")
|
||||||
|
}
|
||||||
|
PlaceHome(p, map[string]string{"output-styles/o.md": "x"})
|
||||||
|
if _, err := os.Stat(filepath.Join(elsewhere, "o.md")); err == nil {
|
||||||
|
t.Fatal("a file was written through a symbolic link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
|
||||||
|
p, _ := node(t, "laptop")
|
||||||
|
w := map[string]string{}
|
||||||
|
failing := func(name, content string) (string, error) {
|
||||||
|
if name == MarketplaceDir+"/" {
|
||||||
|
return "", os.ErrPermission
|
||||||
|
}
|
||||||
|
w[name] = content
|
||||||
|
return name + ": written", nil
|
||||||
|
}
|
||||||
|
if _, err := RenderNow(p, failing); err == nil {
|
||||||
|
t.Fatal("the failure was not reported")
|
||||||
|
}
|
||||||
|
if w["managed-settings.json"] == "" || w["managed-mcp.json"] == "" || w["CLAUDE.md"] == "" {
|
||||||
|
t.Fatalf("the other files were not written: %v", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The answer says what this node wrote even when its own watch took the change first.
|
||||||
|
func TestTheAnswerSaysWhatWasWrittenWhenTheWatchWasFirst(t *testing.T) {
|
||||||
|
p, w := node(t, "laptop")
|
||||||
|
state, view := memConfig{}, NewConfigView(p)
|
||||||
|
it := Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}
|
||||||
|
first := it
|
||||||
|
view.Take(it.Key("laptop"), "put", &first) // the watch, first
|
||||||
|
answer, err := Register(p, it, nil, false, state, view, writer(w))
|
||||||
|
if err != nil || answer["rendered here"] == nil || answer["here"] != nil {
|
||||||
|
t.Fatalf("%v %v", answer, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The tools that register the agent's configuration (novox/hq ADR 0216): for each kind a list, a register and an
|
||||||
|
// unregister; for settings, a read, a set, a clear and the permission rules; and the status and import tools
|
||||||
|
// for what the module did not place.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kindTool is how one kind is named in its tools and described to whoever calls them.
|
||||||
|
type kindTool struct {
|
||||||
|
kind, tool, what, lands string
|
||||||
|
}
|
||||||
|
|
||||||
|
var kindTools = []kindTool{
|
||||||
|
{KindSkill, "skill", "a skill: a folder with a SKILL.md (front matter `name` and `description`) and any files beside it",
|
||||||
|
"the plugin's skills/<name>/ (home: ~/.claude/skills/<name>/)"},
|
||||||
|
{KindAgent, "agent", "a subagent: one markdown file with front matter (`name`, `description`, optionally `tools`, `model`)",
|
||||||
|
"the plugin's agents/<name>.md (home: ~/.claude/agents/<name>.md)"},
|
||||||
|
{KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)",
|
||||||
|
"the plugin's commands/<name>.md, run as /" + Plugin + ":<name> (home: ~/.claude/commands/<name>.md, run as /<name>)"},
|
||||||
|
{KindHook, "hook", "a hook: an event, an optional matcher, a command, and optionally the scripts it runs — ${HOOK_DIR} in the command is their directory",
|
||||||
|
"the plugin's hooks/hooks.json, its scripts in hooks/<name>/ (mesh and node scopes only)"},
|
||||||
|
{KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)",
|
||||||
|
"the plugin's output-styles/<name>.md (home: ~/.claude/output-styles/<name>.md)"},
|
||||||
|
{KindInstructions, "instructions", "a section of instructions every session reads",
|
||||||
|
"a section of the managed instruction file, after the mesh's own text (home: a rule file, ~/.claude/rules/<name>.md)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolArg(a map[string]any, k string) bool { b, _ := a[k].(bool); return b }
|
||||||
|
func strArg(a map[string]any, k string) string {
|
||||||
|
s, _ := a[k].(string)
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list.
|
||||||
|
func targetNodes(a map[string]any) ([]string, error) {
|
||||||
|
return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for.
|
||||||
|
func scopeOf(a map[string]any) string {
|
||||||
|
if s := strArg(a, "scope"); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ScopeMesh
|
||||||
|
}
|
||||||
|
|
||||||
|
// filesOf reads an item's files: `content` for a one-file kind, or `files`, a map of path to content.
|
||||||
|
func filesOf(kind, name string, a map[string]any) (map[string]string, error) {
|
||||||
|
out := map[string]string{}
|
||||||
|
if raw, ok := a["files"].(map[string]any); ok {
|
||||||
|
for rel, v := range raw {
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("files.%s is not text", rel)
|
||||||
|
}
|
||||||
|
out[rel] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content, ok := a["content"].(string); ok && content != "" {
|
||||||
|
switch kind {
|
||||||
|
case KindSkill:
|
||||||
|
out["SKILL.md"] = content
|
||||||
|
case KindHook:
|
||||||
|
return nil, errors.New("a hook's scripts are given as files")
|
||||||
|
default:
|
||||||
|
out[name+".md"] = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||||
|
scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`)
|
||||||
|
nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`)
|
||||||
|
var out []stdio.Tool
|
||||||
|
for _, k := range kindTools {
|
||||||
|
k := k
|
||||||
|
input := map[string]any{
|
||||||
|
"name": str("the name: lower-case letters, digits and -"),
|
||||||
|
"scope": scopeArg,
|
||||||
|
"nodes": nodesArg,
|
||||||
|
}
|
||||||
|
switch k.kind {
|
||||||
|
case KindSkill:
|
||||||
|
input["content"] = str("the SKILL.md, when the skill is that one file")
|
||||||
|
input["files"] = map[string]any{"type": "object", "description": "the skill's files by path inside it, SKILL.md among them, e.g. {\"SKILL.md\": \"...\", \"scripts/run.sh\": \"#!/bin/sh ...\"}"}
|
||||||
|
case KindHook:
|
||||||
|
input["event"] = str("PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart, SessionEnd or PreCompact")
|
||||||
|
input["matcher"] = str("for tool events, which tools, e.g. Bash or Edit|Write; absent is every one")
|
||||||
|
input["command"] = str("the shell command; ${HOOK_DIR} is the directory of the files given, e.g. ${HOOK_DIR}/check.sh")
|
||||||
|
input["files"] = map[string]any{"type": "object", "description": "the scripts the command runs, by path, e.g. {\"check.sh\": \"#!/bin/sh ...\"}"}
|
||||||
|
default:
|
||||||
|
input["content"] = str("the file's content, front matter included")
|
||||||
|
}
|
||||||
|
out = append(out,
|
||||||
|
stdio.Tool{Name: "claude_code_" + k.tool + "_list",
|
||||||
|
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
|
||||||
|
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
|
||||||
|
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
|
||||||
|
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, or at home where the person already has one of that name.",
|
||||||
|
Input: input,
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
name := strArg(a, "name")
|
||||||
|
files, err := filesOf(k.kind, name, a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
|
||||||
|
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
|
||||||
|
nodes, err := targetNodes(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||||
|
}},
|
||||||
|
stdio.Tool{Name: "claude_code_" + k.tool + "_unregister",
|
||||||
|
Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.",
|
||||||
|
Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)}
|
||||||
|
nodes, err := targetNodes(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsScope := str(`mesh (every node; the default) or node (the nodes given, or this one)`)
|
||||||
|
out = append(out,
|
||||||
|
stdio.Tool{Name: "claude_code_settings_get",
|
||||||
|
Description: "Claude Code's managed settings on this node as written, and where each part came from: the operator's `managed_settings` setting (ADR 0213), the settings registered for the mesh and for this node, and the mesh's own keys, which always win.",
|
||||||
|
Run: func(map[string]any) (any, error) {
|
||||||
|
written := map[string]any{}
|
||||||
|
_ = readJSON(filepath.Join(ManagedDir, "managed-settings.json"), &written)
|
||||||
|
var settings Settings
|
||||||
|
_ = readJSON(p.Settings, &settings)
|
||||||
|
c := ConfigOf(view.Items())
|
||||||
|
layers := map[string]any{"managed_settings setting": settings.ManagedSettings}
|
||||||
|
for _, it := range c.Mesh {
|
||||||
|
if it.Kind == KindSettings {
|
||||||
|
layers["registered for the mesh"] = it.Settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, it := range c.Node {
|
||||||
|
if it.Kind == KindSettings {
|
||||||
|
layers["registered for this node"] = it.Settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"written": written, "layers": layers,
|
||||||
|
"order": "managed_settings setting, then mesh, then node — objects merged, lists joined — then the mesh's own keys"}, nil
|
||||||
|
}},
|
||||||
|
stdio.Tool{Name: "claude_code_settings_set",
|
||||||
|
Description: "Set Claude Code settings (the vendor's settings keys: permissions, autoMode, env, model, hooks, statusLine, …) for every node or some. Merged into what that scope holds — objects key by key, lists joined — unless replace is set. The mesh's own keys (attribution, the connectors key, the key-helper, the plugin's marketplace) cannot be set. The agent refuses to loosen its own settings: this is the operator's act.",
|
||||||
|
Input: map[string]any{
|
||||||
|
"settings": map[string]any{"type": "object", "description": "settings keys, e.g. {\"permissions\": {\"deny\": [\"Bash(rm -rf:*)\"]}}"},
|
||||||
|
"replace": map[string]any{"type": "boolean", "description": "replace what the scope holds instead of merging into it"},
|
||||||
|
"scope": settingsScope, "nodes": nodesArg,
|
||||||
|
},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
given, _ := a["settings"].(map[string]any)
|
||||||
|
if len(given) == 0 {
|
||||||
|
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
|
||||||
|
}
|
||||||
|
nodes, err := targetNodes(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||||
|
if boolArg(a, "replace") {
|
||||||
|
return given
|
||||||
|
}
|
||||||
|
return mergeSettings(held, given)
|
||||||
|
}, state, view)
|
||||||
|
}},
|
||||||
|
stdio.Tool{Name: "claude_code_settings_clear",
|
||||||
|
Description: "Remove the Claude Code settings registered at a scope.",
|
||||||
|
Input: map[string]any{"scope": settingsScope, "nodes": nodesArg},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)}
|
||||||
|
nodes, err := targetNodes(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||||
|
}},
|
||||||
|
stdio.Tool{Name: "claude_code_permission_add",
|
||||||
|
Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.",
|
||||||
|
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||||
|
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
|
||||||
|
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
|
||||||
|
}
|
||||||
|
nodes, err := targetNodes(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||||
|
return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}})
|
||||||
|
}, state, view)
|
||||||
|
}},
|
||||||
|
stdio.Tool{Name: "claude_code_permission_remove",
|
||||||
|
Description: "Remove a permission rule added through this module, at its scope.",
|
||||||
|
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||||
|
nodes, err := targetNodes(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||||
|
perms, _ := held["permissions"].(map[string]any)
|
||||||
|
rules, _ := perms[list].([]any)
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return held // nothing to remove: what the scope holds stays as it is
|
||||||
|
}
|
||||||
|
kept := []any{}
|
||||||
|
for _, r := range rules {
|
||||||
|
if r != rule {
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := mergeSettings(map[string]any{}, held)
|
||||||
|
np := mergeSettings(map[string]any{}, perms)
|
||||||
|
np[list] = kept
|
||||||
|
next["permissions"] = np
|
||||||
|
return next
|
||||||
|
}, state, view)
|
||||||
|
}},
|
||||||
|
stdio.Tool{Name: "claude_code_config_list",
|
||||||
|
Description: "Everything registered for Claude Code on the mesh through this module — skills, subagents, commands, hooks, output styles, instruction sections, settings — by key, with who registered each and its files.",
|
||||||
|
Run: func(map[string]any) (any, error) { return List(state, "") }},
|
||||||
|
stdio.Tool{Name: "claude_code_config_show",
|
||||||
|
Description: "One registration in full, its files' content included, by its key as a list shows it (e.g. mesh.skill.review).",
|
||||||
|
Input: map[string]any{"key": str("the key")},
|
||||||
|
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
|
||||||
|
stdio.Tool{Name: "claude_code_config_status",
|
||||||
|
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles and rule files — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
||||||
|
Run: func(map[string]any) (any, error) {
|
||||||
|
c := ConfigOf(view.Items())
|
||||||
|
names := func(items []Item) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, it := range items {
|
||||||
|
out = append(out, it.Kind+" "+it.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var mcp struct {
|
||||||
|
MCPServers Servers `json:"mcpServers"`
|
||||||
|
}
|
||||||
|
_ = readJSON(filepath.Join(ManagedDir, "managed-mcp.json"), &mcp)
|
||||||
|
var placed Placed
|
||||||
|
_ = readJSON(p.placed(), &placed)
|
||||||
|
plugin := []string{}
|
||||||
|
root := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin)
|
||||||
|
_ = filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() {
|
||||||
|
rel, _ := filepath.Rel(root, full)
|
||||||
|
plugin = append(plugin, rel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return map[string]any{
|
||||||
|
"applies here": map[string]any{"mesh": names(c.Mesh), "node": names(c.Node), "home": names(c.Home)},
|
||||||
|
"plugin": map[string]any{"name": Plugin, "files": plugin},
|
||||||
|
"home": HomeItems(p, c, mcp.MCPServers),
|
||||||
|
"placed": placed,
|
||||||
|
}, nil
|
||||||
|
}},
|
||||||
|
stdio.Tool{Name: "claude_code_config_import",
|
||||||
|
Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.",
|
||||||
|
Input: map[string]any{
|
||||||
|
"kind": str("skill, agent, command, output-style or instructions (a rule file)"),
|
||||||
|
"name": str("its name in the home: the skill's folder, or the file without .md"),
|
||||||
|
"scope": scopeArg, "nodes": nodesArg,
|
||||||
|
},
|
||||||
|
Run: func(a map[string]any) (any, error) {
|
||||||
|
it, err := ImportFromHome(p, strArg(a, "kind"), strArg(a, "name"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
it.Scope = scopeOf(a)
|
||||||
|
if it.Scope == ScopeHome {
|
||||||
|
return nil, errors.New("it is already at home; import it to the mesh or node scope")
|
||||||
|
}
|
||||||
|
nodes, err := targetNodes(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings changes the settings registered at a scope, one key per node, by what change makes of what
|
||||||
|
// the key holds. An empty result removes the key.
|
||||||
|
func SetSettings(p Paths, scope string, nodes []string, change func(held map[string]any) map[string]any,
|
||||||
|
state ConfigState, view *ConfigView) (map[string]any, error) {
|
||||||
|
if scope != ScopeMesh && scope != ScopeNode {
|
||||||
|
return map[string]any{"set": false, "reason": "settings take the mesh and node scopes only"}, nil
|
||||||
|
}
|
||||||
|
if scope == ScopeMesh {
|
||||||
|
nodes = []string{""}
|
||||||
|
} else if len(nodes) == 0 {
|
||||||
|
nodes = []string{p.Node}
|
||||||
|
} else if problem := nodesProblem(nodes); problem != "" {
|
||||||
|
return map[string]any{"set": false, "reason": problem}, nil
|
||||||
|
}
|
||||||
|
answers := map[string]any{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scope}
|
||||||
|
held := map[string]any{}
|
||||||
|
if raw, found, err := state.Get(it.Key(n)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if found {
|
||||||
|
var was Item
|
||||||
|
if json.Unmarshal(raw, &was) == nil && was.Settings != nil {
|
||||||
|
held = was.Settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
it.Settings = change(held)
|
||||||
|
target := []string(nil)
|
||||||
|
if n != "" {
|
||||||
|
target = []string{n}
|
||||||
|
}
|
||||||
|
var answer map[string]any
|
||||||
|
var err error
|
||||||
|
if len(it.Settings) == 0 {
|
||||||
|
answer, err = Register(p, it, target, true, state, view, writeManaged)
|
||||||
|
} else {
|
||||||
|
answer, err = Register(p, it, target, false, state, view, writeManaged)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
answer["settings"] = it.Settings
|
||||||
|
answers[it.Key(n)] = answer
|
||||||
|
}
|
||||||
|
return answers, nil
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ func say(format string, args ...any) {
|
|||||||
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
||||||
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
||||||
func writeManaged(name, content string) (string, error) {
|
func writeManaged(name, content string) (string, error) {
|
||||||
|
if strings.HasSuffix(name, "/") {
|
||||||
|
return writeManagedTree(strings.TrimSuffix(name, "/"), content)
|
||||||
|
}
|
||||||
path := filepath.Join(ManagedDir, name)
|
path := filepath.Join(ManagedDir, name)
|
||||||
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
||||||
return name + ": unchanged", nil
|
return name + ": unchanged", nil
|
||||||
@@ -54,6 +57,74 @@ func writeManaged(name, content string) (string, error) {
|
|||||||
return name + ": written", nil
|
return name + ": written", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeManagedTree replaces one directory of the managed directory whole — the plugin's marketplace (ADR
|
||||||
|
// 0216) — when what it holds differs from the files given (a JSON map of path to PluginFile). Staged
|
||||||
|
// beside, then swapped in as root, so a session never reads half of it.
|
||||||
|
func writeManagedTree(name, content string) (string, error) {
|
||||||
|
var files map[string]PluginFile
|
||||||
|
if err := json.Unmarshal([]byte(content), &files); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
target := filepath.Join(ManagedDir, name)
|
||||||
|
if sameTree(target, files) {
|
||||||
|
return name + "/: unchanged", nil
|
||||||
|
}
|
||||||
|
staged, err := os.MkdirTemp("", "claude-code-tree-")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(staged)
|
||||||
|
for rel, f := range files {
|
||||||
|
full := filepath.Join(staged, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
if f.Executable {
|
||||||
|
mode = 0o755
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(f.Content), mode); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = os.Chmod(full, mode)
|
||||||
|
}
|
||||||
|
_ = os.Chmod(staged, 0o755)
|
||||||
|
script := `set -e; rm -rf "$2.next" "$2.old"; cp -r "$1" "$2.next"; chown -R root:root "$2.next"; chmod -R go-w,a+rX "$2.next";
|
||||||
|
if [ -d "$2" ]; then mv "$2" "$2.old"; fi; mv "$2.next" "$2"; rm -rf "$2.old"`
|
||||||
|
args := []string{"sh", "-c", script, "sh", staged, target}
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
args = append([]string{"sudo", "-n"}, args...)
|
||||||
|
}
|
||||||
|
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("%s/: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
||||||
|
name, ManagedDir, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s/: written, %d file(s)", name, len(files)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameTree says whether a directory holds exactly these files, with these contents and executable bits.
|
||||||
|
func sameTree(dir string, files map[string]PluginFile) bool {
|
||||||
|
found := 0
|
||||||
|
err := filepath.WalkDir(dir, func(full string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(dir, full)
|
||||||
|
f, ok := files[filepath.ToSlash(rel)]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("not wanted")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(full)
|
||||||
|
info, ierr := d.Info()
|
||||||
|
if err != nil || ierr != nil || string(raw) != f.Content || (info.Mode()&0o111 != 0) != f.Executable {
|
||||||
|
return errors.New("differs")
|
||||||
|
}
|
||||||
|
found++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return err == nil && found == len(files)
|
||||||
|
}
|
||||||
|
|
||||||
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
||||||
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
||||||
|
|
||||||
@@ -63,6 +134,13 @@ type stateOf struct{ s stdio.KeptState }
|
|||||||
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
||||||
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||||
|
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
||||||
|
e, err := s.s.Get(key)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return e.Value, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
||||||
// tool's question.
|
// tool's question.
|
||||||
@@ -152,9 +230,9 @@ func nodesOf(v any) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, configView *ConfigView) []stdio.Tool {
|
||||||
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
||||||
return []stdio.Tool{
|
out := []stdio.Tool{
|
||||||
{Name: "claude_code_status",
|
{Name: "claude_code_status",
|
||||||
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
||||||
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
||||||
@@ -239,6 +317,7 @@ func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
|||||||
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
return append(out, configTools(p, config, configView)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
||||||
@@ -283,15 +362,53 @@ func main() {
|
|||||||
}
|
}
|
||||||
servers := stateOf{stdio.State("servers")}
|
servers := stateOf{stdio.State("servers")}
|
||||||
view := NewServerView(p)
|
view := NewServerView(p)
|
||||||
go run(p, view)
|
config := stateOf{stdio.State("config")}
|
||||||
if err := stdio.Serve("", tools(p, servers, view)); err != nil {
|
configView := NewConfigView(p)
|
||||||
|
go run(p, view, configView)
|
||||||
|
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
||||||
say("%v", err)
|
say("%v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// run is the module's long-running half, beside the tools (ADR 0198).
|
// run is the module's long-running half, beside the tools (ADR 0198).
|
||||||
func run(p Paths, view *ServerView) {
|
func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||||
|
// The agent's configuration, at every scope (ADR 0216): the whole current set first, then each change.
|
||||||
|
go persist("watching the agent's configuration", func() error {
|
||||||
|
keys, err := stdio.State("config").Keys()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if configView.Prune(keys) {
|
||||||
|
if _, err := RenderNow(p, writeManaged); err != nil {
|
||||||
|
say("rendering after what was removed while away: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stdio.State("config").Watch("", func(c stdio.StateChange) error {
|
||||||
|
var item *Item
|
||||||
|
if c.Op == "put" {
|
||||||
|
item = &Item{}
|
||||||
|
if json.Unmarshal(c.Value, item) != nil {
|
||||||
|
item = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !configView.Take(c.Key, c.Op, item) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if out, err := RenderNow(p, writeManaged); err != nil {
|
||||||
|
say("taking %s %s: %v", c.Op, c.Key, err)
|
||||||
|
} else {
|
||||||
|
say("took %s %s", c.Op, c.Key)
|
||||||
|
for _, line := range out {
|
||||||
|
if !strings.HasSuffix(line, "unchanged") {
|
||||||
|
say("%s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}, func(n int) { say("watching the agent's configuration%s", refusals(n)) })
|
||||||
|
|
||||||
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
||||||
go persist("watching the MCP servers", func() error {
|
go persist("watching the MCP servers", func() error {
|
||||||
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ func (p Paths) binding() string { return filepath.Join(p.State, "licence.jso
|
|||||||
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
||||||
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
||||||
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
||||||
|
func (p Paths) config() string { return filepath.Join(p.State, "config.json") }
|
||||||
|
func (p Paths) placed() string { return filepath.Join(p.State, "home-placed.json") }
|
||||||
|
|
||||||
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
||||||
type Ask func(address string, args any) (json.RawMessage, error)
|
type Ask func(address string, args any) (json.RawMessage, error)
|
||||||
@@ -102,9 +104,15 @@ func Registered(p Paths) Servers {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var renderMu sync.Mutex
|
||||||
|
|
||||||
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
||||||
// registered here.
|
// registered here.
|
||||||
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||||
|
// One at a time: the watches and the tools all render, and the home's record of what was placed is
|
||||||
|
// read and written whole.
|
||||||
|
renderMu.Lock()
|
||||||
|
defer renderMu.Unlock()
|
||||||
var facts Facts
|
var facts Facts
|
||||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||||
@@ -116,21 +124,44 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
|||||||
if readJSON(p.binding(), &b) {
|
if readJSON(p.binding(), &b) {
|
||||||
binding = &b
|
binding = &b
|
||||||
}
|
}
|
||||||
files := Render(facts, settings, binding, p.helper(), Registered(p))
|
var items map[string]Item
|
||||||
|
_ = readJSON(p.config(), &items)
|
||||||
|
config := ConfigOf(items)
|
||||||
|
files := Render(facts, settings, binding, p.helper(), Registered(p), config)
|
||||||
|
tree, _ := json.Marshal(Marketplace(config))
|
||||||
|
files[MarketplaceDir+"/"] = string(tree)
|
||||||
names := make([]string, 0, len(files))
|
names := make([]string, 0, len(files))
|
||||||
for n := range files {
|
for n := range files {
|
||||||
names = append(names, n)
|
names = append(names, n)
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
// The marketplace first: the settings that enable its plugin must never name one that is not there yet.
|
||||||
|
sort.Slice(names, func(i, j int) bool {
|
||||||
|
if (names[i] == MarketplaceDir+"/") != (names[j] == MarketplaceDir+"/") {
|
||||||
|
return names[i] == MarketplaceDir+"/"
|
||||||
|
}
|
||||||
|
return names[i] < names[j]
|
||||||
|
})
|
||||||
|
// Every file is attempted: one that cannot be written — a registration the vendor's layout refuses, a
|
||||||
|
// failed escalation — must not keep the licence, the tool servers or the instructions from landing.
|
||||||
var out []string
|
var out []string
|
||||||
|
var failed []error
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
line, err := write(n, files[n])
|
line, err := write(n, files[n])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
failed = append(failed, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, line)
|
out = append(out, line)
|
||||||
}
|
}
|
||||||
return out, nil
|
// The home scope: what this module places in the account's own agent directory (ADR 0182).
|
||||||
|
done, left := PlaceHome(p, HomeFiles(config))
|
||||||
|
for _, line := range done {
|
||||||
|
out = append(out, "home "+line)
|
||||||
|
}
|
||||||
|
for _, line := range left {
|
||||||
|
out = append(out, "home "+line)
|
||||||
|
}
|
||||||
|
return out, errors.Join(failed...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the licence ----------------------------------------------------------------------------------
|
// ---- the licence ----------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ func jsonFile(v any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render composes the three files. registered — what was registered through this module and applies
|
// Render composes the three files. registered — what was registered through this module and applies
|
||||||
// here — is laid over the servers the operator set in its settings.
|
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
||||||
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string {
|
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
||||||
|
// after the mesh's own text. The plugin itself is Marketplace's, and the home's PlaceHome's.
|
||||||
|
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config) map[string]string {
|
||||||
servers := map[string]any{}
|
servers := map[string]any{}
|
||||||
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
||||||
for name, entry := range layer {
|
for name, entry := range layer {
|
||||||
@@ -109,14 +111,27 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
|
|||||||
}
|
}
|
||||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||||
|
|
||||||
managed := map[string]any{}
|
// The operator's `managed_settings` (ADR 0213), then what was registered for the mesh, then for this node.
|
||||||
for key, value := range settings.ManagedSettings {
|
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
|
||||||
managed[key] = value
|
managed = mergeSettings(managed, RegisteredSettings(config))
|
||||||
}
|
|
||||||
// The mesh's own keys are laid last: a setting never replaces them.
|
// The mesh's own keys are laid last: a setting never replaces them.
|
||||||
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
|
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
|
||||||
managed["allowAllClaudeAiMcps"] = true
|
managed["allowAllClaudeAiMcps"] = true
|
||||||
delete(managed, "apiKeyHelper")
|
delete(managed, "apiKeyHelper")
|
||||||
|
// The plugin's marketplace and the plugin itself, as entries in the operator's own maps, the mesh's
|
||||||
|
// entry winning: the operator may know more marketplaces and enable more plugins.
|
||||||
|
for key, value := range MarketplaceKeys() {
|
||||||
|
entries := map[string]any{}
|
||||||
|
if held, ok := managed[key].(map[string]any); ok {
|
||||||
|
for k, v := range held {
|
||||||
|
entries[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range value.(map[string]any) {
|
||||||
|
entries[k] = v
|
||||||
|
}
|
||||||
|
managed[key] = entries
|
||||||
|
}
|
||||||
if binding != nil && binding.Kind == "api-key" {
|
if binding != nil && binding.Kind == "api-key" {
|
||||||
managed["apiKeyHelper"] = helperPath
|
managed["apiKeyHelper"] = helperPath
|
||||||
}
|
}
|
||||||
@@ -127,6 +142,6 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
|
|||||||
return map[string]string{
|
return map[string]string{
|
||||||
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
||||||
"managed-settings.json": jsonFile(managed),
|
"managed-settings.json": jsonFile(managed),
|
||||||
"CLAUDE.md": instructionsText(facts.Node, role),
|
"CLAUDE.md": instructionsText(facts.Node, role) + InstructionSections(config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
},
|
},
|
||||||
"state": [
|
"state": [
|
||||||
"servers",
|
"servers",
|
||||||
"holdings"
|
"holdings",
|
||||||
|
"config"
|
||||||
],
|
],
|
||||||
"reads": [
|
"reads": [
|
||||||
"claude-licence-manager.bindings"
|
"claude-licence-manager.bindings"
|
||||||
@@ -26,7 +27,34 @@
|
|||||||
"claude_code_add_api_key",
|
"claude_code_add_api_key",
|
||||||
"claude_code_mcp_list",
|
"claude_code_mcp_list",
|
||||||
"claude_code_mcp_register",
|
"claude_code_mcp_register",
|
||||||
"claude_code_mcp_unregister"
|
"claude_code_mcp_unregister",
|
||||||
|
"claude_code_skill_list",
|
||||||
|
"claude_code_skill_register",
|
||||||
|
"claude_code_skill_unregister",
|
||||||
|
"claude_code_agent_list",
|
||||||
|
"claude_code_agent_register",
|
||||||
|
"claude_code_agent_unregister",
|
||||||
|
"claude_code_command_list",
|
||||||
|
"claude_code_command_register",
|
||||||
|
"claude_code_command_unregister",
|
||||||
|
"claude_code_hook_list",
|
||||||
|
"claude_code_hook_register",
|
||||||
|
"claude_code_hook_unregister",
|
||||||
|
"claude_code_output_style_list",
|
||||||
|
"claude_code_output_style_register",
|
||||||
|
"claude_code_output_style_unregister",
|
||||||
|
"claude_code_instructions_list",
|
||||||
|
"claude_code_instructions_register",
|
||||||
|
"claude_code_instructions_unregister",
|
||||||
|
"claude_code_settings_get",
|
||||||
|
"claude_code_settings_set",
|
||||||
|
"claude_code_settings_clear",
|
||||||
|
"claude_code_permission_add",
|
||||||
|
"claude_code_permission_remove",
|
||||||
|
"claude_code_config_list",
|
||||||
|
"claude_code_config_show",
|
||||||
|
"claude_code_config_status",
|
||||||
|
"claude_code_config_import"
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
"name": "public-dns",
|
"name": "public-dns",
|
||||||
"scope": "mesh"
|
"scope": "mesh",
|
||||||
|
"identity": {
|
||||||
|
"max": 63,
|
||||||
|
"in": "a DNS label"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"serves": {
|
"serves": {
|
||||||
|
|||||||
@@ -78,6 +78,25 @@ today), remove the unused vendor drivers, once:
|
|||||||
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
|
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
|
||||||
the Canon queue is used.
|
the Canon queue is used.
|
||||||
|
|
||||||
|
## The print applet: not this module's
|
||||||
|
|
||||||
|
The desktop has `system-config-printer` (official, explicit, installed 2026-10-04). Its package ships
|
||||||
|
`/etc/xdg/autostart/print-applet.desktop`, so from the desktop's next login `dex` starts
|
||||||
|
`system-config-printer-applet`, a tray icon for print jobs and printer problems. The laptop does not
|
||||||
|
have the package.
|
||||||
|
|
||||||
|
This module does not take it:
|
||||||
|
|
||||||
|
- `cups` needs no display and declares nothing graphical. The applet requires `x11-display`, and a
|
||||||
|
GTK package in this module would put it on any machine that prints, the laptop included, where the
|
||||||
|
operator never installed it.
|
||||||
|
- The queues and the default printer are already this module's tools (`cups_printers`, `cups_queue`,
|
||||||
|
`cups_default`), which is most of what the applet's window offers.
|
||||||
|
|
||||||
|
So the applet is left as found on the desktop, started by its package's entry. If it is wanted on both
|
||||||
|
workstations, it becomes a `system-config-printer` desktop module of its own, beside `blueman` and
|
||||||
|
`nm-applet`, requiring `x11-display`. If not, removing the package on the desktop ends it.
|
||||||
|
|
||||||
## Leaves as found
|
## Leaves as found
|
||||||
|
|
||||||
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
|
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
|
|
||||||
The uplink seat's module for a machine whose own network is dhcpcd's (novox/hq ADR 0117). It
|
The uplink seat's module for a machine whose own network is dhcpcd's (novox/hq ADR 0117). It
|
||||||
asks two things of dhcpcd, and nothing else: leave the resolver file to the mesh, and leave the
|
asks two things of dhcpcd, and nothing else: leave the resolver file to the mesh, and leave the
|
||||||
private network's interface alone. It never declares an interface, an address, a route, a
|
private network's interface alone — and it writes that resolver file itself (ADR 0223). It never
|
||||||
wireless network or its credentials — the link dhcpcd keeps is the only channel the mesh reaches
|
declares an interface, an address, a route, a wireless network or its credentials — the link
|
||||||
the machine over.
|
dhcpcd keeps is the only channel the mesh reaches the machine over.
|
||||||
|
|
||||||
## What it writes
|
## What it writes
|
||||||
|
|
||||||
|
`/etc/resolv.conf`, whole: every resolver of the mesh by its private address — this machine's own
|
||||||
|
first when it holds one — and `options timeout:1 attempts:2 edns0`, rendered by the mesh from the
|
||||||
|
holders of `mesh-dns-resolver`. The same file every uplink module writes. Not dhcpcd's own static
|
||||||
|
`domain_name_servers`: dhcpcd writes the file only through its hook, with its own header, and reads
|
||||||
|
its configuration only at its next start, so a change to the mesh's resolvers would not reach the
|
||||||
|
file until then.
|
||||||
|
|
||||||
Two lines into `/etc/dhcpcd.conf`, as the mesh's marked region (`into: block`) — dhcpcd reads no
|
Two lines into `/etc/dhcpcd.conf`, as the mesh's marked region (`into: block`) — dhcpcd reads no
|
||||||
drop-in directory, so the mesh writes into its one file rather than over it (ADR 0102):
|
drop-in directory, so the mesh writes into its one file rather than over it (ADR 0102):
|
||||||
|
|
||||||
- `nohook resolv.conf` — dhcpcd's resolv.conf hook rewrites `/etc/resolv.conf` on every lease it
|
- `nohook resolv.conf` — dhcpcd's resolv.conf hook rewrites `/etc/resolv.conf` on every lease it
|
||||||
takes or renews, which would silently replace the resolver `resolv-conf` names.
|
takes or renews, which would silently replace the resolvers this module writes there.
|
||||||
- `denyinterfaces mesh0` — dhcpcd never asks for a lease on the private network's interface, and
|
- `denyinterfaces mesh0` — dhcpcd never asks for a lease on the private network's interface, and
|
||||||
never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so
|
never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so
|
||||||
rather than relying on it.
|
rather than relying on it.
|
||||||
@@ -32,12 +39,11 @@ a restart drops the lease. So the two lines take effect at **dhcpcd's next start
|
|||||||
On an adopted machine that is normally no gap: the predecessor wrote the same `nohook` line, and
|
On an adopted machine that is normally no gap: the predecessor wrote the same `nohook` line, and
|
||||||
it is already in force. **On a machine that was not adopted, it is one:** until dhcpcd next
|
it is already in force. **On a machine that was not adopted, it is one:** until dhcpcd next
|
||||||
starts (a reboot, or the operator restarting it in a window of their choosing), a lease renewal
|
starts (a reboot, or the operator restarting it in a window of their choosing), a lease renewal
|
||||||
still rewrites `/etc/resolv.conf`, and `resolv-conf` puts it back at the next push. Assign this
|
still rewrites `/etc/resolv.conf`, and this module puts it back at the next push. Restart dhcpcd
|
||||||
module before `resolv-conf` on such a machine, and restart dhcpcd once, by hand, when losing the
|
once, by hand, when losing the link for a moment is acceptable.
|
||||||
link for a moment is acceptable.
|
|
||||||
|
|
||||||
## One manager per machine
|
## One manager per machine
|
||||||
|
|
||||||
It claims `the-uplink`: a machine runs one network manager, and assigning a second module that
|
It claims `node-uplink`: a machine runs one network manager, and assigning a second module that
|
||||||
claims the seat is refused. Assigning this one to a machine whose network is NetworkManager's
|
claims the seat is refused. Assigning this one to a machine whose network is NetworkManager's
|
||||||
installs the package and writes the two lines, and starts nothing.
|
installs the package and writes the two lines, and starts nothing.
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"module": "dhcpcd",
|
"module": "dhcpcd",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"requires": [
|
||||||
|
"wildcard-resolution"
|
||||||
|
],
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager",
|
"package-manager",
|
||||||
"service-manager",
|
"service-manager",
|
||||||
@@ -12,6 +15,12 @@
|
|||||||
"scope": "node"
|
"scope": "node"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"facts": {
|
||||||
|
"resolvers": {
|
||||||
|
"path": "/etc/resolv.conf",
|
||||||
|
"template": "# Managed by the mesh, and written by the module holding this machine's uplink:\n# the program that manages the machine's network would otherwise rewrite this\n# file on every change of network, so its holder is the one that writes it\n# (novox/hq ADR 0117, ADR 0223). Replaced on every push; edit nothing here.\n#\n# Every resolver of the mesh, by address, and nothing else (novox/hq ADR 0223) \u2014\n# this machine's own first when it holds one, then the others by name. Each\n# answers the mesh's names from the same roster and forwards every other name, so\n# whichever answers first gives the one answer. There is no public resolver here:\n# a C library that asks every listed server at once and takes the first reply \u2014\n# musl, so every Alpine container \u2014 took a public resolver's \"no such name\" for\n# a mesh name and failed. A machine that reaches none of these has no names until\n# it does. Containers copy these lines from their machine.\n{{range index .Holders \"mesh-dns-resolver\"}}nameserver {{.Address}}\n{{end}}options timeout:1 attempts:2 edns0\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "package",
|
"id": "package",
|
||||||
@@ -25,7 +34,7 @@
|
|||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"into": "block",
|
"into": "block",
|
||||||
"at": "start",
|
"at": "start",
|
||||||
"content": "# The mesh's two lines (module dhcpcd, novox/hq ADR 0117). Global options, so\n# kept above any interface line; read at dhcpcd's next start.\nnohook resolv.conf\ndenyinterfaces mesh0\n"
|
"content": "# The mesh's two lines (module dhcpcd, novox/hq ADR 0117, ADR 0223): the\n# resolver file is this module's, written whole beside this file. Global\n# options, so kept above any interface line; read at dhcpcd's next start.\nnohook resolv.conf\ndenyinterfaces mesh0\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,23 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "collect",
|
||||||
|
"type": "container",
|
||||||
|
"name": "mesh-registry-collect",
|
||||||
|
"image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/mesh-registry:/var/lib/registry"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"garbage-collect",
|
||||||
|
"/etc/docker/registry/config.yml"
|
||||||
|
],
|
||||||
|
"schedule": "30 3 * * *",
|
||||||
|
"while-stopped": [
|
||||||
|
"store"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-24
File diff suppressed because one or more lines are too long
+78
-52
@@ -14,6 +14,8 @@ tools below are the module's own.
|
|||||||
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
|
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
|
||||||
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
|
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
|
||||||
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
|
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
|
||||||
|
| `daemon` | `live-restore` and the mesh's registry in `insecure-registries` written into `/etc/docker/daemon.json`, beside other keys | each key given back as found when the module goes; only the member this module added leaves the list |
|
||||||
|
| `runtime` | `docker.service` running, enabled at boot; reloaded, never restarted, when `daemon` changes | given back as found (ADR 0118) |
|
||||||
|
|
||||||
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
|
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
|
||||||
takes no volume, no container and no image a container uses, so it never touches a container the mesh
|
takes no volume, no container and no image a container uses, so it never touches a container the mesh
|
||||||
@@ -24,62 +26,60 @@ while the machine was off happens at the next boot.
|
|||||||
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
|
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
|
||||||
the module that installs the daemon cannot require it.
|
the module that installs the daemon cannot require it.
|
||||||
|
|
||||||
|
## The runtime's own file and service (issue 190, hq ADR 0196, ADR 0222)
|
||||||
|
|
||||||
|
This module writes two keys into `/etc/docker/daemon.json` (`into: json`, ADR 0102): `live-restore`
|
||||||
|
and `insecure-registries`. `dnsmasq` used to write `live-restore`, beside `dns`; it no longer writes
|
||||||
|
either. Under ADR 0196 a container copies its machine's resolvers, so no module writes `dns`. The
|
||||||
|
controller's private network wrote `insecure-registries`; under ADR 0222 the controller writes
|
||||||
|
nothing into this file, and this module states the registry itself (the order below).
|
||||||
|
|
||||||
|
- `daemon`: `{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"]}`,
|
||||||
|
merged into the file beside the keys others write.
|
||||||
|
- `${seat:mesh-artifact-store:reach}` is where this machine reaches the mesh's artifact store
|
||||||
|
(host:port), filled in by the controller: no binding, no credential, the same address the mesh
|
||||||
|
composes into every image it built. Trusting it in the clear is ADR 0082's decision: every path to
|
||||||
|
it is inside the private network's encryption. While no machine on the network holds the store the
|
||||||
|
answer is empty, and the controller drops the empty member, so the list gets nothing.
|
||||||
|
- `insecure-registries` is a list, and the host adds to it rather than replacing it: a machine's own
|
||||||
|
trusted registries stay, and undeclaring takes out only the member this module added.
|
||||||
|
- `runtime`: `docker.service` running, enabled at boot, and **reloaded, never restarted**, when
|
||||||
|
`daemon` changes. A restart stops every container. A reload turns `live-restore` on and takes the
|
||||||
|
trusted registries, and with `live-restore` on a later restart keeps every container running.
|
||||||
|
|
||||||
|
In the apply that moves the key, the host first gives back `dnsmasq`'s resources, then applies this
|
||||||
|
module's: `live-restore` is set again in the same apply, and the daemon is reloaded once.
|
||||||
|
|
||||||
|
`dns` is removed from the file then, but the daemon reads it only at its next start, and a running
|
||||||
|
container keeps the resolvers it was created with. Each container pinned to a machine's own resolver
|
||||||
|
is restarted before that machine's `dnsmasq` goes (ADR 0194, step 4).
|
||||||
|
|
||||||
|
**The order it lands in.** The controller that fills `${seat:…:reach}` is deployed first: one that
|
||||||
|
does not know the placeholder would send it through unfilled. Then this module. Then the controller
|
||||||
|
stops generating the private network's `registry-trust` and `registry-trust-reload` and refuses a
|
||||||
|
generated resource that collides with a module's (issue 190, steps 2 and 5). In the apply that moves
|
||||||
|
the member, the host removes the private network's record first (the member leaves the list) and
|
||||||
|
then applies this module's (it is added back, recorded as this module's); the daemon is reloaded
|
||||||
|
once, for `daemon`. The address is the same one, so the runtime's trust does not change.
|
||||||
|
|
||||||
|
Still elsewhere:
|
||||||
|
|
||||||
|
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand; they are left
|
||||||
|
as they are until a size is chosen for every machine.
|
||||||
|
|
||||||
## What it does not declare yet, and why
|
## What it does not declare yet, and why
|
||||||
|
|
||||||
Three things this module should own are already declared by other modules on every machine. The
|
One thing this module should own is still declared elsewhere. The controller refuses two modules
|
||||||
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
|
on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`,
|
||||||
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
|
mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module
|
||||||
make the module unassignable everywhere. The refusals were checked against the controller's own
|
unassignable everywhere. The refusals were checked against the controller's own
|
||||||
check:
|
check:
|
||||||
|
|
||||||
```
|
```
|
||||||
zsh and docker both declare the name "${machine:account}"
|
zsh and docker both declare the name "${machine:account}"
|
||||||
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
|
|
||||||
dnsmasq and docker both declare the unit "docker.service"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190)
|
### The operator account's membership of the `docker` group
|
||||||
|
|
||||||
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
|
|
||||||
service:
|
|
||||||
|
|
||||||
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
|
|
||||||
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
|
|
||||||
`insecure-registries`. The collision check does not see generated resources.
|
|
||||||
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
|
|
||||||
|
|
||||||
**The change proposed, in one merge:**
|
|
||||||
|
|
||||||
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
|
|
||||||
2. `docker` adds the two resources below:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
|
|
||||||
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
|
|
||||||
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
|
|
||||||
```
|
|
||||||
|
|
||||||
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
|
|
||||||
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
|
|
||||||
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
|
|
||||||
start keeps every container running.
|
|
||||||
|
|
||||||
**Why one merge, and only after this module is on every machine:**
|
|
||||||
|
|
||||||
- In one apply, the host first gives back the resources that are no longer declared, then applies
|
|
||||||
the new ones (mesh-host `apply.go`).
|
|
||||||
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
|
|
||||||
again in the same apply. The daemon is reloaded once, after both steps.
|
|
||||||
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
|
|
||||||
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
|
|
||||||
every container.
|
|
||||||
|
|
||||||
**Later:** the controller hands the registry to this module as a value, and the overlay stops
|
|
||||||
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
|
|
||||||
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
|
|
||||||
per resource.
|
|
||||||
|
|
||||||
### 2. The operator account's membership of the `docker` group
|
|
||||||
|
|
||||||
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
|
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
|
||||||
`usermod --append` and never takes a group away.
|
`usermod --append` and never takes a group away.
|
||||||
@@ -111,9 +111,8 @@ On the machine the mesh was first installed on, the foundation bundle declared `
|
|||||||
never sees them (mesh-host `store.go`).
|
never sees them (mesh-host `store.go`).
|
||||||
- So `docker.package` here is a **second record of the same package**. The apply says "already
|
- So `docker.package` here is a **second record of the same package**. The apply says "already
|
||||||
installed", and neither record ever uninstalls it.
|
installed", and neither record ever uninstalls it.
|
||||||
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
|
- `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*,
|
||||||
1 would add a second record of that unit. Its found state is *running*, because genesis started
|
because genesis started it, so undeclaring this module leaves the daemon running.
|
||||||
it, so undeclaring this module would leave the daemon running.
|
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
@@ -128,7 +127,8 @@ A failure is an error naming how it failed, never an empty answer.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name |
|
| `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name |
|
||||||
| `docker_inspect` | r | one container whole, **environment values left out** (names kept) |
|
| `docker_inspect` | r | one container whole, **environment values left out** (names kept) |
|
||||||
| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000) |
|
| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000); **a secret the container printed is shown as `[redacted: <name>]`** |
|
||||||
|
| `docker_secrets_in_logs` | r | which containers printed a secret they were given, **by name, never by value** (below) |
|
||||||
| `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first |
|
| `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first |
|
||||||
| `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply |
|
| `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply |
|
||||||
| `docker_top` | r | the processes inside one container |
|
| `docker_top` | r | the processes inside one container |
|
||||||
@@ -143,6 +143,31 @@ A failure is an error naming how it failed, never an empty answer.
|
|||||||
| `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more |
|
| `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more |
|
||||||
| `docker_ports` | r | every published port, and the containers on the host's network |
|
| `docker_ports` | r | every published port, and the containers on the host's network |
|
||||||
|
|
||||||
|
## Secrets in a container's own log (hq issue 268)
|
||||||
|
|
||||||
|
Software prints what it is given: a server announcing its password as it starts, a startup script
|
||||||
|
echoing the database URI it connects with. The container's log is then a copy of the secret, held by
|
||||||
|
whoever reads it — this bundle's `docker_logs` among them. `docker_secrets_in_logs` reads the last
|
||||||
|
lines of each container's log (the mesh's by default, 5000 lines each, at most 50000) and compares
|
||||||
|
them with:
|
||||||
|
|
||||||
|
- the values of the container's environment whose names say they are secrets (`PASSWORD`, `SECRET`,
|
||||||
|
`TOKEN`, `API_KEY`, …; not a path, a URL, a number or a switch), as given and URL-encoded;
|
||||||
|
- the password inside any URI its environment holds;
|
||||||
|
- the shape `scheme://user:password@`, anywhere in a line, whatever the source — a password a program
|
||||||
|
already masked (`***`) is not one.
|
||||||
|
|
||||||
|
A finding names the container, the module, the assignment and the secret's variable, with how many
|
||||||
|
lines carry it and the first and last time. **It never carries the value or the line.** A secret
|
||||||
|
delivered only as a mounted file, never in the environment, is not known here — the bundle runs as the
|
||||||
|
operator account, which cannot read the host's 0600 files — and is caught only inside a URI.
|
||||||
|
|
||||||
|
`docker_logs` redacts the same values before it answers, because what it answers is read by agents
|
||||||
|
and kept in their transcripts. Its answer says how many it redacted, and points here.
|
||||||
|
|
||||||
|
A finding is a secret to rotate once the program stops printing it; recreating the container drops
|
||||||
|
its old log (the runtime's file goes with the container).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -159,6 +184,7 @@ The tests run against a fake runner and cover:
|
|||||||
- the restore note on a mesh-held act;
|
- the restore note on a mesh-held act;
|
||||||
- prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`;
|
- prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`;
|
||||||
- the log merge;
|
- the log merge;
|
||||||
|
- a printed secret found by name and never answered by value, in the scan and in `docker_logs`;
|
||||||
- size parsing;
|
- size parsing;
|
||||||
- what the daemon has not yet taken;
|
- what the daemon has not yet taken;
|
||||||
- event filtering;
|
- event filtering;
|
||||||
|
|||||||
@@ -380,6 +380,44 @@ func (c *Client) Logs(ctx context.Context, ref string, tail int, since string) (
|
|||||||
args = append(args, "--since", since)
|
args = append(args, "--since", since)
|
||||||
}
|
}
|
||||||
args = append(args, ref)
|
args = append(args, ref)
|
||||||
|
all, err := c.logLines(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(all) > tail {
|
||||||
|
all = all[len(all)-tail:]
|
||||||
|
}
|
||||||
|
// What the container printed of the secrets it was given is not shown (novox/hq issue 268): the
|
||||||
|
// answer of this tool is read by agents and kept in their transcripts, which would make it a
|
||||||
|
// second copy of the leak. Redacted before the lines are cut, so a cut never splits a value.
|
||||||
|
answer := map[string]any{"container": ref}
|
||||||
|
env, envErr := c.envOf(ctx, ref)
|
||||||
|
known := secretsIn(env)
|
||||||
|
redacted := 0
|
||||||
|
for i, l := range all {
|
||||||
|
var n int
|
||||||
|
all[i], n = redact(l, known)
|
||||||
|
redacted += n
|
||||||
|
}
|
||||||
|
if envErr != nil {
|
||||||
|
answer["redaction"] = "only passwords inside URIs: the environment could not be read (" + envErr.Error() + ")"
|
||||||
|
}
|
||||||
|
if redacted > 0 {
|
||||||
|
answer["redacted"] = redacted
|
||||||
|
answer["leak"] = "this container printed secrets it was given; docker_secrets_in_logs names them (novox/hq issue 268)"
|
||||||
|
}
|
||||||
|
const most = 4096
|
||||||
|
for i, l := range all {
|
||||||
|
if len(l) > most {
|
||||||
|
all[i] = l[:most] + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answer["lines"], answer["count"] = all, len(all)
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logLines runs `docker logs …` and answers both streams' lines merged in the order written.
|
||||||
|
func (c *Client) logLines(ctx context.Context, args []string) ([]string, error) {
|
||||||
r := c.Run(ctx, "docker", args...)
|
r := c.Run(ctx, "docker", args...)
|
||||||
program := "docker"
|
program := "docker"
|
||||||
if r.Status != 0 && r.Err == "" && c.UID != 0 && socketRefused.MatchString(r.Stderr) {
|
if r.Status != 0 && r.Err == "" && c.UID != 0 && socketRefused.MatchString(r.Stderr) {
|
||||||
@@ -392,16 +430,7 @@ func (c *Client) Logs(ctx context.Context, ref string, tail int, since string) (
|
|||||||
// Both streams carry the container's lines, each led by its timestamp, so they merge in order.
|
// Both streams carry the container's lines, each led by its timestamp, so they merge in order.
|
||||||
all := append(lines(r.Stdout), lines(r.Stderr)...)
|
all := append(lines(r.Stdout), lines(r.Stderr)...)
|
||||||
sort.SliceStable(all, func(a, b int) bool { return all[a] < all[b] })
|
sort.SliceStable(all, func(a, b int) bool { return all[a] < all[b] })
|
||||||
if len(all) > tail {
|
return all, nil
|
||||||
all = all[len(all)-tail:]
|
|
||||||
}
|
|
||||||
const most = 4096
|
|
||||||
for i, l := range all {
|
|
||||||
if len(l) > most {
|
|
||||||
all[i] = l[:most] + "…"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map[string]any{"container": ref, "lines": all, "count": len(all)}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stat is one container's use of the machine now.
|
// Stat is one container's use of the machine now.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,7 @@ type call struct {
|
|||||||
|
|
||||||
// fake answers each command by the first rule whose prefix matches "name arg arg…".
|
// fake answers each command by the first rule whose prefix matches "name arg arg…".
|
||||||
type fake struct {
|
type fake struct {
|
||||||
|
mu sync.Mutex
|
||||||
rules []rule
|
rules []rule
|
||||||
calls []call
|
calls []call
|
||||||
}
|
}
|
||||||
@@ -31,6 +33,8 @@ type rule struct {
|
|||||||
func (f *fake) on(prefix string, r Ran) *fake { f.rules = append(f.rules, rule{prefix, r}); return f }
|
func (f *fake) on(prefix string, r Ran) *fake { f.rules = append(f.rules, rule{prefix, r}); return f }
|
||||||
|
|
||||||
func (f *fake) run(_ context.Context, name string, args ...string) Ran {
|
func (f *fake) run(_ context.Context, name string, args ...string) Ran {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
f.calls = append(f.calls, call{name, args})
|
f.calls = append(f.calls, call{name, args})
|
||||||
line := strings.Join(append([]string{name}, args...), " ")
|
line := strings.Join(append([]string{name}, args...), " ")
|
||||||
for _, r := range f.rules {
|
for _, r := range f.rules {
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ func tools(c *Client) []stdio.Tool {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "docker_logs",
|
Name: "docker_logs",
|
||||||
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB).",
|
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB). " +
|
||||||
|
"A secret the container was given that it printed is shown as [redacted: <name>], and so is a password inside a URI.",
|
||||||
Input: map[string]any{
|
Input: map[string]any{
|
||||||
"container": containerArg,
|
"container": containerArg,
|
||||||
"lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"},
|
"lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"},
|
||||||
@@ -90,6 +91,27 @@ func tools(c *Client) []stdio.Tool {
|
|||||||
return c.Logs(ctx, ref, n, optional(args, "since"))
|
return c.Logs(ctx, ref, n, optional(args, "since"))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_secrets_in_logs",
|
||||||
|
Description: "Which containers printed a secret they were given into their own log — by container, module and the secret's name, never its value: " +
|
||||||
|
"each one's recent lines compared with the values of its environment named like a secret and the passwords in its URIs, and any URI carrying a password. " +
|
||||||
|
"A finding is a secret to rotate once the program stops printing it (novox/hq issue 268).",
|
||||||
|
Input: map[string]any{
|
||||||
|
"held": map[string]any{"type": "string", "enum": []string{"all", "mesh", "other"}, "description": "whose: the mesh's (default), every container, or the others"},
|
||||||
|
"lines": map[string]any{"type": "integer", "description": "how many lines from the end of each log (default 5000, at most 50000)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
n, err := bounded(args, "lines", 5000, 50000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
held := optional(args, "held")
|
||||||
|
if held == "" {
|
||||||
|
held = "mesh"
|
||||||
|
}
|
||||||
|
return c.SecretsInLogs(ctx, held, n)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "docker_stats",
|
Name: "docker_stats",
|
||||||
Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.",
|
Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.",
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A container's secrets in its own output (novox/hq issue 268).
|
||||||
|
//
|
||||||
|
// **The leak this catches.** Software prints what it was given: a server that announces its
|
||||||
|
// password when it starts in secure mode, a startup script that echoes the database URI it
|
||||||
|
// connects with, password and all. The container's log is then a copy of the secret that every
|
||||||
|
// reader of the log holds — this bundle's docker_logs, the journal where a container logs there,
|
||||||
|
// and whatever kept a transcript of either. Nothing in the mesh noticed, because nothing looked.
|
||||||
|
//
|
||||||
|
// **What is known here, and what is not.** A container's environment is readable through the
|
||||||
|
// runtime (docker inspect), so its values can be compared with what it printed: a variable named
|
||||||
|
// like a secret (PASSWORD, SECRET, TOKEN, KEY, …) and the password inside any URI a variable holds.
|
||||||
|
// Beside that, a credential-bearing URI anywhere in a line (`scheme://user:password@`) is caught
|
||||||
|
// by its shape, whatever the source. A secret handed only as a mounted file and never in the
|
||||||
|
// environment is not known to this bundle — it runs as the operator account, which cannot read
|
||||||
|
// the files the host writes at 0600 — and is caught only if the program prints it inside a URI.
|
||||||
|
//
|
||||||
|
// **Never the value.** A finding names the container, the module and the variable; it carries
|
||||||
|
// no value and no line. A tool that quoted the leak to report it would be a second leak.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secretName is a variable name that says its value is a secret.
|
||||||
|
var secretName = regexp.MustCompile(`(?i)(pass(word|wd|phrase)?|secret|token|api_?key|private_?key|access_?key|credential|auth)`)
|
||||||
|
|
||||||
|
// notAValue is a name that says its value is where a secret is, not the secret: a file or a path.
|
||||||
|
var notAValue = regexp.MustCompile(`(?i)(_FILE|FILE|_PATH|_DIR)$`)
|
||||||
|
|
||||||
|
// uriPassword is a URI carrying a password in its userinfo: scheme://user:password@.
|
||||||
|
var uriPassword = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^\s/:@'"]*:([^\s/@'"]+)@`)
|
||||||
|
|
||||||
|
// masked is a password a program already hid: ***, xxx, <redacted>, [REDACTED].
|
||||||
|
var masked = regexp.MustCompile(`^(\*+|x+|X+|<[^>]*>|\[[^\]]*\]|%2A+)$`)
|
||||||
|
|
||||||
|
// ordinary is a value under a secret's name that is not one: a path, an address, a number, a switch.
|
||||||
|
var ordinary = regexp.MustCompile(`^(/.*|[A-Za-z][A-Za-z0-9+.-]*://.*|[0-9.]+[a-z]?|(?i:true|false|yes|no|on|off|none|null))$`)
|
||||||
|
|
||||||
|
// leastSecret is the shortest value compared as a secret: a shorter one matches ordinary words.
|
||||||
|
const leastSecret = 6
|
||||||
|
|
||||||
|
// knownSecret is one value a container was given, by the name it came under.
|
||||||
|
type knownSecret struct {
|
||||||
|
Name string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretsIn are the values in a container's environment that must never appear in its output.
|
||||||
|
func secretsIn(env []string) []knownSecret {
|
||||||
|
var out []knownSecret
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(name, value string) {
|
||||||
|
if len(value) < leastSecret || masked.MatchString(value) || seen[name+"\x00"+value] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[name+"\x00"+value] = true
|
||||||
|
out = append(out, knownSecret{name, value})
|
||||||
|
}
|
||||||
|
for _, e := range env {
|
||||||
|
name, value, ok := strings.Cut(e, "=")
|
||||||
|
if !ok || value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range uriPassword.FindAllStringSubmatch(value, -1) {
|
||||||
|
add(name+" (the password in its URI)", m[1])
|
||||||
|
if dec, err := url.PathUnescape(m[1]); err == nil && dec != m[1] {
|
||||||
|
add(name+" (the password in its URI)", dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secretName.MatchString(name) && !notAValue.MatchString(name) && !ordinary.MatchString(value) {
|
||||||
|
add(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// forms are the ways a value may appear printed: as given, and URL-encoded.
|
||||||
|
func forms(value string) []string {
|
||||||
|
out := []string{value}
|
||||||
|
for _, f := range []string{url.QueryEscape(value), url.PathEscape(value)} {
|
||||||
|
if f != value && !contains(out, f) {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaksIn is, per secret name, how many lines carry that secret; and how many carry a URI with a
|
||||||
|
// password that is none of the known ones. The lines are read and forgotten.
|
||||||
|
func leaksIn(lines []string, known []knownSecret) (byName map[string][]string, uris []string) {
|
||||||
|
byName = map[string][]string{}
|
||||||
|
for _, l := range lines {
|
||||||
|
hit := false
|
||||||
|
for _, s := range known {
|
||||||
|
for _, f := range forms(s.Value) {
|
||||||
|
if strings.Contains(l, f) {
|
||||||
|
byName[s.Name] = append(byName[s.Name], stamp(l))
|
||||||
|
hit = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range uriPassword.FindAllStringSubmatch(l, -1) {
|
||||||
|
if !masked.MatchString(m[1]) {
|
||||||
|
uris = append(uris, stamp(l))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byName, uris
|
||||||
|
}
|
||||||
|
|
||||||
|
// redact is a line with every known secret, and every password inside a URI, replaced by a mark
|
||||||
|
// naming what was there.
|
||||||
|
func redact(line string, known []knownSecret) (string, int) {
|
||||||
|
n := 0
|
||||||
|
for _, s := range known {
|
||||||
|
for _, f := range forms(s.Value) {
|
||||||
|
if c := strings.Count(line, f); c > 0 {
|
||||||
|
line = strings.ReplaceAll(line, f, "[redacted: "+s.Name+"]")
|
||||||
|
n += c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line = uriPassword.ReplaceAllStringFunc(line, func(m string) string {
|
||||||
|
sub := uriPassword.FindStringSubmatch(m)
|
||||||
|
if masked.MatchString(sub[1]) {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
return strings.TrimSuffix(m, sub[1]+"@") + "[redacted: a password in a URI]@"
|
||||||
|
})
|
||||||
|
return line, n
|
||||||
|
}
|
||||||
|
|
||||||
|
// stamp is the timestamp leading a line `docker logs --timestamps` printed.
|
||||||
|
func stamp(line string) string {
|
||||||
|
t, _, _ := strings.Cut(line, " ")
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// envOf is one container's environment, values included — kept inside this process.
|
||||||
|
func (c *Client) envOf(ctx context.Context, ref string) ([]string, error) {
|
||||||
|
out, err := c.docker(ctx, "container", "inspect", "--format", "{{json .Config.Env}}", ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var env []string
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &env); err != nil {
|
||||||
|
return nil, fmt.Errorf("docker inspect answered an environment that is not JSON")
|
||||||
|
}
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leak is one secret a container printed: by name, never by value.
|
||||||
|
type Leak struct {
|
||||||
|
Container string `json:"container"`
|
||||||
|
HeldBy string `json:"held_by,omitempty"`
|
||||||
|
Module string `json:"module,omitempty"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
Lines int `json:"lines"`
|
||||||
|
First string `json:"first"`
|
||||||
|
Last string `json:"last"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanBudget is how long a scan may take: below the runtime's thirty-second call limit, so a scan of
|
||||||
|
// a machine with many containers answers what it read rather than nothing. ScanWidth is how many
|
||||||
|
// containers are read at once.
|
||||||
|
const (
|
||||||
|
ScanBudget = 25 * time.Second
|
||||||
|
ScanWidth = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanned is what one container's log held.
|
||||||
|
type scanned struct {
|
||||||
|
leaks []Leak
|
||||||
|
lines int
|
||||||
|
why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanOne reads one container's environment and log, and keeps only what was printed, by name.
|
||||||
|
func (c *Client) scanOne(ctx context.Context, ct Container, tail int) scanned {
|
||||||
|
env, err := c.envOf(ctx, ct.ID)
|
||||||
|
if err != nil {
|
||||||
|
return scanned{why: err.Error()}
|
||||||
|
}
|
||||||
|
lines, err := c.logLines(ctx, []string{"logs", "--timestamps", "--tail", strconv.Itoa(tail), ct.ID})
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return scanned{why: "not read within the scan's " + ScanBudget.String()}
|
||||||
|
}
|
||||||
|
return scanned{why: err.Error()}
|
||||||
|
}
|
||||||
|
byName, uris := leaksIn(lines, secretsIn(env))
|
||||||
|
if len(uris) > 0 {
|
||||||
|
byName["a password inside a URI (not from its environment)"] = uris
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(byName))
|
||||||
|
for n := range byName {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
out := scanned{lines: len(lines)}
|
||||||
|
for _, n := range names {
|
||||||
|
at := byName[n]
|
||||||
|
sort.Strings(at)
|
||||||
|
out.leaks = append(out.leaks, Leak{Container: ct.Name, HeldBy: ct.HeldBy, Module: ct.Module, Secret: n,
|
||||||
|
Lines: len(at), First: at[0], Last: at[len(at)-1]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretsInLogs scans the last `tail` lines of each container — the mesh's, or every one — for the
|
||||||
|
// secrets it was given. A container whose log could not be read is listed as unread, never as clean.
|
||||||
|
func (c *Client) SecretsInLogs(ctx context.Context, held string, tail int) (map[string]any, error) {
|
||||||
|
all, err := c.Containers(ctx, held, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, ScanBudget)
|
||||||
|
defer cancel()
|
||||||
|
results := make([]scanned, len(all))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
slots := make(chan struct{}, ScanWidth)
|
||||||
|
for i, ct := range all {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, ct Container) {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case slots <- struct{}{}:
|
||||||
|
defer func() { <-slots }()
|
||||||
|
results[i] = c.scanOne(ctx, ct, tail)
|
||||||
|
case <-ctx.Done():
|
||||||
|
results[i] = scanned{why: "not read within the scan's " + ScanBudget.String()}
|
||||||
|
}
|
||||||
|
}(i, ct)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
leaks := []Leak{}
|
||||||
|
unread := []map[string]string{}
|
||||||
|
count, read := 0, 0
|
||||||
|
for i, r := range results {
|
||||||
|
if r.why != "" {
|
||||||
|
unread = append(unread, map[string]string{"container": all[i].Name, "why": r.why})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
read += r.lines
|
||||||
|
leaks = append(leaks, r.leaks...)
|
||||||
|
}
|
||||||
|
verdict := "no container printed a secret it was given in the lines read"
|
||||||
|
if len(leaks) > 0 {
|
||||||
|
verdict = fmt.Sprintf("%d secret(s) printed into container logs: rotate each one after the program stops printing it, "+
|
||||||
|
"and recreate the container to drop its old log", len(leaks))
|
||||||
|
}
|
||||||
|
if len(unread) > 0 {
|
||||||
|
verdict += fmt.Sprintf("; %d container(s) not read, so not known to be clean", len(unread))
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"verdict": verdict, "leaks": leaks, "count": len(leaks), "containers_scanned": count, "lines_read": read,
|
||||||
|
"unread": unread,
|
||||||
|
"knows": "values in each container's environment named like a secret, the password in any URI it holds, and any " +
|
||||||
|
"URI carrying a password; a secret delivered only as a mounted file is caught only inside a URI",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The shape of the leak in hq issue 268: a server password announced at start and a database URI
|
||||||
|
// echoed whole. The values are made up for the test.
|
||||||
|
const (
|
||||||
|
serverPassword = "Zq8-server-pass_word"
|
||||||
|
dbPassword = "Db_pa55-word-xyz"
|
||||||
|
)
|
||||||
|
|
||||||
|
var lettaEnv = []string{
|
||||||
|
"LETTA_PG_URI=postgresql://letta@db:5432/letta",
|
||||||
|
"LETTA_SERVER_PASSWORD=" + serverPassword,
|
||||||
|
"OTHER_URI=postgresql://other:" + dbPassword + "@db:5432/other",
|
||||||
|
"PGPASSFILE=/run/secrets/pgpass",
|
||||||
|
"SECURE=true",
|
||||||
|
"AUTH_URL=https://id.example/auth",
|
||||||
|
"TOKEN_TTL=3600",
|
||||||
|
"POSTGRES_PASSWORD=letta",
|
||||||
|
"TZ=Europe/Brussels",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyValuesNamedAsSecretsAndPasswordsInURIsAreKnown(t *testing.T) {
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, s := range secretsIn(lettaEnv) {
|
||||||
|
got[s.Name] = s.Value
|
||||||
|
}
|
||||||
|
if got["LETTA_SERVER_PASSWORD"] != serverPassword || got["OTHER_URI (the password in its URI)"] != dbPassword {
|
||||||
|
t.Fatalf("missed a secret: %v", keys(got))
|
||||||
|
}
|
||||||
|
for _, not := range []string{"LETTA_PG_URI (the password in its URI)", "PGPASSFILE", "SECURE", "AUTH_URL", "TOKEN_TTL", "POSTGRES_PASSWORD", "TZ"} {
|
||||||
|
if _, ok := got[not]; ok {
|
||||||
|
t.Errorf("%s taken for a secret", not)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMachine(logs string) *fake {
|
||||||
|
env, _ := json.Marshal(lettaEnv)
|
||||||
|
return (&fake{}).
|
||||||
|
on("docker ps --all --quiet --no-trunc", Ran{Stdout: "aaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbb\n"}).
|
||||||
|
on("docker container inspect aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbb", Ran{Stdout: "[" + held + "," + stray + "]"}).
|
||||||
|
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: string(env) + "\n"}).
|
||||||
|
on("docker logs --timestamps --tail 5000 aaaaaaaaaaaa", Ran{Stdout: logs})
|
||||||
|
}
|
||||||
|
|
||||||
|
const leakyLog = "2026-10-05T19:16:40Z External Postgres configuration detected, using postgresql://letta@db:5432/letta\n" +
|
||||||
|
"2026-10-05T19:16:41Z Creating engine postgresql://other:" + dbPassword + "@db:5432/other\n" +
|
||||||
|
"2026-10-05T19:16:42Z ▶ Using secure mode with password: " + serverPassword + "\n" +
|
||||||
|
"2026-10-05T19:16:43Z connecting to mongodb://app:s3cr3t-elsewhere@mongo:27017\n" +
|
||||||
|
"2026-10-05T19:16:44Z Using database: postgresql://letta:***@db:5432/letta\n" +
|
||||||
|
"2026-10-05T19:20:42Z ▶ Using secure mode with password: " + serverPassword + "\n"
|
||||||
|
|
||||||
|
func TestAScanNamesEachPrintedSecretAndNeverItsValue(t *testing.T) {
|
||||||
|
got, err := client(scanMachine(leakyLog), 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(got)
|
||||||
|
for _, v := range []string{serverPassword, dbPassword, "s3cr3t-elsewhere"} {
|
||||||
|
if strings.Contains(string(raw), v) {
|
||||||
|
t.Fatalf("the answer carries a secret's value: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leaks := got["leaks"].([]Leak)
|
||||||
|
byName := map[string]Leak{}
|
||||||
|
for _, l := range leaks {
|
||||||
|
byName[l.Secret] = l
|
||||||
|
if l.Container != "mesh-web" || l.Module != "hello-web" || l.HeldBy != "hello-web.server" {
|
||||||
|
t.Errorf("finding not named by its container and module: %+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l := byName["LETTA_SERVER_PASSWORD"]; l.Lines != 2 || l.First != "2026-10-05T19:16:42Z" || l.Last != "2026-10-05T19:20:42Z" {
|
||||||
|
t.Errorf("server password: %+v", l)
|
||||||
|
}
|
||||||
|
if l := byName["OTHER_URI (the password in its URI)"]; l.Lines != 1 {
|
||||||
|
t.Errorf("password in a URI from the environment: %+v", l)
|
||||||
|
}
|
||||||
|
if l := byName["a password inside a URI (not from its environment)"]; l.Lines != 1 {
|
||||||
|
t.Errorf("a URI's password by its shape (and not a masked one): %+v", l)
|
||||||
|
}
|
||||||
|
if len(leaks) != 3 || got["containers_scanned"] != 1 {
|
||||||
|
t.Errorf("leaks %d, scanned %v (only the mesh's)", len(leaks), got["containers_scanned"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACleanLogIsSaidToBeClean(t *testing.T) {
|
||||||
|
got, err := client(scanMachine("2026-10-05T19:16:40Z started\n"), 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["count"] != 0 || !strings.HasPrefix(got["verdict"].(string), "no container") {
|
||||||
|
t.Errorf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAContainerWhoseLogCannotBeReadIsSaidSoRatherThanCalledClean(t *testing.T) {
|
||||||
|
f := scanMachine("")
|
||||||
|
f.rules = append([]rule{{"docker logs", Ran{Status: 1, Stderr: "Error response from daemon: configured logging driver does not support reading\n"}}}, f.rules...)
|
||||||
|
got, err := client(f, 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got["unread"].([]map[string]string)) != 1 || got["containers_scanned"] != 0 {
|
||||||
|
t.Errorf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsRedactWhatTheContainerPrintedOfItsSecrets(t *testing.T) {
|
||||||
|
env, _ := json.Marshal(lettaEnv)
|
||||||
|
f := (&fake{}).
|
||||||
|
on("docker logs", Ran{Stdout: leakyLog}).
|
||||||
|
on("docker container inspect --format {{json .Config.Env}} letta", Ran{Stdout: string(env)})
|
||||||
|
got, err := client(f, 1000).Logs(context.Background(), "letta", 200, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(got)
|
||||||
|
for _, v := range []string{serverPassword, dbPassword, "s3cr3t-elsewhere"} {
|
||||||
|
if strings.Contains(string(raw), v) {
|
||||||
|
t.Fatalf("docker_logs answered a secret: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines := got["lines"].([]string)
|
||||||
|
if !strings.Contains(lines[2], "[redacted: LETTA_SERVER_PASSWORD]") ||
|
||||||
|
!strings.Contains(lines[3], "mongodb://app:[redacted: a password in a URI]@mongo") ||
|
||||||
|
!strings.Contains(lines[4], "letta:***@db") || got["redacted"] != 4 {
|
||||||
|
t.Errorf("%v %v", lines, got["redacted"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsWithoutTheEnvironmentStillHideAURIsPasswordAndSaySo(t *testing.T) {
|
||||||
|
f := (&fake{}).on("docker logs", Ran{Stdout: leakyLog})
|
||||||
|
got, err := client(f, 1000).Logs(context.Background(), "letta", 200, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(got)
|
||||||
|
if strings.Contains(string(raw), dbPassword) || got["redaction"] == nil {
|
||||||
|
t.Errorf("%s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keys(m map[string]string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"docker_list",
|
"docker_list",
|
||||||
"docker_inspect",
|
"docker_inspect",
|
||||||
"docker_logs",
|
"docker_logs",
|
||||||
|
"docker_secrets_in_logs",
|
||||||
"docker_stats",
|
"docker_stats",
|
||||||
"docker_start",
|
"docker_start",
|
||||||
"docker_stop",
|
"docker_stop",
|
||||||
@@ -50,6 +51,24 @@
|
|||||||
"state": "running",
|
"state": "running",
|
||||||
"boot": "enabled"
|
"boot": "enabled"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "daemon",
|
||||||
|
"type": "file",
|
||||||
|
"path": "/etc/docker/daemon.json",
|
||||||
|
"mode": "0644",
|
||||||
|
"into": "json",
|
||||||
|
"content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"]}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "runtime",
|
||||||
|
"type": "service",
|
||||||
|
"unit": "docker.service",
|
||||||
|
"state": "running",
|
||||||
|
"boot": "enabled",
|
||||||
|
"reload-on": [
|
||||||
|
"daemon"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "prune-service",
|
"id": "prune-service",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from
|
|
||||||
// the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept
|
|
||||||
// running by one. This code exists only to read and steer the *live* state the daemon owns: who is
|
|
||||||
// banned now and until when, and the ban or release an operator asks for — the node-intrusion-
|
|
||||||
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
|
|
||||||
// mesh composes the jails and never writes the ban list.
|
|
||||||
//
|
|
||||||
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
|
|
||||||
// package this module declares on the machine, and the socket is root's: root is the module's
|
|
||||||
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be
|
|
||||||
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
|
|
||||||
|
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { accessSync, constants } from "node:fs";
|
|
||||||
import { isIP } from "node:net";
|
|
||||||
import { delimiter, join } from "node:path";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
const execFileP = promisify(execFile);
|
|
||||||
|
|
||||||
/** A command runner, so the verbs can be tested without a daemon. */
|
|
||||||
export type Runner = (cmd: string, args: string[]) => Promise<string>;
|
|
||||||
|
|
||||||
/** The command as it is run: as given when this process is root, else through sudo without a
|
|
||||||
* prompt. The daemon's socket answers only to root. */
|
|
||||||
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
|
|
||||||
if (uid === 0) return [cmd, args];
|
|
||||||
return ["sudo", ["-n", cmd, ...args]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a tool is on this machine: an executable of that name on the path, or where the
|
|
||||||
* system keeps its administration. */
|
|
||||||
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
|
|
||||||
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
|
|
||||||
return dirs.some((dir) => {
|
|
||||||
try {
|
|
||||||
accessSync(join(dir, tool), constants.X_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const execRunner: Runner = async (cmd, args) => {
|
|
||||||
if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`);
|
|
||||||
const [program, argv] = escalated(cmd, args);
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
|
|
||||||
return stdout;
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
|
|
||||||
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
|
|
||||||
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
|
|
||||||
// on its own stderr line, and the rest is the client's own answer.
|
|
||||||
if (program === "sudo") {
|
|
||||||
if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
|
|
||||||
if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
|
|
||||||
}
|
|
||||||
if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) {
|
|
||||||
throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account");
|
|
||||||
}
|
|
||||||
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
|
|
||||||
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
||||||
throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One jail as the daemon reports it. */
|
|
||||||
export interface JailStatus {
|
|
||||||
jail: string;
|
|
||||||
/** What the jail is reading: files or journal matches, as fail2ban names them. */
|
|
||||||
watching: string[];
|
|
||||||
/** Addresses with failures counted against them right now, and all failures since the jail started. */
|
|
||||||
failing: { now: number; total: number };
|
|
||||||
/** Addresses held right now, and all bans since the jail started. */
|
|
||||||
banned: { now: number; total: number; addresses: string[] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One ban as the daemon holds it. */
|
|
||||||
export interface Ban {
|
|
||||||
ip: string;
|
|
||||||
jail: string;
|
|
||||||
/** When the ban was placed, in the machine's local time as fail2ban prints it. */
|
|
||||||
since: string;
|
|
||||||
/** When the ban ends; "never" for a permanent ban. */
|
|
||||||
until: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JailSettings {
|
|
||||||
jail: string;
|
|
||||||
bantime: string;
|
|
||||||
findtime: string;
|
|
||||||
maxretry: number;
|
|
||||||
ignoreip: string[];
|
|
||||||
actions: string[];
|
|
||||||
/** The log files the jail reads, when it reads files. */
|
|
||||||
logpath: string[];
|
|
||||||
/** The journal match the jail reads, when it reads the journal. */
|
|
||||||
journalmatch: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Fail2banClient {
|
|
||||||
private readonly run: Runner;
|
|
||||||
|
|
||||||
constructor(run: Runner = execRunner) {
|
|
||||||
this.run = run;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The daemon as this machine has it, through its own client. */
|
|
||||||
static onThisMachine(): Fail2banClient {
|
|
||||||
return new Fail2banClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
private client(...args: string[]): Promise<string> {
|
|
||||||
return this.run("fail2ban-client", args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The jails the daemon runs, by name. */
|
|
||||||
async jails(): Promise<string[]> {
|
|
||||||
const out = await this.client("status");
|
|
||||||
const m = out.match(/Jail list:\s*(.*)/);
|
|
||||||
if (!m) return [];
|
|
||||||
return m[1].split(",").map((j) => j.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every jail with what it watches and holds, or one jail's detail. */
|
|
||||||
async status(jail?: string): Promise<{ jails: JailStatus[] }> {
|
|
||||||
const names = jail ? [jail] : await this.jails();
|
|
||||||
const jails: JailStatus[] = [];
|
|
||||||
for (const name of names) {
|
|
||||||
jails.push(parseJailStatus(name, await this.client("status", name)));
|
|
||||||
}
|
|
||||||
return { jails };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every address banned now, with the jail holding it and when the ban ends. */
|
|
||||||
async banned(jail?: string): Promise<{ banned: Ban[] }> {
|
|
||||||
const names = jail ? [jail] : await this.jails();
|
|
||||||
const banned: Ban[] = [];
|
|
||||||
for (const name of names) {
|
|
||||||
banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time")));
|
|
||||||
}
|
|
||||||
banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip));
|
|
||||||
return { banned };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */
|
|
||||||
async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> {
|
|
||||||
address(ip);
|
|
||||||
name(jail);
|
|
||||||
const out = await this.client("set", jail, "banip", ip);
|
|
||||||
const added = Number.parseInt(out.trim(), 10) || 0;
|
|
||||||
const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null;
|
|
||||||
return { banned: held, added };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */
|
|
||||||
async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> {
|
|
||||||
address(ip);
|
|
||||||
let out: string;
|
|
||||||
if (jail) {
|
|
||||||
name(jail);
|
|
||||||
out = await this.client("set", jail, "unbanip", ip);
|
|
||||||
} else {
|
|
||||||
out = await this.client("unban", ip);
|
|
||||||
}
|
|
||||||
return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One jail's effective settings — the module's own tool, beside the seat's verbs. */
|
|
||||||
async settings(jail: string): Promise<JailSettings> {
|
|
||||||
name(jail);
|
|
||||||
const get = (key: string) => this.client("get", jail, key);
|
|
||||||
const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([
|
|
||||||
get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"),
|
|
||||||
get("journalmatch"),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
jail,
|
|
||||||
bantime: bantime.trim(),
|
|
||||||
findtime: findtime.trim(),
|
|
||||||
maxretry: Number.parseInt(maxretry.trim(), 10),
|
|
||||||
ignoreip: listed(ignoreip),
|
|
||||||
actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean),
|
|
||||||
logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath),
|
|
||||||
journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */
|
|
||||||
function listed(out: string): string[] {
|
|
||||||
return out
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.replace(/^[\s|`-]+/, "").trim())
|
|
||||||
.filter((l, i) => i > 0 && l.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseJailStatus(jail: string, out: string): JailStatus {
|
|
||||||
const field = (label: string) => {
|
|
||||||
const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)"));
|
|
||||||
return m ? m[1].trim() : "";
|
|
||||||
};
|
|
||||||
const num = (label: string) => Number.parseInt(field(label), 10) || 0;
|
|
||||||
const watching = [field("File list"), field("Journal matches")].filter(Boolean);
|
|
||||||
return {
|
|
||||||
jail,
|
|
||||||
watching,
|
|
||||||
failing: { now: num("Currently failed"), total: num("Total failed") },
|
|
||||||
banned: {
|
|
||||||
now: num("Currently banned"),
|
|
||||||
total: num("Total banned"),
|
|
||||||
addresses: field("Banned IP list").split(/\s+/).filter(Boolean),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `get <jail> banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */
|
|
||||||
export function parseBans(jail: string, out: string): Ban[] {
|
|
||||||
const bans: Ban[] = [];
|
|
||||||
for (const line of out.split("\n")) {
|
|
||||||
const m = line.match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)/);
|
|
||||||
if (!m) continue;
|
|
||||||
bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] });
|
|
||||||
}
|
|
||||||
return bans;
|
|
||||||
}
|
|
||||||
|
|
||||||
function address(ip: string): void {
|
|
||||||
if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function name(jail: string): void {
|
|
||||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from the
|
||||||
|
// modules a machine runs (to-be 31) and written as declared resources; the daemon is kept running by
|
||||||
|
// one. This code exists only to read and steer the *live* state the daemon owns: who is banned now
|
||||||
|
// and until when, and the ban or release an operator asks for — the node-intrusion-prevention seat's
|
||||||
|
// four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the mesh composes the
|
||||||
|
// jails and never writes the ban list.
|
||||||
|
//
|
||||||
|
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
|
||||||
|
// package this module declares on the machine, and the socket is root's: root is the module's
|
||||||
|
// concern (ADR 0175 §4), and the runtime launching this binary runs as the operator's account (to-be
|
||||||
|
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runner runs one command and answers what it printed, so the verbs can be tested without a daemon.
|
||||||
|
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||||
|
|
||||||
|
// escalated is the command as it is run: as given when this process is root, else through sudo
|
||||||
|
// without a prompt. The daemon's socket answers only to root.
|
||||||
|
func escalated(uid int, name string, args []string) (string, []string) {
|
||||||
|
if uid == 0 {
|
||||||
|
return name, args
|
||||||
|
}
|
||||||
|
return "sudo", append([]string{"-n", name}, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// installed is whether a tool is on this machine: an executable of that name on the path, or where
|
||||||
|
// the system keeps its administration.
|
||||||
|
func installed(tool, path string) bool {
|
||||||
|
dirs := append(filepath.SplitList(path), "/usr/sbin", "/sbin", "/usr/bin")
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if dir == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(filepath.Join(dir, tool)); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var socketTrouble = regexp.MustCompile(`(?i)Failed to access socket path|Is fail2ban running|Permission denied to socket`)
|
||||||
|
|
||||||
|
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
if !installed(name, os.Getenv("PATH")) {
|
||||||
|
return "", fmt.Errorf("%s is not installed on this machine", name)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
program, argv := escalated(os.Getuid(), name, args)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd := exec.CommandContext(ctx, program, argv...)
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
|
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||||
|
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks on
|
||||||
|
// its own stderr line, and the rest is the client's own answer.
|
||||||
|
if program == "sudo" {
|
||||||
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
|
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||||
|
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if socketTrouble.MatchString(said) {
|
||||||
|
return "", errors.New("fail2ban is not running on this machine, or its socket does not answer the runtime's account")
|
||||||
|
}
|
||||||
|
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
|
||||||
|
var lines []string
|
||||||
|
for _, l := range strings.Split(said, "\n") {
|
||||||
|
if l = strings.TrimSpace(l); l != "" {
|
||||||
|
lines = append(lines, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) > 0 {
|
||||||
|
return "", errors.New(lines[len(lines)-1])
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted is a jail's count now and since it started.
|
||||||
|
type Counted struct {
|
||||||
|
Now int `json:"now"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held is what a jail holds: the count now and since it started, and the addresses.
|
||||||
|
type Held struct {
|
||||||
|
Now int `json:"now"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JailStatus is one jail as the daemon reports it.
|
||||||
|
type JailStatus struct {
|
||||||
|
Jail string `json:"jail"`
|
||||||
|
// Watching is what the jail is reading: files or journal matches, as fail2ban names them.
|
||||||
|
Watching []string `json:"watching"`
|
||||||
|
// Failing is the addresses with failures counted against them now, and all failures since the
|
||||||
|
// jail started.
|
||||||
|
Failing Counted `json:"failing"`
|
||||||
|
// Banned is the addresses held right now, and all bans since the jail started.
|
||||||
|
Banned Held `json:"banned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ban is one ban as the daemon holds it.
|
||||||
|
type Ban struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Jail string `json:"jail"`
|
||||||
|
// Since is when the ban was placed, in the machine's local time as fail2ban prints it.
|
||||||
|
Since string `json:"since"`
|
||||||
|
// Until is when the ban ends; "never" for a permanent ban.
|
||||||
|
Until string `json:"until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JailSettings is one jail's effective settings.
|
||||||
|
type JailSettings struct {
|
||||||
|
Jail string `json:"jail"`
|
||||||
|
Bantime string `json:"bantime"`
|
||||||
|
Findtime string `json:"findtime"`
|
||||||
|
Maxretry int `json:"maxretry"`
|
||||||
|
Ignoreip []string `json:"ignoreip"`
|
||||||
|
Actions []string `json:"actions"`
|
||||||
|
Logpath []string `json:"logpath"`
|
||||||
|
Journal string `json:"journalmatch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail2ban is the daemon as this machine has it, through its own client.
|
||||||
|
type Fail2ban struct {
|
||||||
|
Run Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Fail2ban) client(ctx context.Context, args ...string) (string, error) {
|
||||||
|
return f.Run(ctx, "fail2ban-client", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var jailList = regexp.MustCompile(`Jail list:[ \t]*(.*)`)
|
||||||
|
|
||||||
|
// Jails is the jails the daemon runs, by name.
|
||||||
|
func (f Fail2ban) Jails(ctx context.Context) ([]string, error) {
|
||||||
|
out, err := f.client(ctx, "status")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := jailList.FindStringSubmatch(out)
|
||||||
|
if m == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
var jails []string
|
||||||
|
for _, j := range strings.Split(m[1], ",") {
|
||||||
|
if j = strings.TrimSpace(j); j != "" {
|
||||||
|
jails = append(jails, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jails, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Fail2ban) named(ctx context.Context, jail string) ([]string, error) {
|
||||||
|
if jail != "" {
|
||||||
|
return []string{jail}, nil
|
||||||
|
}
|
||||||
|
return f.Jails(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is every jail with what it watches and holds, or one jail's detail.
|
||||||
|
func (f Fail2ban) Status(ctx context.Context, jail string) (map[string][]JailStatus, error) {
|
||||||
|
names, err := f.named(ctx, jail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jails := []JailStatus{}
|
||||||
|
for _, name := range names {
|
||||||
|
out, err := f.client(ctx, "status", name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jails = append(jails, parseJailStatus(name, out))
|
||||||
|
}
|
||||||
|
return map[string][]JailStatus{"jails": jails}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banned is every address banned now, with the jail holding it and when the ban ends, soonest to
|
||||||
|
// end first.
|
||||||
|
func (f Fail2ban) Banned(ctx context.Context, jail string) (map[string][]Ban, error) {
|
||||||
|
names, err := f.named(ctx, jail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
banned := []Ban{}
|
||||||
|
for _, name := range names {
|
||||||
|
out, err := f.client(ctx, "get", name, "banip", "--with-time")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
banned = append(banned, parseBans(name, out)...)
|
||||||
|
}
|
||||||
|
sort.SliceStable(banned, func(a, b int) bool {
|
||||||
|
if banned[a].Until != banned[b].Until {
|
||||||
|
return banned[a].Until < banned[b].Until
|
||||||
|
}
|
||||||
|
return banned[a].IP < banned[b].IP
|
||||||
|
})
|
||||||
|
return map[string][]Ban{"banned": banned}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanOutcome is a ban as held, and how many addresses the daemon said it added.
|
||||||
|
type BanOutcome struct {
|
||||||
|
Banned *Ban `json:"banned"`
|
||||||
|
Added int `json:"added"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ban bans one address in one jail now. The daemon's own answer is how many addresses it added.
|
||||||
|
func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) {
|
||||||
|
if err := address(ip); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := jailName(jail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := f.client(ctx, "set", jail, "banip", ip)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
added, _ := strconv.Atoi(strings.TrimSpace(out))
|
||||||
|
held, err := f.Banned(ctx, jail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
outcome := &BanOutcome{Added: added}
|
||||||
|
for _, b := range held["banned"] {
|
||||||
|
if b.IP == ip {
|
||||||
|
b := b
|
||||||
|
outcome.Banned = &b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcome, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Released is how many bans the daemon let go, of which address, from where.
|
||||||
|
type Released struct {
|
||||||
|
Released int `json:"released"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Jail string `json:"jail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unban lets one address go, from one jail or from every jail. The daemon's answer is how many it
|
||||||
|
// released.
|
||||||
|
func (f Fail2ban) Unban(ctx context.Context, ip, jail string) (*Released, error) {
|
||||||
|
if err := address(ip); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out string
|
||||||
|
var err error
|
||||||
|
if jail != "" {
|
||||||
|
if err := jailName(jail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err = f.client(ctx, "set", jail, "unbanip", ip)
|
||||||
|
} else {
|
||||||
|
out, err = f.client(ctx, "unban", ip)
|
||||||
|
jail = "every jail"
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
released, _ := strconv.Atoi(strings.TrimSpace(out))
|
||||||
|
return &Released{Released: released, IP: ip, Jail: jail}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings is one jail's effective settings — the module's own tool, beside the seat's verbs.
|
||||||
|
func (f Fail2ban) Settings(ctx context.Context, jail string) (*JailSettings, error) {
|
||||||
|
if err := jailName(jail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, key := range []string{"bantime", "findtime", "maxretry", "ignoreip", "actions", "logpath", "journalmatch"} {
|
||||||
|
out, err := f.client(ctx, "get", jail, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
got[key] = out
|
||||||
|
}
|
||||||
|
maxretry, _ := strconv.Atoi(strings.TrimSpace(got["maxretry"]))
|
||||||
|
s := &JailSettings{
|
||||||
|
Jail: jail,
|
||||||
|
Bantime: strings.TrimSpace(got["bantime"]),
|
||||||
|
Findtime: strings.TrimSpace(got["findtime"]),
|
||||||
|
Maxretry: maxretry,
|
||||||
|
Ignoreip: listed(got["ignoreip"]),
|
||||||
|
Actions: afterHeading(got["actions"]),
|
||||||
|
Logpath: []string{},
|
||||||
|
Journal: strings.Join(afterHeading(got["journalmatch"]), " "),
|
||||||
|
}
|
||||||
|
if !strings.Contains(got["logpath"], "No file is currently monitored") {
|
||||||
|
s.Logpath = listed(got["logpath"])
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var treeMarks = regexp.MustCompile("^[\\s|`-]+")
|
||||||
|
|
||||||
|
// listed reads fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading.
|
||||||
|
func listed(out string) []string {
|
||||||
|
items := []string{}
|
||||||
|
for i, l := range strings.Split(out, "\n") {
|
||||||
|
l = strings.TrimSpace(treeMarks.ReplaceAllString(l, ""))
|
||||||
|
if i > 0 && l != "" {
|
||||||
|
items = append(items, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterHeading is every non-empty line after the first, trimmed.
|
||||||
|
func afterHeading(out string) []string {
|
||||||
|
items := []string{}
|
||||||
|
for i, l := range strings.Split(out, "\n") {
|
||||||
|
if l = strings.TrimSpace(l); i > 0 && l != "" {
|
||||||
|
items = append(items, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJailStatus(jail, out string) JailStatus {
|
||||||
|
field := func(label string) string {
|
||||||
|
m := regexp.MustCompile(regexp.QuoteMeta(label) + `:\t?[ \t]*(.*)`).FindStringSubmatch(out)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
num := func(label string) int {
|
||||||
|
n, _ := strconv.Atoi(field(label))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
watching := []string{}
|
||||||
|
for _, w := range []string{field("File list"), field("Journal matches")} {
|
||||||
|
if w != "" {
|
||||||
|
watching = append(watching, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addresses := strings.Fields(field("Banned IP list"))
|
||||||
|
if addresses == nil {
|
||||||
|
addresses = []string{}
|
||||||
|
}
|
||||||
|
return JailStatus{
|
||||||
|
Jail: jail,
|
||||||
|
Watching: watching,
|
||||||
|
Failing: Counted{Now: num("Currently failed"), Total: num("Total failed")},
|
||||||
|
Banned: Held{Now: num("Currently banned"), Total: num("Total banned"), Addresses: addresses},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var banLine = regexp.MustCompile(`^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)`)
|
||||||
|
|
||||||
|
// parseBans reads `get <jail> banip --with-time`, one ban per line: "IP \tsince + seconds = until".
|
||||||
|
func parseBans(jail, out string) []Ban {
|
||||||
|
bans := []Ban{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
m := banLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
until := m[4]
|
||||||
|
if seconds, _ := strconv.Atoi(m[3]); seconds < 0 {
|
||||||
|
until = "never"
|
||||||
|
}
|
||||||
|
bans = append(bans, Ban{IP: m[1], Jail: jail, Since: m[2], Until: until})
|
||||||
|
}
|
||||||
|
return bans
|
||||||
|
}
|
||||||
|
|
||||||
|
func address(ip string) error {
|
||||||
|
if net.ParseIP(ip) == nil {
|
||||||
|
return fmt.Errorf("%q is not an address", ip)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var jailNamed = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||||
|
|
||||||
|
func jailName(jail string) error {
|
||||||
|
if !jailNamed.MatchString(jail) {
|
||||||
|
return fmt.Errorf("%q is not a jail's name", jail)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
|
||||||
|
// on the control node on 2026-10-02 (novox/hq ADR 0179).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusAll = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n"
|
||||||
|
const recidive = "Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
|
||||||
|
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
|
||||||
|
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n"
|
||||||
|
const sshd = "Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
|
||||||
|
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
|
||||||
|
" |- Total banned:\t150\n `- Banned IP list:\t\n"
|
||||||
|
const withTime = "195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
|
||||||
|
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n"
|
||||||
|
|
||||||
|
func fake(answers map[string]string, calls *[][]string) Runner {
|
||||||
|
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
if calls != nil {
|
||||||
|
*calls = append(*calls, append([]string{name}, args...))
|
||||||
|
}
|
||||||
|
if out, ok := answers[strings.Join(args, " ")]; ok {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unexpected %s %s", name, strings.Join(args, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx = context.Background()
|
||||||
|
|
||||||
|
func TestAJailsStatusIsReadIntoNumbersWhatItWatchesAndWhoItHolds(t *testing.T) {
|
||||||
|
got := parseJailStatus("recidive", recidive)
|
||||||
|
want := JailStatus{Jail: "recidive", Watching: []string{"/var/log/fail2ban.log"}, Failing: Counted{36, 149},
|
||||||
|
Banned: Held{9, 13, []string{"195.178.110.30", "45.148.10.240", "92.118.39.71"}}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
j := parseJailStatus("sshd", sshd)
|
||||||
|
if !reflect.DeepEqual(j.Watching, []string{"_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}) {
|
||||||
|
t.Errorf("watching %v", j.Watching)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(j.Banned, Held{0, 150, []string{}}) {
|
||||||
|
t.Errorf("banned %+v", j.Banned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusCoversEveryJailTheDaemonListsOrTheOneNamed(t *testing.T) {
|
||||||
|
var calls [][]string
|
||||||
|
f := Fail2ban{Run: fake(map[string]string{"status": statusAll, "status recidive": recidive, "status sshd": sshd}, &calls)}
|
||||||
|
all, err := f.Status(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all["jails"]) != 2 || all["jails"][0].Jail != "recidive" || all["jails"][1].Jail != "sshd" {
|
||||||
|
t.Errorf("%+v", all)
|
||||||
|
}
|
||||||
|
one, err := f.Status(ctx, "sshd")
|
||||||
|
if err != nil || len(one["jails"]) != 1 {
|
||||||
|
t.Fatalf("%+v %v", one, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(calls[len(calls)-1], []string{"fail2ban-client", "status", "sshd"}) {
|
||||||
|
t.Errorf("last call %v", calls[len(calls)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBansAreReadWithWhenTheyEndAPermanentOneAsNever(t *testing.T) {
|
||||||
|
bans := parseBans("recidive", withTime+"203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n")
|
||||||
|
if len(bans) != 3 {
|
||||||
|
t.Fatalf("%+v", bans)
|
||||||
|
}
|
||||||
|
if bans[0] != (Ban{IP: "195.178.110.30", Jail: "recidive", Since: "2026-09-26 23:18:47", Until: "2026-10-03 23:18:47"}) {
|
||||||
|
t.Errorf("%+v", bans[0])
|
||||||
|
}
|
||||||
|
if bans[2].Until != "never" {
|
||||||
|
t.Errorf("a permanent ban ends %q", bans[2].Until)
|
||||||
|
}
|
||||||
|
if got := parseBans("sshd", "\n"); len(got) != 0 {
|
||||||
|
t.Errorf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBannedGathersEveryJailsBansSoonestToEndFirst(t *testing.T) {
|
||||||
|
f := Fail2ban{Run: fake(map[string]string{
|
||||||
|
"status": statusAll,
|
||||||
|
"get recidive banip --with-time": withTime,
|
||||||
|
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
|
||||||
|
}, nil)}
|
||||||
|
got, err := f.Banned(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var order []string
|
||||||
|
for _, b := range got["banned"] {
|
||||||
|
order = append(order, b.IP+"@"+b.Jail)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(order, []string{"198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"}) {
|
||||||
|
t.Errorf("%v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.T) {
|
||||||
|
var calls [][]string
|
||||||
|
f := Fail2ban{Run: fake(map[string]string{
|
||||||
|
"set recidive banip 198.51.100.7": "1\n",
|
||||||
|
"get recidive banip --with-time": withTime + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
|
||||||
|
}, &calls)}
|
||||||
|
r, err := f.Ban(ctx, "198.51.100.7", "recidive")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.Added != 1 || r.Banned == nil || r.Banned.Until != "2026-10-09 17:00:00" {
|
||||||
|
t.Errorf("%+v", r)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(calls[0], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) {
|
||||||
|
t.Errorf("first call %v", calls[0])
|
||||||
|
}
|
||||||
|
if _, err := f.Ban(ctx, "not-an-ip", "recidive"); err == nil || !strings.Contains(err.Error(), "is not an address") {
|
||||||
|
t.Errorf("a non-address: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.Ban(ctx, "198.51.100.7", "a jail; rm"); err == nil || !strings.Contains(err.Error(), "is not a jail's name") {
|
||||||
|
t.Errorf("a non-name: %v", err)
|
||||||
|
}
|
||||||
|
if len(calls) != 2 {
|
||||||
|
t.Errorf("a refused ban reached the daemon: %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnbanReleasesFromOneJailOrFromEveryJail(t *testing.T) {
|
||||||
|
var calls [][]string
|
||||||
|
f := Fail2ban{Run: fake(map[string]string{"set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n"}, &calls)}
|
||||||
|
one, err := f.Unban(ctx, "198.51.100.7", "sshd")
|
||||||
|
if err != nil || *one != (Released{1, "198.51.100.7", "sshd"}) {
|
||||||
|
t.Errorf("%+v %v", one, err)
|
||||||
|
}
|
||||||
|
every, err := f.Unban(ctx, "198.51.100.7", "")
|
||||||
|
if err != nil || *every != (Released{2, "198.51.100.7", "every jail"}) {
|
||||||
|
t.Errorf("%+v %v", every, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(calls[1], []string{"fail2ban-client", "unban", "198.51.100.7"}) {
|
||||||
|
t.Errorf("%v", calls[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAJailsSettingsAreReadFromTheDaemonsListings(t *testing.T) {
|
||||||
|
f := Fail2ban{Run: fake(map[string]string{
|
||||||
|
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
|
||||||
|
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
|
||||||
|
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
|
||||||
|
"get sshd logpath": "No file is currently monitored\n",
|
||||||
|
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
|
||||||
|
}, nil)}
|
||||||
|
got, err := f.Settings(ctx, "sshd")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := &JailSettings{Jail: "sshd", Bantime: "86400", Findtime: "86400", Maxretry: 3,
|
||||||
|
Ignoreip: []string{"127.0.0.0/8", "10.10.0.0/24", "::1"}, Actions: []string{"iptables-allports-dualchain"},
|
||||||
|
Logpath: []string{}, Journal: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheClientRunsAsGivenByRootAndThroughSudoByAnyoneElse(t *testing.T) {
|
||||||
|
if p, a := escalated(0, "fail2ban-client", []string{"status"}); p != "fail2ban-client" || !reflect.DeepEqual(a, []string{"status"}) {
|
||||||
|
t.Errorf("as root: %s %v", p, a)
|
||||||
|
}
|
||||||
|
if p, a := escalated(1000, "fail2ban-client", []string{"set", "sshd", "banip", "198.51.100.7"}); p != "sudo" ||
|
||||||
|
!reflect.DeepEqual(a, []string{"-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"}) {
|
||||||
|
t.Errorf("as an account: %s %v", p, a)
|
||||||
|
}
|
||||||
|
if !installed("sh", "/bin:/usr/bin") || installed("no-such-client-of-the-mesh", "/bin:/usr/bin") {
|
||||||
|
t.Error("installed is wrong about sh or about a tool nobody has")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tools carry the seat's four verbs under the seat's name, and the module's own under its own.
|
||||||
|
func TestTheSeatsVerbsAndTheModulesOwnToolAreServed(t *testing.T) {
|
||||||
|
var names []string
|
||||||
|
for _, tool := range tools(Fail2ban{Run: fake(nil, nil)}) {
|
||||||
|
names = append(names, tool.Name)
|
||||||
|
}
|
||||||
|
want := []string{"node-intrusion-prevention.status", "node-intrusion-prevention.banned", "node-intrusion-prevention.ban",
|
||||||
|
"node-intrusion-prevention.unban", "fail2ban_settings"}
|
||||||
|
if !reflect.DeepEqual(names, want) {
|
||||||
|
t.Errorf("%v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The daemon on this machine, read only — status, bans and one jail's settings — when asked for with
|
||||||
|
// FAIL2BAN_LIVE=1: the shapes above are what fail2ban-client printed once, and this is what it prints
|
||||||
|
// now.
|
||||||
|
func TestTheLiveDaemonReadsBack(t *testing.T) {
|
||||||
|
if os.Getenv("FAIL2BAN_LIVE") != "1" {
|
||||||
|
t.Skip("set FAIL2BAN_LIVE=1 to read the daemon on this machine")
|
||||||
|
}
|
||||||
|
f := Fail2ban{Run: execRunner}
|
||||||
|
status, err := f.Status(ctx, "")
|
||||||
|
if err != nil || len(status["jails"]) == 0 {
|
||||||
|
t.Fatalf("status: %+v %v", status, err)
|
||||||
|
}
|
||||||
|
for _, j := range status["jails"] {
|
||||||
|
t.Logf("%s: watching %v, failing %d, banned %d now of %d", j.Jail, j.Watching, j.Failing.Now, j.Banned.Now, j.Banned.Total)
|
||||||
|
if len(j.Watching) == 0 {
|
||||||
|
t.Errorf("%s watches nothing as read", j.Jail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
banned, err := f.Banned(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("banned: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("%d bans held", len(banned["banned"]))
|
||||||
|
settings, err := f.Settings(ctx, "sshd")
|
||||||
|
if err != nil || settings.Maxretry == 0 || len(settings.Ignoreip) == 0 {
|
||||||
|
t.Fatalf("settings: %+v %v", settings, err)
|
||||||
|
}
|
||||||
|
t.Logf("sshd: bantime %s, maxretry %d, ignores %v", settings.Bantime, settings.Maxretry, settings.Ignoreip)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// fail2ban-tools (novox/hq to-be 31, ADR 0179): the intrusion prevention's tools. One binary, launched
|
||||||
|
// by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR
|
||||||
|
// 0198): the node-intrusion-prevention seat's four verbs — who is banned, the jails' state, ban one,
|
||||||
|
// let one go — and the module's own reading of a jail's settings. The jails themselves are composed
|
||||||
|
// by the mesh from the modules a machine runs and written as declared resources; these touch only
|
||||||
|
// what the running daemon holds.
|
||||||
|
//
|
||||||
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seat is the role this module holds.
|
||||||
|
const Seat = "node-intrusion-prevention"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools(Fail2ban{Run: execRunner})); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func arg(a map[string]any, k string) string {
|
||||||
|
v, _ := a[k].(string)
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||||
|
// subject. The module's own tools keep their bare names.
|
||||||
|
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||||
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools(f Fail2ban) []stdio.Tool {
|
||||||
|
ctx := context.Background()
|
||||||
|
oneJail := map[string]any{"jail": str("one jail (optional)")}
|
||||||
|
return []stdio.Tool{
|
||||||
|
verb("status", "Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
|
||||||
|
oneJail, func(a map[string]any) (any, error) { return f.Status(ctx, arg(a, "jail")) }),
|
||||||
|
verb("banned", "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
|
||||||
|
oneJail, func(a map[string]any) (any, error) { return f.Banned(ctx, arg(a, "jail")) }),
|
||||||
|
verb("ban", "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
|
||||||
|
map[string]any{"ip": str("the address"), "jail": str("the jail to hold it (recidive for the long ban)")},
|
||||||
|
func(a map[string]any) (any, error) { return f.Ban(ctx, arg(a, "ip"), arg(a, "jail")) }),
|
||||||
|
verb("unban", "Let one address go, from one jail or from every jail when none is named.",
|
||||||
|
map[string]any{"ip": str("the address"), "jail": str("one jail (optional)")},
|
||||||
|
func(a map[string]any) (any, error) { return f.Unban(ctx, arg(a, "ip"), arg(a, "jail")) }),
|
||||||
|
{Name: "fail2ban_settings",
|
||||||
|
Description: "One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
||||||
|
Input: map[string]any{"jail": str("the jail")},
|
||||||
|
Run: func(a map[string]any) (any, error) { return f.Settings(ctx, arg(a, "jail")) }},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module fail2ban
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
@@ -116,9 +116,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tools",
|
"name": "tools",
|
||||||
"kind": "bundle",
|
"kind": "bundle",
|
||||||
"language": "typescript",
|
"language": "go",
|
||||||
"entrypoints": [
|
"system": "arch",
|
||||||
"tools/index.js"
|
"from": "cmd/fail2ban-tools",
|
||||||
|
"binary": "fail2ban-tools",
|
||||||
|
"loads": [
|
||||||
|
"fail2ban-tools"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@novox/module-fail2ban",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@novox/mesh-sdk": "^0.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"typescript": "^5.6.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
|
|
||||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
|
|
||||||
// on the control node on 2026-10-02 (novox/hq ADR 0179).
|
|
||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts";
|
|
||||||
|
|
||||||
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
|
|
||||||
const RECIDIVE =
|
|
||||||
"Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
|
|
||||||
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
|
|
||||||
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n";
|
|
||||||
const SSHD =
|
|
||||||
"Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
|
|
||||||
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
|
|
||||||
" |- Total banned:\t150\n `- Banned IP list:\t\n";
|
|
||||||
const WITH_TIME =
|
|
||||||
"195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
|
|
||||||
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n";
|
|
||||||
|
|
||||||
function fake(answers: Record<string, string>, calls: string[][] = []): Runner {
|
|
||||||
return async (cmd, args) => {
|
|
||||||
calls.push([cmd, ...args]);
|
|
||||||
const key = args.join(" ");
|
|
||||||
if (key in answers) return answers[key];
|
|
||||||
throw new Error(`unexpected ${cmd} ${key}`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a jail's status is read into numbers, what it watches and who it holds", () => {
|
|
||||||
const s = parseJailStatus("recidive", RECIDIVE);
|
|
||||||
assert.deepEqual(s, {
|
|
||||||
jail: "recidive",
|
|
||||||
watching: ["/var/log/fail2ban.log"],
|
|
||||||
failing: { now: 36, total: 149 },
|
|
||||||
banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] },
|
|
||||||
});
|
|
||||||
const j = parseJailStatus("sshd", SSHD);
|
|
||||||
assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]);
|
|
||||||
assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("status covers every jail the daemon lists, or the one named", async () => {
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls));
|
|
||||||
const all = await f.status();
|
|
||||||
assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]);
|
|
||||||
const one = await f.status("sshd");
|
|
||||||
assert.equal(one.jails.length, 1);
|
|
||||||
assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bans are read with when they were placed and when they end, a permanent one as never", () => {
|
|
||||||
const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n");
|
|
||||||
assert.equal(bans.length, 3);
|
|
||||||
assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" });
|
|
||||||
assert.equal(bans[2].until, "never");
|
|
||||||
assert.deepEqual(parseBans("sshd", "\n"), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("banned gathers every jail's bans, soonest to end first", async () => {
|
|
||||||
const f = new Fail2banClient(fake({
|
|
||||||
status: STATUS,
|
|
||||||
"get recidive banip --with-time": WITH_TIME,
|
|
||||||
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
|
|
||||||
}));
|
|
||||||
const { banned } = await f.banned();
|
|
||||||
assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => {
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const f = new Fail2banClient(fake({
|
|
||||||
"set recidive banip 198.51.100.7": "1\n",
|
|
||||||
"get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
|
|
||||||
}, calls));
|
|
||||||
const r = await f.ban("198.51.100.7", "recidive");
|
|
||||||
assert.equal(r.added, 1);
|
|
||||||
assert.equal(r.banned?.until, "2026-10-09 17:00:00");
|
|
||||||
assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]);
|
|
||||||
await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/);
|
|
||||||
await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/);
|
|
||||||
assert.equal(calls.length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unban releases from one jail or from every jail", async () => {
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls));
|
|
||||||
assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" });
|
|
||||||
assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" });
|
|
||||||
assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a jail's settings are read from the daemon's listings", async () => {
|
|
||||||
const f = new Fail2banClient(fake({
|
|
||||||
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
|
|
||||||
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
|
|
||||||
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
|
|
||||||
"get sshd logpath": "No file is currently monitored\n",
|
|
||||||
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
|
|
||||||
}));
|
|
||||||
assert.deepEqual(await f.settings("sshd"), {
|
|
||||||
jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3,
|
|
||||||
ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"],
|
|
||||||
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
|
|
||||||
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
|
|
||||||
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
|
|
||||||
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
|
|
||||||
assert.equal(installed("sh"), true);
|
|
||||||
assert.equal(installed("no-such-client-of-the-mesh"), false);
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned,
|
|
||||||
// the jails' state, ban one, let one go — and the module's own reading of a jail's settings
|
|
||||||
// (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a
|
|
||||||
// machine runs and written as declared resources; these touch only what the running daemon holds.
|
|
||||||
|
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
|
||||||
import { Fail2banClient } from "../client.js";
|
|
||||||
|
|
||||||
export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "status",
|
|
||||||
description:
|
|
||||||
"Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
|
|
||||||
input: { jail: { type: "string", description: "one jail (optional)" } },
|
|
||||||
run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "banned",
|
|
||||||
description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
|
|
||||||
input: { jail: { type: "string", description: "one jail (optional)" } },
|
|
||||||
run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ban",
|
|
||||||
description:
|
|
||||||
"Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
|
|
||||||
input: {
|
|
||||||
ip: { type: "string", description: "the address" },
|
|
||||||
jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" },
|
|
||||||
},
|
|
||||||
run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unban",
|
|
||||||
description: "Let one address go, from one jail or from every jail when none is named.",
|
|
||||||
input: {
|
|
||||||
ip: { type: "string", description: "the address" },
|
|
||||||
jail: { type: "string", description: "one jail (optional)" },
|
|
||||||
},
|
|
||||||
run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "fail2ban_settings",
|
|
||||||
description:
|
|
||||||
"One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
|
||||||
input: { jail: { type: "string", description: "the jail" } },
|
|
||||||
run: async (args) => fail2ban.settings(String(args.jail ?? "")),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail2ban = Fail2banClient.onThisMachine();
|
|
||||||
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
|
|
||||||
// module holds it (ADR 0159, 0160). The module's own under its own.
|
|
||||||
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
|
|
||||||
registerModuleTools("fail2ban", () => getFail2banTools(fail2ban));
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"client.ts",
|
|
||||||
"tools/index.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# forticlient
|
||||||
|
|
||||||
|
The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the
|
||||||
|
operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned
|
||||||
|
only where a display server is held on the same machine.
|
||||||
|
|
||||||
|
**This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no
|
||||||
|
credential, no gateway, no certificate is declared, read, printed or stored by the module or its
|
||||||
|
tools. The tools report running and connected state only.
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled |
|
||||||
|
|
||||||
|
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||||
|
|
||||||
|
- **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on
|
||||||
|
both workstations it is a foreign (AUR) package that repackages the vendor's build, installed
|
||||||
|
explicitly. The host installs from the official repositories only, so the module cannot declare it.
|
||||||
|
ADR 0205's pinned archive does not fit: it is a vendor binary set with a root service, a firewall
|
||||||
|
helper and an install script. It waits for the mesh's package repository (research 027 question 1,
|
||||||
|
option P2). Until then a fresh workstation installs it by hand.
|
||||||
|
- **The service is declared, and so depended on.** `forticlient.service` is the package's unit, running
|
||||||
|
and enabled on both workstations. Declared running and enabled, it is held in that state, and on a
|
||||||
|
machine without the package the host refuses it by name (*does not exist on this machine*): loud,
|
||||||
|
never a silent pass. The `asus-zephyrus-g14` module does the same with its foreign daemons. The
|
||||||
|
module never restarts it: a change to nothing of the module's would, and nothing of the module's
|
||||||
|
changes.
|
||||||
|
- **The configuration stays the operator's, and unread.** `/etc/forticlient/`, the client's database
|
||||||
|
under `/opt/forticlient/`, the account's FortiClient settings, the VPN profiles, saved credentials
|
||||||
|
and certificates are set in the client's own window. They are found (ADR 0182), and unlike any other
|
||||||
|
found file, the tools do not even read them.
|
||||||
|
|
||||||
|
## How it starts: the vendor's autostart entry, and nothing else
|
||||||
|
|
||||||
|
The tray has two processes: `fortitraylauncher`, which starts and watches `fortitray`. The package's
|
||||||
|
install script links `/etc/xdg/autostart/Fortitray.desktop` to the package's
|
||||||
|
`/opt/forticlient/Fortitray.desktop` (`Exec=/opt/forticlient/fortitraylauncher`). The session runs it
|
||||||
|
once at login through the `i3` module's `dex --autostart --environment i3`. **That entry is the tray's
|
||||||
|
one start.** The module adds no `xinitrc` slot and no `node-display-session` exec, because either would
|
||||||
|
start it a second time. The link is the vendor's, made by its install script; the mesh does not make
|
||||||
|
or remove it.
|
||||||
|
|
||||||
|
The tunnel is not the tray's: the service's processes hold it, as root. Ending the tray leaves a
|
||||||
|
connected tunnel connected.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
They are served by the node's runtime as the operator account (ADR 0175).
|
||||||
|
|
||||||
|
| tool | does |
|
||||||
|
|---|---|
|
||||||
|
| `forticlient_status` (r) | <ul><li>the installed version, and that it is from outside the official repositories</li><li>the service: active, enabled</li><li>whether the launcher and the tray run: pid, since, and the scope or unit they run in</li><li>what starts the tray at login</li><li>connected or not, as the number of the client's tunnel interfaces that are up</li></ul> |
|
||||||
|
| `forticlient_restart` (a) | asks the tray and its launcher to end (SIGTERM), forces them after 5 s, and starts the launcher in the operator's session as a transient user unit `mesh-forticlient-tray`, so it outlives the tools runtime. The launcher starts the tray. The service and the tunnel are not touched. Refused plainly when nobody is logged in to the desktop |
|
||||||
|
| `forticlient_check` (r) | <ul><li>the package is installed</li><li>the service is running and enabled</li><li>exactly one start: the vendor's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one launcher and one tray run in a desktop session</li></ul>Being connected is never a finding: that is the operator's to decide. Each finding says what to do |
|
||||||
|
|
||||||
|
**What the tools never touch**, held by the tests (a fake machine carries a profile, a gateway, an
|
||||||
|
address, a secret and a certificate where the client keeps them, and no answer may hold any of them):
|
||||||
|
|
||||||
|
- no file under `/etc/forticlient` or the account's FortiClient settings is opened, and under
|
||||||
|
`/opt/forticlient` only the tray's autostart entry, through its link (it names the launcher and
|
||||||
|
nothing else);
|
||||||
|
- the vendor's command-line client and `fortivpn` are never run, and its logs are never read;
|
||||||
|
- a process is named by its command name only, never by its arguments;
|
||||||
|
- *connected* is whether an interface named `fctvpn…` is up, from its flags. The interface's name
|
||||||
|
(it carries an identifier) and its addresses are never answered. A tunnel of a kind that brings up
|
||||||
|
no such interface (IPsec) is not seen, and the answer says *not connected*.
|
||||||
|
|
||||||
|
## What changes when it is assigned
|
||||||
|
|
||||||
|
| | laptop | desktop |
|
||||||
|
|---|---|---|
|
||||||
|
| package | none: `forticlient-vpn` 7.4.3.5411, explicit, foreign | the same |
|
||||||
|
| service | none: running and enabled | the same |
|
||||||
|
| tray | none: dex starts it from the vendor's entry, in the login session's scope | none on disk. **No tray runs now:** that session began before `dex` was installed, and the predecessor's window manager never started it. The next login is the first that starts it |
|
||||||
|
|
||||||
|
## Migration (ADR 0182)
|
||||||
|
|
||||||
|
Nothing is required on either machine. On the desktop, log out and in once, or run
|
||||||
|
`forticlient_restart`, and the tray runs from its one start. `forticlient_check` then answers `ok`.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
Everything of the client's: its configuration and database, the VPN profiles and credentials, its
|
||||||
|
logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package itself.
|
||||||
|
|
||||||
|
## Relies on
|
||||||
|
|
||||||
|
- **The package, installed by hand.** Without it the host refuses the service by name.
|
||||||
|
- **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does
|
||||||
|
not start. `forticlient_check` says so.
|
||||||
|
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||||
|
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||||
|
|
||||||
|
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||||
|
compared := 0
|
||||||
|
for _, module := range carriers {
|
||||||
|
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||||
|
mine, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||||
|
if err != nil || !bytes.Equal(mine, theirs) {
|
||||||
|
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compared++
|
||||||
|
}
|
||||||
|
if compared == 0 {
|
||||||
|
t.Log("no sibling copies beside this module")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||||
|
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||||
|
// node's tool runtime (novox/hq ADR 0208).
|
||||||
|
//
|
||||||
|
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||||
|
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||||
|
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||||
|
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||||
|
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||||
|
//
|
||||||
|
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||||
|
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||||
|
//
|
||||||
|
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||||
|
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||||
|
// to at most MostRead.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command and read is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 10 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
MostRead = 16 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Output is what a command did.
|
||||||
|
type Output struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Code int
|
||||||
|
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||||
|
Err error
|
||||||
|
Cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||||
|
var (
|
||||||
|
ErrNotInstalled = errors.New("not installed")
|
||||||
|
ErrTimedOut = errors.New("timed out")
|
||||||
|
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||||
|
ErrNoSession = errors.New("no graphical session")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||||
|
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||||
|
|
||||||
|
// Machine is what the tools read and act on.
|
||||||
|
type Machine struct {
|
||||||
|
Root string // "" on the machine; a fake root in tests
|
||||||
|
Home string // the operator's home, as the machine names it
|
||||||
|
UID int
|
||||||
|
Run Runner
|
||||||
|
Kill func(pid int, sig syscall.Signal) error
|
||||||
|
Sleep func(time.Duration)
|
||||||
|
Now func() time.Time
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMachine is the machine the bundle runs on.
|
||||||
|
func NewMachine() *Machine {
|
||||||
|
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||||
|
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||||
|
func operatorHome() string {
|
||||||
|
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
h, _ := os.UserHomeDir()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||||
|
|
||||||
|
// home is a path under the operator's home, on this machine's filesystem.
|
||||||
|
func (m *Machine) home(rel ...string) string {
|
||||||
|
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||||
|
func (m *Machine) tilde(p string) string {
|
||||||
|
if m.Home != "" && m.Home != "/" {
|
||||||
|
h := strings.TrimSuffix(m.Home, "/")
|
||||||
|
if p == h {
|
||||||
|
return "~"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, h+"/") {
|
||||||
|
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||||
|
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||||
|
if timeout <= 0 || timeout > m.Timeout {
|
||||||
|
timeout = m.Timeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
return m.Run(ctx, env, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||||
|
func failed(o Output, name string, args ...string) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(o.Err, ErrNotInstalled):
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", name)
|
||||||
|
case errors.Is(o.Err, ErrTimedOut):
|
||||||
|
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||||
|
case o.Err != nil:
|
||||||
|
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||||
|
case o.Code != 0:
|
||||||
|
said := strings.TrimSpace(o.Stderr)
|
||||||
|
if said == "" {
|
||||||
|
said = strings.TrimSpace(o.Stdout)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
type capped struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *capped) Write(p []byte) (int, error) {
|
||||||
|
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||||
|
if room > 0 {
|
||||||
|
c.b.Write(p[:room])
|
||||||
|
}
|
||||||
|
c.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return c.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||||
|
path, err := exec.LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
return Output{Code: 127, Err: ErrNotInstalled}
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, path, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
var out, errs capped
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
err = cmd.Run()
|
||||||
|
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
o.Code, o.Err = 124, ErrTimedOut
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
o.Code = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
o.Code, o.Err = 127, err
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBounded reads a file to at most MostRead bytes.
|
||||||
|
func readBounded(path string) ([]byte, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proc is one process of the account.
|
||||||
|
type Proc struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||||
|
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||||
|
StartedIn string `json:"started_in,omitempty"`
|
||||||
|
Since string `json:"since,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// procs are this account's processes named comm, oldest first.
|
||||||
|
func (m *Machine) procs(comm string) []Proc {
|
||||||
|
entries, err := os.ReadDir(m.path("/proc"))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
boot := m.bootTime()
|
||||||
|
var out []Proc
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||||
|
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||||
|
if p.Command == "" {
|
||||||
|
p.Command = comm
|
||||||
|
}
|
||||||
|
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||||
|
line := strings.Split(cg, "\n")[0]
|
||||||
|
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||||
|
}
|
||||||
|
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||||
|
p.Since = t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||||
|
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||||
|
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||||
|
// The program is the first word of the command line, or the second for a script run by its
|
||||||
|
// interpreter. An empty word keeps every process named comm.
|
||||||
|
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||||
|
var out []Proc
|
||||||
|
for _, p := range m.procs(comm) {
|
||||||
|
f := strings.Fields(p.Command)
|
||||||
|
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||||
|
func (m *Machine) uidOf(dir string) int {
|
||||||
|
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||||
|
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||||
|
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) bootTime() int64 {
|
||||||
|
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||||
|
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||||
|
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||||
|
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||||
|
i := strings.LastIndexByte(stat, ')')
|
||||||
|
if i < 0 || boot == 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
f := strings.Fields(stat[i+1:])
|
||||||
|
if len(f) < 20 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return time.Unix(boot+ticks/100, 0), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTrimmed(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is what a tool needs to start something on the operator's desktop.
|
||||||
|
type Session struct {
|
||||||
|
Display string `json:"display"`
|
||||||
|
XAuthority string `json:"xauthority,omitempty"`
|
||||||
|
Bus string `json:"bus,omitempty"`
|
||||||
|
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||||
|
From string `json:"found_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionHolders are the processes whose environment is the session's, best first.
|
||||||
|
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||||
|
|
||||||
|
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||||
|
func (m *Machine) session() (Session, error) {
|
||||||
|
entries, _ := os.ReadDir(m.path("/proc"))
|
||||||
|
best, bestRank := -1, len(sessionHolders)+1
|
||||||
|
var env map[string]string
|
||||||
|
var from string
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||||
|
if m.uidOf(dir) != m.UID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vars := parseEnviron(raw)
|
||||||
|
if vars["DISPLAY"] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||||
|
rank := len(sessionHolders)
|
||||||
|
for i, h := range sessionHolders {
|
||||||
|
if h == comm {
|
||||||
|
rank = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||||
|
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env == nil {
|
||||||
|
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||||
|
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||||
|
}
|
||||||
|
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||||
|
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||||
|
if s.RuntimeDir == "" {
|
||||||
|
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||||
|
}
|
||||||
|
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||||
|
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||||
|
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||||
|
func (m *Machine) bus() []string {
|
||||||
|
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||||
|
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||||
|
func (s Session) Env() []string {
|
||||||
|
var env []string
|
||||||
|
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||||
|
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||||
|
if kv[1] != "" {
|
||||||
|
env = append(env, kv[0]+"="+kv[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnviron(raw []byte) map[string]string {
|
||||||
|
env := map[string]string{}
|
||||||
|
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||||
|
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||||
|
env[string(kv[:i])] = string(kv[i+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||||
|
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||||
|
// first, so the fixed name means at most one.
|
||||||
|
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||||
|
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||||
|
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||||
|
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||||
|
if kv[1] != "" {
|
||||||
|
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call = append(append(call, "--"), argv...)
|
||||||
|
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||||
|
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||||
|
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||||
|
var ps []Proc
|
||||||
|
for _, c := range comms {
|
||||||
|
ps = append(ps, m.procs(c)...)
|
||||||
|
}
|
||||||
|
return m.stopProcs(grace, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopProcs ends the processes given, as stop does.
|
||||||
|
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||||
|
var pids []int
|
||||||
|
for _, p := range ps {
|
||||||
|
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||||
|
pids = append(pids, p.PID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alive := func() []int {
|
||||||
|
var left []int
|
||||||
|
for _, pid := range pids {
|
||||||
|
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||||
|
left = append(left, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
step := 200 * time.Millisecond
|
||||||
|
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||||
|
m.Sleep(step)
|
||||||
|
}
|
||||||
|
left := alive()
|
||||||
|
for _, pid := range left {
|
||||||
|
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||||
|
killed = append(killed, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gone := map[int]bool{}
|
||||||
|
for _, pid := range left {
|
||||||
|
gone[pid] = true
|
||||||
|
}
|
||||||
|
for _, pid := range pids {
|
||||||
|
if !gone[pid] {
|
||||||
|
ended = append(ended, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ended, killed
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||||
|
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||||
|
|
||||||
|
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||||
|
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||||
|
step := 250 * time.Millisecond
|
||||||
|
for waited := time.Duration(0); ; waited += step {
|
||||||
|
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
m.Sleep(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||||
|
func desktopEntry(path string) map[string]string {
|
||||||
|
raw, err := readBounded(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
in := false
|
||||||
|
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||||
|
for s.Scan() {
|
||||||
|
l := strings.TrimSpace(s.Text())
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(l, "["):
|
||||||
|
in = l == "[Desktop Entry]"
|
||||||
|
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||||
|
if i := strings.IndexByte(l, '='); i > 0 {
|
||||||
|
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||||
|
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||||
|
type Autostart struct {
|
||||||
|
Entry string `json:"entry"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Exec string `json:"exec,omitempty"`
|
||||||
|
Starts bool `json:"starts"`
|
||||||
|
Because string `json:"because,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||||
|
func (m *Machine) autostart(name string) Autostart {
|
||||||
|
a := Autostart{Entry: name}
|
||||||
|
user := m.home(".config", "autostart", name)
|
||||||
|
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||||
|
var e map[string]string
|
||||||
|
switch {
|
||||||
|
case exists(user):
|
||||||
|
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||||
|
case exists(system):
|
||||||
|
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||||
|
default:
|
||||||
|
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
a.Exec = e["Exec"]
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(e["Hidden"], "true"):
|
||||||
|
a.Because = "Hidden=true"
|
||||||
|
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||||
|
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||||
|
case a.Exec == "":
|
||||||
|
a.Because = "the entry has no Exec"
|
||||||
|
default:
|
||||||
|
a.Starts = true
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||||
|
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||||
|
func (m *Machine) i3Starts(word string) []string {
|
||||||
|
files := []string{m.home(".config", "i3", "config")}
|
||||||
|
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||||
|
files = append(files, more...)
|
||||||
|
var out []string
|
||||||
|
for _, f := range files {
|
||||||
|
raw, err := readBounded(f)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for n, l := range strings.Split(string(raw), "\n") {
|
||||||
|
t := strings.TrimSpace(l)
|
||||||
|
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range strings.Fields(t)[1:] {
|
||||||
|
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||||
|
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||||
|
func (m *Machine) installed(pkg string) (string, error) {
|
||||||
|
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||||
|
if o.Err != nil {
|
||||||
|
return "", failed(o, "pacman", "-Q", pkg)
|
||||||
|
}
|
||||||
|
if o.Code != 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
f := strings.Fields(o.Stdout)
|
||||||
|
if len(f) < 2 {
|
||||||
|
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||||
|
}
|
||||||
|
return f[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finding is one thing a check found wrong, and what to do about it.
|
||||||
|
type Finding struct {
|
||||||
|
What string `json:"what"`
|
||||||
|
Do string `json:"do,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||||
|
// application's bundle (copies_test.go).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testHome = "/home/operator"
|
||||||
|
|
||||||
|
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||||
|
type fake struct {
|
||||||
|
*Machine
|
||||||
|
t *testing.T
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []string
|
||||||
|
answer func(name string, args []string) Output
|
||||||
|
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||||
|
onStart func(argv []string)
|
||||||
|
// stubborn pids ignore SIGTERM.
|
||||||
|
stubborn map[int]bool
|
||||||
|
signals []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFake(t *testing.T) *fake {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||||
|
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||||
|
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||||
|
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
f.mu.Unlock()
|
||||||
|
if name == "systemd-run" && f.onStart != nil {
|
||||||
|
for i, a := range args {
|
||||||
|
if a == "--" {
|
||||||
|
f.onStart(args[i+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.answer != nil {
|
||||||
|
return f.answer(name, args)
|
||||||
|
}
|
||||||
|
return Output{}
|
||||||
|
}
|
||||||
|
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||||
|
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||||
|
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||||
|
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) write(path, content string) {
|
||||||
|
f.t.Helper()
|
||||||
|
p := filepath.Join(f.Root, path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||||
|
f.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||||
|
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||||
|
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||||
|
f.write(d+"comm", comm+"\n")
|
||||||
|
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||||
|
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||||
|
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||||
|
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||||
|
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||||
|
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) desktopSession() {
|
||||||
|
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||||
|
f.write("/run/user/1000/bus", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) called(prefix string) bool {
|
||||||
|
for _, c := range f.calls {
|
||||||
|
if strings.HasPrefix(c, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||||
|
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||||
|
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||||
|
got := f.procs("worker")
|
||||||
|
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||||
|
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||||
|
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||||
|
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||||
|
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||||
|
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||||
|
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||||
|
f.desktopSession()
|
||||||
|
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||||
|
s, err := f.session()
|
||||||
|
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||||
|
!strings.Contains(s.From, "i3") {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.desktopSession()
|
||||||
|
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||||
|
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||||
|
f.stubborn[21] = true
|
||||||
|
ended, killed := f.stop(time.Second, "app")
|
||||||
|
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||||
|
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||||
|
}
|
||||||
|
s, _ := f.session()
|
||||||
|
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||||
|
"/.Xauthority -- /usr/bin/app --background"
|
||||||
|
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||||
|
t.Fatalf("%q", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||||
|
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||||
|
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||||
|
t.Fatalf("%+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||||
|
f := newFake(t)
|
||||||
|
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||||
|
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||||
|
got := f.i3Starts("app")
|
||||||
|
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||||
|
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := failed(Output{}, "true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||||
|
t.Fatalf("%+v", o)
|
||||||
|
}
|
||||||
|
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||||
|
t.Fatalf("%+v", o)
|
||||||
|
}
|
||||||
|
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||||
|
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||||
|
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// FortiClient as the tools see it: the vendor's scheduler service, the tray and the launcher that
|
||||||
|
// starts it, the tray's XDG autostart entry, and whether a tunnel is up. This is the operator's work
|
||||||
|
// VPN, so the tools never read its profiles, its credentials, its gateways or its certificates: no file
|
||||||
|
// under /etc/forticlient or the account's FortiClient settings is opened, and under /opt/forticlient
|
||||||
|
// only the tray's autostart entry (through its link in /etc/xdg/autostart); the vendor's command-line
|
||||||
|
// client is never run, its logs are never read, a process is named by its command name only (never its
|
||||||
|
// arguments), and a tunnel is counted, never named or addressed.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
launcherComm = "fortitraylaunch" // the kernel keeps 15 characters of fortitraylauncher
|
||||||
|
launcherWord = "fortitraylauncher"
|
||||||
|
trayComm = "fortitray"
|
||||||
|
launcherBin = "/opt/forticlient/fortitraylauncher"
|
||||||
|
entryName = "Fortitray.desktop"
|
||||||
|
restartAs = "mesh-forticlient-tray"
|
||||||
|
packageFor = "forticlient-vpn"
|
||||||
|
serviceUnit = "forticlient.service"
|
||||||
|
// tunnelPrefix starts the name of the interface the client brings up for a connected tunnel.
|
||||||
|
tunnelPrefix = "fctvpn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// named keeps a process's command name and drops its arguments.
|
||||||
|
func named(ps []Proc, comm string) []Proc {
|
||||||
|
out := []Proc{}
|
||||||
|
for _, p := range ps {
|
||||||
|
p.Command = comm
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunnel is whether the client holds a tunnel up: how many of its tunnel interfaces exist and are up.
|
||||||
|
// Their names and addresses are never answered.
|
||||||
|
type Tunnel struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Up int `json:"tunnels_up"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) tunnel() Tunnel {
|
||||||
|
t := Tunnel{}
|
||||||
|
entries, _ := os.ReadDir(m.path("/sys/class/net"))
|
||||||
|
for _, e := range entries {
|
||||||
|
if !strings.HasPrefix(e.Name(), tunnelPrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flags, err := strconv.ParseUint(strings.TrimPrefix(readTrimmed(m.path(filepath.Join("/sys/class/net", e.Name(), "flags"))), "0x"), 16, 32)
|
||||||
|
if err == nil && flags&1 == 1 { // IFF_UP
|
||||||
|
t.Up++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Connected = t.Up > 0
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service is the vendor's scheduler, which holds the tunnel.
|
||||||
|
type Service struct {
|
||||||
|
Active string `json:"active"`
|
||||||
|
Enabled string `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Machine) service() Service {
|
||||||
|
word := func(verb string) string {
|
||||||
|
o := m.cmd(5*time.Second, nil, "systemctl", verb, serviceUnit)
|
||||||
|
if f := strings.Fields(o.Stdout); o.Err == nil && len(f) > 0 {
|
||||||
|
return f[0]
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return Service{Active: word("is-active"), Enabled: word("is-enabled")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreign says whether the package came from outside the official repositories (pacman -Qm).
|
||||||
|
func (m *Machine) foreign() bool {
|
||||||
|
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
|
||||||
|
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusAnswer is what forticlient_status answers.
|
||||||
|
type StatusAnswer struct {
|
||||||
|
Installed string `json:"installed,omitempty"`
|
||||||
|
Foreign bool `json:"outside_official_repositories"`
|
||||||
|
Service Service `json:"service"`
|
||||||
|
Launcher []Proc `json:"launcher"`
|
||||||
|
Tray []Proc `json:"tray"`
|
||||||
|
StartedBy Autostart `json:"started_by"`
|
||||||
|
Tunnel Tunnel `json:"tunnel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status reads the client's running state, and nothing of its configuration.
|
||||||
|
func (m *Machine) Status() (StatusAnswer, error) {
|
||||||
|
s := StatusAnswer{Service: m.service(), Launcher: named(m.procs(launcherComm), launcherWord),
|
||||||
|
Tray: named(m.procs(trayComm), trayComm), StartedBy: m.autostart(entryName), Tunnel: m.tunnel()}
|
||||||
|
v, err := m.installed(packageFor)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.Installed = v
|
||||||
|
if v != "" {
|
||||||
|
s.Foreign = m.foreign()
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartAnswer is what forticlient_restart answers.
|
||||||
|
type RestartAnswer struct {
|
||||||
|
Ended []int `json:"ended"`
|
||||||
|
Killed []int `json:"killed,omitempty"`
|
||||||
|
Running []Proc `json:"running"`
|
||||||
|
Session Session `json:"session"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
// Tunnel is after the restart: the tray is only the client's face, the service holds the tunnel.
|
||||||
|
Tunnel Tunnel `json:"tunnel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart ends the tray and its launcher and starts the launcher again in the operator's session,
|
||||||
|
// under the account's service manager. The launcher starts the tray. The tunnel is the service's,
|
||||||
|
// and is not touched.
|
||||||
|
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||||
|
s, err := m.session()
|
||||||
|
if err != nil {
|
||||||
|
return RestartAnswer{}, err
|
||||||
|
}
|
||||||
|
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||||
|
a.Ended, a.Killed = m.stop(5*time.Second, launcherComm, trayComm)
|
||||||
|
if err := m.detach(s, restartAs, launcherBin); err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
a.Running = named(m.waitFor(launcherComm, 4*time.Second), launcherWord)
|
||||||
|
a.Tunnel = m.tunnel()
|
||||||
|
if len(a.Running) == 0 {
|
||||||
|
return a, fmt.Errorf("the launcher was started as %s but no %s process appeared within 4 s",
|
||||||
|
a.Unit, launcherWord)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAnswer is what forticlient_check answers. Being connected or not is never a finding: that is
|
||||||
|
// the operator's choice, and forticlient_status says which.
|
||||||
|
type CheckAnswer struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Findings []Finding `json:"findings"`
|
||||||
|
Starts []string `json:"starts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check verifies what the module promises and relies on: the package (found, not installed by the
|
||||||
|
// mesh); the service running and enabled; one start for the tray (the vendor's autostart entry, which
|
||||||
|
// the session's dex runs); and the tray running once in a desktop session.
|
||||||
|
func (m *Machine) Check() (CheckAnswer, error) {
|
||||||
|
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||||
|
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||||
|
v, err := m.installed(packageFor)
|
||||||
|
if err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
add("forticlient-vpn is not installed. It is outside the official repositories, so the mesh does not install it",
|
||||||
|
"install it from the AUR by hand")
|
||||||
|
}
|
||||||
|
if s := m.service(); s.Active != "active" || s.Enabled != "enabled" {
|
||||||
|
add(fmt.Sprintf("the service %s is %s and %s", serviceUnit, s.Active, s.Enabled),
|
||||||
|
"push the module, which declares it running and enabled")
|
||||||
|
}
|
||||||
|
entry := m.autostart(entryName)
|
||||||
|
if entry.Starts {
|
||||||
|
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
||||||
|
if entry.From != "/etc/xdg/autostart/"+entryName {
|
||||||
|
add("the account's own "+entry.From+" replaces the vendor's entry", "remove it, so the vendor's entry is the one start")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add("the tray does not start with the session ("+entry.Because+")",
|
||||||
|
"remove ~/.config/autostart/"+entryName+" if it hides the vendor's entry; if the vendor's is gone, reinstall forticlient-vpn, whose install links it")
|
||||||
|
}
|
||||||
|
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
|
||||||
|
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
|
||||||
|
}
|
||||||
|
for _, word := range []string{launcherWord, trayComm} {
|
||||||
|
for _, l := range m.i3Starts(word) {
|
||||||
|
a.Starts = append(a.Starts, "window manager: "+l)
|
||||||
|
add("a second start: "+l, "remove the line; the vendor's autostart entry is the tray's one start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := m.session(); err == nil {
|
||||||
|
for _, p := range []struct{ comm, word string }{{launcherComm, launcherWord}, {trayComm, trayComm}} {
|
||||||
|
switch running := m.procs(p.comm); {
|
||||||
|
case len(running) == 0:
|
||||||
|
add("no "+p.word+" runs in the desktop session", "forticlient_restart")
|
||||||
|
case len(running) > 1:
|
||||||
|
add(fmt.Sprintf("%d of %s run", len(running), p.word), "forticlient_restart ends them all and starts one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.OK = len(a.Findings) == 0
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secrets are what the operator's VPN configuration holds on a real machine. The fake machine carries
|
||||||
|
// them where the client keeps them, and no answer may.
|
||||||
|
var secrets = []string{"vpn.example.invalid", "192.0.2.10", "s3cret-psk", "work-profile", "BEGIN CERTIFICATE", "65d16a50"}
|
||||||
|
|
||||||
|
func newClient(t *testing.T, running, connected bool) *fake {
|
||||||
|
f := newFake(t)
|
||||||
|
f.write("/opt/forticlient/Fortitray.desktop", "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
|
||||||
|
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
|
||||||
|
f.write("/etc/forticlient/config.db", "work-profile vpn.example.invalid 192.0.2.10 s3cret-psk\n")
|
||||||
|
f.write(testHome+"/.config/FortiClient/state.json", `{"gateway":"vpn.example.invalid","cert":"-----BEGIN CERTIFICATE-----"}`)
|
||||||
|
f.write("/sys/class/net/wlp3s0/flags", "0x1003\n")
|
||||||
|
if connected {
|
||||||
|
f.write("/sys/class/net/fctvpn65d16a50/flags", "0x1091\n")
|
||||||
|
f.write("/sys/class/net/fctvpn65d16a50/address", "192.0.2.10\n")
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
f.proc(3857, 1000, launcherComm, []string{launcherBin, "--profile=work-profile"}, "session-c1.scope")
|
||||||
|
f.proc(4509, 1000, trayComm, []string{"/opt/forticlient/fortitray", "--gateway", "vpn.example.invalid"}, "session-c1.scope")
|
||||||
|
}
|
||||||
|
f.answer = func(name string, args []string) Output {
|
||||||
|
switch {
|
||||||
|
case name == "pacman" && args[0] == "-Q":
|
||||||
|
return Output{Stdout: "forticlient-vpn 7.4.3.5411-1\n"}
|
||||||
|
case name == "pacman" && args[0] == "-Qqm":
|
||||||
|
return Output{Stdout: "forticlient-vpn\n"}
|
||||||
|
case name == "systemctl" && args[0] == "is-active":
|
||||||
|
return Output{Stdout: "active\n"}
|
||||||
|
case name == "systemctl" && args[0] == "is-enabled":
|
||||||
|
return Output{Stdout: "enabled\n"}
|
||||||
|
}
|
||||||
|
return Output{}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// carries fails the test when an answer holds anything of the VPN's configuration.
|
||||||
|
func carries(t *testing.T, answer any) {
|
||||||
|
t.Helper()
|
||||||
|
raw, _ := json.Marshal(answer)
|
||||||
|
for _, s := range secrets {
|
||||||
|
if strings.Contains(string(raw), s) {
|
||||||
|
t.Fatalf("the answer carries %q: %s", s, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusIsRunningAndConnectedStateAndNothingOfTheConfiguration(t *testing.T) {
|
||||||
|
f := newClient(t, true, true)
|
||||||
|
s, err := f.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Installed != "7.4.3.5411-1" || !s.Foreign || s.Service != (Service{"active", "enabled"}) || len(s.Launcher) != 1 || len(s.Tray) != 1 ||
|
||||||
|
!s.StartedBy.Starts || !s.Tunnel.Connected || s.Tunnel.Up != 1 {
|
||||||
|
t.Fatalf("%+v", s)
|
||||||
|
}
|
||||||
|
if s.Launcher[0].Command != launcherWord || s.Tray[0].Command != trayComm {
|
||||||
|
t.Fatalf("processes by command name only: %+v %+v", s.Launcher, s.Tray)
|
||||||
|
}
|
||||||
|
carries(t, s)
|
||||||
|
for _, c := range f.calls {
|
||||||
|
for _, never := range []string{"forticlient-cli", "fortivpn", "journalctl", "sqlite", "/etc/forticlient", "/opt/forticlient/.config"} {
|
||||||
|
if strings.Contains(c, never) {
|
||||||
|
t.Fatalf("ran %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
down := newClient(t, true, false)
|
||||||
|
down.write("/sys/class/net/fctvpn0/flags", "0x1090\n")
|
||||||
|
if s, _ := down.Status(); s.Tunnel.Connected || s.Tunnel.Up != 0 {
|
||||||
|
t.Fatalf("an interface that is down is no tunnel: %+v", s.Tunnel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPassesTheVendorsOneStartAndNeverJudgesTheConnection(t *testing.T) {
|
||||||
|
f := newClient(t, true, false)
|
||||||
|
f.desktopSession()
|
||||||
|
c, err := f.Check()
|
||||||
|
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
|
||||||
|
t.Fatalf("%+v %v", c, err)
|
||||||
|
}
|
||||||
|
carries(t, c)
|
||||||
|
|
||||||
|
f.write(testHome+"/.config/i3/config", "exec --no-startup-id /opt/forticlient/fortitraylauncher\n")
|
||||||
|
f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=/opt/forticlient/fortitraylauncher\nHidden=true\n")
|
||||||
|
f.proc(3858, 1000, launcherComm, []string{launcherBin}, "session-c1.scope")
|
||||||
|
f.answer = func(name string, args []string) Output {
|
||||||
|
switch name {
|
||||||
|
case "pacman":
|
||||||
|
return Output{Code: 1}
|
||||||
|
case "systemctl":
|
||||||
|
return Output{Stdout: "inactive\n", Code: 3}
|
||||||
|
case "dex":
|
||||||
|
return Output{Code: 127, Err: ErrNotInstalled}
|
||||||
|
}
|
||||||
|
return Output{}
|
||||||
|
}
|
||||||
|
c, _ = f.Check()
|
||||||
|
var all []string
|
||||||
|
for _, x := range c.Findings {
|
||||||
|
all = append(all, x.What)
|
||||||
|
}
|
||||||
|
got := strings.Join(all, "\n")
|
||||||
|
for _, want := range []string{"not installed", "forticlient.service is inactive", "does not start with the session (Hidden=true)", "dex",
|
||||||
|
"a second start: ~/.config/i3/config:1", "2 of fortitraylauncher run"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("no finding %q in\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
carries(t, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestartStartsTheLauncherAndLeavesTheTunnel(t *testing.T) {
|
||||||
|
f := newClient(t, true, true)
|
||||||
|
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||||
|
t.Fatalf("without a desktop: %v", err)
|
||||||
|
}
|
||||||
|
f.desktopSession()
|
||||||
|
f.onStart = func(argv []string) { f.proc(9100, 1000, launcherComm, argv, "app.slice/"+restartAs+".service") }
|
||||||
|
a, err := f.Restart()
|
||||||
|
if err != nil || len(a.Ended) != 2 || len(a.Running) != 1 || !a.Tunnel.Connected {
|
||||||
|
t.Fatalf("%+v %v", a, err)
|
||||||
|
}
|
||||||
|
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
|
||||||
|
t.Fatalf("%q", f.calls)
|
||||||
|
}
|
||||||
|
for _, c := range f.calls {
|
||||||
|
if strings.Contains(c, serviceUnit) {
|
||||||
|
t.Fatalf("the restart touched the service: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
carries(t, a)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient
|
||||||
|
// VPN client's tray in the operator's session and the vendor's service behind it, served by the node's
|
||||||
|
// runtime as the operator account. The module holds no seat, so every tool is its own. The tools
|
||||||
|
// report running and connected state only: never a profile, a credential, a gateway or a certificate.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var machine = NewMachine()
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "forticlient_status",
|
||||||
|
Description: "The VPN client: the installed version and whether it came from outside the official " +
|
||||||
|
"repositories, the vendor's service (active, enabled), whether the tray and its launcher run " +
|
||||||
|
"(pid, since, scope or unit), what starts the tray at login, and whether a tunnel is up (a " +
|
||||||
|
"count; never a name, an address, a gateway or a profile). (r)",
|
||||||
|
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "forticlient_restart",
|
||||||
|
Description: "End the tray and its launcher (asked first, then forced after 5 s) and start the " +
|
||||||
|
"launcher again in the operator's desktop session, under the account's service manager. The " +
|
||||||
|
"tunnel is the service's and is not touched. Needs someone logged in to the desktop. (a)",
|
||||||
|
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "forticlient_check",
|
||||||
|
Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
|
||||||
|
"outside the official repositories); the service runs and is enabled; the tray has exactly one " +
|
||||||
|
"start (the vendor's XDG autostart entry, which the session's dex runs; no window-manager exec); " +
|
||||||
|
"and the tray and its launcher run once in a desktop session. Being connected is not checked. (r)",
|
||||||
|
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forticlient's shape (novox/hq ADR 0205, ADR 0207, ADR 0208): no package (the client is outside the
|
||||||
|
// official repositories and kept as found), the vendor's service declared running and enabled, the X
|
||||||
|
// display on its own machine, no start of its own (the vendor's autostart entry is the tray's one
|
||||||
|
// start), nothing of the VPN's configuration, and the Go bundle serving exactly the listed forticlient_
|
||||||
|
// tools.
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Requires []string `json:"requires"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Shell []any `json:"shell"`
|
||||||
|
Contributions []struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"contributions"`
|
||||||
|
Environment any `json:"environment"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) (manifest, string) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
var m manifest
|
||||||
|
if err := dec.Decode(&m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m, string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||||
|
m, raw := readManifest(t)
|
||||||
|
served := map[string]bool{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
served[tool.Name] = true
|
||||||
|
if !strings.HasPrefix(tool.Name, "forticlient_") || strings.TrimSpace(tool.Description) == "" {
|
||||||
|
t.Errorf("%s: prefixed %s and described", tool.Name, "forticlient_")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range m.Tools {
|
||||||
|
if !served[name] {
|
||||||
|
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||||
|
}
|
||||||
|
delete(served, name)
|
||||||
|
}
|
||||||
|
for name := range served {
|
||||||
|
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("%v", m.Build.Artifacts)
|
||||||
|
}
|
||||||
|
b := m.Build.Artifacts[0]
|
||||||
|
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||||
|
b["from"] != "cmd/forticlient-tools" || b["binary"] != "forticlient-tools" {
|
||||||
|
t.Errorf("the Go tools bundle: %v", b)
|
||||||
|
}
|
||||||
|
s := strings.ToLower(raw)
|
||||||
|
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||||
|
if strings.Contains(s, never) {
|
||||||
|
t.Errorf("module.json names %q", never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) {
|
||||||
|
m, raw := readManifest(t)
|
||||||
|
if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||||
|
!reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) {
|
||||||
|
t.Fatalf("%+v", m)
|
||||||
|
}
|
||||||
|
if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
|
||||||
|
m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil {
|
||||||
|
t.Fatalf("resources: %v", m.Resources)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
|
||||||
|
t.Fatal("no seat, no environment, and no second start")
|
||||||
|
}
|
||||||
|
for _, never := range []string{"/etc/forticlient", "/opt/forticlient", "autostart", "\"package\"", "vpn.", "gateway", "profile"} {
|
||||||
|
if strings.Contains(raw, never) {
|
||||||
|
t.Errorf("module.json names %s", never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module forticlient
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"module": "forticlient",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"service-manager"
|
||||||
|
],
|
||||||
|
"requires": [
|
||||||
|
"x11-display"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"forticlient_status",
|
||||||
|
"forticlient_restart",
|
||||||
|
"forticlient_check"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "scheduler",
|
||||||
|
"type": "service",
|
||||||
|
"unit": "forticlient.service",
|
||||||
|
"state": "running",
|
||||||
|
"boot": "enabled"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/forticlient-tools",
|
||||||
|
"binary": "forticlient-tools",
|
||||||
|
"loads": [
|
||||||
|
"forticlient-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-4
@@ -17,6 +17,8 @@ export interface GiteaRepo {
|
|||||||
description?: string;
|
description?: string;
|
||||||
html_url: string;
|
html_url: string;
|
||||||
default_branch?: string;
|
default_branch?: string;
|
||||||
|
/** When anything last moved in it — a push, and so a merge. */
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An issue, with its labels flattened to names. */
|
/** An issue, with its labels flattened to names. */
|
||||||
@@ -249,10 +251,24 @@ export class GiteaClient {
|
|||||||
* `limit` is what is asked for, and a merge that changed more says so rather than being read
|
* `limit` is what is asked for, and a merge that changed more says so rather than being read
|
||||||
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
|
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
|
||||||
* so more pages would buy nothing. */
|
* so more pages would buy nothing. */
|
||||||
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
|
/** Every file a pull request changed, page by page. **The forge caps a page below what is asked**
|
||||||
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
|
* (fifty, asked for a hundred), so one page read as the whole list dropped files silently, and a module
|
||||||
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
|
* whose own files moved was not rebuilt (novox/hq issue 252). Read until a page comes back short; past
|
||||||
return { paths, truncated: paths.length >= limit };
|
* `most` files the list is cut and says so, and the mesh then rebuilds everything built from the
|
||||||
|
* repository, the safe direction. */
|
||||||
|
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; truncated: boolean }> {
|
||||||
|
const paths: string[] = [];
|
||||||
|
let pageSize = 0;
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const files = (await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=50&page=${page}`)) ?? [];
|
||||||
|
if (page === 1) pageSize = files.length;
|
||||||
|
for (const f of files) {
|
||||||
|
const name = String(f?.filename ?? "");
|
||||||
|
if (name !== "") paths.push(name);
|
||||||
|
}
|
||||||
|
if (files.length === 0 || files.length < pageSize) return { paths, truncated: false };
|
||||||
|
if (paths.length >= most) return { paths, truncated: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPullRequest(
|
async createPullRequest(
|
||||||
@@ -342,6 +358,7 @@ export class GiteaClient {
|
|||||||
description: r.description || undefined,
|
description: r.description || undefined,
|
||||||
html_url: r.html_url,
|
html_url: r.html_url,
|
||||||
default_branch: r.default_branch,
|
default_branch: r.default_branch,
|
||||||
|
updated_at: r.updated_at ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,3 +601,11 @@ export class GiteaAdmin {
|
|||||||
GiteaAdmin.fail(`/admin/users/${username}`, res);
|
GiteaAdmin.fail(`/admin/users/${username}`, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose
|
||||||
|
* update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */
|
||||||
|
export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] {
|
||||||
|
if (!floor) return repos;
|
||||||
|
const at = Date.parse(floor);
|
||||||
|
return repos.filter((r) => !r.updated_at || !(Date.parse(r.updated_at) < at));
|
||||||
|
}
|
||||||
|
|||||||
+21
-9
@@ -3,18 +3,18 @@
|
|||||||
//
|
//
|
||||||
// Emits (novox/hq ADR 0041/0042):
|
// Emits (novox/hq ADR 0041/0042):
|
||||||
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
|
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
|
||||||
|
// module.gitea.pull.merged — a pull request was merged, however it was merged (web UI, API, or tool)
|
||||||
//
|
//
|
||||||
// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh
|
// issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
|
||||||
// takes that action — the natural point, and one process only. repo.created belongs here instead:
|
// repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
|
||||||
// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the
|
// polling is the only way to catch every path — and the only emitter, so a fact is never announced
|
||||||
// repo list is the only way to catch every path — and keeping it out of the create-repo tool means
|
// twice. The merge tool announced too until novox/hq issue 250, and every merge it made was heard twice.
|
||||||
// the fact is never announced twice from two processes.
|
|
||||||
//
|
//
|
||||||
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
|
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
|
||||||
// the forge for an immediacy nobody asked for is not.
|
// the forge for an immediacy nobody asked for is not.
|
||||||
|
|
||||||
import { emit } from "@novox/mesh-sdk/events";
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
import { GiteaClient } from "./client.js";
|
import { GiteaClient, movedSince } from "./client.js";
|
||||||
|
|
||||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||||
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
|
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
|
||||||
@@ -84,8 +84,16 @@ function keepAnnounced(): void {
|
|||||||
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
||||||
renameSync(tmp, mergedRecord);
|
renameSync(tmp, mergedRecord);
|
||||||
}
|
}
|
||||||
|
// **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the
|
||||||
|
// repository's update time; asking every repository for its pull requests on every tick took longer than the
|
||||||
|
// tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a
|
||||||
|
// minute before the last look is skipped — the minute absorbs the forge's clock against this one.
|
||||||
|
let lastLook = "";
|
||||||
|
const MARGIN_MS = 60_000;
|
||||||
async function pollMerged(client: GiteaClient): Promise<void> {
|
async function pollMerged(client: GiteaClient): Promise<void> {
|
||||||
const repos = await client.listAllRepos();
|
const began = new Date().toISOString();
|
||||||
|
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
|
||||||
|
const repos = movedSince(await client.listAllRepos(), floor);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
||||||
@@ -124,6 +132,7 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
|||||||
if (!primedMerges) since = new Date().toISOString();
|
if (!primedMerges) since = new Date().toISOString();
|
||||||
if (!primedMerges || changed) keepAnnounced();
|
if (!primedMerges || changed) keepAnnounced();
|
||||||
primedMerges = true;
|
primedMerges = true;
|
||||||
|
lastLook = began;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gitea) {
|
if (gitea) {
|
||||||
@@ -132,6 +141,9 @@ if (gitea) {
|
|||||||
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
|
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
|
||||||
let failing: string | null = null;
|
let failing: string | null = null;
|
||||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||||
|
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
|
||||||
|
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
|
||||||
|
// could each announce the same merge before either recorded it.
|
||||||
const run = (): void =>
|
const run = (): void =>
|
||||||
void fn()
|
void fn()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -142,8 +154,8 @@ if (gitea) {
|
|||||||
const why = err instanceof Error ? err.message : String(err);
|
const why = err instanceof Error ? err.message : String(err);
|
||||||
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
|
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
|
||||||
failing = why;
|
failing = why;
|
||||||
});
|
})
|
||||||
setInterval(run, everyMs);
|
.finally(() => setTimeout(run, everyMs));
|
||||||
run();
|
run();
|
||||||
};
|
};
|
||||||
tick(() => pollRepos(client), 60_000);
|
tick(() => pollRepos(client), 60_000);
|
||||||
|
|||||||
@@ -182,7 +182,11 @@
|
|||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
"name": "npm-package-registry",
|
"name": "npm-package-registry",
|
||||||
"scope": "mesh"
|
"scope": "mesh",
|
||||||
|
"identity": {
|
||||||
|
"max": 40,
|
||||||
|
"in": "a Gitea user name"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "git",
|
"name": "git",
|
||||||
@@ -222,5 +226,12 @@
|
|||||||
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
|
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
|
||||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"seat": "node-backup",
|
||||||
|
"kind": "backup",
|
||||||
|
"content": "path ${dir:data}\n"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { GiteaClient } from "../client.ts";
|
||||||
|
|
||||||
|
test("every page of a pull request's files is read, though the forge caps a page at fifty", async () => {
|
||||||
|
const total = 59;
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const url = new URL(req.url ?? "", "http://x");
|
||||||
|
const page = Number(url.searchParams.get("page") ?? "1");
|
||||||
|
const start = (page - 1) * 50;
|
||||||
|
const files = Array.from({ length: Math.max(0, Math.min(50, total - start)) }, (_, i) => ({ filename: `modules/m${start + i}/x` }));
|
||||||
|
res.setHeader("content-type", "application/json");
|
||||||
|
res.end(JSON.stringify(files));
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => server.listen(0, r));
|
||||||
|
const port = (server.address() as any).port;
|
||||||
|
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
|
||||||
|
const got = await client.listPullFiles("novox", "mesh-catalog", 60);
|
||||||
|
server.close();
|
||||||
|
assert.equal(got.paths.length, total);
|
||||||
|
assert.equal(got.truncated, false);
|
||||||
|
assert.equal(new Set(got.paths).size, total);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pass asks only the repositories that moved since the last look, every one before the first", async () => {
|
||||||
|
const { movedSince } = await import("../client.ts");
|
||||||
|
const repos = [
|
||||||
|
{ full_name: "a/old", name: "old", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T10:00:00Z" },
|
||||||
|
{ full_name: "a/new", name: "new", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T16:10:02Z" },
|
||||||
|
{ full_name: "a/unknown", name: "unknown", owner: "a", private: false, html_url: "" },
|
||||||
|
];
|
||||||
|
assert.deepEqual(movedSince(repos, "").map((r) => r.name), ["old", "new", "unknown"]);
|
||||||
|
assert.deepEqual(movedSince(repos, "2026-10-05T16:09:00.000Z").map((r) => r.name), ["new", "unknown"]);
|
||||||
|
assert.deepEqual(movedSince(repos, "2026-10-05T18:10:02+02:00").map((r) => r.name), ["new", "unknown"], "an offset is a moment, not a string");
|
||||||
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client.
|
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client.
|
||||||
// They return structured data; the mesh serves them through the sdk's tool harness.
|
// They return structured data; the mesh serves them through the sdk's tool harness.
|
||||||
//
|
//
|
||||||
// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0041/0042):
|
// One tool emits an event at the natural point of the action it takes (novox/hq ADR 0041/0042):
|
||||||
// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on
|
// create-issue emits issue.opened. pull.merged and repo.created are deliberately NOT emitted here: a
|
||||||
// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos
|
// merge or a repository is as often made in the web UI or by a plain API call as by these tools, so the
|
||||||
// are far more often born from a `git push` or the web UI than from this tool, so the events
|
// events entrypoint (index.ts) owns both by polling, which catches every path. Announcing a merge here
|
||||||
// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and
|
// as well announced every merge made through this tool twice — the tool's at once, the poll's moments
|
||||||
// the poll double-announcing the same repo from two processes.
|
// later (novox/hq issue 250).
|
||||||
|
|
||||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||||
import { emit } from "@novox/mesh-sdk/events";
|
import { emit } from "@novox/mesh-sdk/events";
|
||||||
@@ -228,29 +228,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
const number = Number(args.number);
|
const number = Number(args.number);
|
||||||
const method = args.method ? String(args.method) : "merge";
|
const method = args.method ? String(args.method) : "merge";
|
||||||
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
||||||
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
|
||||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
|
||||||
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
||||||
// Read it again: the merge commit only exists now, and it is what a build is made from.
|
// The merge commit only exists now; answered so the caller can follow what is built from it.
|
||||||
|
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
|
||||||
const merged = await gitea.getPullRequest(owner, repo, number);
|
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||||
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
|
return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
|
||||||
// every module built from the repository (novox/hq 04-ISSUES/131).
|
|
||||||
const changed = await gitea.listPullFiles(owner, repo, number);
|
|
||||||
await emit("pull.merged", {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
number,
|
|
||||||
title: pull.title,
|
|
||||||
head: pull.head,
|
|
||||||
base: pull.base,
|
|
||||||
merge_commit_sha: merged.merge_commit_sha,
|
|
||||||
merged_at: merged.merged_at,
|
|
||||||
method,
|
|
||||||
html_url: pull.html_url,
|
|
||||||
paths: changed.paths,
|
|
||||||
paths_truncated: changed.truncated,
|
|
||||||
});
|
|
||||||
return { merged: true, number, method, deleted_branch: deleteBranch };
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# hostname
|
||||||
|
|
||||||
|
The machine's names (novox/hq ADR 0199, ADR 0223): it holds the node seat `node-hostname` and owns
|
||||||
|
the two files that say what a machine is called — `/etc/hostname` and the machine's own lines in
|
||||||
|
`/etc/hosts`. It was `hosts`, holding `node-hosts-file`; the seat was renamed, and the old name
|
||||||
|
resolves to it as an alias.
|
||||||
|
|
||||||
|
## What it writes
|
||||||
|
|
||||||
|
- **`/etc/hostname`, whole**: the module's `hostname` setting, and nothing else. There is no
|
||||||
|
default. A machine's name is the operator's: the mesh's name for a machine and the name it calls
|
||||||
|
itself need not be the same, and writing the mesh's name silently would rename a machine. Set it
|
||||||
|
per machine — `settings set hostname '{"hostname": "<name>"}' --node <node>` — before the module
|
||||||
|
is assigned there; without it the module is left out of that machine's declaration, naming the
|
||||||
|
key. A mesh-wide `{"hostname": "${machine:name}"}` makes every machine call itself by its mesh
|
||||||
|
name, and a machine's own setting still overrides it.
|
||||||
|
- **The machine's own lines in `/etc/hosts`**, as the mesh's marked region at the start of the file:
|
||||||
|
`localhost` and `127.0.1.1` with the machine's mesh name. Every other line is the operator's, kept
|
||||||
|
byte for byte and given back when the module goes.
|
||||||
|
|
||||||
|
## When a new name takes effect
|
||||||
|
|
||||||
|
At the machine's **next boot**. The kernel's name is set from `/etc/hostname` when the machine
|
||||||
|
starts; writing the file changes what `hostnamectl` reports as the static name and nothing that is
|
||||||
|
running. The module declares nothing that would set it live: a graphical session's X authority is
|
||||||
|
keyed by the name the session started under, so changing it underneath a running session refuses
|
||||||
|
every new window until the person logs in again. Reboot when that is acceptable, or run
|
||||||
|
`hostnamectl hostname <name>` by hand.
|
||||||
|
|
||||||
|
## Its verbs
|
||||||
|
|
||||||
|
`<node>/node-hostname.entries`, `.add` and `.remove`: the hosts file's lines, each marked whose it
|
||||||
|
is; add one address and its names to the operator's lines; remove a name or an address from them.
|
||||||
|
They change the machine's file and nothing else. `/etc/hostname` has no verb — it is the setting.
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
|
||||||
|
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
|
||||||
|
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
|
||||||
|
//
|
||||||
|
// Root is the module's concern (ADR 0175 §4): the runtime launching this binary runs as the operator's
|
||||||
|
// account, so the file is written through sudo without a prompt where the account is not root, as the
|
||||||
|
// packet filter's is.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two
|
||||||
|
// together.
|
||||||
|
const HostsPath = "/etc/hosts"
|
||||||
|
|
||||||
|
// Operator is the owner of every line outside a block.
|
||||||
|
const Operator = "operator"
|
||||||
|
|
||||||
|
// Runner runs one command as root and answers what it printed, so the writes can be tested without a
|
||||||
|
// machine.
|
||||||
|
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||||
|
|
||||||
|
// escalated is the command as it is run: as given when this process is root, else through sudo
|
||||||
|
// without a prompt.
|
||||||
|
func escalated(uid int, name string, args []string) (string, []string) {
|
||||||
|
if uid == 0 {
|
||||||
|
return name, args
|
||||||
|
}
|
||||||
|
return "sudo", append([]string{"-n", name}, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
program, argv := escalated(os.Getuid(), name, args)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd := exec.CommandContext(ctx, program, argv...)
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
err := cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
|
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||||
|
if program == "sudo" {
|
||||||
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
|
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||||
|
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if said != "" {
|
||||||
|
return "", fmt.Errorf("%s: %s", name, said)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line is one line of the file, as a reader sees it.
|
||||||
|
type Line struct {
|
||||||
|
// Text is the line exactly as it is in the file.
|
||||||
|
Text string `json:"text"`
|
||||||
|
// Owner is whose it is: the block's id (`mesh hostname.own`, or another tool's) or "operator".
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
// Address and Names are an entry's; absent for a comment or a blank line.
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Names []string `json:"names,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`)
|
||||||
|
end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be.
|
||||||
|
func isAddress(s string) bool {
|
||||||
|
_, err := netip.ParseAddr(s)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse is every line of a hosts file, each marked whose it is.
|
||||||
|
func Parse(text string) []Line {
|
||||||
|
out := []Line{}
|
||||||
|
block := ""
|
||||||
|
for _, raw := range strings.Split(text, "\n") {
|
||||||
|
if block == "" {
|
||||||
|
if m := begin.FindStringSubmatch(raw); m != nil {
|
||||||
|
block = m[1]
|
||||||
|
out = append(out, Line{Text: raw, Owner: block})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner := block
|
||||||
|
if owner == "" {
|
||||||
|
owner = Operator
|
||||||
|
}
|
||||||
|
line := Line{Text: raw, Owner: owner}
|
||||||
|
entry, _, _ := strings.Cut(raw, "#")
|
||||||
|
if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) {
|
||||||
|
line.Address, line.Names = fields[0], fields[1:]
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block {
|
||||||
|
block = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
|
||||||
|
if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") {
|
||||||
|
out = out[:n-1]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var label = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||||
|
|
||||||
|
// Refused input says why, so a caller is one edit from right.
|
||||||
|
func checkAddress(address string) error {
|
||||||
|
if !isAddress(address) {
|
||||||
|
return fmt.Errorf("%q is not an IPv4 or IPv6 address", address)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkName(name string) error {
|
||||||
|
bad := fmt.Errorf("%q is not a host name", name)
|
||||||
|
if len(name) < 1 || len(name) > 253 {
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") {
|
||||||
|
if !label.MatchString(l) {
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAdded is the file with one address and its names added to the operator's lines; unchanged when
|
||||||
|
// they are already there.
|
||||||
|
func WithAdded(text, address string, names []string) (string, error) {
|
||||||
|
if err := checkAddress(address); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return "", errors.New("add names at least one name for the address")
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if err := checkName(n); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, l := range Parse(text) {
|
||||||
|
if l.Owner == Operator && l.Address == address {
|
||||||
|
for _, n := range l.Names {
|
||||||
|
have[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for _, n := range names {
|
||||||
|
if !have[n] && !slices.Contains(missing, n) {
|
||||||
|
missing = append(missing, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
body := text
|
||||||
|
if body != "" && !strings.HasSuffix(body, "\n") {
|
||||||
|
body += "\n"
|
||||||
|
}
|
||||||
|
return body + address + "\t" + strings.Join(missing, " ") + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRemoved is the file with one name, or every line of one address, taken out of the operator's
|
||||||
|
// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool
|
||||||
|
// writes is refused, naming whose it is.
|
||||||
|
func WithRemoved(text, what string) (string, int, error) {
|
||||||
|
byAddress := isAddress(what)
|
||||||
|
if !byAddress {
|
||||||
|
if err := checkName(what); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches := func(l Line) bool {
|
||||||
|
if byAddress {
|
||||||
|
return l.Address == what
|
||||||
|
}
|
||||||
|
return slices.Contains(l.Names, what)
|
||||||
|
}
|
||||||
|
lines := Parse(text)
|
||||||
|
removed := 0
|
||||||
|
kept := []string{}
|
||||||
|
for _, l := range lines {
|
||||||
|
if l.Owner != Operator || l.Address == "" || !matches(l) {
|
||||||
|
kept = append(kept, l.Text)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
removed++
|
||||||
|
if byAddress {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rest []string
|
||||||
|
for _, n := range l.Names {
|
||||||
|
if n != what {
|
||||||
|
rest = append(rest, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rest) > 0 {
|
||||||
|
kept = append(kept, l.Address+"\t"+strings.Join(rest, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed == 0 {
|
||||||
|
for _, l := range lines {
|
||||||
|
if l.Owner != Operator && matches(l) {
|
||||||
|
return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n") + "\n", removed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostsFile is the machine's hosts file.
|
||||||
|
type HostsFile struct {
|
||||||
|
Path string
|
||||||
|
Run Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries is the file's lines, each marked whose.
|
||||||
|
type Entries struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Lines []Line `json:"lines"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HostsFile) read() (string, error) {
|
||||||
|
b, err := os.ReadFile(h.Path)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries is every line of the file, each marked whose it is.
|
||||||
|
func (h HostsFile) Entries() (*Entries, error) {
|
||||||
|
text, err := h.read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Entries{Path: h.Path, Lines: Parse(text)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Added is whether add changed the file, and the line it wrote.
|
||||||
|
type Added struct {
|
||||||
|
Added bool `json:"added"`
|
||||||
|
Line string `json:"line,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds one address and its names to the operator's lines.
|
||||||
|
func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) {
|
||||||
|
before, err := h.read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
after, err := WithAdded(before, address, names)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if after == before {
|
||||||
|
return &Added{Added: false}, nil
|
||||||
|
}
|
||||||
|
if err := h.write(ctx, after); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed is how many of the operator's lines remove touched.
|
||||||
|
type Removed struct {
|
||||||
|
Removed int `json:"removed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove takes one name, or every line of one address, out of the operator's lines.
|
||||||
|
func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) {
|
||||||
|
before, err := h.read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
after, removed, err := WithRemoved(before, what)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if removed > 0 {
|
||||||
|
if err := h.write(ctx, after); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Removed{Removed: removed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// write puts the file in place whole, so a reader never sees half of it: the content is staged in a
|
||||||
|
// private copy, installed as root beside the file — the same directory, so the same filesystem — and
|
||||||
|
// renamed over it.
|
||||||
|
func (h HostsFile) write(ctx context.Context, content string) error {
|
||||||
|
dir, err := os.MkdirTemp("", "hosts-")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
staged := filepath.Join(dir, "hosts")
|
||||||
|
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hostname-tools")
|
||||||
|
if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil {
|
||||||
|
_, _ = h.Run(ctx, "rm", "-f", beside)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
|
||||||
|
// distribution lines, an operator's development names, the mesh's block and another tool's.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const file = "# Static table lookup for hostnames.\n" +
|
||||||
|
"127.0.0.1\tlocaldev.example.com\n" +
|
||||||
|
"127.0.0.1 a.example.com b.example.com\n" +
|
||||||
|
"# BEGIN mesh hostname.own\n" +
|
||||||
|
"127.0.0.1\tlocalhost\n" +
|
||||||
|
"::1\tlocalhost\n" +
|
||||||
|
"# END mesh hostname.own\n" +
|
||||||
|
"# BEGIN other-tool\n" +
|
||||||
|
"192.0.2.7\tproject.test\n" +
|
||||||
|
"# END other-tool\n"
|
||||||
|
|
||||||
|
func blocks(text string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range Parse(text) {
|
||||||
|
if l.Owner != Operator {
|
||||||
|
out = append(out, l.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryLineSaysWhoseItIs(t *testing.T) {
|
||||||
|
lines := Parse(file)
|
||||||
|
if len(lines) != 10 {
|
||||||
|
t.Fatalf("%d lines: %+v", len(lines), lines)
|
||||||
|
}
|
||||||
|
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
|
||||||
|
if !reflect.DeepEqual(lines[1], want) {
|
||||||
|
t.Errorf("%+v", lines[1])
|
||||||
|
}
|
||||||
|
if lines[0].Address != "" || lines[0].Owner != Operator {
|
||||||
|
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
|
||||||
|
}
|
||||||
|
if lines[3].Owner != "mesh hostname.own" || lines[4].Owner != "mesh hostname.own" || lines[6].Owner != "mesh hostname.own" {
|
||||||
|
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
|
||||||
|
}
|
||||||
|
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
|
||||||
|
t.Errorf("%+v", lines[8])
|
||||||
|
}
|
||||||
|
if lines[5].Address != "::1" {
|
||||||
|
t.Errorf("an IPv6 entry: %+v", lines[5])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
|
||||||
|
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
|
||||||
|
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
|
||||||
|
t.Errorf("%+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
|
||||||
|
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
|
||||||
|
for _, l := range lines {
|
||||||
|
if l.Owner != "x" {
|
||||||
|
t.Errorf("%+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
|
||||||
|
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
|
||||||
|
t.Errorf("%q", after)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(blocks(after), blocks(file)) {
|
||||||
|
t.Errorf("blocks changed")
|
||||||
|
}
|
||||||
|
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
|
||||||
|
t.Errorf("a name already there changed the file")
|
||||||
|
}
|
||||||
|
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
|
||||||
|
t.Errorf("%q", some)
|
||||||
|
}
|
||||||
|
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
|
||||||
|
t.Errorf("a file without a last newline: %q", ended)
|
||||||
|
}
|
||||||
|
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
|
||||||
|
t.Errorf("an empty file: %q", empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
|
||||||
|
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
|
||||||
|
t.Errorf("%q", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
address string
|
||||||
|
names []string
|
||||||
|
says string
|
||||||
|
}{
|
||||||
|
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
|
||||||
|
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
|
||||||
|
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
|
||||||
|
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
|
||||||
|
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
|
||||||
|
{"192.0.2.9", []string{}, "at least one name"},
|
||||||
|
} {
|
||||||
|
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("%q %q: %v", c.address, c.names, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
|
||||||
|
t.Errorf("an IPv6 address and a rooted name: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
|
||||||
|
one, n, err := WithRemoved(file, "a.example.com")
|
||||||
|
if err != nil || n != 1 {
|
||||||
|
t.Fatalf("%d %v", n, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
|
||||||
|
t.Errorf("%q", one)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(blocks(one), blocks(file)) {
|
||||||
|
t.Errorf("blocks changed")
|
||||||
|
}
|
||||||
|
all, n, err := WithRemoved(file, "127.0.0.1")
|
||||||
|
if err != nil || n != 2 {
|
||||||
|
t.Fatalf("%d %v", n, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(all, "# BEGIN mesh hostname.own\n127.0.0.1\tlocalhost\n") {
|
||||||
|
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(blocks(all), blocks(file)) {
|
||||||
|
t.Errorf("blocks changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
|
||||||
|
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
|
||||||
|
t.Errorf("%v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hostname.own") {
|
||||||
|
t.Errorf("%v", err)
|
||||||
|
}
|
||||||
|
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
|
||||||
|
t.Errorf("%d %v", n, err)
|
||||||
|
}
|
||||||
|
if _, _, err := WithRemoved(file, "bad name"); err == nil {
|
||||||
|
t.Errorf("a name that is neither an address nor a host name is refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
|
||||||
|
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
|
||||||
|
t.Errorf("%s %v", p, a)
|
||||||
|
}
|
||||||
|
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
|
||||||
|
t.Errorf("%s %v", p, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPlain runs a command as given, unescalated: the test's file is the test's own.
|
||||||
|
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "hosts")
|
||||||
|
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var calls [][]string
|
||||||
|
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
calls = append(calls, append([]string{name}, args...))
|
||||||
|
return runPlain(ctx, name, args...)
|
||||||
|
}}
|
||||||
|
ctx := context.Background()
|
||||||
|
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
|
||||||
|
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
|
||||||
|
t.Fatalf("%+v %v", added, err)
|
||||||
|
}
|
||||||
|
beside := filepath.Join(dir, ".hosts.hostname-tools")
|
||||||
|
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
|
||||||
|
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
|
||||||
|
t.Errorf("%v", calls)
|
||||||
|
}
|
||||||
|
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
|
||||||
|
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
|
||||||
|
}
|
||||||
|
got, err := h.Entries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
last := got.Lines[len(got.Lines)-1]
|
||||||
|
if last.Owner != Operator || last.Address != "192.0.2.9" {
|
||||||
|
t.Errorf("add then entries shows the line as the operator's: %+v", last)
|
||||||
|
}
|
||||||
|
removed, err := h.Remove(ctx, "lab.test")
|
||||||
|
if err != nil || removed.Removed != 1 {
|
||||||
|
t.Fatalf("%+v %v", removed, err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(path); string(b) != file {
|
||||||
|
t.Errorf("add then remove gives the file back: %q", b)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(beside); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("the staged copy beside the file is left: %v", err)
|
||||||
|
}
|
||||||
|
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
|
||||||
|
t.Errorf("mode %v", info.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Claims []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Serves []string `json:"serves"`
|
||||||
|
} `json:"claims"`
|
||||||
|
Resources []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r.ID == "own" {
|
||||||
|
found = true
|
||||||
|
if r.Path != HostsPath {
|
||||||
|
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("no resource own")
|
||||||
|
}
|
||||||
|
var served []string
|
||||||
|
for _, tool := range tools(HostsFile{}) {
|
||||||
|
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
|
||||||
|
if tool.Description == "" || tool.Run == nil {
|
||||||
|
t.Errorf("%s", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
|
||||||
|
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
|
||||||
|
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
|
||||||
|
t.Errorf("%v", got)
|
||||||
|
}
|
||||||
|
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
|
||||||
|
t.Errorf("%v", got)
|
||||||
|
}
|
||||||
|
if got := namesArg(map[string]any{}); len(got) != 0 {
|
||||||
|
t.Errorf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /etc/hostname is the module's whole file (novox/hq ADR 0223), and what it says is the operator's
|
||||||
|
// `hostname` setting — never the mesh's name for the machine written silently: on the mesh this was
|
||||||
|
// built for, three of four machines call themselves something else, and renaming a machine is the
|
||||||
|
// operator's to decide.
|
||||||
|
func TestTheMachinesNameIsTheOperatorsSetting(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["path"] != "/etc/hostname" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r["id"] != "name" || r["into"] != nil || r["content"] != "${setting:hostname}\n" {
|
||||||
|
t.Errorf("/etc/hostname is not written whole from the hostname setting: %v", r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Error("the module does not write /etc/hostname")
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// hostname-tools (novox/hq ADR 0199, ADR 0223): the tools of the machine's names. One binary,
|
||||||
|
// launched by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR
|
||||||
|
// 0193, ADR 0198): the node-hostname seat's three verbs — the hosts file's lines with whose each
|
||||||
|
// is, add an operator's line, remove one. They change the machine's file and nothing else; the
|
||||||
|
// controller holds none of it. /etc/hostname has no verb: it is the module's resource, set by the
|
||||||
|
// module's `hostname` setting.
|
||||||
|
//
|
||||||
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seat is the role this module holds.
|
||||||
|
const Seat = "node-hostname"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[hostname] %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func arg(a map[string]any, k string) string {
|
||||||
|
v, _ := a[k].(string)
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var separators = regexp.MustCompile(`[\s,]+`)
|
||||||
|
|
||||||
|
// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list,
|
||||||
|
// the list.
|
||||||
|
func namesArg(a map[string]any) []string {
|
||||||
|
var raw []string
|
||||||
|
switch v := a["names"].(type) {
|
||||||
|
case string:
|
||||||
|
raw = separators.Split(v, -1)
|
||||||
|
case []any:
|
||||||
|
for _, n := range v {
|
||||||
|
if s, ok := n.(string); ok {
|
||||||
|
raw = append(raw, separators.Split(s, -1)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := []string{}
|
||||||
|
for _, n := range raw {
|
||||||
|
if n != "" {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||||
|
// subject, as <node>/node-hostname.<verb>.
|
||||||
|
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||||
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools(h HostsFile) []stdio.Tool {
|
||||||
|
ctx := context.Background()
|
||||||
|
return []stdio.Tool{
|
||||||
|
verb("entries", "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
|
||||||
|
nil, func(map[string]any) (any, error) { return h.Entries() }),
|
||||||
|
verb("add", "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
|
||||||
|
map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")},
|
||||||
|
func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }),
|
||||||
|
verb("remove", "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||||||
|
map[string]any{"name": str("a host name, or an address to remove every line of")},
|
||||||
|
func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module hostname
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"module": "hostname",
|
||||||
|
"version": "1",
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"name": "node-hostname",
|
||||||
|
"scope": "node",
|
||||||
|
"serves": [
|
||||||
|
"entries",
|
||||||
|
"add",
|
||||||
|
"remove"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "own",
|
||||||
|
"type": "file",
|
||||||
|
"path": "/etc/hosts",
|
||||||
|
"mode": "0644",
|
||||||
|
"into": "block",
|
||||||
|
"at": "start",
|
||||||
|
"content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "name",
|
||||||
|
"type": "file",
|
||||||
|
"path": "/etc/hostname",
|
||||||
|
"mode": "0644",
|
||||||
|
"content": "${setting:hostname}\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/hostname-tools",
|
||||||
|
"binary": "hostname-tools",
|
||||||
|
"loads": [
|
||||||
|
"hostname-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ file because each module carries them now:
|
|||||||
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
|
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
|
||||||
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
|
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
|
||||||
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
|
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
|
||||||
|
| the peripherals' tray (`exec … polychromatic-tray-applet`) | `polychromatic`'s contribution to node-display-session |
|
||||||
|
|
||||||
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
|
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
|
||||||
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
|
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
|
||||||
@@ -98,9 +99,9 @@ because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDrop
|
|||||||
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
|
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
|
||||||
key.
|
key.
|
||||||
|
|
||||||
**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the
|
**Kept until their owners exist.** A marked section holds the operator's own scripts: volume, games
|
||||||
operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each
|
volume, the sessions launcher and the screenshot binding. Each leaves when the module that owns it is
|
||||||
leaves when the module that owns it is written.
|
written. The peripherals' tray applet left it for the `polychromatic` module.
|
||||||
|
|
||||||
## What it leaves found
|
## What it leaves found
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,9 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin
|
|||||||
for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm",
|
for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm",
|
||||||
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
|
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
|
||||||
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
|
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
|
||||||
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} {
|
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring",
|
||||||
|
// the peripherals' tray: polychromatic's contribution
|
||||||
|
"polychromatic"} {
|
||||||
if strings.Contains(code, gone) {
|
if strings.Contains(code, gone) {
|
||||||
t.Errorf("the configuration still holds %q", gone)
|
t.Errorf("the configuration still holds %q", gone)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,11 +174,9 @@ client.urgent #900000 #900000 #ffffff #900000 #900000
|
|||||||
#########################################
|
#########################################
|
||||||
# Each line below belongs to something other than i3, named on its line. When that module is written
|
# Each line below belongs to something other than i3, named on its line. When that module is written
|
||||||
# it contributes the line to node-display-session, and the line goes from here in the same change.
|
# it contributes the line to node-display-session, and the line goes from here in the same change.
|
||||||
# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute
|
# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'
|
||||||
# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14).
|
# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,
|
||||||
|
# polychromatic).
|
||||||
# the peripherals' tray (the operator's application)
|
|
||||||
exec --no-startup-id polychromatic-tray-applet
|
|
||||||
|
|
||||||
# the operator's scripts: volume, games volume, sessions, screenshot
|
# the operator's scripts: volume, games volume, sessions, screenshot
|
||||||
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
|
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
|
||||||
@@ -194,7 +192,8 @@ bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/script
|
|||||||
###### Other modules' lines ####
|
###### Other modules' lines ####
|
||||||
#########################################
|
#########################################
|
||||||
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
|
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
|
||||||
# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.
|
# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's
|
||||||
|
# under a line naming it.
|
||||||
${contribution:node-display-session:config}
|
${contribution:node-display-session:config}
|
||||||
#########################################
|
#########################################
|
||||||
###### Your own files ####
|
###### Your own files ####
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,10 @@
|
|||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
"name": "influxdb-api",
|
"name": "influxdb-api",
|
||||||
"scope": "mesh"
|
"scope": "mesh",
|
||||||
|
"identity": {
|
||||||
|
"in": "an InfluxDB v1 authorization"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
@@ -132,5 +135,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"seat": "node-backup",
|
||||||
|
"kind": "backup",
|
||||||
|
"content": "path ${dir:data}\npath ${dir:config}\n"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# keycloak
|
||||||
|
|
||||||
|
The mesh's identity provider: one Keycloak server that provides the `oidc-client` provision to every
|
||||||
|
module that logs a person in. Each consumer is given one confidential OpenID Connect client in the
|
||||||
|
realm named by the assignment's `issuer` setting, under the client id the mesh derived for it and
|
||||||
|
the secret the mesh minted (ADR 0048); its redirect is its contributed `callback` under the names the
|
||||||
|
mesh composed for its endpoint. A client the mesh did not make — no `mesh.provisioned` attribute — is
|
||||||
|
never adopted, changed or deleted.
|
||||||
|
|
||||||
|
## The admin keeps the mesh's password
|
||||||
|
|
||||||
|
The manifest mints the `admin` own-secret, and the server takes it from its environment **only when
|
||||||
|
it creates its master realm**. A database that was adopted, restored or moved already has one, and
|
||||||
|
its `admin` keeps the password it had. Every admin call then fails with `401 invalid_grant`, and
|
||||||
|
with it every consumer's client. On 2026-10-05 that went on for a day, about 31,000 failures, seen only
|
||||||
|
in the journal (hq issue 179). Both times the fix was the same, done by hand.
|
||||||
|
|
||||||
|
The module now does that fix itself. The **guard** in the provider:
|
||||||
|
|
||||||
|
- checks that `admin` logs in with the mesh's secret: at start (every 15 s until the server answers),
|
||||||
|
then every 5 minutes, and at once when the admin API refuses the credentials;
|
||||||
|
- on a refusal — `invalid_grant`, including a missing or disabled admin — and only then, repairs it
|
||||||
|
inside the `keycloak` container with Keycloak's own recovery: `kc.sh bootstrap-admin user` makes a
|
||||||
|
temporary admin (on a free management port; the server holds 9000), `kcadm.sh` creates or
|
||||||
|
re-enables `admin` if it must and sets its password to the mesh's, and the temporary admin is
|
||||||
|
deleted. Both passwords go in on the exec's standard input. Neither is on a command line or printed;
|
||||||
|
- checks again, and says `REPAIRED the admin …` in the log and emits `admin.repaired`;
|
||||||
|
- when it cannot, logs `COULD NOT REPAIR …` with the step that failed, emits `admin.unrepaired`, and
|
||||||
|
**brakes**: the next automatic attempt comes 10 minutes later and the wait doubles each time, up to
|
||||||
|
6 hours. If the temporary admin may be left behind, the log and the event say so.
|
||||||
|
|
||||||
|
While the admin is refused, the provisioner does not call Keycloak. Each attempt would be one more
|
||||||
|
failed admin login, and enough of those lock the account. It still counts each attempt as a failure
|
||||||
|
of the consumer, so the provider's standing (below) reports `credentials-rejected`.
|
||||||
|
|
||||||
|
`keycloak_admin_check` reports the state, the last repair and the brake. With `repair: true` it
|
||||||
|
repairs a refused admin straight away, ignoring the brake, because a person asked.
|
||||||
|
|
||||||
|
## A consumer failing for minutes is announced
|
||||||
|
|
||||||
|
The provisioner loop (`harness.go`) is shared, byte for byte, with postgres. A consumer whose create,
|
||||||
|
check or secret keeps failing for 5 minutes with no success in between is announced as
|
||||||
|
`provisioner.failing`, with the consumer, its machine and the error's class. The announcement repeats
|
||||||
|
every 15 minutes while the failure lasts. `provisioner.recovered` follows the first success
|
||||||
|
(ADR 0224), and the controller shows the latest one in `status`.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
The realm, user, client, group and role tools (`keycloak_list_realms`, `keycloak_create_user`,
|
||||||
|
`keycloak_list_clients`, `keycloak_assign_user_role`, …), and `keycloak_admin_check`. A tool that
|
||||||
|
writes something announces it: `user.created`, `user.deleted`, `password.reset`, `client.created`,
|
||||||
|
`group.created`, `role.created`.
|
||||||
|
|
||||||
|
## Where the code lives
|
||||||
|
|
||||||
|
One Go bundle, `cmd/keycloak-provider`, launched by the node's runtime. It speaks MCP over stdio
|
||||||
|
through the Go SDK, and was ported from TypeScript in 2026-10. It reaches the server on
|
||||||
|
`MESH_KEYCLOAK_URL`, reads the mesh's admin secret from `MESH_KEYCLOAK_PASSWORD_FILE` at every check,
|
||||||
|
and reaches the container named by `MESH_KEYCLOAK_CONTAINER` through the `container-runtime`
|
||||||
|
capability.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`go test ./...` runs against a fake Keycloak and a fake container. It covers:
|
||||||
|
|
||||||
|
- repair on refusal, with no password in argv;
|
||||||
|
- no repair while the server is unreachable;
|
||||||
|
- the brake, and the operator overriding it;
|
||||||
|
- the provisioner going quiet while the admin is refused;
|
||||||
|
- the OIDC client rules;
|
||||||
|
- the harness and its standing;
|
||||||
|
- `harness_same_test.go`, which fails when this module's `harness.go` and postgres's differ.
|
||||||
|
|
||||||
|
`live_test.go` runs the repair against a real, throwaway Keycloak 26 container whose admin keeps an
|
||||||
|
older password. The file's comment has the commands.
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0039).
|
|
||||||
// Moved out of the shared hal sdk, where a change to Keycloak's admin API rebuilt everything; here
|
|
||||||
// it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and
|
|
||||||
// nothing outside keycloak does.
|
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
|
|
||||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
|
||||||
function meshConfig(file?: string): Record<string, string> {
|
|
||||||
if (!file) return {};
|
|
||||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
||||||
catch { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A secret file's value, trailing newline trimmed; undefined when unset or unreadable. */
|
|
||||||
function secretFile(file?: string): string | undefined {
|
|
||||||
if (!file) return undefined;
|
|
||||||
try { return readFileSync(file, "utf8").replace(/\n$/, "") || undefined; }
|
|
||||||
catch { return undefined; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A client as the admin API represents it — only the fields this module reads or writes are typed;
|
|
||||||
* the rest travel through untouched, so an update never drops what somebody else set. */
|
|
||||||
export interface ClientRepresentation {
|
|
||||||
id?: string;
|
|
||||||
clientId: string;
|
|
||||||
name?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
protocol?: string;
|
|
||||||
publicClient?: boolean;
|
|
||||||
clientAuthenticatorType?: string;
|
|
||||||
secret?: string;
|
|
||||||
rootUrl?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
redirectUris?: string[];
|
|
||||||
webOrigins?: string[];
|
|
||||||
standardFlowEnabled?: boolean;
|
|
||||||
implicitFlowEnabled?: boolean;
|
|
||||||
directAccessGrantsEnabled?: boolean;
|
|
||||||
serviceAccountsEnabled?: boolean;
|
|
||||||
attributes?: Record<string, string>;
|
|
||||||
protocolMappers?: ProtocolMapperRepresentation[];
|
|
||||||
[other: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProtocolMapperRepresentation {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
protocol: string;
|
|
||||||
protocolMapper: string;
|
|
||||||
config: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class KeycloakClient {
|
|
||||||
readonly baseUrl: string;
|
|
||||||
readonly defaultRealm: string;
|
|
||||||
// The admin token is short-lived; caching it (minus a safety margin) spares every call a fresh
|
|
||||||
// password grant, and a 401 mid-flight refreshes it once rather than failing the request.
|
|
||||||
private tokenCache: { token: string; expiresAt: number } | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
url: string,
|
|
||||||
private readonly adminUser: string,
|
|
||||||
private readonly adminPass: string,
|
|
||||||
defaultRealm = "master",
|
|
||||||
) {
|
|
||||||
this.baseUrl = url.replace(/\/+$/, "");
|
|
||||||
this.defaultRealm = defaultRealm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build from the module's resolved environment. Admin URL, credentials and the fallback realm are
|
|
||||||
* read from MESH_KEYCLOAK_* — the names the mesh sets — falling back to the container's own
|
|
||||||
* KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD so a co-located server needs nothing configured twice.
|
|
||||||
* Throws when no admin password can be found: without it the client can do nothing, so failing
|
|
||||||
* here lets the tool runtime expose no keycloak tools rather than tools that always error.
|
|
||||||
*/
|
|
||||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient {
|
|
||||||
const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE);
|
|
||||||
const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`;
|
|
||||||
const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin";
|
|
||||||
// The admin password reaches the runtime as a file (novox/hq ADR 0086): the module's own `admin`
|
|
||||||
// secret, mounted read-only. The environment forms stay for a co-located server that has them.
|
|
||||||
const adminPass = cfg.password ?? secretFile(env.MESH_KEYCLOAK_PASSWORD_FILE)
|
|
||||||
?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
|
|
||||||
if (!adminPass) {
|
|
||||||
throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)");
|
|
||||||
}
|
|
||||||
const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master";
|
|
||||||
return new KeycloakClient(url, adminUser, adminPass, realm);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getToken(): Promise<string> {
|
|
||||||
if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) return this.tokenCache.token;
|
|
||||||
|
|
||||||
const res = await fetch(`${this.baseUrl}/realms/master/protocol/openid-connect/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "password",
|
|
||||||
client_id: "admin-cli",
|
|
||||||
username: this.adminUser,
|
|
||||||
password: this.adminPass,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Keycloak token request failed: ${res.status} ${await res.text()}`);
|
|
||||||
|
|
||||||
const data = (await res.json()) as { access_token: string; expires_in: number };
|
|
||||||
this.tokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 30) * 1000 };
|
|
||||||
return data.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
|
||||||
const doRequest = async (token: string): Promise<Response> =>
|
|
||||||
fetch(`${this.baseUrl}/admin/realms${path}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...(options.headers as Record<string, string>),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let res = await doRequest(await this.getToken());
|
|
||||||
// A cached token that expired against the server's clock reads as 401; drop it and retry once.
|
|
||||||
if (res.status === 401) {
|
|
||||||
this.tokenCache = null;
|
|
||||||
res = await doRequest(await this.getToken());
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(`Keycloak API error ${res.status}: ${await res.text()}`);
|
|
||||||
// 201/204 carry no body — the admin API's create/update/delete answer with an empty response.
|
|
||||||
if (res.status === 201 || res.status === 204) return null as T;
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Realms
|
|
||||||
async listRealms(): Promise<Array<{ id: string; realm: string; displayName?: string; enabled: boolean }>> {
|
|
||||||
return this.request("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users
|
|
||||||
async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise<unknown[]> {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params.search) qs.set("search", params.search);
|
|
||||||
if (params.max) qs.set("max", String(params.max));
|
|
||||||
const query = qs.toString();
|
|
||||||
return this.request(`/${realm}/users${query ? `?${query}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createUser(realm: string, data: {
|
|
||||||
username: string;
|
|
||||||
email?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
credentials?: Array<{ type: string; value: string; temporary: boolean }>;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUser(realm: string, userId: string, data: Record<string, unknown>): Promise<void> {
|
|
||||||
await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUser(realm: string, userId: string): Promise<void> {
|
|
||||||
await this.request(`/${realm}/users/${userId}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise<void> {
|
|
||||||
await this.request(`/${realm}/users/${userId}/reset-password`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ type: "password", value: password, temporary }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserSessions(realm: string, userId: string): Promise<unknown[]> {
|
|
||||||
return this.request(`/${realm}/users/${userId}/sessions`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clients
|
|
||||||
async listClients(realm: string): Promise<unknown[]> {
|
|
||||||
return this.request(`/${realm}/clients`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createClient(realm: string, data: {
|
|
||||||
clientId: string;
|
|
||||||
name?: string;
|
|
||||||
rootUrl?: string;
|
|
||||||
redirectUris?: string[];
|
|
||||||
publicClient?: boolean;
|
|
||||||
protocol?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await this.request(`/${realm}/clients`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ protocol: "openid-connect", enabled: true, ...data }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The admin API addresses a client by its internal UUID, not the human clientId a caller knows;
|
|
||||||
// every client-scoped call resolves the one to the other first.
|
|
||||||
private async resolveClientId(realm: string, clientId: string): Promise<string> {
|
|
||||||
const clients = (await this.listClients(realm)) as Array<Record<string, unknown>>;
|
|
||||||
const client = clients.find((c) => c.clientId === clientId);
|
|
||||||
if (!client) throw new Error(`Client '${clientId}' not found in realm '${realm}'`);
|
|
||||||
return client.id as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The one client with exactly this clientId, or undefined. The admin API's `clientId` filter is an
|
|
||||||
* exact match unless `search=true` is asked for. */
|
|
||||||
async findClient(realm: string, clientId: string): Promise<ClientRepresentation | undefined> {
|
|
||||||
const found = await this.request<ClientRepresentation[]>(
|
|
||||||
`/${realm}/clients?clientId=${encodeURIComponent(clientId)}`);
|
|
||||||
return found.find((c) => c.clientId === clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createClientFrom(realm: string, rep: ClientRepresentation): Promise<void> {
|
|
||||||
await this.request(`/${realm}/clients`, { method: "POST", body: JSON.stringify(rep) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace a client's representation, addressed by its internal id. */
|
|
||||||
async updateClient(realm: string, id: string, rep: ClientRepresentation): Promise<void> {
|
|
||||||
await this.request(`/${realm}/clients/${id}`, { method: "PUT", body: JSON.stringify(rep) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteClientById(realm: string, id: string): Promise<void> {
|
|
||||||
await this.request(`/${realm}/clients/${id}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async clientSecretById(realm: string, id: string): Promise<string | undefined> {
|
|
||||||
const result = await this.request<{ value?: string }>(`/${realm}/clients/${id}/client-secret`);
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async listClientMappers(realm: string, id: string): Promise<ProtocolMapperRepresentation[]> {
|
|
||||||
return this.request(`/${realm}/clients/${id}/protocol-mappers/models`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
|
|
||||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(mapper),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
|
|
||||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models/${mapper.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(mapper),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteClient(realm: string, clientId: string): Promise<void> {
|
|
||||||
await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getClientSecret(realm: string, clientId: string): Promise<string> {
|
|
||||||
const id = await this.resolveClientId(realm, clientId);
|
|
||||||
const result = await this.request<{ value: string }>(`/${realm}/clients/${id}/client-secret`);
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addProtocolMapper(realm: string, clientId: string, mapper: {
|
|
||||||
name: string;
|
|
||||||
protocolMapper: string;
|
|
||||||
config: Record<string, string>;
|
|
||||||
}): Promise<void> {
|
|
||||||
const id = await this.resolveClientId(realm, clientId);
|
|
||||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ protocol: "openid-connect", ...mapper }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Roles
|
|
||||||
async listRealmRoles(realm: string): Promise<Array<{ id: string; name: string; description?: string; composite: boolean }>> {
|
|
||||||
return this.request(`/${realm}/roles`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createRealmRole(realm: string, data: { name: string; description?: string }): Promise<void> {
|
|
||||||
await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
|
|
||||||
return this.request(`/${realm}/users/${userId}/role-mappings/realm`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAvailableRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
|
|
||||||
return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise<void> {
|
|
||||||
await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "POST", body: JSON.stringify(roles) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise<void> {
|
|
||||||
await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groups
|
|
||||||
async listGroups(realm: string): Promise<Array<{ id: string; name: string; path: string; subGroupCount?: number }>> {
|
|
||||||
return this.request(`/${realm}/groups`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createGroup(realm: string, name: string): Promise<void> {
|
|
||||||
await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserGroups(realm: string, userId: string): Promise<Array<{ id: string; name: string; path: string }>> {
|
|
||||||
return this.request(`/${realm}/users/${userId}/groups`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addUserToGroup(realm: string, userId: string, groupId: string): Promise<void> {
|
|
||||||
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise<void> {
|
|
||||||
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The admin guard: Keycloak's admin must log in with the password the mesh minted, and when it does
|
||||||
|
// not, the module makes it — itself, inside the container, and says so (novox/hq issue 179).
|
||||||
|
//
|
||||||
|
// **Why it is needed.** The manifest mints an `admin` own-secret and renders it into the server's
|
||||||
|
// environment, and Keycloak applies that environment only when it creates its master realm. A
|
||||||
|
// database that was adopted, restored or moved already has a master realm, whose `admin` keeps the
|
||||||
|
// password it had. Every admin call then fails with *401 invalid_grant*. It happened twice: an
|
||||||
|
// adopted database on 2026-10-01, and a moved one on 2026-10-05, when the provisioner failed every
|
||||||
|
// five seconds for twenty-three hours — about 31,000 times — and nothing but the journal said so.
|
||||||
|
// Both times the fix was the same by hand. This is that fix, run by the module.
|
||||||
|
//
|
||||||
|
// **Where it runs, and why here.** In the module's own bundle, which already holds the two things the
|
||||||
|
// repair needs: the mesh's admin secret (the file the provisioner reads) and the container runtime
|
||||||
|
// (the `container-runtime` capability; the nats and nextcloud bundles reach their containers the
|
||||||
|
// same way). A declared host step would have to be handed the secret a second time and could not tell
|
||||||
|
// the provisioner to stop; the bundle can, and it is the process that sees the 401 first.
|
||||||
|
//
|
||||||
|
// **What it does.** Checks the admin's login at start, every five minutes, and at once when the
|
||||||
|
// admin API refuses the credentials. On a refusal — and only a refusal: an unreachable server is
|
||||||
|
// waited for, never repaired — it runs Keycloak's own recovery inside the container: a temporary
|
||||||
|
// admin through `kc.sh bootstrap-admin`, which sets the mesh's admin password (creating or enabling
|
||||||
|
// that admin if it must), and is removed again. Then it checks again. Both passwords travel on the
|
||||||
|
// exec's standard input, never on a command line, and neither is ever printed.
|
||||||
|
//
|
||||||
|
// **When it cannot.** It says so loudly (`admin.unrepaired`, and the provisioner's standing names the
|
||||||
|
// consumers it fails), and it brakes: the next automatic attempt is ten minutes on, doubling to six
|
||||||
|
// hours. While the admin is refused, the provisioner does not call Keycloak at all — each attempt
|
||||||
|
// would be one more failed login against the admin, and enough of those lock it out.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminState is what the last check found.
|
||||||
|
type AdminState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AdminUnknown AdminState = "unknown"
|
||||||
|
AdminOK AdminState = "ok"
|
||||||
|
AdminRejected AdminState = "rejected"
|
||||||
|
AdminUnreachable AdminState = "unreachable"
|
||||||
|
// AdminUnchecked is a check that could not be made for a reason of the module's own — the
|
||||||
|
// mesh's secret unreadable, say. Nothing to repair in Keycloak.
|
||||||
|
AdminUnchecked AdminState = "unchecked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Events the guard emits.
|
||||||
|
const (
|
||||||
|
EventRepaired = "admin.repaired"
|
||||||
|
EventUnrepaired = "admin.unrepaired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrAdminRejected is what the provisioner is answered while the admin is refused: fast, and without
|
||||||
|
// asking Keycloak.
|
||||||
|
var ErrAdminRejected = fmt.Errorf("the admin is refused by Keycloak and not yet repaired (%w); not asking it again until it is", ErrRejected)
|
||||||
|
|
||||||
|
// Executor runs one command with something on its standard input, answering its combined output.
|
||||||
|
type Executor interface {
|
||||||
|
Run(ctx context.Context, argv []string, stdin []byte) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DockerExec runs commands on this machine.
|
||||||
|
type DockerExec struct{}
|
||||||
|
|
||||||
|
func (DockerExec) Run(ctx context.Context, argv []string, stdin []byte) ([]byte, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
cmd.Stdin = bytes.NewReader(stdin)
|
||||||
|
return cmd.CombinedOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repair is what one repair did.
|
||||||
|
type Repair struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Outcome string `json:"outcome"` // "repaired" | "unrepaired"
|
||||||
|
Step string `json:"step,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
TempUser string `json:"temporaryAdmin,omitempty"`
|
||||||
|
// TempLeft says the temporary admin may still be in the master realm, for a person to delete.
|
||||||
|
TempLeft bool `json:"temporaryAdminLeft,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard keeps the admin's login true.
|
||||||
|
type Guard struct {
|
||||||
|
KC *Client
|
||||||
|
Exec Executor
|
||||||
|
Container string
|
||||||
|
Every time.Duration // 5m
|
||||||
|
Waiting time.Duration // 15s: how often to look for a server not yet seen
|
||||||
|
BrakeFrom time.Duration // 10m
|
||||||
|
BrakeMax time.Duration // 6h
|
||||||
|
Timeout time.Duration // 5m: the whole repair
|
||||||
|
Now func() time.Time
|
||||||
|
Log func(format string, args ...any)
|
||||||
|
Announce func(event string, body map[string]any)
|
||||||
|
|
||||||
|
once sync.Once
|
||||||
|
mu sync.Mutex // one check-and-repair at a time: the background's or an operator's
|
||||||
|
state atomic.Value
|
||||||
|
last *Repair
|
||||||
|
brakeTill time.Time
|
||||||
|
brakeWait time.Duration
|
||||||
|
nudge chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Guard) init() {
|
||||||
|
g.once.Do(func() {
|
||||||
|
if g.Every == 0 {
|
||||||
|
g.Every = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if g.Waiting == 0 {
|
||||||
|
g.Waiting = 15 * time.Second
|
||||||
|
}
|
||||||
|
if g.BrakeFrom == 0 {
|
||||||
|
g.BrakeFrom = 10 * time.Minute
|
||||||
|
}
|
||||||
|
if g.BrakeMax == 0 {
|
||||||
|
g.BrakeMax = 6 * time.Hour
|
||||||
|
}
|
||||||
|
if g.Timeout == 0 {
|
||||||
|
g.Timeout = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if g.Now == nil {
|
||||||
|
g.Now = time.Now
|
||||||
|
}
|
||||||
|
if g.Log == nil {
|
||||||
|
g.Log = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
if g.Container == "" {
|
||||||
|
g.Container = "keycloak"
|
||||||
|
}
|
||||||
|
if g.Exec == nil {
|
||||||
|
g.Exec = DockerExec{}
|
||||||
|
}
|
||||||
|
g.nudge = make(chan struct{}, 1)
|
||||||
|
g.state.Store(AdminUnknown)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is what the last check found.
|
||||||
|
func (g *Guard) State() AdminState {
|
||||||
|
g.init()
|
||||||
|
return g.state.Load().(AdminState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refused says the admin was refused at the last check and has not been repaired since: what the
|
||||||
|
// provisioner asks before calling Keycloak.
|
||||||
|
func (g *Guard) Refused() bool { return g.State() == AdminRejected }
|
||||||
|
|
||||||
|
// Nudge asks for a check now; never blocks.
|
||||||
|
func (g *Guard) Nudge() {
|
||||||
|
g.init()
|
||||||
|
select {
|
||||||
|
case g.nudge <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check asks Keycloak for a token as the admin, with the mesh's secret as it is now.
|
||||||
|
func (g *Guard) Check(ctx context.Context) (AdminState, error) {
|
||||||
|
g.init()
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, _, err := g.KC.Login(cctx)
|
||||||
|
state := classifyLogin(err)
|
||||||
|
g.state.Store(state)
|
||||||
|
return state, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyLogin(err error) AdminState {
|
||||||
|
if err == nil {
|
||||||
|
return AdminOK
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrRejected) {
|
||||||
|
return AdminRejected
|
||||||
|
}
|
||||||
|
var terr *TokenError
|
||||||
|
if errors.As(err, &terr) {
|
||||||
|
if terr.Status >= 500 || terr.Status == 404 {
|
||||||
|
return AdminUnreachable // starting, or not Keycloak yet
|
||||||
|
}
|
||||||
|
return AdminUnchecked
|
||||||
|
}
|
||||||
|
var nerr net.Error
|
||||||
|
if errors.As(err, &nerr) || errors.Is(err, context.DeadlineExceeded) ||
|
||||||
|
strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "EOF") {
|
||||||
|
return AdminUnreachable
|
||||||
|
}
|
||||||
|
return AdminUnchecked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is the guard's account of itself, for the tool.
|
||||||
|
type Report struct {
|
||||||
|
State AdminState `json:"state"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Repaired bool `json:"repaired,omitempty"`
|
||||||
|
LastRepair *Repair `json:"lastRepair,omitempty"`
|
||||||
|
BrakeUntil string `json:"brakeUntil,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure checks the admin and repairs a refused one. operator is a person asking: the brake is
|
||||||
|
// theirs to override. Without repair, it only checks.
|
||||||
|
func (g *Guard) Ensure(ctx context.Context, repair, operator bool) Report {
|
||||||
|
g.init()
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
state, err := g.Check(ctx)
|
||||||
|
r := g.report(state, err)
|
||||||
|
if state != AdminRejected || !repair {
|
||||||
|
if state == AdminOK {
|
||||||
|
g.brakeTill, g.brakeWait = time.Time{}, 0
|
||||||
|
r.BrakeUntil = ""
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if !operator && g.Now().Before(g.brakeTill) {
|
||||||
|
r.Note = "the last repair failed; the next automatic attempt is at brakeUntil (keycloak_admin_check with repair: true tries now)"
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Log("[keycloak] the admin is refused by Keycloak (invalid_grant) with the mesh's password; repairing it inside %s", g.Container)
|
||||||
|
done := g.repair(ctx)
|
||||||
|
state, err = g.Check(ctx)
|
||||||
|
if state == AdminOK && done.Outcome == "repaired" {
|
||||||
|
g.brakeTill, g.brakeWait = time.Time{}, 0
|
||||||
|
g.last = &done
|
||||||
|
g.Log("[keycloak] REPAIRED the admin: the realm's admin did not take the mesh's password (invalid_grant) — " +
|
||||||
|
"the database was adopted or moved and kept an older one; set to the mesh's through a temporary " +
|
||||||
|
"bootstrap admin, which was removed (novox/hq issue 179)")
|
||||||
|
if done.TempLeft {
|
||||||
|
g.Log("[keycloak] the temporary admin %s could not be removed: delete it from the master realm", done.TempUser)
|
||||||
|
}
|
||||||
|
g.announce(EventRepaired, map[string]any{
|
||||||
|
"cause": "the master realm's admin kept a password older than the mesh's — an adopted, restored or moved database",
|
||||||
|
"at": done.At.UTC().Format(time.RFC3339), "temporaryAdminLeft": done.TempLeft,
|
||||||
|
})
|
||||||
|
r = g.report(state, err)
|
||||||
|
r.Repaired = true
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if done.Outcome == "repaired" {
|
||||||
|
// The script finished and the login still fails: say what the check found.
|
||||||
|
done.Outcome, done.Step = "unrepaired", "verify"
|
||||||
|
done.Error = fmt.Sprintf("after the repair the admin still cannot log in: %s", errText(err))
|
||||||
|
}
|
||||||
|
g.last = &done
|
||||||
|
if g.brakeWait == 0 {
|
||||||
|
g.brakeWait = g.BrakeFrom
|
||||||
|
} else if g.brakeWait *= 2; g.brakeWait > g.BrakeMax {
|
||||||
|
g.brakeWait = g.BrakeMax
|
||||||
|
}
|
||||||
|
g.brakeTill = g.Now().Add(g.brakeWait)
|
||||||
|
left := ""
|
||||||
|
if done.TempLeft {
|
||||||
|
left = fmt.Sprintf(" A temporary admin %s may be left in the master realm: delete it.", done.TempUser)
|
||||||
|
}
|
||||||
|
g.Log("[keycloak] COULD NOT REPAIR the admin at step %s: %s. Every consumer's client is unmanaged until it is; "+
|
||||||
|
"the next automatic attempt is in %s. By hand: novox/hq issue 179.%s",
|
||||||
|
done.Step, done.Error, g.brakeWait, left)
|
||||||
|
g.announce(EventUnrepaired, map[string]any{
|
||||||
|
"step": done.Step, "error": done.Error, "temporaryAdminLeft": done.TempLeft,
|
||||||
|
"next": g.brakeTill.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
r = g.report(state, err)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Guard) report(state AdminState, err error) Report {
|
||||||
|
r := Report{State: state, LastRepair: g.last}
|
||||||
|
if err != nil {
|
||||||
|
r.Error = errText(err)
|
||||||
|
}
|
||||||
|
if g.Now().Before(g.brakeTill) {
|
||||||
|
r.BrakeUntil = g.brakeTill.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func errText(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Guard) announce(event string, body map[string]any) {
|
||||||
|
if g.Announce != nil {
|
||||||
|
g.Announce(event, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run checks until ctx ends: often until the server has been seen, then every Every, and at once
|
||||||
|
// when nudged.
|
||||||
|
func (g *Guard) Run(ctx context.Context) {
|
||||||
|
g.init()
|
||||||
|
seen := false
|
||||||
|
for {
|
||||||
|
r := g.Ensure(ctx, true, false)
|
||||||
|
if r.State != AdminUnreachable && r.State != AdminUnknown {
|
||||||
|
seen = true
|
||||||
|
}
|
||||||
|
wait := g.Every
|
||||||
|
if !seen {
|
||||||
|
wait = g.Waiting
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-g.nudge:
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairScript is Keycloak's own recovery, run inside its container (Keycloak 26). It reads the
|
||||||
|
// temporary admin's password and then the mesh's admin password from standard input; nothing secret
|
||||||
|
// is on its command line or in its environment as docker sees it. Every step announces itself on
|
||||||
|
// stderr, so a failure names the step it failed at.
|
||||||
|
const repairScript = `set -eu
|
||||||
|
umask 077
|
||||||
|
IFS= read -r TMP_PW
|
||||||
|
IFS= read -r NEW_PW
|
||||||
|
export TMP_PW
|
||||||
|
bin=/opt/keycloak/bin
|
||||||
|
cfg=/tmp/mesh-kcadm.$$.config
|
||||||
|
bootstrapped=
|
||||||
|
logged_in=
|
||||||
|
step() { echo "mesh-repair-step: $1" >&2; }
|
||||||
|
user_id() {
|
||||||
|
"$bin/kcadm.sh" get users -r master --config "$cfg" -q username="$1" -q exact=true --fields id --format csv --noquotes
|
||||||
|
}
|
||||||
|
cleanup() {
|
||||||
|
rc=$?
|
||||||
|
set +e
|
||||||
|
if [ -z "$logged_in" ] && [ -n "$bootstrapped" ]; then
|
||||||
|
# The bootstrap may have made the temporary admin and failed after (it did once, on a held port):
|
||||||
|
# log in as it anyway, so it is removed rather than left behind.
|
||||||
|
"$bin/kcadm.sh" config credentials --config "$cfg" --server http://localhost:8080 --realm master \
|
||||||
|
--user "$TMP_USER" --password "$TMP_PW" >/dev/null 2>&1 && logged_in=1
|
||||||
|
fi
|
||||||
|
if [ -n "$logged_in" ]; then
|
||||||
|
tid=$(user_id "$TMP_USER" 2>/dev/null)
|
||||||
|
if [ -n "$tid" ] && "$bin/kcadm.sh" delete "users/$tid" -r master --config "$cfg" >&2; then
|
||||||
|
echo "mesh-repair-removed: $TMP_USER" >&2
|
||||||
|
else
|
||||||
|
echo "mesh-repair-left: $TMP_USER" >&2
|
||||||
|
fi
|
||||||
|
elif [ -n "$bootstrapped" ]; then
|
||||||
|
echo "mesh-repair-left: $TMP_USER" >&2
|
||||||
|
fi
|
||||||
|
rm -f "$cfg"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
step bootstrap-admin
|
||||||
|
bootstrapped=1
|
||||||
|
"$bin/kc.sh" bootstrap-admin user --username "$TMP_USER" --password:env TMP_PW --http-management-port="$MGMT_PORT" >&2
|
||||||
|
step login
|
||||||
|
"$bin/kcadm.sh" config credentials --config "$cfg" --server http://localhost:8080 --realm master --user "$TMP_USER" --password "$TMP_PW" >&2
|
||||||
|
logged_in=1
|
||||||
|
step find-admin
|
||||||
|
id=$(user_id "$ADMIN_USER")
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
step create-admin
|
||||||
|
"$bin/kcadm.sh" create users -r master --config "$cfg" -s username="$ADMIN_USER" -s enabled=true >&2
|
||||||
|
"$bin/kcadm.sh" add-roles -r master --config "$cfg" --uusername "$ADMIN_USER" --rolename admin >&2
|
||||||
|
id=$(user_id "$ADMIN_USER")
|
||||||
|
fi
|
||||||
|
step enable-admin
|
||||||
|
"$bin/kcadm.sh" update "users/$id" -r master --config "$cfg" -s enabled=true >&2
|
||||||
|
step set-password
|
||||||
|
"$bin/kcadm.sh" set-password -r master --config "$cfg" --username "$ADMIN_USER" --new-password "$NEW_PW" >&2
|
||||||
|
step remove-temporary-admin
|
||||||
|
echo "mesh-repair-done" >&2
|
||||||
|
`
|
||||||
|
|
||||||
|
// repair runs the script once.
|
||||||
|
func (g *Guard) repair(ctx context.Context) Repair {
|
||||||
|
done := Repair{At: g.Now(), Outcome: "unrepaired", Step: "prepare"}
|
||||||
|
meshPW, err := g.KC.Password()
|
||||||
|
if err != nil {
|
||||||
|
done.Error = "the mesh's admin password cannot be read: " + err.Error()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(meshPW, "\n\r") {
|
||||||
|
done.Error = "the mesh's admin password spans lines and cannot be handed over on one"
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
tmpPW, user, port, err := temporaries()
|
||||||
|
if err != nil {
|
||||||
|
done.Error = err.Error()
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
done.TempUser = user
|
||||||
|
argv := []string{"docker", "exec", "-i",
|
||||||
|
"-e", "TMP_USER=" + user, "-e", "MGMT_PORT=" + port, "-e", "ADMIN_USER=" + g.KC.AdminUser,
|
||||||
|
g.Container, "bash", "-c", repairScript}
|
||||||
|
rctx, cancel := context.WithTimeout(ctx, g.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
out, runErr := g.Exec.Run(rctx, argv, []byte(tmpPW+"\n"+meshPW+"\n"))
|
||||||
|
text := scrubAll(string(out), tmpPW, meshPW)
|
||||||
|
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(text))
|
||||||
|
finished := false
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "mesh-repair-step: "):
|
||||||
|
done.Step = strings.TrimPrefix(line, "mesh-repair-step: ")
|
||||||
|
case strings.HasPrefix(line, "mesh-repair-left: "):
|
||||||
|
done.TempLeft = true
|
||||||
|
case line == "mesh-repair-done":
|
||||||
|
finished = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runErr == nil && finished {
|
||||||
|
done.Outcome, done.Step = "repaired", ""
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
why := "the script did not finish"
|
||||||
|
if runErr != nil {
|
||||||
|
why = scrubAll(runErr.Error(), tmpPW, meshPW)
|
||||||
|
}
|
||||||
|
done.Error = why + ": " + tail(text, 20)
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
// temporaries are the temporary admin's password and name, and a management port for the second
|
||||||
|
// server bootstrap-admin starts (the running server holds the default one).
|
||||||
|
func temporaries() (pw, user, port string, err error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(raw); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("no randomness for a temporary password: %w", err)
|
||||||
|
}
|
||||||
|
id := make([]byte, 4)
|
||||||
|
if _, err = rand.Read(id); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(1000))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw), "mesh-repair-" + hex.EncodeToString(id),
|
||||||
|
fmt.Sprint(19000 + n.Int64()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrubAll(text string, secrets ...string) string {
|
||||||
|
for _, s := range secrets {
|
||||||
|
if s != "" {
|
||||||
|
text = strings.ReplaceAll(text, s, "***")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// tail is the last n non-empty lines of text, on one line each joined by " | ".
|
||||||
|
func tail(text string, n int) string {
|
||||||
|
var lines []string
|
||||||
|
for _, l := range strings.Split(text, "\n") {
|
||||||
|
if l = strings.TrimSpace(l); l != "" {
|
||||||
|
lines = append(lines, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) > n {
|
||||||
|
lines = lines[len(lines)-n:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, " | ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The guard (novox/hq issue 179): an admin refused with the mesh's password is repaired inside the
|
||||||
|
// container, without a secret on any command line, verified, and said; one it cannot repair is said
|
||||||
|
// loudly and braked; one it cannot reach is waited for; and while it is refused the provisioner does
|
||||||
|
// not ask Keycloak.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeExec is the container: it records what it was asked, and — when it works — does what the
|
||||||
|
// script does, setting the fake server's admin password to the second line of its input.
|
||||||
|
type fakeExec struct {
|
||||||
|
f *fakeKeycloak
|
||||||
|
runs []execRun
|
||||||
|
fails bool
|
||||||
|
output string
|
||||||
|
noEffect bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type execRun struct {
|
||||||
|
argv []string
|
||||||
|
stdin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *fakeExec) Run(_ context.Context, argv []string, stdin []byte) ([]byte, error) {
|
||||||
|
x.runs = append(x.runs, execRun{argv, string(stdin)})
|
||||||
|
if x.fails {
|
||||||
|
lines := strings.Split(string(stdin), "\n")
|
||||||
|
return []byte("mesh-repair-step: bootstrap-admin\nERROR: boom " + lines[0] + " " + lines[1] + "\nmesh-repair-left: x\n"), errors.New("exit status 1")
|
||||||
|
}
|
||||||
|
if !x.noEffect {
|
||||||
|
x.f.set(func() { x.f.password = strings.Split(string(stdin), "\n")[1] })
|
||||||
|
}
|
||||||
|
return []byte("mesh-repair-step: set-password\nmesh-repair-step: remove-temporary-admin\nmesh-repair-removed: x\nmesh-repair-done\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type guardWorld struct {
|
||||||
|
f *fakeKeycloak
|
||||||
|
x *fakeExec
|
||||||
|
g *Guard
|
||||||
|
now time.Time
|
||||||
|
events []string
|
||||||
|
said []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGuardWorld(t *testing.T) *guardWorld {
|
||||||
|
w := &guardWorld{now: time.Date(2026, 10, 5, 23, 55, 0, 0, time.UTC)}
|
||||||
|
w.f = newFakeKeycloak(t, "Novox", "old-password-from-2022")
|
||||||
|
w.x = &fakeExec{f: w.f}
|
||||||
|
w.g = &Guard{KC: w.f.client("the-mesh-minted-this"), Exec: w.x,
|
||||||
|
Now: func() time.Time { return w.now },
|
||||||
|
Log: func(f string, a ...any) { w.said = append(w.said, f) },
|
||||||
|
Announce: func(e string, _ map[string]any) { w.events = append(w.events, e) }}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARefusedAdminIsRepairedInsideTheContainerAndSaid(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
r := w.g.Ensure(ctx, true, false)
|
||||||
|
if !r.Repaired || r.State != AdminOK || w.f.password != "the-mesh-minted-this" {
|
||||||
|
t.Fatalf("%+v, server password %q", r, w.f.password)
|
||||||
|
}
|
||||||
|
if strings.Join(w.events, ",") != EventRepaired {
|
||||||
|
t.Fatal(w.events)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(w.said, "\n"), "REPAIRED the admin") {
|
||||||
|
t.Fatal(w.said)
|
||||||
|
}
|
||||||
|
run := w.x.runs[0]
|
||||||
|
if run.argv[0] != "docker" || run.argv[1] != "exec" || run.argv[2] != "-i" || !contains(run.argv, "keycloak") ||
|
||||||
|
!contains(run.argv, "ADMIN_USER=admin") {
|
||||||
|
t.Fatal(run.argv)
|
||||||
|
}
|
||||||
|
// Both passwords on standard input — the temporary one, then the mesh's — and on no command line.
|
||||||
|
lines := strings.Split(run.stdin, "\n")
|
||||||
|
if len(lines) != 3 || lines[1] != "the-mesh-minted-this" || len(lines[0]) < 40 {
|
||||||
|
t.Fatalf("stdin has %d lines", len(lines))
|
||||||
|
}
|
||||||
|
for _, a := range run.argv {
|
||||||
|
if strings.Contains(a, "the-mesh-minted-this") || strings.Contains(a, lines[0]) {
|
||||||
|
t.Fatalf("a password is on the command line: %q", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.g.Refused() {
|
||||||
|
t.Fatal("still refused after a repair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheScriptIsKeycloaksOwnRecovery(t *testing.T) {
|
||||||
|
for _, want := range []string{
|
||||||
|
`kc.sh" bootstrap-admin user --username "$TMP_USER" --password:env TMP_PW --http-management-port="$MGMT_PORT"`,
|
||||||
|
`set-password -r master --config "$cfg" --username "$ADMIN_USER" --new-password "$NEW_PW"`,
|
||||||
|
`umask 077`, `trap cleanup EXIT`, `rm -f "$cfg"`, `delete "users/$tid"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(repairScript, want) {
|
||||||
|
t.Errorf("the script lacks %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(repairScript, "--cache") {
|
||||||
|
t.Error("bootstrap-admin takes no --cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAdminThatLogsInIsLeftAlone(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
w.f.password = "the-mesh-minted-this"
|
||||||
|
if r := w.g.Ensure(ctx, true, false); r.State != AdminOK || r.Repaired || len(w.x.runs) != 0 {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAServerNotAnsweringIsWaitedForNeverRepaired(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
w.f.down = true
|
||||||
|
if r := w.g.Ensure(ctx, true, false); r.State != AdminUnreachable || len(w.x.runs) != 0 {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
w.f.srv.Close()
|
||||||
|
if r := w.g.Ensure(ctx, true, false); r.State != AdminUnreachable || len(w.x.runs) != 0 {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARepairThatFailsIsSaidLoudlyAndBraked(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
w.x.fails = true
|
||||||
|
r := w.g.Ensure(ctx, true, false)
|
||||||
|
if r.State != AdminRejected || r.LastRepair == nil || r.LastRepair.Step != "bootstrap-admin" || !r.LastRepair.TempLeft || r.BrakeUntil == "" {
|
||||||
|
t.Fatalf("%+v %+v", r, r.LastRepair)
|
||||||
|
}
|
||||||
|
// Neither password survives into what is said.
|
||||||
|
if strings.Contains(r.LastRepair.Error, "the-mesh-minted-this") || strings.Contains(r.LastRepair.Error, strings.Split(w.x.runs[0].stdin, "\n")[0]) {
|
||||||
|
t.Fatal(r.LastRepair.Error)
|
||||||
|
}
|
||||||
|
if strings.Join(w.events, ",") != EventUnrepaired || !strings.Contains(strings.Join(w.said, "\n"), "COULD NOT REPAIR") {
|
||||||
|
t.Fatal(w.events, w.said)
|
||||||
|
}
|
||||||
|
if !w.g.Refused() {
|
||||||
|
t.Fatal("not refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside the brake: checked, not repaired.
|
||||||
|
w.now = w.now.Add(9 * time.Minute)
|
||||||
|
w.g.Ensure(ctx, true, false)
|
||||||
|
if len(w.x.runs) != 1 {
|
||||||
|
t.Fatalf("repaired inside the brake: %d runs", len(w.x.runs))
|
||||||
|
}
|
||||||
|
// Past it: tried again, and the next brake is twice as long.
|
||||||
|
w.now = w.now.Add(2 * time.Minute)
|
||||||
|
r = w.g.Ensure(ctx, true, false)
|
||||||
|
if len(w.x.runs) != 2 {
|
||||||
|
t.Fatalf("%d runs", len(w.x.runs))
|
||||||
|
}
|
||||||
|
if until, _ := time.Parse(time.RFC3339, r.BrakeUntil); until.Sub(w.now) != 20*time.Minute {
|
||||||
|
t.Fatal(r.BrakeUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator asking repairs now, brake or not.
|
||||||
|
w.x.fails = false
|
||||||
|
if r := w.g.Ensure(ctx, true, true); !r.Repaired || len(w.x.runs) != 3 || r.BrakeUntil != "" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAScriptThatFinishesButChangesNothingIsNotARepair(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
w.x.noEffect = true
|
||||||
|
r := w.g.Ensure(ctx, true, false)
|
||||||
|
if r.Repaired || r.LastRepair.Step != "verify" || strings.Join(w.events, ",") != EventUnrepaired {
|
||||||
|
t.Fatalf("%+v %+v %v", r, r.LastRepair, w.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyCheckingRepairsNothing(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
if r := w.g.Ensure(ctx, false, true); r.State != AdminRejected || len(w.x.runs) != 0 {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhileTheAdminIsRefusedTheProvisionerDoesNotAskKeycloak(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
w.x.fails = true
|
||||||
|
w.g.Ensure(ctx, true, false)
|
||||||
|
a := provisioner{clients: OidcClients{KC: w.g.KC, Realm: "Novox"}, guard: w.g,
|
||||||
|
announce: func(string, map[string]any) {}, log: func(string, ...any) {}}
|
||||||
|
logins := w.f.logins
|
||||||
|
err := a.Create(ctx, grafana("s", nil))
|
||||||
|
if !errors.Is(err, ErrRejected) || a.Class(err) != ClassCredentials {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.Holds(ctx, grafana("s", nil)); !errors.Is(err, ErrRejected) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.f.logins != logins {
|
||||||
|
t.Fatal("Keycloak was asked while the admin is refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARefusalSeenByTheAdminAPINudgesTheGuard(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
w.g.init()
|
||||||
|
w.g.KC.onRejected = w.g.Nudge
|
||||||
|
if _, err := w.g.KC.ListRealms(ctx); !errors.Is(err, ErrRejected) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-w.g.nudge:
|
||||||
|
default:
|
||||||
|
t.Fatal("not nudged")
|
||||||
|
}
|
||||||
|
// The guard's own check does not nudge it: that would be a guard checking in a loop.
|
||||||
|
w.g.Check(ctx)
|
||||||
|
select {
|
||||||
|
case <-w.g.nudge:
|
||||||
|
t.Fatal("the guard's own check nudged it")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheGuardReadsTheMeshsSecretEachTime(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
secret := "first"
|
||||||
|
w.g.KC.password = func() (string, error) { return secret, nil }
|
||||||
|
w.f.password = "second"
|
||||||
|
if s, _ := w.g.Check(ctx); s != AdminRejected {
|
||||||
|
t.Fatal(s)
|
||||||
|
}
|
||||||
|
secret = "second"
|
||||||
|
if s, _ := w.g.Check(ctx); s != AdminOK {
|
||||||
|
t.Fatal(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRepairsWhenNudged(t *testing.T) {
|
||||||
|
w := newGuardWorld(t)
|
||||||
|
w.f.password = "the-mesh-minted-this"
|
||||||
|
w.g.Every, w.g.Waiting = time.Hour, time.Hour
|
||||||
|
c, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
go w.g.Run(c)
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for w.g.State() != AdminOK {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("no first check")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
w.f.set(func() { w.f.password = "moved-database" })
|
||||||
|
w.g.Nudge()
|
||||||
|
for {
|
||||||
|
w.f.mu.Lock()
|
||||||
|
done := w.f.password == "the-mesh-minted-this"
|
||||||
|
w.f.mu.Unlock()
|
||||||
|
if done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("not repaired when nudged")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0039).
|
||||||
|
// Ported from client.ts: the same environment, the same token cache, the same one retry on a 401.
|
||||||
|
//
|
||||||
|
// A representation is a map rather than a struct, so an update carries back every field the server
|
||||||
|
// sent — including ones this module does not know — and never drops what somebody else set.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rep is a representation as the admin API sends and takes it.
|
||||||
|
type Rep = map[string]any
|
||||||
|
|
||||||
|
// ErrRejected marks a token request the server refused for the credentials: 401, or an
|
||||||
|
// `invalid_grant` — wrong password, missing or disabled user. The guard repairs exactly this.
|
||||||
|
var ErrRejected = errors.New("credentials rejected: invalid_grant")
|
||||||
|
|
||||||
|
// Client speaks to one Keycloak server's admin API as one admin.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
AdminUser string
|
||||||
|
DefaultRealm string
|
||||||
|
// password is read each time a token is needed, so a rotated secret is used without a restart.
|
||||||
|
password func() (string, error)
|
||||||
|
http *http.Client
|
||||||
|
// onRejected is told when the server refuses the admin's credentials (the guard's nudge).
|
||||||
|
onRejected func()
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// meshConfig is the settings-merged config the mesh delivers (novox/hq ADR 0046).
|
||||||
|
func meshConfig(file string) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
if file == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cfgString(cfg map[string]any, key string) string {
|
||||||
|
if s, ok := cfg[key].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOf(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretFile is a secret file's value with its trailing newline trimmed.
|
||||||
|
func secretFile(file string) (string, error) {
|
||||||
|
raw, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s := strings.TrimSuffix(string(raw), "\n")
|
||||||
|
if s == "" {
|
||||||
|
return "", fmt.Errorf("%s is empty", file)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientFromEnv builds the client from the module's resolved environment, as client.ts did: the
|
||||||
|
// config file first, then MESH_KEYCLOAK_*, then the container's own KEYCLOAK_ADMIN* names.
|
||||||
|
// Refused when no admin password can be found at all: without one there is nothing to serve.
|
||||||
|
func ClientFromEnv(getenv func(string) string) (*Client, error) {
|
||||||
|
cfg := meshConfig(getenv("MESH_KEYCLOAK_CONFIG_FILE"))
|
||||||
|
port := firstOf(getenv("KEYCLOAK_PORT"), "8080")
|
||||||
|
base := firstOf(cfgString(cfg, "url"), getenv("MESH_KEYCLOAK_URL"), "http://127.0.0.1:"+port)
|
||||||
|
user := firstOf(cfgString(cfg, "user"), getenv("MESH_KEYCLOAK_ADMIN"), getenv("KEYCLOAK_ADMIN"), "admin")
|
||||||
|
realm := firstOf(cfgString(cfg, "realm"), getenv("MESH_KEYCLOAK_REALM"), "master")
|
||||||
|
|
||||||
|
// The admin password reaches the runtime as a file (novox/hq ADR 0086): the module's own `admin`
|
||||||
|
// secret. Read on every token request, so the guard and the provisioner always use the mesh's
|
||||||
|
// current one.
|
||||||
|
fixed := firstOf(cfgString(cfg, "password"))
|
||||||
|
file := getenv("MESH_KEYCLOAK_PASSWORD_FILE")
|
||||||
|
env := firstOf(getenv("MESH_KEYCLOAK_PASSWORD"), getenv("KEYCLOAK_ADMIN_PASSWORD"))
|
||||||
|
password := func() (string, error) {
|
||||||
|
if fixed != "" {
|
||||||
|
return fixed, nil
|
||||||
|
}
|
||||||
|
if file != "" {
|
||||||
|
if s, err := secretFile(file); err == nil {
|
||||||
|
return s, nil
|
||||||
|
} else if env == "" {
|
||||||
|
return "", fmt.Errorf("the admin password cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env != "" {
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)")
|
||||||
|
}
|
||||||
|
if fixed == "" && file == "" && env == "" {
|
||||||
|
return nil, errors.New("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)")
|
||||||
|
}
|
||||||
|
return NewClient(base, user, password, realm), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient is a client for one server and admin.
|
||||||
|
func NewClient(base, user string, password func() (string, error), realm string) *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(base, "/"), AdminUser: user, DefaultRealm: realm,
|
||||||
|
password: password, http: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password is the admin password the mesh holds now.
|
||||||
|
func (c *Client) Password() (string, error) { return c.password() }
|
||||||
|
|
||||||
|
// TokenError is a token request the server answered with something other than a token.
|
||||||
|
type TokenError struct {
|
||||||
|
Status int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TokenError) Error() string {
|
||||||
|
return fmt.Sprintf("Keycloak token request failed: %d %s", e.Status, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap makes a refusal of the credentials an ErrRejected.
|
||||||
|
func (e *TokenError) Unwrap() error {
|
||||||
|
if e.Status == http.StatusUnauthorized || strings.Contains(e.Body, "invalid_grant") {
|
||||||
|
return ErrRejected
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login asks for a fresh token with the admin's credentials, bypassing the cache: what the guard
|
||||||
|
// checks with. It tells nobody about a refusal; getToken does.
|
||||||
|
func (c *Client) Login(ctx context.Context) (string, time.Duration, error) {
|
||||||
|
pw, err := c.password()
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
form := url.Values{"grant_type": {"password"}, "client_id": {"admin-cli"},
|
||||||
|
"username": {c.AdminUser}, "password": {pw}}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.BaseURL+"/realms/master/protocol/openid-connect/token", strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
res, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return "", 0, &TokenError{Status: res.StatusCode, Body: strings.TrimSpace(string(body))}
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil || data.AccessToken == "" {
|
||||||
|
return "", 0, fmt.Errorf("Keycloak token response unreadable: %v", err)
|
||||||
|
}
|
||||||
|
return data.AccessToken, time.Duration(data.ExpiresIn) * time.Second, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getToken is a cached token, valid for at least thirty seconds more.
|
||||||
|
func (c *Client) getToken(ctx context.Context) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.token != "" && time.Now().Before(c.expiresAt) {
|
||||||
|
t := c.token
|
||||||
|
c.mu.Unlock()
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
token, life, err := c.Login(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// The guard is told, so a refused admin is repaired now rather than at its next check. Only
|
||||||
|
// here, never in Login: the guard checks with Login, and a check that nudged the guard
|
||||||
|
// would be a guard checking in a loop.
|
||||||
|
if errors.Is(err, ErrRejected) && c.onRejected != nil {
|
||||||
|
c.onRejected()
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token, c.expiresAt = token, time.Now().Add(life-30*time.Second)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropToken() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = ""
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError is an admin API answer that was not a success.
|
||||||
|
type APIError struct {
|
||||||
|
Status int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string { return fmt.Sprintf("Keycloak API error %d: %s", e.Status, e.Body) }
|
||||||
|
|
||||||
|
// request calls the admin API under /admin/realms, decoding the answer into out when there is one.
|
||||||
|
func (c *Client) request(ctx context.Context, method, path string, in, out any) error {
|
||||||
|
var payload []byte
|
||||||
|
if in != nil {
|
||||||
|
var err error
|
||||||
|
if payload, err = json.Marshal(in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do := func(token string) (*http.Response, error) {
|
||||||
|
var body io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
body = bytes.NewReader(payload)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/admin/realms"+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
return c.http.Do(req)
|
||||||
|
}
|
||||||
|
token, err := c.getToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := do(token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A cached token that expired against the server's clock reads as 401; drop it and retry once.
|
||||||
|
if res.StatusCode == http.StatusUnauthorized {
|
||||||
|
res.Body.Close()
|
||||||
|
c.dropToken()
|
||||||
|
if token, err = c.getToken(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res, err = do(token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 16<<20))
|
||||||
|
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||||
|
return &APIError{Status: res.StatusCode, Body: strings.TrimSpace(string(raw))}
|
||||||
|
}
|
||||||
|
// 201/204 carry no body — the admin API's create/update/delete answer with an empty response.
|
||||||
|
if out == nil || res.StatusCode == http.StatusCreated || res.StatusCode == http.StatusNoContent || len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(raw, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func esc(s string) string { return url.PathEscape(s) }
|
||||||
|
|
||||||
|
// Realms
|
||||||
|
|
||||||
|
func (c *Client) ListRealms(ctx context.Context) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users
|
||||||
|
|
||||||
|
func (c *Client) ListUsers(ctx context.Context, realm, search string, max int) ([]Rep, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
if search != "" {
|
||||||
|
q.Set("search", search)
|
||||||
|
}
|
||||||
|
if max > 0 {
|
||||||
|
q.Set("max", fmt.Sprint(max))
|
||||||
|
}
|
||||||
|
path := "/" + esc(realm) + "/users"
|
||||||
|
if len(q) > 0 {
|
||||||
|
path += "?" + q.Encode()
|
||||||
|
}
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, path, nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateUser(ctx context.Context, realm string, rep Rep) error {
|
||||||
|
body := Rep{"enabled": true}
|
||||||
|
for k, v := range rep {
|
||||||
|
body[k] = v
|
||||||
|
}
|
||||||
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/users", body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateUser(ctx context.Context, realm, id string, rep Rep) error {
|
||||||
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id), rep, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteUser(ctx context.Context, realm, id string) error {
|
||||||
|
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ResetPassword(ctx context.Context, realm, id, password string, temporary bool) error {
|
||||||
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id)+"/reset-password",
|
||||||
|
Rep{"type": "password", "value": password, "temporary": temporary}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UserSessions(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/sessions", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients
|
||||||
|
|
||||||
|
func (c *Client) ListClients(ctx context.Context, realm string) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateClient(ctx context.Context, realm string, rep Rep) error {
|
||||||
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/clients", rep, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindClient is the one client with exactly this clientId, or nil. The admin API's `clientId`
|
||||||
|
// filter is an exact match unless `search=true` is asked for.
|
||||||
|
func (c *Client) FindClient(ctx context.Context, realm, clientID string) (Rep, error) {
|
||||||
|
var found []Rep
|
||||||
|
if err := c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients?clientId="+url.QueryEscape(clientID), nil, &found); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, f := range found {
|
||||||
|
if f["clientId"] == clientID {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveClientID is a client's internal id from the clientId a caller knows.
|
||||||
|
func (c *Client) resolveClientID(ctx context.Context, realm, clientID string) (string, error) {
|
||||||
|
clients, err := c.ListClients(ctx, realm)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, cl := range clients {
|
||||||
|
if cl["clientId"] == clientID {
|
||||||
|
id, _ := cl["id"].(string)
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("Client '%s' not found in realm '%s'", clientID, realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateClient(ctx context.Context, realm, id string, rep Rep) error {
|
||||||
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/clients/"+esc(id), rep, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteClientByID(ctx context.Context, realm, id string) error {
|
||||||
|
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/clients/"+esc(id), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ClientSecretByID(ctx context.Context, realm, id string) (string, error) {
|
||||||
|
var out struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
err := c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients/"+esc(id)+"/client-secret", nil, &out)
|
||||||
|
return out.Value, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListClientMappers(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddClientMapper(ctx context.Context, realm, id string, mapper Rep) error {
|
||||||
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models", mapper, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateClientMapper(ctx context.Context, realm, id string, mapper Rep) error {
|
||||||
|
mid, _ := mapper["id"].(string)
|
||||||
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models/"+esc(mid), mapper, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteClient(ctx context.Context, realm, clientID string) error {
|
||||||
|
id, err := c.resolveClientID(ctx, realm, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.DeleteClientByID(ctx, realm, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetClientSecret(ctx context.Context, realm, clientID string) (string, error) {
|
||||||
|
id, err := c.resolveClientID(ctx, realm, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return c.ClientSecretByID(ctx, realm, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddProtocolMapper(ctx context.Context, realm, clientID string, mapper Rep) error {
|
||||||
|
id, err := c.resolveClientID(ctx, realm, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := Rep{"protocol": "openid-connect"}
|
||||||
|
for k, v := range mapper {
|
||||||
|
body[k] = v
|
||||||
|
}
|
||||||
|
return c.AddClientMapper(ctx, realm, id, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles
|
||||||
|
|
||||||
|
func (c *Client) ListRealmRoles(ctx context.Context, realm string) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/roles", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateRealmRole(ctx context.Context, realm string, rep Rep) error {
|
||||||
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/roles", rep, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UserRealmRoles(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AvailableRealmRoles(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm/available", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AssignRealmRoles(ctx context.Context, realm, id string, roles []Rep) error {
|
||||||
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", roles, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) RemoveRealmRoles(ctx context.Context, realm, id string, roles []Rep) error {
|
||||||
|
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", roles, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
|
||||||
|
func (c *Client) ListGroups(ctx context.Context, realm string) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/groups", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateGroup(ctx context.Context, realm, name string) error {
|
||||||
|
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/groups", Rep{"name": name}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UserGroups(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||||
|
var out []Rep
|
||||||
|
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/groups", nil, &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddUserToGroup(ctx context.Context, realm, id, group string) error {
|
||||||
|
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id)+"/groups/"+esc(group), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) RemoveUserFromGroup(ctx context.Context, realm, id, group string) error {
|
||||||
|
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id)+"/groups/"+esc(group), nil, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A fake Keycloak: the token endpoint, accepting one password that a test may change, and the admin
|
||||||
|
// routes this module touches on one realm's clients, answering with the status codes and shapes
|
||||||
|
// Keycloak gives.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ctx = context.Background()
|
||||||
|
|
||||||
|
type fakeKeycloak struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
srv *httptest.Server
|
||||||
|
realm string
|
||||||
|
password string // what the admin's login accepts
|
||||||
|
down bool // answer 503, as a starting server does
|
||||||
|
logins int
|
||||||
|
clients map[string]Rep
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeKeycloak(t *testing.T, realm, password string) *fakeKeycloak {
|
||||||
|
f := &fakeKeycloak{realm: realm, password: password, clients: map[string]Rep{}}
|
||||||
|
f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
|
||||||
|
t.Cleanup(f.srv.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func uuid() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if v != nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeycloak) set(fn func()) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeycloak) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, r.Method+" "+r.URL.Path)
|
||||||
|
if f.down {
|
||||||
|
send(w, 503, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/realms/master/protocol/openid-connect/token" {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
f.logins++
|
||||||
|
if r.PostForm.Get("password") != f.password {
|
||||||
|
send(w, 401, map[string]string{"error": "invalid_grant", "error_description": "Invalid user credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(w, 200, map[string]any{"access_token": "t", "expires_in": 300})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := "/admin/realms/" + f.realm + "/clients"
|
||||||
|
if !strings.HasPrefix(r.URL.Path, base) {
|
||||||
|
send(w, 404, map[string]string{"error": "Realm not found."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rest []string
|
||||||
|
for _, p := range strings.Split(strings.TrimPrefix(r.URL.Path, base), "/") {
|
||||||
|
if p != "" {
|
||||||
|
rest = append(rest, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := func() Rep {
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
var v Rep
|
||||||
|
_ = json.Unmarshal(raw, &v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if len(rest) == 0 && r.Method == "GET" {
|
||||||
|
want := r.URL.Query().Get("clientId")
|
||||||
|
out := []Rep{}
|
||||||
|
for _, c := range f.clients {
|
||||||
|
if want == "" || c["clientId"] == want {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send(w, 200, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(rest) == 0 && r.Method == "POST" {
|
||||||
|
rep := body()
|
||||||
|
for _, c := range f.clients {
|
||||||
|
if c["clientId"] == rep["clientId"] {
|
||||||
|
send(w, 409, map[string]string{"errorMessage": "exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := uuid()
|
||||||
|
mappers := []any{}
|
||||||
|
if list, ok := rep["protocolMappers"].([]any); ok {
|
||||||
|
for _, m := range list {
|
||||||
|
mm := m.(map[string]any)
|
||||||
|
mm["id"] = uuid()
|
||||||
|
mappers = append(mappers, mm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rep["id"], rep["protocolMappers"] = id, mappers
|
||||||
|
f.clients[id] = rep
|
||||||
|
send(w, 201, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := f.clients[rest[0]]
|
||||||
|
if c == nil {
|
||||||
|
send(w, 404, map[string]string{"error": "Could not find client"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(rest) == 1 && r.Method == "PUT":
|
||||||
|
// Keycloak ignores protocolMappers on a client update: they have their own endpoints.
|
||||||
|
rep := body()
|
||||||
|
rep["id"], rep["protocolMappers"] = c["id"], c["protocolMappers"]
|
||||||
|
f.clients[rest[0]] = rep
|
||||||
|
send(w, 204, nil)
|
||||||
|
case len(rest) == 1 && r.Method == "DELETE":
|
||||||
|
delete(f.clients, rest[0])
|
||||||
|
send(w, 204, nil)
|
||||||
|
case rest[1] == "client-secret" && r.Method == "GET":
|
||||||
|
send(w, 200, map[string]any{"type": "secret", "value": c["secret"]})
|
||||||
|
case rest[1] == "protocol-mappers" && r.Method == "GET":
|
||||||
|
send(w, 200, c["protocolMappers"])
|
||||||
|
case rest[1] == "protocol-mappers" && r.Method == "POST":
|
||||||
|
m := body()
|
||||||
|
m["id"] = uuid()
|
||||||
|
list, _ := c["protocolMappers"].([]any)
|
||||||
|
c["protocolMappers"] = append(list, m)
|
||||||
|
send(w, 201, nil)
|
||||||
|
case rest[1] == "protocol-mappers" && r.Method == "PUT":
|
||||||
|
m := body()
|
||||||
|
list, _ := c["protocolMappers"].([]any)
|
||||||
|
for i, x := range list {
|
||||||
|
if x.(map[string]any)["id"] == rest[4] {
|
||||||
|
list[i] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send(w, 204, nil)
|
||||||
|
default:
|
||||||
|
send(w, 405, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeycloak) client(password string) *Client {
|
||||||
|
return NewClient(f.srv.URL, "admin", func() (string, error) { return password, nil }, "master")
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The reconcile loop every provider shares, as the TypeScript SDK's runProvisioner runs it
|
||||||
|
// (@novox/mesh-sdk/provisioner, 0.1.10). The Go SDK has no provisioner yet, so this module carries
|
||||||
|
// the loop itself, line for line in behaviour; when the Go SDK grows one, this file is what moves
|
||||||
|
// there (novox/hq ADR 0039: the loop is the SDK's, the adapter is the module's).
|
||||||
|
//
|
||||||
|
// Read the contributions the mesh delivered; bring each consumer's resource into being through the
|
||||||
|
// adapter, under the login and password the mesh minted; withdraw what the mesh no longer asks for.
|
||||||
|
// **A provider creates the credential the mesh minted, and seals nothing (novox/hq ADR 0048).**
|
||||||
|
//
|
||||||
|
// **A provider that keeps failing a consumer says so on the bus (novox/hq ADR 0224).** A consumer
|
||||||
|
// whose create, check or secret has failed without one success in between for FailingAfter is
|
||||||
|
// announced as `provisioner.failing` — naming the consumer, its machine and the class of error — and
|
||||||
|
// again every SayAgainEvery while it lasts; the first success after that is `provisioner.recovered`.
|
||||||
|
// The controller keeps the newest per provider and consumer and `status` names it. On 2026-10-05 the
|
||||||
|
// identity provider failed every consumer 31,000 times in a day and said so only in its journal
|
||||||
|
// (novox/hq issue 179).
|
||||||
|
//
|
||||||
|
// Carried, identical, by every Go provider until the Go SDK has the loop: postgres and keycloak.
|
||||||
|
// Each module's `harness_same_test.go` fails when its copy and the other's differ.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provision is one consumer's resource to bring into being — everything the mesh derived and delivered.
|
||||||
|
type Provision struct {
|
||||||
|
// As is the login the mesh derived and gave the consumer to present.
|
||||||
|
As string
|
||||||
|
// Password is the one the mesh minted, read from the file the host unsealed.
|
||||||
|
Password string
|
||||||
|
// Values are what the consumer contributed (e.g. {"name": "letta", "extensions": ["vector"]}).
|
||||||
|
Values map[string]any
|
||||||
|
// Derived is what this provider's own definition derives for the consumer (novox/hq ADR 0201).
|
||||||
|
Derived map[string]any
|
||||||
|
// At is where the consumer is; Consumer is its node.
|
||||||
|
At string
|
||||||
|
Consumer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapter is the per-service half.
|
||||||
|
type Adapter interface {
|
||||||
|
Create(ctx context.Context, p Provision) error
|
||||||
|
// Remove withdraws what Create made; derived is what the mesh last derived, remembered here.
|
||||||
|
Remove(ctx context.Context, as string, derived map[string]any) error
|
||||||
|
// Holds says whether the backend still holds the consumer exactly as p says. Read-only.
|
||||||
|
Holds(ctx context.Context, p Provision) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Harness is the loop's settings and memory.
|
||||||
|
type Harness struct {
|
||||||
|
Resource string
|
||||||
|
Receives string
|
||||||
|
Adapter Adapter
|
||||||
|
Every time.Duration // 5s
|
||||||
|
VerifyEvery time.Duration // 60s
|
||||||
|
HoldsTimeout time.Duration // 30s
|
||||||
|
Log func(format string, args ...any)
|
||||||
|
Now func() time.Time
|
||||||
|
// Announce publishes one of the provider's standing events; nil announces nothing. Node is the
|
||||||
|
// machine this provider runs on, said in each.
|
||||||
|
Announce func(event string, body map[string]any)
|
||||||
|
Node string
|
||||||
|
// FailingAfter is how long a consumer fails without a success before it is announced (5m);
|
||||||
|
// SayAgainEvery is how often it is announced again while it lasts (15m), so a controller that
|
||||||
|
// missed the first hears the next, and a standing nobody repeats can be told from one that holds.
|
||||||
|
FailingAfter time.Duration
|
||||||
|
SayAgainEvery time.Duration
|
||||||
|
|
||||||
|
verifiedAt time.Time
|
||||||
|
applied map[string]appliedEntry
|
||||||
|
lost map[string]brake
|
||||||
|
waiting map[string]int
|
||||||
|
failing map[string]failure
|
||||||
|
trouble map[string]*standing
|
||||||
|
cleared map[string]bool
|
||||||
|
lastWarning string
|
||||||
|
}
|
||||||
|
|
||||||
|
// standing is one consumer's unbroken run of failures: since when, how often, and the last error.
|
||||||
|
type standing struct {
|
||||||
|
node string
|
||||||
|
since time.Time
|
||||||
|
attempts int
|
||||||
|
class string
|
||||||
|
text string
|
||||||
|
saidAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// The events a provider's standing is announced as (novox/hq ADR 0224). The controller derives the
|
||||||
|
// permission to emit them for every module that receives contributions; no manifest lists them.
|
||||||
|
const (
|
||||||
|
EventFailing = "provisioner.failing"
|
||||||
|
EventRecovered = "provisioner.recovered"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The classes of error a standing is announced with: what a person reading `status` needs to know
|
||||||
|
// before reading the journal. An adapter may say better (Classifier).
|
||||||
|
const (
|
||||||
|
ClassCredentials = "credentials-rejected"
|
||||||
|
ClassUnreachable = "unreachable"
|
||||||
|
ClassSecret = "secret-unreadable"
|
||||||
|
ClassRefused = "refused"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Classifier is an adapter that can say what class an error of its own is.
|
||||||
|
type Classifier interface {
|
||||||
|
Class(err error) string
|
||||||
|
}
|
||||||
|
|
||||||
|
type appliedEntry struct {
|
||||||
|
hash string
|
||||||
|
derived map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type brake struct {
|
||||||
|
times int
|
||||||
|
nextAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type failure struct {
|
||||||
|
text string
|
||||||
|
times int
|
||||||
|
}
|
||||||
|
|
||||||
|
// The longest a consumer whose create keeps failing to satisfy holds waits between checks.
|
||||||
|
const maxBackoff = time.Hour
|
||||||
|
|
||||||
|
// How many passes a secret may be unreadable before it stops being called a race (issue 225), and
|
||||||
|
// once said loudly, how often it is repeated. The same cadence quiets a create that keeps failing
|
||||||
|
// the same way.
|
||||||
|
const (
|
||||||
|
patiently = 12
|
||||||
|
loudlyEvery = 240
|
||||||
|
)
|
||||||
|
|
||||||
|
type contribution struct {
|
||||||
|
As string `json:"as"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
At string `json:"at"`
|
||||||
|
Values map[string]any `json:"values"`
|
||||||
|
Derived map[string]any `json:"derived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Harness) init() {
|
||||||
|
if h.Every == 0 {
|
||||||
|
h.Every = 5 * time.Second
|
||||||
|
}
|
||||||
|
if h.VerifyEvery == 0 {
|
||||||
|
h.VerifyEvery = time.Minute
|
||||||
|
}
|
||||||
|
if h.HoldsTimeout == 0 {
|
||||||
|
h.HoldsTimeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if h.Now == nil {
|
||||||
|
h.Now = time.Now
|
||||||
|
}
|
||||||
|
if h.FailingAfter == 0 {
|
||||||
|
h.FailingAfter = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if h.SayAgainEvery == 0 {
|
||||||
|
h.SayAgainEvery = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if h.Log == nil {
|
||||||
|
h.Log = func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) }
|
||||||
|
}
|
||||||
|
if h.applied == nil {
|
||||||
|
h.applied = map[string]appliedEntry{}
|
||||||
|
h.lost = map[string]brake{}
|
||||||
|
h.waiting = map[string]int{}
|
||||||
|
h.failing = map[string]failure{}
|
||||||
|
h.trouble = map[string]*standing{}
|
||||||
|
h.cleared = map[string]bool{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run reconciles until ctx ends. One consumer's failure never stops the others'.
|
||||||
|
func (h *Harness) Run(ctx context.Context) {
|
||||||
|
h.init()
|
||||||
|
for {
|
||||||
|
h.Reconcile(ctx)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(h.Every):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Harness) say(format string, args ...any) {
|
||||||
|
h.Log("[provisioner:"+h.Resource+"] "+format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Harness) warn(why string) {
|
||||||
|
if why == h.lastWarning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if why != "" {
|
||||||
|
h.say("%s; nothing applied or removed until it can be read", why)
|
||||||
|
} else {
|
||||||
|
h.say("contributions file readable again")
|
||||||
|
}
|
||||||
|
h.lastWarning = why
|
||||||
|
}
|
||||||
|
|
||||||
|
// readContributions answers the consumers asked for, or nil when the file says nothing usable.
|
||||||
|
// **Nothing read is not nobody asking** (novox/hq issue 241): only a file that was read can withdraw.
|
||||||
|
func (h *Harness) readContributions() []contribution {
|
||||||
|
raw, err := os.ReadFile(h.Receives)
|
||||||
|
if err != nil {
|
||||||
|
h.warn(fmt.Sprintf("contributions file unreadable (%s): %v", h.Receives, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Requirement string `json:"requirement"`
|
||||||
|
Given json.RawMessage `json:"given"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||||
|
h.warn(fmt.Sprintf("contributions file is not JSON (%s): %v", h.Receives, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if doc.Requirement != "" && doc.Requirement != h.Resource {
|
||||||
|
h.warn(fmt.Sprintf("%s is for %s, not %s", h.Receives, doc.Requirement, h.Resource))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var given []contribution
|
||||||
|
if len(doc.Given) == 0 || string(doc.Given) == "null" || json.Unmarshal(doc.Given, &given) != nil {
|
||||||
|
h.warn(fmt.Sprintf("%s has no given list", h.Receives))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h.warn("")
|
||||||
|
out := []contribution{}
|
||||||
|
for _, g := range given {
|
||||||
|
// No `as` is not a credential grant: nothing to create for it.
|
||||||
|
if g.As != "" && g.Secret != "" {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashOf(as, password string, values, derived map[string]any) string {
|
||||||
|
// Derived is in the hash: a provider that renames what it derives gave a different resource.
|
||||||
|
b, _ := json.Marshal([]any{as, password, orEmpty(values), orEmpty(derived)})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func orEmpty(m map[string]any) map[string]any {
|
||||||
|
if m == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile is one pass.
|
||||||
|
func (h *Harness) Reconcile(ctx context.Context) {
|
||||||
|
h.init()
|
||||||
|
given := h.readContributions()
|
||||||
|
if given == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, g := range given {
|
||||||
|
want[g.As] = true
|
||||||
|
}
|
||||||
|
verifying := h.Now().Sub(h.verifiedAt) >= h.VerifyEvery
|
||||||
|
if verifying {
|
||||||
|
h.verifiedAt = h.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range given {
|
||||||
|
raw, err := os.ReadFile(g.Secret)
|
||||||
|
if err != nil {
|
||||||
|
// A secret the host has not written yet is a race on the first pass; past a minute it is
|
||||||
|
// a person's to look at, and said so (novox/hq issue 225).
|
||||||
|
n := h.waiting[g.As] + 1
|
||||||
|
h.waiting[g.As] = n
|
||||||
|
if n <= patiently {
|
||||||
|
h.say("%s: secret not readable yet (%s): %v", g.As, g.Secret, err)
|
||||||
|
} else if n == patiently+1 || n%loudlyEvery == 0 {
|
||||||
|
h.say("%s: CANNOT READ the secret after %d attempts (%s): %v. This is not a race any more — "+
|
||||||
|
"nothing has been provisioned for this consumer and nothing will be until somebody looks. "+
|
||||||
|
"Check who owns the file and who this process runs as (novox/hq issue 225)", g.As, n, g.Secret, err)
|
||||||
|
}
|
||||||
|
h.failed(g.As, g.Node, ClassSecret, fmt.Sprintf("secret not readable (%s): %v", g.Secret, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(h.waiting, g.As)
|
||||||
|
password := strings.TrimSuffix(string(raw), "\n")
|
||||||
|
p := Provision{As: g.As, Password: password, Values: orEmpty(g.Values), Derived: orEmpty(g.Derived), At: g.At, Consumer: g.Node}
|
||||||
|
hash := hashOf(g.As, password, g.Values, g.Derived)
|
||||||
|
|
||||||
|
reapplying := 0
|
||||||
|
if was, ok := h.applied[g.As]; ok && was.hash == hash {
|
||||||
|
if !verifying {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, braked := h.lost[g.As]
|
||||||
|
if braked && h.Now().Before(b.nextAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout)
|
||||||
|
held, err := h.Adapter.Holds(hctx, p)
|
||||||
|
timedOut := errors.Is(hctx.Err(), context.DeadlineExceeded)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
// Unable to ask is not evidence of loss. A backend that timed out will time out for
|
||||||
|
// the next consumer too, so the rest of this pass is not asked.
|
||||||
|
text := scrub(err, password)
|
||||||
|
h.say("%s: could not check the backend, will ask again: %s", g.As, text)
|
||||||
|
h.failed(g.As, g.Node, h.classOf(err, text), text)
|
||||||
|
if timedOut {
|
||||||
|
verifying = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if held {
|
||||||
|
delete(h.lost, g.As)
|
||||||
|
h.succeeded(g.As)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reapplying = b.times + 1
|
||||||
|
if reapplying == 1 {
|
||||||
|
h.say("%s: the backend no longer holds it; applying again", g.As)
|
||||||
|
} else {
|
||||||
|
h.say("%s: still not held after being applied again (%d times in a row) — create does not "+
|
||||||
|
"produce what holds checks; applying again", g.As, reapplying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.Adapter.Create(ctx, p); err != nil {
|
||||||
|
text := scrub(err, password)
|
||||||
|
f := h.failing[g.As]
|
||||||
|
if f.text != text {
|
||||||
|
f = failure{text: text}
|
||||||
|
}
|
||||||
|
f.times++
|
||||||
|
h.failing[g.As] = f
|
||||||
|
// Said each time it changes, and while it stays the same, as rarely as a lost secret.
|
||||||
|
if f.times == 1 || f.times%loudlyEvery == 0 {
|
||||||
|
h.say("%s: create failed, will retry: %s", g.As, text)
|
||||||
|
}
|
||||||
|
h.failed(g.As, g.Node, h.classOf(err, text), text)
|
||||||
|
if reapplying > 0 {
|
||||||
|
h.lost[g.As] = brake{times: reapplying - 1}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f, was := h.failing[g.As]; was {
|
||||||
|
h.say("%s: created, after %d failed attempt(s)", g.As, f.times)
|
||||||
|
delete(h.failing, g.As)
|
||||||
|
}
|
||||||
|
h.succeeded(g.As)
|
||||||
|
h.applied[g.As] = appliedEntry{hash: hash, derived: p.Derived}
|
||||||
|
if reapplying == 0 {
|
||||||
|
delete(h.lost, g.As)
|
||||||
|
} else {
|
||||||
|
wait := h.VerifyEvery << (reapplying - 1)
|
||||||
|
if wait > maxBackoff || wait <= 0 {
|
||||||
|
wait = maxBackoff
|
||||||
|
}
|
||||||
|
h.lost[g.As] = brake{times: reapplying, nextAt: h.Now().Add(wait)}
|
||||||
|
if reapplying > 1 {
|
||||||
|
h.say("%s: next check in %s", g.As, wait.Round(time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Withdraw every login this process made that the mesh no longer asks for.
|
||||||
|
for as, was := range h.applied {
|
||||||
|
if want[as] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.say("%s: no longer in %s; withdrawing it from the backend", as, h.Receives)
|
||||||
|
if err := h.Adapter.Remove(ctx, as, was.derived); err != nil {
|
||||||
|
h.say("%s: remove failed, will retry: %v", as, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(h.applied, as)
|
||||||
|
delete(h.lost, as)
|
||||||
|
}
|
||||||
|
for as := range h.failing {
|
||||||
|
if !want[as] {
|
||||||
|
delete(h.failing, as)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A consumer the mesh stopped asking for is no longer failed by anyone: said, so a standing
|
||||||
|
// the controller keeps for it is cleared rather than left naming a consumer that is gone.
|
||||||
|
for as := range h.trouble {
|
||||||
|
if !want[as] {
|
||||||
|
h.recovered(as, "withdrawn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failed counts one more failure in a consumer's unbroken run, and announces the run once it has
|
||||||
|
// lasted FailingAfter — then again every SayAgainEvery while it lasts.
|
||||||
|
func (h *Harness) failed(as, node, class, text string) {
|
||||||
|
now := h.Now()
|
||||||
|
s := h.trouble[as]
|
||||||
|
if s == nil {
|
||||||
|
s = &standing{since: now}
|
||||||
|
h.trouble[as] = s
|
||||||
|
}
|
||||||
|
s.node, s.class, s.text = node, class, text
|
||||||
|
s.attempts++
|
||||||
|
if now.Sub(s.since) < h.FailingAfter {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.saidAt.IsZero() && now.Sub(s.saidAt) < h.SayAgainEvery {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
first := s.saidAt.IsZero()
|
||||||
|
s.saidAt = now
|
||||||
|
if first {
|
||||||
|
h.say("%s: FAILING for %s (%d attempts, %s): %s. Announced as %s; `status` names it until it "+
|
||||||
|
"succeeds (novox/hq ADR 0224)", as, now.Sub(s.since).Round(time.Second), s.attempts, class, text, EventFailing)
|
||||||
|
}
|
||||||
|
h.announce(EventFailing, map[string]any{
|
||||||
|
"provider": h.Resource, "provider-node": h.Node,
|
||||||
|
"consumer": as, "node": node,
|
||||||
|
"class": class, "error": clip(text),
|
||||||
|
"since": s.since.UTC().Format(time.RFC3339), "attempts": s.attempts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// succeeded ends a consumer's run of failures; one that was announced is announced recovered.
|
||||||
|
//
|
||||||
|
// **And the first success for a consumer since this process started is announced too**, failing or
|
||||||
|
// not: a provider that announced a failure and was restarted has forgotten it, and without this the
|
||||||
|
// controller would name the consumer failing for ever after it recovered unheard.
|
||||||
|
func (h *Harness) succeeded(as string) {
|
||||||
|
if h.trouble[as] == nil && !h.cleared[as] {
|
||||||
|
h.cleared[as] = true
|
||||||
|
h.announce(EventRecovered, map[string]any{
|
||||||
|
"provider": h.Resource, "provider-node": h.Node, "consumer": as, "why": "first-success",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.cleared[as] = true
|
||||||
|
h.recovered(as, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Harness) recovered(as, why string) {
|
||||||
|
s := h.trouble[as]
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(h.trouble, as)
|
||||||
|
if s.saidAt.IsZero() {
|
||||||
|
return // never announced, so there is nothing to take back
|
||||||
|
}
|
||||||
|
if why == "" {
|
||||||
|
h.say("%s: recovered after %s and %d failed attempt(s)", as, h.Now().Sub(s.since).Round(time.Second), s.attempts)
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"provider": h.Resource, "provider-node": h.Node, "consumer": as, "node": s.node,
|
||||||
|
"since": s.since.UTC().Format(time.RFC3339), "attempts": s.attempts,
|
||||||
|
}
|
||||||
|
if why != "" {
|
||||||
|
body["why"] = why
|
||||||
|
}
|
||||||
|
h.announce(EventRecovered, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Harness) announce(event string, body map[string]any) {
|
||||||
|
if h.Announce != nil {
|
||||||
|
h.Announce(event, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// classOf is an error's class: the adapter's word when it has one, else read from the text.
|
||||||
|
func (h *Harness) classOf(err error, text string) string {
|
||||||
|
if c, ok := h.Adapter.(Classifier); ok {
|
||||||
|
if class := c.Class(err); class != "" {
|
||||||
|
return class
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ClassOf(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassOf reads an error's class from its text — the words the backends the mesh runs use.
|
||||||
|
func ClassOf(text string) string {
|
||||||
|
t := strings.ToLower(text)
|
||||||
|
for _, w := range []string{"invalid_grant", "invalid user credentials", "password authentication failed",
|
||||||
|
"authentication failed", "unauthorized", " 401"} {
|
||||||
|
if strings.Contains(t, w) {
|
||||||
|
return ClassCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range []string{"connection refused", "no such host", "i/o timeout", "deadline exceeded",
|
||||||
|
"connection reset", "network is unreachable", "no route to host", "eof"} {
|
||||||
|
if strings.Contains(t, w) {
|
||||||
|
return ClassUnreachable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ClassRefused
|
||||||
|
}
|
||||||
|
|
||||||
|
// clip keeps an announced error to what belongs in a status line.
|
||||||
|
func clip(text string) string {
|
||||||
|
const most = 300
|
||||||
|
if len(text) <= most {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return text[:most] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrub is an error's text with the consumer's password removed, raw and URL-encoded.
|
||||||
|
func scrub(err error, password string) string {
|
||||||
|
text := err.Error()
|
||||||
|
if password == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
for _, form := range []string{password, url.QueryEscape(password), url.PathEscape(password)} {
|
||||||
|
text = strings.ReplaceAll(text, form, "***")
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The provisioner loop is carried, identical, by every Go provider until the Go SDK has it
|
||||||
|
// (harness.go). Two copies drift the moment one is fixed and the other is not — and the one left
|
||||||
|
// behind is the provider that fails a consumer without saying so (novox/hq ADR 0224). This holds
|
||||||
|
// them to one text. Skipped where postgres is not beside this module, as in a build of this one alone.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheHarnessIsTheSameAsPostgress(t *testing.T) {
|
||||||
|
theirs, err := os.ReadFile("../../../postgres/cmd/postgres-provider/harness.go")
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Skip("postgres is not beside this module")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ours, err := os.ReadFile("harness.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(ours, theirs) {
|
||||||
|
t.Fatal("harness.go differs from postgres/cmd/postgres-provider/harness.go: change both, identically")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The shared harness, as postgres tests it (harness.go is the same file in both modules).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recorder struct {
|
||||||
|
created []Provision
|
||||||
|
removed []string
|
||||||
|
held bool
|
||||||
|
failing error
|
||||||
|
holdsErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recorder) Create(_ context.Context, p Provision) error {
|
||||||
|
if r.failing != nil {
|
||||||
|
return r.failing
|
||||||
|
}
|
||||||
|
r.created = append(r.created, p)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recorder) Remove(_ context.Context, as string, _ map[string]any) error {
|
||||||
|
r.removed = append(r.removed, as)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recorder) Holds(context.Context, Provision) (bool, error) {
|
||||||
|
if r.holdsErr != nil {
|
||||||
|
return false, r.holdsErr
|
||||||
|
}
|
||||||
|
return r.held, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type world struct {
|
||||||
|
t *testing.T
|
||||||
|
dir string
|
||||||
|
receives string
|
||||||
|
now time.Time
|
||||||
|
h *Harness
|
||||||
|
a *recorder
|
||||||
|
said []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWorld(t *testing.T) *world {
|
||||||
|
w := &world{t: t, dir: t.TempDir(), now: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), a: &recorder{held: true}}
|
||||||
|
w.receives = filepath.Join(w.dir, "mesh.json")
|
||||||
|
w.h = &Harness{Resource: "oidc-client", Receives: w.receives, Adapter: w.a,
|
||||||
|
Now: func() time.Time { return w.now },
|
||||||
|
Log: func(f string, a ...any) { w.said = append(w.said, f) }}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *world) give(given ...map[string]any) {
|
||||||
|
for _, g := range given {
|
||||||
|
secret := filepath.Join(w.dir, g["as"].(string)+".secret")
|
||||||
|
if err := os.WriteFile(secret, []byte("pw-"+g["as"].(string)+"\n"), 0o600); err != nil {
|
||||||
|
w.t.Fatal(err)
|
||||||
|
}
|
||||||
|
g["secret"] = secret
|
||||||
|
}
|
||||||
|
if given == nil {
|
||||||
|
given = []map[string]any{}
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(map[string]any{"requirement": "oidc-client", "given": given})
|
||||||
|
if err := os.WriteFile(w.receives, raw, 0o600); err != nil {
|
||||||
|
w.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConsumerIsCreatedOnceUnderTheMeshsLoginAndPassword(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
w.give(map[string]any{"as": "mesh_ace_letta", "node": "ace", "values": map[string]any{"name": "letta"}})
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
if len(w.a.created) != 1 {
|
||||||
|
t.Fatalf("created %d times", len(w.a.created))
|
||||||
|
}
|
||||||
|
p := w.a.created[0]
|
||||||
|
if p.As != "mesh_ace_letta" || p.Password != "pw-mesh_ace_letta" || p.Consumer != "ace" {
|
||||||
|
t.Fatalf("%+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConsumerNoLongerAskedForIsWithdrawn(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
w.give(map[string]any{"as": "a"}, map[string]any{"as": "b"})
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
if strings.Join(w.a.removed, ",") != "b" {
|
||||||
|
t.Fatal(w.a.removed)
|
||||||
|
}
|
||||||
|
// Only a file that says nobody asks withdraws everybody.
|
||||||
|
w.give()
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
if strings.Join(w.a.removed, ",") != "b,a" {
|
||||||
|
t.Fatal(w.a.removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNothingReadIsNotNobodyAsking(t *testing.T) {
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
"unreadable": "",
|
||||||
|
"not JSON": "{",
|
||||||
|
"no given": `{"requirement": "oidc-client"}`,
|
||||||
|
"another": `{"requirement": "mssql-database", "given": []}`,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
if content == "" {
|
||||||
|
os.Remove(w.receives)
|
||||||
|
} else {
|
||||||
|
os.WriteFile(w.receives, []byte(content), 0o600)
|
||||||
|
}
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
if len(w.a.removed) != 0 {
|
||||||
|
t.Fatalf("withdrew %v on a file it could not use", w.a.removed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestALostConsumerIsAppliedAgainAndBraked(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
w.a.held = false
|
||||||
|
w.now = w.now.Add(2 * time.Minute)
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
if len(w.a.created) != 2 {
|
||||||
|
t.Fatalf("created %d times", len(w.a.created))
|
||||||
|
}
|
||||||
|
// Still not held a minute later: applied again, then braked for two minutes.
|
||||||
|
w.now = w.now.Add(61 * time.Second)
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
w.now = w.now.Add(61 * time.Second)
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
if len(w.a.created) != 3 {
|
||||||
|
t.Fatalf("not braked: created %d times", len(w.a.created))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailingCreateIsRetriedAndSaidOnce(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
w.a.failing = &pgErr{"realm refused, password pw-a"}
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, s := range w.said {
|
||||||
|
if strings.Contains(s, "create failed") {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("said %d times", n)
|
||||||
|
}
|
||||||
|
w.a.failing = nil
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
if len(w.a.created) != 1 {
|
||||||
|
t.Fatal("not retried")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pgErr struct{ s string }
|
||||||
|
|
||||||
|
func (e *pgErr) Error() string { return e.s }
|
||||||
|
|
||||||
|
func TestScrubRemovesThePassword(t *testing.T) {
|
||||||
|
if got := scrub(&pgErr{"bad pw a/b c and a%2Fb+c"}, "a/b c"); strings.Contains(got, "a/b c") || strings.Contains(got, "a%2Fb+c") {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The repair against a real Keycloak (issue 179's procedure, run by the guard). Skipped unless
|
||||||
|
// MESH_KEYCLOAK_LIVE_CONTAINER names a throwaway Keycloak 26 container whose realm was created with
|
||||||
|
// another admin password than "new", and MESH_KEYCLOAK_LIVE_URL reaches it, e.g.:
|
||||||
|
//
|
||||||
|
// docker network create kc-live
|
||||||
|
// docker run -d --name kc-live-db --network kc-live -e POSTGRES_PASSWORD=pg -e POSTGRES_DB=keycloak postgres:17-alpine
|
||||||
|
// docker run -d --name kc-live --network kc-live -p 127.0.0.1:18080:8080 \
|
||||||
|
// -e KC_DB=postgres -e KC_DB_URL=jdbc:postgresql://kc-live-db/keycloak -e KC_DB_USERNAME=postgres \
|
||||||
|
// -e KC_DB_PASSWORD=pg -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=old \
|
||||||
|
// quay.io/keycloak/keycloak:26.0.8 start-dev
|
||||||
|
// MESH_KEYCLOAK_LIVE_CONTAINER=kc-live MESH_KEYCLOAK_LIVE_URL=http://127.0.0.1:18080 go test -run Live ./...
|
||||||
|
//
|
||||||
|
// A database whose admin kept an older password than the mesh's is exactly that container.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLiveRepair(t *testing.T) {
|
||||||
|
container, base := os.Getenv("MESH_KEYCLOAK_LIVE_CONTAINER"), os.Getenv("MESH_KEYCLOAK_LIVE_URL")
|
||||||
|
if container == "" || base == "" {
|
||||||
|
t.Skip("no MESH_KEYCLOAK_LIVE_CONTAINER / MESH_KEYCLOAK_LIVE_URL")
|
||||||
|
}
|
||||||
|
kc := NewClient(base, "admin", func() (string, error) { return "new", nil }, "master")
|
||||||
|
var said, events []string
|
||||||
|
g := &Guard{KC: kc, Container: container,
|
||||||
|
Log: func(f string, a ...any) { said = append(said, f) },
|
||||||
|
Announce: func(e string, _ map[string]any) { events = append(events, e) }}
|
||||||
|
|
||||||
|
if s, err := g.Check(ctx); s != AdminRejected {
|
||||||
|
t.Fatalf("the container's admin should refuse the mesh's password first: %s %v", s, err)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r := g.Ensure(ctx, true, false)
|
||||||
|
if !r.Repaired {
|
||||||
|
t.Fatalf("not repaired after %s: %+v %+v", time.Since(start), r, r.LastRepair)
|
||||||
|
}
|
||||||
|
t.Logf("repaired in %s", time.Since(start).Round(time.Second))
|
||||||
|
users, err := kc.ListUsers(ctx, "master", "", 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if name, _ := u["username"].(string); strings.HasPrefix(name, "mesh-repair-") {
|
||||||
|
t.Fatalf("the temporary admin %s is still there", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(events, ",") != EventRepaired {
|
||||||
|
t.Fatal(events)
|
||||||
|
}
|
||||||
|
// And a second pass changes nothing.
|
||||||
|
if r := g.Ensure(ctx, true, false); r.Repaired || r.State != AdminOK {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// keycloak-provider: keycloak's code, one binary the node's runtime launches and speaks MCP to over
|
||||||
|
// stdio through the Go SDK (novox/hq ADR 0188, 0193). It serves keycloak's tools and, beside them,
|
||||||
|
// runs long: the provisioner that makes keycloak the provider of the mesh `oidc-client` interface,
|
||||||
|
// and the guard that keeps the admin logging in with the mesh's secret (novox/hq issue 179).
|
||||||
|
//
|
||||||
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func say(format string, args ...any) { logStderr("[keycloak] "+format, args...) }
|
||||||
|
|
||||||
|
// announce emits an event without letting a broker hiccup fail what it announces: the change already
|
||||||
|
// happened in Keycloak.
|
||||||
|
func announce(event string, body map[string]any) {
|
||||||
|
if err := stdio.Emit(event, body); err != nil {
|
||||||
|
say("emit %s failed: %v", event, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
kc, err := ClientFromEnv(os.Getenv)
|
||||||
|
if err != nil {
|
||||||
|
// Without the admin password there is nothing to serve, provision or guard; said, not fatal,
|
||||||
|
// so the runtime does not restart a process that cannot do better.
|
||||||
|
say("%v; serving no tools and provisioning nothing", err)
|
||||||
|
if err := stdio.Serve("", nil); err != nil {
|
||||||
|
say("%v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard := &Guard{
|
||||||
|
KC: kc, Container: os.Getenv("MESH_KEYCLOAK_CONTAINER"),
|
||||||
|
Log: logStderr, Announce: announce,
|
||||||
|
}
|
||||||
|
kc.onRejected = guard.Nudge
|
||||||
|
go guard.Run(context.Background())
|
||||||
|
|
||||||
|
if receives := os.Getenv("MESH_RECEIVES"); receives == "" {
|
||||||
|
say("MESH_RECEIVES is not set — the provisioner cannot run without it")
|
||||||
|
} else if issuer, err := Issuer(os.Getenv); err != nil {
|
||||||
|
say("%v — the provisioner cannot run without it", err)
|
||||||
|
} else if realm, err := RealmOf(issuer); err != nil {
|
||||||
|
say("%v — the provisioner cannot run without it", err)
|
||||||
|
} else {
|
||||||
|
h := &Harness{
|
||||||
|
Resource: "oidc-client",
|
||||||
|
Receives: receives,
|
||||||
|
Adapter: provisioner{clients: OidcClients{KC: kc, Realm: realm}, guard: guard, announce: announce, log: logStderr},
|
||||||
|
Log: logStderr,
|
||||||
|
// A consumer failed for minutes is said on the bus, where the controller hears it and
|
||||||
|
// `status` names it (novox/hq ADR 0224).
|
||||||
|
Announce: announce,
|
||||||
|
Node: os.Getenv("MESH_NODE"),
|
||||||
|
}
|
||||||
|
go h.Run(context.Background())
|
||||||
|
}
|
||||||
|
if err := stdio.Serve("", Tools(kc, guard)); err != nil {
|
||||||
|
say("%v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What the `oidc-client` provision means in Keycloak: one confidential OpenID Connect client per
|
||||||
|
// consumer, in the realm this module serves, under the name and secret the mesh gave both ends.
|
||||||
|
// Ported from oidc.ts, behaviour for behaviour.
|
||||||
|
//
|
||||||
|
// **The client id and the secret are the mesh's, not Keycloak's (novox/hq ADR 0048).** The mesh
|
||||||
|
// derives the consumer's identity (`as`) and hands it to both ends, and mints the secret, which this
|
||||||
|
// sets as the client's secret. Keycloak generates neither.
|
||||||
|
//
|
||||||
|
// **Where the consumer's browser comes back to is the consumer's to say.** Its contribution carries
|
||||||
|
// `callback` (a path) and the mesh composes its endpoint's names into `name` (public) and
|
||||||
|
// `internal-name` (private network) exactly as it does for a route (novox/hq ADR 0056, 0138).
|
||||||
|
//
|
||||||
|
// **Only what the mesh made is touched.** A client this module creates carries the attribute
|
||||||
|
// `mesh.provisioned=true`. A client with the same id that lacks the mark is somebody else's: it is
|
||||||
|
// refused, never adopted, never updated, never deleted.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mark is the attribute marking a client as the mesh's own work.
|
||||||
|
const Mark = "mesh.provisioned"
|
||||||
|
|
||||||
|
// RolesMapper is the mapper every mesh client carries: realm roles as a flat `roles` claim in the id
|
||||||
|
// token, the access token and userinfo — what a consumer maps its own roles from.
|
||||||
|
func RolesMapper() Rep {
|
||||||
|
return Rep{
|
||||||
|
"name": "realm roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||||
|
"config": map[string]any{
|
||||||
|
"claim.name": "roles",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"multivalued": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var realmPath = regexp.MustCompile(`/realms/([^/]+)/?$`)
|
||||||
|
|
||||||
|
// RealmOf is the realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`.
|
||||||
|
// The issuer is the one value an assignment sets, so the realm is read out of it rather than set a
|
||||||
|
// second time where the two could disagree.
|
||||||
|
func RealmOf(issuer string) (string, error) {
|
||||||
|
u, err := url.Parse(issuer)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return "", fmt.Errorf("the issuer %q is not a URL", issuer)
|
||||||
|
}
|
||||||
|
m := realmPath.FindStringSubmatch(u.EscapedPath())
|
||||||
|
if m == nil {
|
||||||
|
return "", fmt.Errorf("the issuer %q does not end in /realms/<realm>", issuer)
|
||||||
|
}
|
||||||
|
realm, err := url.PathUnescape(m[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return realm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedirectsOf is the redirect URIs a consumer's contribution asks for: its callback under each name
|
||||||
|
// the mesh composed for its endpoint. Refused when there is nothing to register.
|
||||||
|
func RedirectsOf(values map[string]any) (root string, redirects []string, err error) {
|
||||||
|
callback, _ := values["callback"].(string)
|
||||||
|
if !strings.HasPrefix(callback, "/") {
|
||||||
|
raw, _ := json.Marshal(values["callback"])
|
||||||
|
return "", nil, fmt.Errorf("contributes no callback path (`callback`, starting with \"/\"): %s", raw)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, key := range []string{"name", "internal-name"} {
|
||||||
|
n, _ := values[key].(string)
|
||||||
|
n = strings.TrimSpace(n)
|
||||||
|
if n != "" && !contains(names, n) {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return "", nil, errors.New("has no name the mesh composed (`name` / `internal-name`) — contribute a `label` and the `endpoint` it is reached on")
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
redirects = append(redirects, "https://"+n+callback)
|
||||||
|
}
|
||||||
|
return "https://" + names[0], redirects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// wanted is the fields the mesh owns on a client it made. Everything else is left as found.
|
||||||
|
func wanted(p Provision) (Rep, []string, error) {
|
||||||
|
root, redirects, err := RedirectsOf(p.Values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
whose := "a consumer"
|
||||||
|
if p.Consumer != "" {
|
||||||
|
whose = "a module on " + p.Consumer
|
||||||
|
}
|
||||||
|
uris := make([]any, len(redirects))
|
||||||
|
for i, r := range redirects {
|
||||||
|
uris[i] = r
|
||||||
|
}
|
||||||
|
return Rep{
|
||||||
|
"clientId": p.As,
|
||||||
|
"name": p.As,
|
||||||
|
"description": "made by the mesh for " + whose + " — do not edit; it is reset",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": p.Password,
|
||||||
|
"rootUrl": root,
|
||||||
|
"baseUrl": root,
|
||||||
|
"redirectUris": uris,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
}, redirects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringList(v any) []string {
|
||||||
|
list, _ := v.([]any)
|
||||||
|
out := make([]string, 0, len(list))
|
||||||
|
for _, x := range list {
|
||||||
|
if s, ok := x.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameSet(a, b []string) bool {
|
||||||
|
x, y := append([]string(nil), a...), append([]string(nil), b...)
|
||||||
|
sort.Strings(x)
|
||||||
|
sort.Strings(y)
|
||||||
|
if len(x) != len(y) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range x {
|
||||||
|
if x[i] != y[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func attributes(c Rep) map[string]any {
|
||||||
|
if a, ok := c["attributes"].(map[string]any); ok {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func marked(c Rep) bool { return attributes(c)[Mark] == "true" }
|
||||||
|
|
||||||
|
// OidcClients is the provision's meaning in one realm.
|
||||||
|
type OidcClients struct {
|
||||||
|
KC *Client
|
||||||
|
Realm string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure creates the consumer's client, or brings the mesh's existing one back to what the grant
|
||||||
|
// says. Answers "created" or "updated". Idempotent.
|
||||||
|
func (o OidcClients) Ensure(ctx context.Context, p Provision) (string, error) {
|
||||||
|
want, _, err := wanted(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
found, err := o.KC.FindClient(ctx, o.Realm, p.As)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if found != nil && !marked(found) {
|
||||||
|
return "", fmt.Errorf("realm %s already has a client %s the mesh did not make — left alone; "+
|
||||||
|
"delete or rename it if the mesh should own that id", o.Realm, p.As)
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
want["attributes"] = map[string]any{Mark: "true"}
|
||||||
|
want["protocolMappers"] = []any{RolesMapper()}
|
||||||
|
if err := o.KC.CreateClient(ctx, o.Realm, want); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "created", nil
|
||||||
|
}
|
||||||
|
// Overlay what the mesh owns on what is there, so a field Keycloak added or an operator set on a
|
||||||
|
// field the mesh does not own survives the update.
|
||||||
|
merged := Rep{}
|
||||||
|
for k, v := range found {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
merged[k] = v
|
||||||
|
}
|
||||||
|
attrs := map[string]any{}
|
||||||
|
for k, v := range attributes(found) {
|
||||||
|
attrs[k] = v
|
||||||
|
}
|
||||||
|
attrs[Mark] = "true"
|
||||||
|
merged["attributes"] = attrs
|
||||||
|
id, _ := found["id"].(string)
|
||||||
|
if err := o.KC.UpdateClient(ctx, o.Realm, id, merged); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "updated", o.ensureMapper(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o OidcClients) ensureMapper(ctx context.Context, id string) error {
|
||||||
|
want := RolesMapper()
|
||||||
|
mappers, err := o.KC.ListClientMappers(ctx, o.Realm, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var have Rep
|
||||||
|
for _, m := range mappers {
|
||||||
|
if m["name"] == want["name"] {
|
||||||
|
have = m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if have == nil {
|
||||||
|
return o.KC.AddClientMapper(ctx, o.Realm, id, want)
|
||||||
|
}
|
||||||
|
drifted := have["protocolMapper"] != want["protocolMapper"]
|
||||||
|
hc, _ := have["config"].(map[string]any)
|
||||||
|
for k, v := range want["config"].(map[string]any) {
|
||||||
|
if hc[k] != v {
|
||||||
|
drifted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if drifted {
|
||||||
|
want["id"] = have["id"]
|
||||||
|
return o.KC.UpdateClientMapper(ctx, o.Realm, id, want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holds says whether Keycloak still holds this consumer's client exactly as the grant says: present,
|
||||||
|
// the mesh's, enabled, confidential, with the mesh's secret and the redirects asked for. Reads only.
|
||||||
|
func (o OidcClients) Holds(ctx context.Context, p Provision) (bool, error) {
|
||||||
|
_, redirects, err := wanted(p)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
found, err := o.KC.FindClient(ctx, o.Realm, p.As)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if found == nil || !marked(found) || found["enabled"] == false || found["publicClient"] == true {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if !sameSet(stringList(found["redirectUris"]), redirects) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
id, _ := found["id"].(string)
|
||||||
|
mappers, err := o.KC.ListClientMappers(ctx, o.Realm, id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
hasMapper := false
|
||||||
|
for _, m := range mappers {
|
||||||
|
if m["name"] == RolesMapper()["name"] {
|
||||||
|
hasMapper = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasMapper {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
secret, err := o.KC.ClientSecretByID(ctx, o.Realm, id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return secret == p.Password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove withdraws a consumer's client — only one the mesh made. Answers what happened.
|
||||||
|
func (o OidcClients) Remove(ctx context.Context, as string) (string, error) {
|
||||||
|
found, err := o.KC.FindClient(ctx, o.Realm, as)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return "absent", nil
|
||||||
|
}
|
||||||
|
if !marked(found) {
|
||||||
|
return "not ours", nil
|
||||||
|
}
|
||||||
|
id, _ := found["id"].(string)
|
||||||
|
return "removed", o.KC.DeleteClientByID(ctx, o.Realm, id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What holds keycloak to the `oidc-client` provision: one confidential client per consumer, under
|
||||||
|
// the id and secret the mesh gave, redirecting only to the consumer's own callback under the names
|
||||||
|
// the mesh composed; made once and brought back on every apply; and a client the mesh did not make —
|
||||||
|
// same id or not — never adopted, changed or deleted. Ported from test/oidc.test.ts.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func oidcWorld(t *testing.T) (*fakeKeycloak, OidcClients) {
|
||||||
|
f := newFakeKeycloak(t, "Novox", "pw")
|
||||||
|
return f, OidcClients{KC: f.client("pw"), Realm: "Novox"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grafana is a dashboard on the home server, as the mesh hands it to the provisioner.
|
||||||
|
func grafana(secret string, extra map[string]any) Provision {
|
||||||
|
values := map[string]any{"label": "grafana", "endpoint": "web", "port": 20010.0, "callback": "/login/generic_oauth",
|
||||||
|
"name": "grafana.example.org", "internal-name": "grafana.home.internal"}
|
||||||
|
for k, v := range extra {
|
||||||
|
values[k] = v
|
||||||
|
}
|
||||||
|
return Provision{As: "mesh_home_grafana", Password: secret, Consumer: "home", Values: values}
|
||||||
|
}
|
||||||
|
|
||||||
|
func only(t *testing.T, f *fakeKeycloak, clientID string) Rep {
|
||||||
|
t.Helper()
|
||||||
|
var found []Rep
|
||||||
|
for _, c := range f.clients {
|
||||||
|
if c["clientId"] == clientID {
|
||||||
|
found = append(found, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Fatalf("exactly one client %s, found %d", clientID, len(found))
|
||||||
|
}
|
||||||
|
return found[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustHold(t *testing.T, o OidcClients, p Provision, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
held, err := o.Holds(ctx, p)
|
||||||
|
if err != nil || held != want {
|
||||||
|
t.Fatalf("holds = %v, %v; want %v", held, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRealmIsReadOutOfTheIssuer(t *testing.T) {
|
||||||
|
for issuer, want := range map[string]string{
|
||||||
|
"https://id.example.org/realms/Novox": "Novox",
|
||||||
|
"https://id.example.org/realms/Novox/": "Novox",
|
||||||
|
"http://127.0.0.1:18500/realms/master": "master",
|
||||||
|
} {
|
||||||
|
if got, err := RealmOf(issuer); err != nil || got != want {
|
||||||
|
t.Errorf("%s: %q %v", issuer, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := RealmOf("https://id.example.org"); err == nil || !strings.Contains(err.Error(), "realms") {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if _, err := RealmOf("keycloak"); err == nil || !strings.Contains(err.Error(), "not a URL") {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRedirectIsTheCallbackUnderEveryComposedName(t *testing.T) {
|
||||||
|
root, redirects, err := RedirectsOf(grafana("s", nil).Values)
|
||||||
|
if err != nil || root != "https://grafana.example.org" || !reflect.DeepEqual(redirects,
|
||||||
|
[]string{"https://grafana.example.org/login/generic_oauth", "https://grafana.home.internal/login/generic_oauth"}) {
|
||||||
|
t.Fatal(root, redirects, err)
|
||||||
|
}
|
||||||
|
if _, r, _ := RedirectsOf(map[string]any{"callback": "/cb", "internal-name": "x.home.internal"}); !reflect.DeepEqual(r, []string{"https://x.home.internal/cb"}) {
|
||||||
|
t.Fatal(r)
|
||||||
|
}
|
||||||
|
for _, values := range []map[string]any{{"name": "g"}, {"name": "g", "callback": "login"}} {
|
||||||
|
if _, _, err := RedirectsOf(values); err == nil || !strings.Contains(err.Error(), "callback") {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := RedirectsOf(map[string]any{"callback": "/cb"}); err == nil || !strings.Contains(err.Error(), "label") {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConsumerIsGivenOneConfidentialClient(t *testing.T) {
|
||||||
|
f, o := oidcWorld(t)
|
||||||
|
if done, err := o.Ensure(ctx, grafana("s3cret", nil)); err != nil || done != "created" {
|
||||||
|
t.Fatal(done, err)
|
||||||
|
}
|
||||||
|
c := only(t, f, "mesh_home_grafana")
|
||||||
|
for k, v := range map[string]any{"publicClient": false, "clientAuthenticatorType": "client-secret", "secret": "s3cret",
|
||||||
|
"enabled": true, "standardFlowEnabled": true, "directAccessGrantsEnabled": false, "implicitFlowEnabled": false} {
|
||||||
|
if c[k] != v {
|
||||||
|
t.Errorf("%s = %v, want %v", k, c[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attributes(c)[Mark] != "true" || len(c["protocolMappers"].([]any)) != 1 {
|
||||||
|
t.Fatal(c)
|
||||||
|
}
|
||||||
|
mustHold(t, o, grafana("s3cret", nil), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyingTheSameGrantAgainMakesNoSecondClient(t *testing.T) {
|
||||||
|
f, o := oidcWorld(t)
|
||||||
|
o.Ensure(ctx, grafana("s", nil))
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if done, err := o.Ensure(ctx, grafana("s", nil)); err != nil || done != "updated" {
|
||||||
|
t.Fatal(done, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := len(only(t, f, "mesh_home_grafana")["protocolMappers"].([]any)); n != 1 {
|
||||||
|
t.Fatalf("the roles mapper was added %d times", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANewSecretIsAppliedInPlaceAndWhatTheMeshDoesNotOwnSurvives(t *testing.T) {
|
||||||
|
f, o := oidcWorld(t)
|
||||||
|
o.Ensure(ctx, grafana("s", nil))
|
||||||
|
c := only(t, f, "mesh_home_grafana")
|
||||||
|
id := c["id"]
|
||||||
|
c["consentRequired"] = true
|
||||||
|
attributes(c)["post.logout.redirect.uris"] = "+"
|
||||||
|
|
||||||
|
mustHold(t, o, grafana("rotated", nil), false)
|
||||||
|
if _, err := o.Ensure(ctx, grafana("rotated", map[string]any{"name": "dash.example.org"})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c = only(t, f, "mesh_home_grafana")
|
||||||
|
if c["id"] != id || c["secret"] != "rotated" || c["rootUrl"] != "https://dash.example.org" ||
|
||||||
|
c["consentRequired"] != true || attributes(c)["post.logout.redirect.uris"] != "+" || attributes(c)[Mark] != "true" {
|
||||||
|
raw, _ := json.Marshal(c)
|
||||||
|
t.Fatal(string(raw))
|
||||||
|
}
|
||||||
|
mustHold(t, o, grafana("rotated", map[string]any{"name": "dash.example.org"}), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAClientEditedBehindTheMeshsBackIsNotHeldAndIsMadeWhole(t *testing.T) {
|
||||||
|
f, o := oidcWorld(t)
|
||||||
|
o.Ensure(ctx, grafana("s", nil))
|
||||||
|
only(t, f, "mesh_home_grafana")["redirectUris"] = []any{"*"}
|
||||||
|
mustHold(t, o, grafana("s", nil), false)
|
||||||
|
o.Ensure(ctx, grafana("s", nil))
|
||||||
|
mustHold(t, o, grafana("s", nil), true)
|
||||||
|
|
||||||
|
only(t, f, "mesh_home_grafana")["protocolMappers"] = []any{}
|
||||||
|
mustHold(t, o, grafana("s", nil), false)
|
||||||
|
o.Ensure(ctx, grafana("s", nil))
|
||||||
|
mustHold(t, o, grafana("s", nil), true)
|
||||||
|
|
||||||
|
f.clients = map[string]Rep{}
|
||||||
|
mustHold(t, o, grafana("s", nil), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAClientTheMeshDidNotMakeIsRefusedAndLeftAlone(t *testing.T) {
|
||||||
|
f, o := oidcWorld(t)
|
||||||
|
f.clients["theirs"] = Rep{"id": "theirs", "clientId": "mesh_home_grafana", "secret": "their-secret", "redirectUris": []any{"*"}}
|
||||||
|
before, _ := json.Marshal(f.clients["theirs"])
|
||||||
|
from := len(f.calls)
|
||||||
|
if _, err := o.Ensure(ctx, grafana("s", nil)); err == nil || !strings.Contains(err.Error(), "did not make") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, _ := json.Marshal(f.clients["theirs"])
|
||||||
|
if string(before) != string(after) {
|
||||||
|
t.Fatal("changed")
|
||||||
|
}
|
||||||
|
for _, c := range f.calls[from:] {
|
||||||
|
if !strings.HasPrefix(c, "GET") && !strings.HasPrefix(c, "POST /realms/master") {
|
||||||
|
t.Fatalf("wrote: %v", f.calls[from:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mustHold(t, o, grafana("s", nil), false)
|
||||||
|
if done, _ := o.Remove(ctx, "mesh_home_grafana"); done != "not ours" || f.clients["theirs"] == nil {
|
||||||
|
t.Fatal(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWithdrawnClientIsRemovedAndAnAbsentOneIsNoError(t *testing.T) {
|
||||||
|
f, o := oidcWorld(t)
|
||||||
|
o.Ensure(ctx, grafana("s", nil))
|
||||||
|
if done, err := o.Remove(ctx, "mesh_home_grafana"); done != "removed" || err != nil || len(f.clients) != 0 {
|
||||||
|
t.Fatal(done, err)
|
||||||
|
}
|
||||||
|
if done, err := o.Remove(ctx, "mesh_home_grafana"); done != "absent" || err != nil {
|
||||||
|
t.Fatal(done, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAContributionWithNoCallbackMakesNoClient(t *testing.T) {
|
||||||
|
f, o := oidcWorld(t)
|
||||||
|
p := grafana("s", nil)
|
||||||
|
p.Values = map[string]any{"name": "grafana.example.org"}
|
||||||
|
if _, err := o.Ensure(ctx, p); err == nil || !strings.Contains(err.Error(), "callback") || len(f.clients) != 0 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// keycloak's provisioner — the adapter that makes keycloak a provider of the mesh `oidc-client`
|
||||||
|
// interface (novox/hq ADR 0039/0040/0048). The reconcile loop is harness.go's; this writes only how
|
||||||
|
// Keycloak creates, checks and removes a consumer's client. What a client is, is oidc.go's.
|
||||||
|
//
|
||||||
|
// **The realm is read out of the issuer**, the one value an assignment sets (settings reach both the
|
||||||
|
// served facts and this module's config.json): a realm set in one place and an issuer in another
|
||||||
|
// would let the consumer be told one realm while its client is made in another.
|
||||||
|
//
|
||||||
|
// **While the admin is refused, Keycloak is not asked** (admin.go): every attempt would be one more
|
||||||
|
// failed login against the admin. The harness still counts each pass as a failure of the consumer,
|
||||||
|
// classed credentials-rejected, so the standing it announces says what is wrong.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issuer is the issuer this assignment serves, from the settings-merged config the mesh delivers.
|
||||||
|
func Issuer(getenv func(string) string) (string, error) {
|
||||||
|
if said := cfgString(meshConfig(getenv("MESH_KEYCLOAK_CONFIG_FILE")), "issuer"); said != "" {
|
||||||
|
return said, nil
|
||||||
|
}
|
||||||
|
if said := getenv("MESH_KEYCLOAK_ISSUER"); said != "" {
|
||||||
|
return said, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("no issuer — the module's config.json carries none and MESH_KEYCLOAK_ISSUER is unset")
|
||||||
|
}
|
||||||
|
|
||||||
|
// provisioner is the adapter.
|
||||||
|
type provisioner struct {
|
||||||
|
clients OidcClients
|
||||||
|
guard *Guard
|
||||||
|
announce func(event string, body map[string]any)
|
||||||
|
log func(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a provisioner) refused() error {
|
||||||
|
if a.guard != nil && a.guard.Refused() {
|
||||||
|
return ErrAdminRejected
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a provisioner) Create(ctx context.Context, p Provision) error {
|
||||||
|
if err := a.refused(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
done, err := a.clients.Ensure(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if done == "created" {
|
||||||
|
a.log("[provisioner:oidc-client] created client %s in realm %s", p.As, a.clients.Realm)
|
||||||
|
a.announce("client.created", map[string]any{"realm": a.clients.Realm, "clientId": p.As, "consumer": p.Consumer})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a provisioner) Remove(ctx context.Context, as string, _ map[string]any) error {
|
||||||
|
if err := a.refused(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
done, err := a.clients.Remove(ctx, as)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch done {
|
||||||
|
case "not ours":
|
||||||
|
a.log("[provisioner:oidc-client] %s: a client of that id exists that the mesh did not make — left alone", as)
|
||||||
|
case "removed":
|
||||||
|
a.log("[provisioner:oidc-client] removed client %s from realm %s", as, a.clients.Realm)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holds is asked every minute by the harness: whether Keycloak still holds this consumer's client
|
||||||
|
// exactly as the mesh gave it, so one deleted or edited behind the mesh's back is made again
|
||||||
|
// (novox/hq issue 120).
|
||||||
|
func (a provisioner) Holds(ctx context.Context, p Provision) (bool, error) {
|
||||||
|
if err := a.refused(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return a.clients.Holds(ctx, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class says a refused admin is a credentials problem, whatever the words around it.
|
||||||
|
func (provisioner) Class(err error) string {
|
||||||
|
if errors.Is(err, ErrRejected) {
|
||||||
|
return ClassCredentials
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func logStderr(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) }
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// A provider that keeps failing a consumer says so on the bus (novox/hq ADR 0224): not on the first
|
||||||
|
// failure, which may be a restart; after FailingAfter of failures with no success between; again
|
||||||
|
// every SayAgainEvery while it lasts; and recovered on the first success, or when the consumer goes.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type announced struct {
|
||||||
|
event string
|
||||||
|
body map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func standingWorld(t *testing.T) (*world, *[]announced) {
|
||||||
|
w := newWorld(t)
|
||||||
|
var said []announced
|
||||||
|
w.h.Announce = func(e string, b map[string]any) {
|
||||||
|
// The first success since start is its own test's; every other test reads past it.
|
||||||
|
if b["why"] != "first-success" {
|
||||||
|
said = append(said, announced{e, b})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.h.Node = "anchor"
|
||||||
|
return w, &said
|
||||||
|
}
|
||||||
|
|
||||||
|
// passes reconciles every five seconds for d, as Run would.
|
||||||
|
func (w *world) passes(d time.Duration) {
|
||||||
|
for end := w.now.Add(d); w.now.Before(end); w.now = w.now.Add(5 * time.Second) {
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func events(said []announced) string {
|
||||||
|
var out []string
|
||||||
|
for _, a := range said {
|
||||||
|
out = append(out, a.event)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConsumerFailedForMinutesIsAnnouncedNamingItAndTheClass(t *testing.T) {
|
||||||
|
w, said := standingWorld(t)
|
||||||
|
w.a.failing = errors.New(`token request failed: 401 {"error":"invalid_grant","error_description":"Invalid user credentials"}`)
|
||||||
|
w.give(map[string]any{"as": "mesh_home_grafana", "node": "home-server"})
|
||||||
|
|
||||||
|
w.passes(4 * time.Minute)
|
||||||
|
if len(*said) != 0 {
|
||||||
|
t.Fatalf("announced before FailingAfter: %v", events(*said))
|
||||||
|
}
|
||||||
|
w.passes(2 * time.Minute)
|
||||||
|
if events(*said) != EventFailing {
|
||||||
|
t.Fatalf("want one %s, got %q", EventFailing, events(*said))
|
||||||
|
}
|
||||||
|
b := (*said)[0].body
|
||||||
|
if b["consumer"] != "mesh_home_grafana" || b["node"] != "home-server" || b["class"] != ClassCredentials ||
|
||||||
|
b["provider"] != "oidc-client" || b["provider-node"] != "anchor" || b["attempts"].(int) < 60 {
|
||||||
|
t.Fatalf("%v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Said again while it lasts, not every pass.
|
||||||
|
w.passes(14 * time.Minute)
|
||||||
|
if events(*said) != EventFailing {
|
||||||
|
t.Fatalf("repeated too soon: %q", events(*said))
|
||||||
|
}
|
||||||
|
w.passes(2 * time.Minute)
|
||||||
|
if events(*said) != EventFailing+","+EventFailing {
|
||||||
|
t.Fatalf("not repeated: %q", events(*said))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first success takes it back.
|
||||||
|
w.a.failing = nil
|
||||||
|
w.passes(5 * time.Second)
|
||||||
|
if events(*said) != EventFailing+","+EventFailing+","+EventRecovered {
|
||||||
|
t.Fatalf("no recovery: %q", events(*said))
|
||||||
|
}
|
||||||
|
if (*said)[2].body["consumer"] != "mesh_home_grafana" {
|
||||||
|
t.Fatal((*said)[2].body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneSuccessBetweenFailuresStartsTheRunAgain(t *testing.T) {
|
||||||
|
w, said := standingWorld(t)
|
||||||
|
w.a.failing = errors.New("connection refused")
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
w.passes(4 * time.Minute)
|
||||||
|
w.a.failing = nil
|
||||||
|
w.passes(5 * time.Second)
|
||||||
|
w.give(map[string]any{"as": "a", "values": map[string]any{"name": "changed"}})
|
||||||
|
w.a.failing = errors.New("connection refused")
|
||||||
|
w.passes(4 * time.Minute)
|
||||||
|
if len(*said) != 0 {
|
||||||
|
t.Fatalf("two runs of four minutes are not one of eight: %q", events(*said))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check that failed for a day on 2026-10-05: clients already made, every minute's check refused
|
||||||
|
// at the token. A check that cannot be asked is a failure too.
|
||||||
|
func TestACheckThatKeepsFailingIsAFailureToo(t *testing.T) {
|
||||||
|
w, said := standingWorld(t)
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
w.h.Reconcile(ctx)
|
||||||
|
w.a.holdsErr = errors.New("401 invalid_grant")
|
||||||
|
w.passes(7 * time.Minute)
|
||||||
|
if events(*said) != EventFailing || (*said)[0].body["class"] != ClassCredentials {
|
||||||
|
t.Fatalf("%q %v", events(*said), *said)
|
||||||
|
}
|
||||||
|
w.a.holdsErr = nil
|
||||||
|
w.passes(time.Minute + 5*time.Second)
|
||||||
|
if events(*said) != EventFailing+","+EventRecovered {
|
||||||
|
t.Fatalf("%q", events(*said))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnreadableSecretIsAnnouncedAsSuch(t *testing.T) {
|
||||||
|
w, said := standingWorld(t)
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
os.Remove(w.dir + "/a.secret")
|
||||||
|
w.passes(6 * time.Minute)
|
||||||
|
if events(*said) != EventFailing || (*said)[0].body["class"] != ClassSecret {
|
||||||
|
t.Fatalf("%q %v", events(*said), *said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWithdrawnConsumerIsNoLongerFailing(t *testing.T) {
|
||||||
|
w, said := standingWorld(t)
|
||||||
|
w.a.failing = errors.New("boom")
|
||||||
|
w.give(map[string]any{"as": "a"}, map[string]any{"as": "b"})
|
||||||
|
w.passes(6 * time.Minute)
|
||||||
|
if events(*said) != EventFailing+","+EventFailing {
|
||||||
|
t.Fatalf("%q", events(*said))
|
||||||
|
}
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
w.passes(5 * time.Second)
|
||||||
|
last := (*said)[len(*said)-1]
|
||||||
|
if last.event != EventRecovered || last.body["consumer"] != "b" || last.body["why"] != "withdrawn" {
|
||||||
|
t.Fatalf("%v", *said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnErrorIsClassedByItsWords(t *testing.T) {
|
||||||
|
for text, want := range map[string]string{
|
||||||
|
`Keycloak token request failed: 401 {"error":"invalid_grant"}`: ClassCredentials,
|
||||||
|
`FATAL: password authentication failed for user "postgres"`: ClassCredentials,
|
||||||
|
`dial tcp 127.0.0.1:5432: connect: connection refused`: ClassUnreachable,
|
||||||
|
`context deadline exceeded`: ClassUnreachable,
|
||||||
|
`extension "nope" is not available`: ClassRefused,
|
||||||
|
} {
|
||||||
|
if got := ClassOf(text); got != want {
|
||||||
|
t.Errorf("%s: %s, want %s", text, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAdapterThatClassesItsOwnErrorsIsBelieved(t *testing.T) {
|
||||||
|
w, said := standingWorld(t)
|
||||||
|
w.h.Adapter = classing{w.a}
|
||||||
|
w.a.failing = errors.New("anything")
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
w.passes(6 * time.Minute)
|
||||||
|
if (*said)[0].body["class"] != "its-own" {
|
||||||
|
t.Fatal((*said)[0].body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type classing struct{ *recorder }
|
||||||
|
|
||||||
|
func (classing) Class(error) string { return "its-own" }
|
||||||
|
|
||||||
|
// A provider restarted after announcing a failure has forgotten it; its first success for each
|
||||||
|
// consumer is announced, so the controller clears what it kept rather than naming it for ever.
|
||||||
|
func TestTheFirstSuccessSinceStartIsAnnouncedOnce(t *testing.T) {
|
||||||
|
w := newWorld(t)
|
||||||
|
var said []announced
|
||||||
|
w.h.Announce = func(e string, b map[string]any) { said = append(said, announced{e, b}) }
|
||||||
|
w.give(map[string]any{"as": "a"})
|
||||||
|
w.passes(3 * time.Minute)
|
||||||
|
if events(said) != EventRecovered || said[0].body["why"] != "first-success" || said[0].body["consumer"] != "a" {
|
||||||
|
t.Fatalf("%v", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// keycloak's tools — ported from tools/index.ts with the same names, arguments and answers. Write
|
||||||
|
// actions announce themselves at the point they succeed, in the module's single event vocabulary:
|
||||||
|
//
|
||||||
|
// user.created / user.deleted an identity appeared or was removed
|
||||||
|
// password.reset a user's credential was reset (no secret in the body)
|
||||||
|
// client.created an OIDC client was registered
|
||||||
|
// group.created / role.created a group or a realm role was created
|
||||||
|
// admin.repaired / .unrepaired the guard set the admin to the mesh's password, or could not
|
||||||
|
//
|
||||||
|
// Keycloak consumes nothing: it is upstream of everything that authenticates against it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prop(kind, description string) map[string]any {
|
||||||
|
return map[string]any{"type": kind, "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
var realmProp = prop("string", "realm name (defaults to the module's realm)")
|
||||||
|
|
||||||
|
func str(args map[string]any, key string) string {
|
||||||
|
switch v := args[key].(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flag(args map[string]any, key string) (value, set bool) {
|
||||||
|
v, ok := args[key].(bool)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(args map[string]any, key string) int {
|
||||||
|
switch v := args[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func pick(r Rep, keys ...string) Rep {
|
||||||
|
out := Rep{}
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := r[k]; ok {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func bg() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools are keycloak's own; the guard answers keycloak_admin_check.
|
||||||
|
func Tools(kc *Client, guard *Guard) []stdio.Tool {
|
||||||
|
realmOf := func(args map[string]any) string {
|
||||||
|
if r := str(args, "realm"); r != "" {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return kc.DefaultRealm
|
||||||
|
}
|
||||||
|
tool := func(name, description string, input map[string]any, run func(ctx context.Context, args map[string]any) (any, error)) stdio.Tool {
|
||||||
|
return stdio.Tool{Name: name, Description: description, Input: input, Run: func(args map[string]any) (any, error) {
|
||||||
|
ctx, cancel := bg()
|
||||||
|
defer cancel()
|
||||||
|
return run(ctx, args)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
userID := prop("string", "user ID (UUID)")
|
||||||
|
|
||||||
|
return []stdio.Tool{
|
||||||
|
// The guard
|
||||||
|
{
|
||||||
|
Name: "keycloak_admin_check",
|
||||||
|
Description: "Check that Keycloak's admin logs in with the password the mesh minted. With repair: true, a " +
|
||||||
|
"refused admin is repaired now — its password set to the mesh's through a temporary bootstrap admin " +
|
||||||
|
"inside the container, which is removed again — even inside the brake a failed automatic repair set.",
|
||||||
|
Input: map[string]any{"repair": prop("boolean", "repair a refused admin now (default false: only check)")},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
repair, _ := flag(args, "repair")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
return guard.Ensure(ctx, repair, true), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Realms & sessions
|
||||||
|
tool("keycloak_list_realms", "List all Keycloak realms.", map[string]any{},
|
||||||
|
func(ctx context.Context, _ map[string]any) (any, error) {
|
||||||
|
realms, err := kc.ListRealms(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []Rep{}
|
||||||
|
for _, r := range realms {
|
||||||
|
out = append(out, pick(r, "id", "realm", "displayName", "enabled"))
|
||||||
|
}
|
||||||
|
return map[string]any{"realms": out}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_list_sessions", "List active sessions for a user in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
s, err := kc.UserSessions(ctx, realmOf(a), str(a, "user_id"))
|
||||||
|
return map[string]any{"sessions": s}, err
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Users
|
||||||
|
tool("keycloak_list_users", "List users in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "search": prop("string", "search by username, email, first/last name"),
|
||||||
|
"max": prop("number", "maximum number of results")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
u, err := kc.ListUsers(ctx, realmOf(a), str(a, "search"), number(a, "max"))
|
||||||
|
return map[string]any{"users": u}, err
|
||||||
|
}),
|
||||||
|
tool("keycloak_create_user", "Create a user in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "username": prop("string", "username"), "email": prop("string", "email address"),
|
||||||
|
"password": prop("string", "initial password"),
|
||||||
|
"temporary_password": prop("boolean", "require a password change on first login (default true)")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, username, email := realmOf(a), str(a, "username"), str(a, "email")
|
||||||
|
rep := Rep{"username": username}
|
||||||
|
if email != "" {
|
||||||
|
rep["email"] = email
|
||||||
|
}
|
||||||
|
if pw := str(a, "password"); pw != "" {
|
||||||
|
temporary, set := flag(a, "temporary_password")
|
||||||
|
rep["credentials"] = []any{Rep{"type": "password", "value": pw, "temporary": temporary || !set}}
|
||||||
|
}
|
||||||
|
if err := kc.CreateUser(ctx, realm, rep); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body := map[string]any{"realm": realm, "username": username}
|
||||||
|
if email != "" {
|
||||||
|
body["email"] = email
|
||||||
|
}
|
||||||
|
announce("user.created", body)
|
||||||
|
return map[string]any{"created": body}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_delete_user", "Delete a user from a Keycloak realm (requires confirm).",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID, "confirm": prop("boolean", "must be true to confirm deletion")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, id := realmOf(a), str(a, "user_id")
|
||||||
|
if ok, _ := flag(a, "confirm"); !ok {
|
||||||
|
return map[string]any{"aborted": "confirm must be true to delete a user"}, nil
|
||||||
|
}
|
||||||
|
if err := kc.DeleteUser(ctx, realm, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
announce("user.deleted", map[string]any{"realm": realm, "userId": id})
|
||||||
|
return map[string]any{"deleted": map[string]any{"realm": realm, "userId": id}}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_update_user", "Update a user's attributes in a Keycloak realm (enable/disable, change email, name).",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID, "enabled": prop("boolean", "enable or disable the user"),
|
||||||
|
"email": prop("string", "new email address"), "firstName": prop("string", "new first name"),
|
||||||
|
"lastName": prop("string", "new last name")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, id := realmOf(a), str(a, "user_id")
|
||||||
|
updates := Rep{}
|
||||||
|
var fields []string
|
||||||
|
if v, set := flag(a, "enabled"); set {
|
||||||
|
updates["enabled"], fields = v, append(fields, "enabled")
|
||||||
|
}
|
||||||
|
for _, k := range []string{"email", "firstName", "lastName"} {
|
||||||
|
if _, set := a[k]; set {
|
||||||
|
updates[k], fields = str(a, k), append(fields, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return map[string]any{"aborted": "no updates provided"}, nil
|
||||||
|
}
|
||||||
|
if err := kc.UpdateUser(ctx, realm, id, updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"updated": map[string]any{"realm": realm, "userId": id, "fields": fields}}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_reset_password", "Reset a user's password in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID, "password": prop("string", "new password"),
|
||||||
|
"temporary": prop("boolean", "require a password change on next login (default false)")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, id := realmOf(a), str(a, "user_id")
|
||||||
|
temporary, _ := flag(a, "temporary")
|
||||||
|
if err := kc.ResetPassword(ctx, realm, id, str(a, "password"), temporary); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
announce("password.reset", map[string]any{"realm": realm, "userId": id})
|
||||||
|
return map[string]any{"reset": map[string]any{"realm": realm, "userId": id}}, nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Clients
|
||||||
|
tool("keycloak_list_clients", "List OIDC clients in a Keycloak realm.", map[string]any{"realm": realmProp},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
clients, err := kc.ListClients(ctx, realmOf(a))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []Rep{}
|
||||||
|
for _, c := range clients {
|
||||||
|
out = append(out, pick(c, "id", "clientId", "name", "enabled", "protocol", "publicClient", "rootUrl"))
|
||||||
|
}
|
||||||
|
return map[string]any{"clients": out}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_create_client", "Create an OIDC client in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "client_id": prop("string", "client ID (e.g. 'my-app')"),
|
||||||
|
"name": prop("string", "display name"), "root_url": prop("string", "root URL of the application"),
|
||||||
|
"redirect_uris": prop("array", "allowed redirect URIs"),
|
||||||
|
"public_client": prop("boolean", "public client, no client secret (default true)")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, clientID, name := realmOf(a), str(a, "client_id"), str(a, "name")
|
||||||
|
public, set := flag(a, "public_client")
|
||||||
|
rep := Rep{"protocol": "openid-connect", "enabled": true, "clientId": clientID, "publicClient": public || !set}
|
||||||
|
if name != "" {
|
||||||
|
rep["name"] = name
|
||||||
|
}
|
||||||
|
if u := str(a, "root_url"); u != "" {
|
||||||
|
rep["rootUrl"] = u
|
||||||
|
}
|
||||||
|
if list, ok := a["redirect_uris"].([]any); ok {
|
||||||
|
uris := []any{}
|
||||||
|
for _, u := range list {
|
||||||
|
uris = append(uris, fmt.Sprint(u))
|
||||||
|
}
|
||||||
|
rep["redirectUris"] = uris
|
||||||
|
}
|
||||||
|
if err := kc.CreateClient(ctx, realm, rep); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body := map[string]any{"realm": realm, "clientId": clientID}
|
||||||
|
if name != "" {
|
||||||
|
body["name"] = name
|
||||||
|
}
|
||||||
|
announce("client.created", body)
|
||||||
|
return map[string]any{"created": body}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_delete_client", "Delete an OIDC client from a Keycloak realm (requires confirm).",
|
||||||
|
map[string]any{"realm": realmProp, "client_id": prop("string", "client ID (e.g. 'my-app')"),
|
||||||
|
"confirm": prop("boolean", "must be true to confirm deletion")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, clientID := realmOf(a), str(a, "client_id")
|
||||||
|
if ok, _ := flag(a, "confirm"); !ok {
|
||||||
|
return map[string]any{"aborted": "confirm must be true to delete a client"}, nil
|
||||||
|
}
|
||||||
|
if err := kc.DeleteClient(ctx, realm, clientID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"deleted": map[string]any{"realm": realm, "clientId": clientID}}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_get_client_secret", "Get the client secret for a confidential OIDC client.",
|
||||||
|
map[string]any{"realm": realmProp, "client_id": prop("string", "client ID")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
s, err := kc.GetClientSecret(ctx, realmOf(a), str(a, "client_id"))
|
||||||
|
return map[string]any{"secret": s}, err
|
||||||
|
}),
|
||||||
|
tool("keycloak_add_protocol_mapper",
|
||||||
|
"Add a protocol mapper to an OIDC client. Common types: oidc-usermodel-realm-role-mapper "+
|
||||||
|
"(realm roles), oidc-usermodel-attribute-mapper (user attributes), oidc-audience-mapper.",
|
||||||
|
map[string]any{"realm": realmProp, "client_id": prop("string", "client ID (e.g. 'grafana')"),
|
||||||
|
"name": prop("string", "mapper name (e.g. 'realm roles')"),
|
||||||
|
"mapper_type": prop("string", "protocol mapper type (e.g. 'oidc-usermodel-realm-role-mapper')"),
|
||||||
|
"claim_name": prop("string", "token claim name (e.g. 'realm_access.roles')"),
|
||||||
|
"claim_type": prop("string", "JSON type: String, long, int, boolean (default String)"),
|
||||||
|
"multivalued": prop("boolean", "whether the claim has multiple values (default false)"),
|
||||||
|
"id_token": prop("boolean", "include in ID token (default true)"),
|
||||||
|
"access_token": prop("boolean", "include in access token (default true)"),
|
||||||
|
"userinfo": prop("boolean", "include in userinfo response (default true)")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, clientID, name := realmOf(a), str(a, "client_id"), str(a, "name")
|
||||||
|
claimType := str(a, "claim_type")
|
||||||
|
if claimType == "" {
|
||||||
|
claimType = "String"
|
||||||
|
}
|
||||||
|
on := func(key string) string {
|
||||||
|
v, set := flag(a, key)
|
||||||
|
return fmt.Sprint(v || !set)
|
||||||
|
}
|
||||||
|
multi, _ := flag(a, "multivalued")
|
||||||
|
err := kc.AddProtocolMapper(ctx, realm, clientID, Rep{
|
||||||
|
"name": name, "protocolMapper": str(a, "mapper_type"),
|
||||||
|
"config": map[string]any{
|
||||||
|
"claim.name": str(a, "claim_name"), "jsonType.label": claimType,
|
||||||
|
"multivalued": fmt.Sprint(multi), "id.token.claim": on("id_token"),
|
||||||
|
"access.token.claim": on("access_token"), "userinfo.token.claim": on("userinfo"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"added": map[string]any{"realm": realm, "clientId": clientID, "mapper": name}}, nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
tool("keycloak_list_groups", "List groups in a Keycloak realm.", map[string]any{"realm": realmProp},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
g, err := kc.ListGroups(ctx, realmOf(a))
|
||||||
|
return map[string]any{"groups": g}, err
|
||||||
|
}),
|
||||||
|
tool("keycloak_create_group", "Create a group in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "name": prop("string", "group name")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, name := realmOf(a), str(a, "name")
|
||||||
|
if err := kc.CreateGroup(ctx, realm, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
announce("group.created", map[string]any{"realm": realm, "name": name})
|
||||||
|
return map[string]any{"created": map[string]any{"realm": realm, "group": name}}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_get_user_groups", "List the groups a user belongs to in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
g, err := kc.UserGroups(ctx, realmOf(a), str(a, "user_id"))
|
||||||
|
return map[string]any{"groups": g}, err
|
||||||
|
}),
|
||||||
|
tool("keycloak_add_user_to_group", "Add a user to a group in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID, "group_id": prop("string", "group ID (UUID)")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm := realmOf(a)
|
||||||
|
if err := kc.AddUserToGroup(ctx, realm, str(a, "user_id"), str(a, "group_id")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"added": map[string]any{"realm": realm, "userId": str(a, "user_id"), "groupId": str(a, "group_id")}}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_remove_user_from_group", "Remove a user from a group in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID, "group_id": prop("string", "group ID (UUID)")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm := realmOf(a)
|
||||||
|
if err := kc.RemoveUserFromGroup(ctx, realm, str(a, "user_id"), str(a, "group_id")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"removed": map[string]any{"realm": realm, "userId": str(a, "user_id"), "groupId": str(a, "group_id")}}, nil
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Roles
|
||||||
|
tool("keycloak_get_user_roles", "List the realm roles assigned to a user in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
r, err := kc.UserRealmRoles(ctx, realmOf(a), str(a, "user_id"))
|
||||||
|
return map[string]any{"roles": r}, err
|
||||||
|
}),
|
||||||
|
tool("keycloak_create_role", "Create a realm role in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "role_name": prop("string", "role name"), "description": prop("string", "role description")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, name := realmOf(a), str(a, "role_name")
|
||||||
|
rep := Rep{"name": name}
|
||||||
|
if d := str(a, "description"); d != "" {
|
||||||
|
rep["description"] = d
|
||||||
|
}
|
||||||
|
if err := kc.CreateRealmRole(ctx, realm, rep); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
announce("role.created", map[string]any{"realm": realm, "name": name})
|
||||||
|
return map[string]any{"created": map[string]any{"realm": realm, "role": name}}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_assign_user_role", "Assign an existing realm role to a user. Create it first with keycloak_create_role if needed.",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID, "role_name": prop("string", "role name to assign")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, id, role := realmOf(a), str(a, "user_id"), str(a, "role_name")
|
||||||
|
// The mapping API needs the role's UUID, which only the "available" list carries; a role
|
||||||
|
// neither available nor assigned does not exist in this realm.
|
||||||
|
available, err := kc.AvailableRealmRoles(ctx, realm, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range available {
|
||||||
|
if r["name"] == role {
|
||||||
|
if err := kc.AssignRealmRoles(ctx, realm, id, []Rep{pick(r, "id", "name")}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"assigned": map[string]any{"realm": realm, "userId": id, "role": role}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assigned, err := kc.UserRealmRoles(ctx, realm, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range assigned {
|
||||||
|
if r["name"] == role {
|
||||||
|
return map[string]any{"alreadyAssigned": map[string]any{"realm": realm, "userId": id, "role": role}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"notFound": map[string]any{"realm": realm, "role": role}}, nil
|
||||||
|
}),
|
||||||
|
tool("keycloak_remove_user_role", "Remove a realm role from a user in a Keycloak realm.",
|
||||||
|
map[string]any{"realm": realmProp, "user_id": userID, "role_name": prop("string", "role name to remove")},
|
||||||
|
func(ctx context.Context, a map[string]any) (any, error) {
|
||||||
|
realm, id, role := realmOf(a), str(a, "user_id"), str(a, "role_name")
|
||||||
|
assigned, err := kc.UserRealmRoles(ctx, realm, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range assigned {
|
||||||
|
if r["name"] == role {
|
||||||
|
if err := kc.RemoveRealmRoles(ctx, realm, id, []Rep{pick(r, "id", "name")}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"removed": map[string]any{"realm": realm, "userId": id, "role": role}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"notAssigned": map[string]any{"realm": realm, "userId": id, "role": role}}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module keycloak
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// keycloak's events. Keycloak's worth to the mesh is in what it changes — an identity created, a
|
|
||||||
// client registered, a password reset — so its events are emitted from the admin actions themselves
|
|
||||||
// (novox/hq ADR 0041/0042), not scraped back by polling. This module is the single vocabulary for
|
|
||||||
// them: every keycloak event goes through one of the helpers here, and the tools call them at the
|
|
||||||
// point the change succeeds.
|
|
||||||
//
|
|
||||||
// Emits:
|
|
||||||
// module.keycloak.user.created / .deleted — an identity appeared or was removed
|
|
||||||
// module.keycloak.password.reset — a user's credential was reset (no secret in the body)
|
|
||||||
// module.keycloak.client.created — an OIDC client was registered
|
|
||||||
// module.keycloak.group.created — a group was created
|
|
||||||
// module.keycloak.role.created — a realm role was created
|
|
||||||
// Consumes:
|
|
||||||
// nothing — Keycloak is upstream of the things that authenticate against it; it reacts to none of
|
|
||||||
// their events. There is no honest `on(...)` to write, so there is none.
|
|
||||||
|
|
||||||
import { emit } from "@novox/mesh-sdk/events";
|
|
||||||
|
|
||||||
// A completed admin action must not be undone by a flaky broker: the change already happened in
|
|
||||||
// Keycloak, so a failed emit is logged and swallowed rather than thrown back through the tool.
|
|
||||||
async function announce(type: string, body: Record<string, unknown>): Promise<void> {
|
|
||||||
try {
|
|
||||||
await emit(type, body);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[keycloak] emit ${type} failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const events = {
|
|
||||||
userCreated: (realm: string, username: string, email?: string) =>
|
|
||||||
announce("user.created", { realm, username, ...(email ? { email } : {}) }),
|
|
||||||
userDeleted: (realm: string, userId: string) =>
|
|
||||||
announce("user.deleted", { realm, userId }),
|
|
||||||
passwordReset: (realm: string, userId: string) =>
|
|
||||||
announce("password.reset", { realm, userId }),
|
|
||||||
clientCreated: (realm: string, clientId: string, name?: string) =>
|
|
||||||
announce("client.created", { realm, clientId, ...(name ? { name } : {}) }),
|
|
||||||
groupCreated: (realm: string, name: string) =>
|
|
||||||
announce("group.created", { realm, name }),
|
|
||||||
roleCreated: (realm: string, name: string) =>
|
|
||||||
announce("role.created", { realm, name }),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("[keycloak] event surface ready — identity, client, group and role changes are announced");
|
|
||||||
@@ -4,7 +4,11 @@
|
|||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
"name": "oidc-client",
|
"name": "oidc-client",
|
||||||
"scope": "mesh"
|
"scope": "mesh",
|
||||||
|
"identity": {
|
||||||
|
"max": 255,
|
||||||
|
"in": "a Keycloak client id"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requires": [
|
"requires": [
|
||||||
@@ -36,7 +40,9 @@
|
|||||||
"password.reset",
|
"password.reset",
|
||||||
"client.created",
|
"client.created",
|
||||||
"group.created",
|
"group.created",
|
||||||
"role.created"
|
"role.created",
|
||||||
|
"admin.repaired",
|
||||||
|
"admin.unrepaired"
|
||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
@@ -150,22 +156,19 @@
|
|||||||
{
|
{
|
||||||
"name": "code",
|
"name": "code",
|
||||||
"kind": "bundle",
|
"kind": "bundle",
|
||||||
"language": "typescript",
|
"language": "go",
|
||||||
"entrypoints": [
|
"system": "arch",
|
||||||
"index.js",
|
"from": "cmd/keycloak-provider",
|
||||||
"tools/index.js",
|
"binary": "keycloak-provider",
|
||||||
"provisioner/index.js"
|
|
||||||
],
|
|
||||||
"loads": [
|
"loads": [
|
||||||
"index.js",
|
"keycloak-provider"
|
||||||
"tools/index.js",
|
|
||||||
"provisioner/index.js"
|
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
|
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
|
||||||
"MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
"MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||||
"MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret",
|
"MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||||
|
"MESH_KEYCLOAK_CONTAINER": "keycloak"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
// What the `oidc-client` provision means in Keycloak: one confidential OpenID Connect client per
|
|
||||||
// consumer, in the realm this module serves, under the name and secret the mesh gave both ends.
|
|
||||||
// The provisioner (provisioner/index.ts) is the sdk harness calling these; they are here, apart from
|
|
||||||
// it, so they can be exercised against a fake admin API without a broker or a contributions file.
|
|
||||||
//
|
|
||||||
// **The client id and the secret are the mesh's, not Keycloak's (novox/hq ADR 0048).** The mesh
|
|
||||||
// derives the consumer's identity (`as`, e.g. `mesh_ace_grafana`) and hands it to both ends — the
|
|
||||||
// consumer names it as its client id through `${bound:oidc-client:as}` — and mints the secret, which
|
|
||||||
// this sets as the client's secret. Keycloak generates neither.
|
|
||||||
//
|
|
||||||
// **Where the consumer's browser comes back to is the consumer's to say.** Its contribution carries
|
|
||||||
// `callback` (a path, e.g. `/login/generic_oauth`) and the `label`/`endpoint` of the endpoint it is
|
|
||||||
// reached on; the mesh composes that endpoint's names into `name` (public) and `internal-name`
|
|
||||||
// (private network) exactly as it does for a route (novox/hq ADR 0056, 0138), so the redirect URI
|
|
||||||
// registered here is built from the same names the proxy serves the consumer under.
|
|
||||||
//
|
|
||||||
// **Only what the mesh made is touched.** A client this module creates carries the attribute
|
|
||||||
// `mesh.provisioned=true`, and its id starts with the mesh's own prefix. A client with the same id
|
|
||||||
// that lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted.
|
|
||||||
|
|
||||||
import type { ClientRepresentation, KeycloakClient, ProtocolMapperRepresentation } from "./client.js";
|
|
||||||
|
|
||||||
/** The attribute marking a client as the mesh's own work. */
|
|
||||||
export const MARK = "mesh.provisioned";
|
|
||||||
|
|
||||||
/** The mapper every mesh client carries: realm roles as a flat `roles` claim in the id token, the
|
|
||||||
* access token and userinfo — what a consumer maps its own roles from (grafana's role path reads
|
|
||||||
* `roles[*]`), and what the predecessor added to its hand-made clients by hand. */
|
|
||||||
export const ROLES_MAPPER: ProtocolMapperRepresentation = {
|
|
||||||
name: "realm roles",
|
|
||||||
protocol: "openid-connect",
|
|
||||||
protocolMapper: "oidc-usermodel-realm-role-mapper",
|
|
||||||
config: {
|
|
||||||
"claim.name": "roles",
|
|
||||||
"jsonType.label": "String",
|
|
||||||
multivalued: "true",
|
|
||||||
"id.token.claim": "true",
|
|
||||||
"access.token.claim": "true",
|
|
||||||
"userinfo.token.claim": "true",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One consumer, as the harness hands it over. */
|
|
||||||
export interface OidcGrant {
|
|
||||||
readonly as: string;
|
|
||||||
readonly password: string;
|
|
||||||
readonly values: Readonly<Record<string, unknown>>;
|
|
||||||
readonly consumer?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`. The issuer is
|
|
||||||
* the one value an assignment sets (it is also what consumers are served), so the realm is read
|
|
||||||
* out of it rather than set a second time where the two could disagree. */
|
|
||||||
export function realmOf(issuer: string): string {
|
|
||||||
let path: string;
|
|
||||||
try {
|
|
||||||
path = new URL(issuer).pathname;
|
|
||||||
} catch {
|
|
||||||
throw new Error(`the issuer ${JSON.stringify(issuer)} is not a URL`);
|
|
||||||
}
|
|
||||||
const m = /\/realms\/([^/]+)\/?$/.exec(path);
|
|
||||||
if (!m) throw new Error(`the issuer ${JSON.stringify(issuer)} does not end in /realms/<realm>`);
|
|
||||||
return decodeURIComponent(m[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The redirect URIs a consumer's contribution asks for: its callback under each name the mesh
|
|
||||||
* composed for its endpoint. Refused when there is nothing to register — a client that accepts no
|
|
||||||
* redirect is a client nobody can log in through, and one that accepts any is worse. */
|
|
||||||
export function redirectsOf(values: Readonly<Record<string, unknown>>): { root: string; redirects: string[] } {
|
|
||||||
const callback = values.callback;
|
|
||||||
if (typeof callback !== "string" || !callback.startsWith("/")) {
|
|
||||||
throw new Error(`contributes no callback path (\`callback\`, starting with "/"): ${JSON.stringify(callback)}`);
|
|
||||||
}
|
|
||||||
const names: string[] = [];
|
|
||||||
for (const key of ["name", "internal-name"]) {
|
|
||||||
const n = values[key];
|
|
||||||
if (typeof n === "string" && n.trim() !== "" && !names.includes(n.trim())) names.push(n.trim());
|
|
||||||
}
|
|
||||||
if (names.length === 0) {
|
|
||||||
throw new Error("has no name the mesh composed (`name` / `internal-name`) — contribute a `label` and the `endpoint` it is reached on");
|
|
||||||
}
|
|
||||||
return { root: `https://${names[0]}`, redirects: names.map((n) => `https://${n}${callback}`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The fields the mesh owns on a client it made. Everything else on the client is left as found. */
|
|
||||||
function wanted(g: OidcGrant): ClientRepresentation {
|
|
||||||
const { root, redirects } = redirectsOf(g.values);
|
|
||||||
return {
|
|
||||||
clientId: g.as,
|
|
||||||
name: g.as,
|
|
||||||
description: `made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`,
|
|
||||||
enabled: true,
|
|
||||||
protocol: "openid-connect",
|
|
||||||
publicClient: false,
|
|
||||||
clientAuthenticatorType: "client-secret",
|
|
||||||
secret: g.password,
|
|
||||||
rootUrl: root,
|
|
||||||
baseUrl: root,
|
|
||||||
redirectUris: redirects,
|
|
||||||
standardFlowEnabled: true,
|
|
||||||
implicitFlowEnabled: false,
|
|
||||||
directAccessGrantsEnabled: false,
|
|
||||||
serviceAccountsEnabled: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameSet(a: readonly string[] | undefined, b: readonly string[]): boolean {
|
|
||||||
const x = [...(a ?? [])].sort();
|
|
||||||
const y = [...b].sort();
|
|
||||||
return x.length === y.length && x.every((v, i) => v === y[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function marked(c: ClientRepresentation): boolean {
|
|
||||||
return c.attributes?.[MARK] === "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OidcClients {
|
|
||||||
constructor(private readonly kc: KeycloakClient, readonly realm: string) {}
|
|
||||||
|
|
||||||
/** Create the consumer's client, or bring the mesh's existing one back to what the grant says.
|
|
||||||
* Returns whether it was newly created. Idempotent: applying the same grant twice changes nothing
|
|
||||||
* the second time beyond re-asserting it. */
|
|
||||||
async ensure(g: OidcGrant): Promise<"created" | "updated"> {
|
|
||||||
const want = wanted(g);
|
|
||||||
const found = await this.kc.findClient(this.realm, g.as);
|
|
||||||
if (found && !marked(found)) {
|
|
||||||
throw new Error(
|
|
||||||
`realm ${this.realm} already has a client ${g.as} the mesh did not make — left alone; ` +
|
|
||||||
`delete or rename it if the mesh should own that id`);
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
await this.kc.createClientFrom(this.realm, {
|
|
||||||
...want,
|
|
||||||
attributes: { [MARK]: "true" },
|
|
||||||
protocolMappers: [ROLES_MAPPER],
|
|
||||||
});
|
|
||||||
return "created";
|
|
||||||
}
|
|
||||||
// Overlay what the mesh owns on what is there, so a field Keycloak added or an operator set on a
|
|
||||||
// field the mesh does not own survives the update.
|
|
||||||
await this.kc.updateClient(this.realm, found.id!, {
|
|
||||||
...found,
|
|
||||||
...want,
|
|
||||||
attributes: { ...(found.attributes ?? {}), [MARK]: "true" },
|
|
||||||
});
|
|
||||||
await this.ensureMapper(found.id!);
|
|
||||||
return "updated";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureMapper(id: string): Promise<void> {
|
|
||||||
const mappers = await this.kc.listClientMappers(this.realm, id);
|
|
||||||
const have = mappers.find((m) => m.name === ROLES_MAPPER.name);
|
|
||||||
if (!have) {
|
|
||||||
await this.kc.addClientMapper(this.realm, id, ROLES_MAPPER);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const drifted =
|
|
||||||
have.protocolMapper !== ROLES_MAPPER.protocolMapper ||
|
|
||||||
Object.entries(ROLES_MAPPER.config).some(([k, v]) => have.config?.[k] !== v);
|
|
||||||
if (drifted) {
|
|
||||||
await this.kc.updateClientMapper(this.realm, id, { ...ROLES_MAPPER, id: have.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether Keycloak still holds this consumer's client exactly as the grant says: present, the
|
|
||||||
* mesh's, enabled, confidential, with the mesh's secret and the redirects asked for. Reads only. */
|
|
||||||
async holds(g: OidcGrant): Promise<boolean> {
|
|
||||||
const want = wanted(g);
|
|
||||||
const found = await this.kc.findClient(this.realm, g.as);
|
|
||||||
if (!found || !marked(found) || found.enabled === false || found.publicClient) return false;
|
|
||||||
if (!sameSet(found.redirectUris, want.redirectUris!)) return false;
|
|
||||||
const mappers = await this.kc.listClientMappers(this.realm, found.id!);
|
|
||||||
if (!mappers.some((m) => m.name === ROLES_MAPPER.name)) return false;
|
|
||||||
return (await this.kc.clientSecretById(this.realm, found.id!)) === g.password;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Withdraw a consumer's client — only one the mesh made. Returns what happened, for the log. */
|
|
||||||
async remove(as: string): Promise<"removed" | "absent" | "not ours"> {
|
|
||||||
const found = await this.kc.findClient(this.realm, as);
|
|
||||||
if (!found) return "absent";
|
|
||||||
if (!marked(found)) return "not ours";
|
|
||||||
await this.kc.deleteClientById(this.realm, found.id!);
|
|
||||||
return "removed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@novox/module-keycloak",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "keycloak — identity and access; provides the mesh oidc-client interface. Its admin API client, provisioner, tools and events live here (novox/hq ADR 0039).",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
|
||||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@novox/mesh-sdk": "^0.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"typescript": "^5.6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// keycloak's provisioner — the adapter that makes keycloak a provider of the mesh `oidc-client`
|
|
||||||
// interface. The reconcile loop, the contributions file and reading the mesh's minted secret are the
|
|
||||||
// sdk harness's; this writes only the per-service half: how Keycloak creates, checks and removes a
|
|
||||||
// consumer's client (novox/hq ADR 0039/0040/0048). What a client is, and which ones are the mesh's,
|
|
||||||
// is in ../oidc.ts.
|
|
||||||
//
|
|
||||||
// The `oidc-client` interface: a consumer logs people in through the realm this module serves, as
|
|
||||||
// the confidential client `as` with the secret the mesh minted, and is redirected back to the
|
|
||||||
// callback it contributed under the names the mesh composed for its endpoint. What it is served —
|
|
||||||
// the issuer and the endpoint paths under it — is in the manifest's `serves`, settled with the
|
|
||||||
// assignment's settings.
|
|
||||||
//
|
|
||||||
// **The realm is read out of the issuer**, the one value an assignment sets (settings reach both the
|
|
||||||
// served facts and this module's config.json): a realm set in one place and an issuer in another
|
|
||||||
// would let the consumer be told one realm while its client is made in another.
|
|
||||||
|
|
||||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
|
||||||
import { emit } from "@novox/mesh-sdk/events";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { KeycloakClient } from "../client.js";
|
|
||||||
import { OidcClients, realmOf } from "../oidc.js";
|
|
||||||
|
|
||||||
/** The issuer this assignment serves, from the settings-merged config the mesh delivers. */
|
|
||||||
function issuer(): string {
|
|
||||||
const file = process.env.MESH_KEYCLOAK_CONFIG_FILE;
|
|
||||||
let cfg: Record<string, unknown> = {};
|
|
||||||
if (file) {
|
|
||||||
try {
|
|
||||||
cfg = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
// Absent or unreadable: fall through to the environment, and refuse below if that is empty too.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const said = typeof cfg.issuer === "string" ? cfg.issuer : process.env.MESH_KEYCLOAK_ISSUER;
|
|
||||||
if (!said) throw new Error("no issuer — the module's config.json carries none and MESH_KEYCLOAK_ISSUER is unset");
|
|
||||||
return said;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clients = new OidcClients(KeycloakClient.fromEnv(), realmOf(issuer()));
|
|
||||||
|
|
||||||
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
|
|
||||||
async function announce(type: string, body: Record<string, string>): Promise<void> {
|
|
||||||
try {
|
|
||||||
await emit(type, body);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[provisioner:oidc-client] emit ${type} failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runProvisioner("oidc-client", {
|
|
||||||
async create(p: Provision): Promise<void> {
|
|
||||||
const done = await clients.ensure(p);
|
|
||||||
if (done === "created") {
|
|
||||||
console.log(`[provisioner:oidc-client] created client ${p.as} in realm ${clients.realm}`);
|
|
||||||
await announce("client.created", { realm: clients.realm, clientId: p.as, consumer: p.consumer ?? "" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async remove(p: { as: string }): Promise<void> {
|
|
||||||
const done = await clients.remove(p.as);
|
|
||||||
if (done === "not ours") {
|
|
||||||
console.error(`[provisioner:oidc-client] ${p.as}: a client of that id exists that the mesh did not make — left alone`);
|
|
||||||
} else if (done === "removed") {
|
|
||||||
console.log(`[provisioner:oidc-client] removed client ${p.as} from realm ${clients.realm}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Asked every minute by the harness: whether Keycloak still holds this consumer's client exactly as
|
|
||||||
// the mesh gave it, so a client deleted or edited behind the mesh's back is made again (hq issue 120).
|
|
||||||
async holds(p: Provision): Promise<boolean> {
|
|
||||||
return clients.holds(p);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
// What holds keycloak to the `oidc-client` provision (oidc.ts): one confidential client per consumer,
|
|
||||||
// under the id and secret the mesh gave, redirecting only to the consumer's own callback under the
|
|
||||||
// names the mesh composed; made once and brought back on every apply; and a client the mesh did not
|
|
||||||
// make — same id or not — never adopted, changed or deleted.
|
|
||||||
//
|
|
||||||
// Keycloak is a fake: the admin routes the module touches, answering with the status codes and the
|
|
||||||
// shapes Keycloak gives. Run against the compiled module (npm test builds first), the way the runtime
|
|
||||||
// loads it.
|
|
||||||
|
|
||||||
import { test, after } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
import { KeycloakClient } from "../dist/client.js";
|
|
||||||
import { MARK, OidcClients, ROLES_MAPPER, realmOf, redirectsOf } from "../dist/oidc.js";
|
|
||||||
|
|
||||||
type Client = Record<string, any>;
|
|
||||||
|
|
||||||
/** The realm's clients, by internal id, and what the fake was asked. */
|
|
||||||
const realm = "Novox";
|
|
||||||
const clients = new Map<string, Client>();
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
function body(req: IncomingMessage): Promise<any> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let raw = "";
|
|
||||||
req.on("data", (c) => (raw += c));
|
|
||||||
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(res: ServerResponse, status: number, value?: unknown): void {
|
|
||||||
res.writeHead(status, { "Content-Type": "application/json" });
|
|
||||||
res.end(value === undefined ? "" : JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
const url = new URL(req.url!, "http://fake");
|
|
||||||
calls.push(`${req.method} ${url.pathname}`);
|
|
||||||
if (url.pathname === "/realms/master/protocol/openid-connect/token") {
|
|
||||||
return send(res, 200, { access_token: "t", expires_in: 300 });
|
|
||||||
}
|
|
||||||
const base = `/admin/realms/${realm}/clients`;
|
|
||||||
if (!url.pathname.startsWith(base)) return send(res, 404, { error: "Realm not found." });
|
|
||||||
const rest = url.pathname.slice(base.length).split("/").filter(Boolean);
|
|
||||||
if (rest.length === 0 && req.method === "GET") {
|
|
||||||
const want = url.searchParams.get("clientId");
|
|
||||||
return send(res, 200, [...clients.values()].filter((c) => !want || c.clientId === want));
|
|
||||||
}
|
|
||||||
if (rest.length === 0 && req.method === "POST") {
|
|
||||||
const rep = await body(req);
|
|
||||||
if ([...clients.values()].some((c) => c.clientId === rep.clientId)) {
|
|
||||||
return send(res, 409, { errorMessage: `Client ${rep.clientId} already exists` });
|
|
||||||
}
|
|
||||||
const id = randomUUID();
|
|
||||||
const mappers = (rep.protocolMappers ?? []).map((m: Client) => ({ ...m, id: randomUUID() }));
|
|
||||||
clients.set(id, { ...rep, id, protocolMappers: mappers });
|
|
||||||
return send(res, 201);
|
|
||||||
}
|
|
||||||
const c = clients.get(rest[0]);
|
|
||||||
if (!c) return send(res, 404, { error: "Could not find client" });
|
|
||||||
if (rest.length === 1 && req.method === "PUT") {
|
|
||||||
// Keycloak ignores protocolMappers on a client update: they have their own endpoints.
|
|
||||||
const rep = await body(req);
|
|
||||||
clients.set(c.id, { ...rep, id: c.id, protocolMappers: c.protocolMappers });
|
|
||||||
return send(res, 204);
|
|
||||||
}
|
|
||||||
if (rest.length === 1 && req.method === "DELETE") {
|
|
||||||
clients.delete(c.id);
|
|
||||||
return send(res, 204);
|
|
||||||
}
|
|
||||||
if (rest[1] === "client-secret" && req.method === "GET") {
|
|
||||||
return send(res, 200, { type: "secret", value: c.secret });
|
|
||||||
}
|
|
||||||
if (rest[1] === "protocol-mappers") {
|
|
||||||
if (req.method === "GET") return send(res, 200, c.protocolMappers ?? []);
|
|
||||||
if (req.method === "POST") {
|
|
||||||
c.protocolMappers = [...(c.protocolMappers ?? []), { ...(await body(req)), id: randomUUID() }];
|
|
||||||
return send(res, 201);
|
|
||||||
}
|
|
||||||
if (req.method === "PUT") {
|
|
||||||
const m = await body(req);
|
|
||||||
c.protocolMappers = c.protocolMappers.map((x: Client) => (x.id === rest[4] ? m : x));
|
|
||||||
return send(res, 204);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
send(res, 405);
|
|
||||||
});
|
|
||||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
||||||
after(() => server.close());
|
|
||||||
const port = (server.address() as { port: number }).port;
|
|
||||||
|
|
||||||
const oidc = new OidcClients(new KeycloakClient(`http://127.0.0.1:${port}`, "admin", "pw"), realm);
|
|
||||||
|
|
||||||
/** Grafana on ace, as the mesh hands it to the provisioner. */
|
|
||||||
function grafana(secret = "s3cret", values: Record<string, unknown> = {}) {
|
|
||||||
return {
|
|
||||||
as: "mesh_ace_grafana",
|
|
||||||
password: secret,
|
|
||||||
consumer: "ace",
|
|
||||||
values: {
|
|
||||||
label: "grafana", endpoint: "web", port: 20010, callback: "/login/generic_oauth",
|
|
||||||
name: "grafana.zurag.be", "internal-name": "grafana.ace.internal", ...values,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function only(clientId: string): Client {
|
|
||||||
const found = [...clients.values()].filter((c) => c.clientId === clientId);
|
|
||||||
assert.equal(found.length, 1, `exactly one client ${clientId}, found ${found.length}`);
|
|
||||||
return found[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
test("the realm is read out of the issuer, and an issuer that names none is refused", () => {
|
|
||||||
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox"), "Novox");
|
|
||||||
assert.equal(realmOf("https://keycloak.novox.be/realms/Novox/"), "Novox");
|
|
||||||
assert.equal(realmOf("http://127.0.0.1:18500/realms/master"), "master");
|
|
||||||
assert.throws(() => realmOf("https://keycloak.novox.be"), /realms/);
|
|
||||||
assert.throws(() => realmOf("keycloak"), /not a URL/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the redirect is the consumer's callback under every name the mesh composed for it", () => {
|
|
||||||
assert.deepEqual(redirectsOf(grafana().values), {
|
|
||||||
root: "https://grafana.zurag.be",
|
|
||||||
redirects: ["https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"],
|
|
||||||
});
|
|
||||||
// A route reaching only the private network has only the internal name, and that is enough.
|
|
||||||
assert.deepEqual(redirectsOf({ callback: "/cb", "internal-name": "x.ace.internal" }).redirects,
|
|
||||||
["https://x.ace.internal/cb"]);
|
|
||||||
assert.throws(() => redirectsOf({ name: "grafana.zurag.be" }), /callback/);
|
|
||||||
assert.throws(() => redirectsOf({ name: "grafana.zurag.be", callback: "login" }), /callback/);
|
|
||||||
assert.throws(() => redirectsOf({ callback: "/cb" }), /label/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a consumer is given one confidential client, under its id and the mesh's secret", async () => {
|
|
||||||
clients.clear();
|
|
||||||
assert.equal(await oidc.ensure(grafana()), "created");
|
|
||||||
const c = only("mesh_ace_grafana");
|
|
||||||
assert.equal(c.publicClient, false);
|
|
||||||
assert.equal(c.clientAuthenticatorType, "client-secret");
|
|
||||||
assert.equal(c.secret, "s3cret");
|
|
||||||
assert.equal(c.enabled, true);
|
|
||||||
assert.equal(c.standardFlowEnabled, true);
|
|
||||||
assert.equal(c.directAccessGrantsEnabled, false);
|
|
||||||
assert.equal(c.implicitFlowEnabled, false);
|
|
||||||
assert.deepEqual(c.redirectUris, [
|
|
||||||
"https://grafana.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
|
|
||||||
assert.equal(c.attributes[MARK], "true");
|
|
||||||
assert.deepEqual(c.protocolMappers.map((m: Client) => m.name), [ROLES_MAPPER.name]);
|
|
||||||
assert.equal(await oidc.holds(grafana()), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("applying the same grant again makes no second client", async () => {
|
|
||||||
clients.clear();
|
|
||||||
await oidc.ensure(grafana());
|
|
||||||
assert.equal(await oidc.ensure(grafana()), "updated");
|
|
||||||
assert.equal(await oidc.ensure(grafana()), "updated");
|
|
||||||
only("mesh_ace_grafana");
|
|
||||||
assert.equal(only("mesh_ace_grafana").protocolMappers.length, 1, "the roles mapper is not added twice");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a new secret or a moved name is applied in place, and what the mesh does not own survives", async () => {
|
|
||||||
clients.clear();
|
|
||||||
await oidc.ensure(grafana());
|
|
||||||
const id = only("mesh_ace_grafana").id;
|
|
||||||
// Something the mesh does not own, set on the client after it was made.
|
|
||||||
clients.get(id)!.consentRequired = true;
|
|
||||||
clients.get(id)!.attributes["post.logout.redirect.uris"] = "+";
|
|
||||||
|
|
||||||
assert.equal(await oidc.holds(grafana("rotated")), false, "a rotated secret is not held until applied");
|
|
||||||
await oidc.ensure(grafana("rotated", { name: "dash.zurag.be" }));
|
|
||||||
const c = only("mesh_ace_grafana");
|
|
||||||
assert.equal(c.id, id, "updated, not replaced");
|
|
||||||
assert.equal(c.secret, "rotated");
|
|
||||||
assert.deepEqual(c.redirectUris, [
|
|
||||||
"https://dash.zurag.be/login/generic_oauth", "https://grafana.ace.internal/login/generic_oauth"]);
|
|
||||||
assert.equal(c.rootUrl, "https://dash.zurag.be");
|
|
||||||
assert.equal(c.consentRequired, true);
|
|
||||||
assert.equal(c.attributes["post.logout.redirect.uris"], "+");
|
|
||||||
assert.equal(c.attributes[MARK], "true");
|
|
||||||
assert.equal(await oidc.holds(grafana("rotated", { name: "dash.zurag.be" })), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a client lost or edited behind the mesh's back is not held, and is made whole again", async () => {
|
|
||||||
clients.clear();
|
|
||||||
await oidc.ensure(grafana());
|
|
||||||
const c = only("mesh_ace_grafana");
|
|
||||||
c.redirectUris = ["*"];
|
|
||||||
assert.equal(await oidc.holds(grafana()), false, "a widened redirect is not what the mesh gave");
|
|
||||||
await oidc.ensure(grafana());
|
|
||||||
assert.equal(await oidc.holds(grafana()), true);
|
|
||||||
|
|
||||||
only("mesh_ace_grafana").protocolMappers = [];
|
|
||||||
assert.equal(await oidc.holds(grafana()), false, "a client without its roles mapper is not held");
|
|
||||||
await oidc.ensure(grafana());
|
|
||||||
assert.equal(await oidc.holds(grafana()), true);
|
|
||||||
|
|
||||||
clients.clear();
|
|
||||||
assert.equal(await oidc.holds(grafana()), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a client of the same id the mesh did not make is refused, and left exactly as it was", async () => {
|
|
||||||
clients.clear();
|
|
||||||
clients.set("theirs", { id: "theirs", clientId: "mesh_ace_grafana", secret: "their-secret", redirectUris: ["*"] });
|
|
||||||
const before = JSON.stringify(clients.get("theirs"));
|
|
||||||
const writes = calls.length;
|
|
||||||
await assert.rejects(oidc.ensure(grafana()), /did not make/);
|
|
||||||
assert.equal(JSON.stringify(clients.get("theirs")), before);
|
|
||||||
assert.ok(calls.slice(writes).every((c) => c.startsWith("GET") || c.startsWith("POST /realms/master")),
|
|
||||||
`only reads were made: ${calls.slice(writes).join(", ")}`);
|
|
||||||
assert.equal(await oidc.holds(grafana()), false);
|
|
||||||
assert.equal(await oidc.remove("mesh_ace_grafana"), "not ours");
|
|
||||||
assert.ok(clients.has("theirs"), "a client the mesh did not make is never deleted");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the predecessor's hand-made client is never touched: the mesh's has its own id", async () => {
|
|
||||||
clients.clear();
|
|
||||||
clients.set("hal", { id: "hal", clientId: "grafana", secret: "old", redirectUris: ["https://grafana.zurag.be/*"] });
|
|
||||||
await oidc.ensure(grafana());
|
|
||||||
assert.equal(clients.get("hal")!.secret, "old");
|
|
||||||
only("mesh_ace_grafana");
|
|
||||||
assert.equal(await oidc.remove("grafana"), "not ours");
|
|
||||||
assert.ok(clients.has("hal"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a withdrawn consumer's client is removed, and an absent one is not an error", async () => {
|
|
||||||
clients.clear();
|
|
||||||
await oidc.ensure(grafana());
|
|
||||||
assert.equal(await oidc.remove("mesh_ace_grafana"), "removed");
|
|
||||||
assert.equal([...clients.values()].length, 0);
|
|
||||||
assert.equal(await oidc.remove("mesh_ace_grafana"), "absent");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a contribution with no callback makes no client at all", async () => {
|
|
||||||
clients.clear();
|
|
||||||
await assert.rejects(oidc.ensure({ ...grafana(), values: { name: "grafana.zurag.be" } }), /callback/);
|
|
||||||
assert.equal(clients.size, 0);
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user