Author SHA1 Message Date
mesh-admin 495bb88111 Merge pull request 'supabase: keep logflare's API key out of its log (hq issue 268)' (#85) from fix/supabase-logflare-key-out-of-logs into main 2026-10-06 00:34:13 +00:00
jochen b87dc29706 supabase: keep logflare's API key out of its log (hq issue 268)
vector handed logflare its API key as ?api_key= in every sink URL, and
logflare 1.4.0 prints a failed request's whole URL in its Plug.Cowboy
error report. Its ingest fails on every request here, so the key was in
the analytics log about every ten seconds. The report is an error, so no
log level hides it.

Every sink now sends the key in the x-api-key header, which logflare
reads first. A start script refuses to start logflare while the vector
config it is given still puts the key in a URL.

The key is marked "applied", not "at-start": logflare writes it into its
default user once and never updates it, so the mesh must not rotate it
by restarting.
2026-10-06 02:33:32 +02:00
mesh-admin e5cb7b071c Merge pull request 'State each provision's identity bound (hq issue 263, ADR 0225)' (#83) from fix/263-identity-bounds-per-provision into main 2026-10-06 00:29:56 +00:00
mesh-admin 7fad19a764 Merge pull request 'letta: keep its passwords out of its log; docker: find and hide secrets a container printed (hq issue 268)' (#82) from fix/letta-secrets-out-of-logs into main 2026-10-06 00:25:32 +00:00
jochen 4769dadf63 State each provision's identity bound (hq issue 263)
Consumers were held to an S3 access key's 20 characters whatever they
required. Each provider now says what its backend keeps: minio 20,
PostgreSQL and MongoDB and DNS 63, Gitea 40, a mailbox 64, SQL Server 128,
Keycloak 255, unbounded where the store has no limit, and none for the
resolver and route provisions, which keep no name of their consumers.
Needs the controller that reads the field (mesh-controller, ADR 0225).
2026-10-06 02:16:27 +02:00
jochen bbb67e41a0 docker: find and hide secrets a container printed into its log (hq issue 268)
letta printed two passwords into its log for weeks and nothing noticed,
and docker_logs handed them to whoever asked. docker_secrets_in_logs
compares each container's recent lines with the secret-named values of
its environment, the passwords in its URIs, and any URI carrying a
password, and names what it found by container, module and variable -
never the value. docker_logs redacts the same values before answering.
2026-10-06 02:13:42 +02:00
jochen f9f27d4878 letta: keep its database and server passwords out of its log (hq issue 268)
letta 0.6.8 prints LETTA_PG_URI whole (startup.sh, alembic, server.py)
and its server password when it starts in secure mode, so both were in
the container's log on every one of its restarts. Newer letta still
prints both, and neither is a log level.

The URI now names no password: libpq reads it from a mounted pgpass
file (PGPASSFILE). The one print of the server password is rewritten by
a start script before the server starts, and the script refuses to start
letta if that print, or a password in the URI, is still there - a letta
that does not start says why; one that leaks says nothing.

Both own secrets say they are read at start, so `rotate` can replace the
server password the mesh made.
2026-10-06 02:13:42 +02:00
mesh-admin cc2f19123a Merge pull request 'nats: run 2.11.17, which hands a consumer with several filters every message (hq issue 266)' (#81) from fix/nats-multi-filter-consumers-skip into main 2026-10-05 23:40:11 +00:00
jochen 0275c2eeac nats: run 2.11.17, which hands a consumer with several filters every message (hq issue 266)
On 2.10.29 such a consumer was moved past a message now and then without
handing it over; the controller's events consumer has seven filters, and a
merge on the stream never reached it. The test reproduces the skip on 2.10.29
and keeps the image's release equal to the server it tests.
2026-10-06 01:29:20 +02:00
mesh-admin 78328d4ab2 Merge pull request 'keycloak: port to Go and repair a refused admin; providers announce a failing consumer' (#80) from feat/identity-provider-admin-safety-nets into main 2026-10-05 22:39:02 +00:00
jochen 48d4188927 keycloak repair: remove the temporary admin even when the bootstrap failed after making it
The bootstrap once created the temporary admin and then failed on a held port; marked only after
it succeeded, the cleanup did not know the admin existed and left it.
2026-10-06 00:17:35 +02:00
mesh-admin 5c2157b81c Merge pull request 'Rename hosts to hostname, which also writes /etc/hostname (hq ADR 0223 part 3)' (#78) from hostname-module into main 2026-10-05 22:15:29 +00:00
jochen 77fb1ecfb2 keycloak: port to Go and repair an admin that refuses the mesh's secret
Twice the identity provider's admin kept an older password than the one the
mesh minted (an adopted, then a moved database), and the provisioner failed
every consumer until it was repaired by hand (hq issue 179). The module now
checks the admin's login and repairs a refusal itself through the server's
bootstrap command, verifies, brakes a failed repair and announces it, and
stops asking the server while refused. Ported to Go to change it.
2026-10-06 00:13:42 +02:00
jochen 6f1e2f5a0d postgres: announce a consumer failed for minutes, and its recovery
A provider failed every consumer for a day and said so only in its journal
(hq issue 179). The provisioner loop now emits provisioner.failing after five
minutes without a success — create, check or secret — and repeats it every
fifteen; provisioner.recovered on the next success, on withdrawal, and on the
first success after a restart, so the controller can name it in status
(hq ADR 0224).
2026-10-06 00:13:42 +02:00
mesh-admin ed6384feb0 Merge pull request 'Remove resolv-conf (hq ADR 0223 part 2, step 2 of 2)' (#77) from retire-resolv-conf into main 2026-10-05 22:08:02 +00:00
mesh-admin 0e072b05c0 Merge pull request 'Uplink modules get short slugs (nm, networkd)' (#79) from fix/uplink-modules-have-short-slugs into main 2026-10-05 22:03:45 +00:00
jochen 60604fcd11 Give the uplink modules short slugs, so their identity fits a backend's limit
Requiring wildcard-resolution gave each a consumer identity; mesh_<machine>_networkmanager is
over the 20 characters a backend keeps, and the anchor's declaration, which carries every
consumer's grant, could not be composed.
2026-10-06 00:03:40 +02:00
mesh-admin ff61578e3e Merge pull request 'Give /etc/resolv.conf to the uplink's holder (hq ADR 0223 part 2, step 1 of 2)' (#76) from resolv-conf-to-uplink into main 2026-10-05 21:57:03 +00:00
jochen 138d9afd7b Rename hosts to hostname, which also writes /etc/hostname (hq ADR 0223)
Two files say one fact, the machine's name, and nothing owned /etc/hostname.
The name written is the operator's hostname setting, with no default: three
of four machines call themselves something other than their mesh name, and
renaming one is the operator's call. It takes effect at the next boot.
2026-10-05 23:43:14 +02:00
jochen dd124966ad Remove resolv-conf now that the uplink's holder writes resolv.conf (hq ADR 0223)
Merge only once resolv-conf is unassigned on every machine and forgotten.
2026-10-05 23:40:38 +02:00
jochen 73d6a51325 Give /etc/resolv.conf to the uplink's holder (hq ADR 0223)
The program that manages a machine's network is the one that would rewrite
the resolver file, so its module now writes it: networkmanager,
systemd-networkd and dhcpcd render the same template from the resolver's
holders. resolv-conf declares nothing for one release, so every machine
hands the file over in one apply; it is removed once unassigned everywhere.
2026-10-05 23:39:13 +02:00
mesh-admin 7b0b80ee05 Merge pull request 'postgres: port to Go, and install the extensions a consumer asks for (letta: vector)' (#75) from feat/postgres-go-extensions into main 2026-10-05 21:31:14 +00:00
jochen d8b4d20886 Port postgres to Go and install the extensions a consumer asks for
letta crash-loops on 'type "vector" does not exist': pgvector is not a
trusted extension, so only the provider's superuser can create it, and
the provisioner never did. A contribution may now name extensions; the
provider creates each (IF NOT EXISTS, available ones only) in the
consumer's database on every pass. Go per the standing rule for a
TypeScript module that changes. letta asks for vector.
2026-10-05 23:29:55 +02:00
mesh-admin 0515db043a Merge pull request 'supabase: studio listens on every address, so its health check reaches it' (#74) from fix/studio-listens-where-its-health-check-asks into main 2026-10-05 21:20:40 +00:00
jochen 7f491fd6bc Studio listens on every address, so its health check reaches it
Next.js binds the address HOSTNAME names; docker sets HOSTNAME to the container's id, so studio
answered only on its network address while its image's health check asks localhost, and it read
unhealthy while working. HOSTNAME=:: as the upstream compose file sets it.
2026-10-05 23:20:35 +02:00
mesh-admin b4b86c1452 Merge pull request 'resolv-conf lists every mesh resolver and no public one (hq ADR 0223)' (#73) from feat/the-mesh-has-two-resolvers into main 2026-10-05 20:50:53 +00:00
jochen 737f42deb4 List every mesh resolver and no public one in resolv.conf
musl asks every nameserver at once and takes the first reply, so a public
resolver's NXDOMAIN for a mesh name beat the mesh's answer in every Alpine
container (hq ADR 0223). resolv-conf now renders /etc/resolv.conf from the
holders of mesh-dns-resolver, this machine first when it holds one; dnsmasq's
comments say the seat may have several holders.
2026-10-05 22:42:54 +02:00
mesh-admin 5c89eaf9a6 Merge pull request 'docker: trust the mesh's registry from the runtime's own module (hq ADR 0222, issue 190 — 2 of 3)' (#72) from fix/190-docker-insecure-registries into main 2026-10-05 20:39:05 +00:00
jochen 964a4fdfbc docker: trust the mesh's registry from the runtime's own module (hq issue 190)
The controller's private network writes insecure-registries into daemon.json, a file this
module owns. The runtime's module states it instead, through ${seat:mesh-artifact-store:reach}
(hq ADR 0222), so the controller can stop generating its registry-trust resources.
2026-10-05 22:17:16 +02:00
mesh-admin 20603b63e6 Merge pull request 'A machine's mesh name has no IPv6 address rather than no name (hq issue 262)' (#71) from fix/a-mesh-name-has-no-ipv6-address-rather-than-no-name into main 2026-10-05 20:07:33 +00:00
jochen 4bf5eef2fd A machine's mesh name has no IPv6 address rather than no name (hq issue 262)
The resolver answered a machine's name only by wildcard, which says there is no such name when
asked for an IPv6 address; musl reads that as final, so Alpine containers could not find a
machine at all. A host record per machine answers that the name exists and has none, as the
hosts file the per-machine resolvers read used to.
2026-10-05 22:07:28 +02:00
mesh-admin 4cac44face Merge pull request 'Retire resolved-split-dns (hq ADR 0220)' (#70) from feat/retire-resolved-split-dns into main 2026-10-05 20:03:01 +00:00
jochen 875d2a0554 Retire resolved-split-dns: ADR 0196 chose no stub, and nothing assigns it
The resolv-conf comment pointed operators at it and at NetworkManager as
alternative claimants; it now says the uplink's holder is required beside it
(hq ADR 0220).
2026-10-05 21:57:04 +02:00
mesh-admin 76707bbe0e Merge pull request 'The mesh's one resolver, what every node asks, and a node's hosts file (hq ADR 0194, 0196, 0199)' (#69) from feat/the-mesh-has-one-resolver into main 2026-10-05 18:44:15 +00:00
jochen 076455ec78 hosts in Go, with the machine's own name in its block (ADR 0199)
The tools were TypeScript; the mesh's modules are Go. The write to /etc/hosts is now staged and
moved into place rather than written over the live file.
2026-10-05 20:42:58 +02:00
jochen f20c4b749b The runtime's file is written by the runtime's module, not by what decides how a machine resolves (issue 190)
resolv-conf would have taken over dnsmasq's write into daemon.json — the same defect issue 190
names. docker, on every machine, now writes live-restore and reloads its own service. Log rotation
is left as each machine has it.
2026-10-05 20:42:58 +02:00
jschoubben 69d6b9066f The mesh's one resolver, what every node asks, and a node's hosts file (hq ADR 0194, 0196, 0199)
- dnsmasq holds mesh-dns-resolver: provides wildcard-resolution mesh-wide, forwards every declared
  zone (zones fact), listens on the private address and loopback only, reads no hosts file and no
  operator's files, and no longer writes the container runtime's dns.
- resolv-conf names the mesh's resolver by address, then 1.1.1.1, timeout 1, one attempt; it now
  holds the runtime's live-restore, which dnsmasq held and every node needs.
- resolved-split-dns routes the suffix to the mesh's resolver by address, not 127.0.0.1.
- hosts: new module holding node-hosts-file — the machine's own lines in its block of /etc/hosts,
  the operator's lines kept, changed by entries/add/remove through sudo -n.
2026-10-05 20:37:59 +02:00
mesh-admin 2cc27e2a74 Merge pull request 'build-agent serves its seat's verbs: current, kill, pause, resume (hq ADR 0219)' (#68) from feat/build-agent-serves-its-seat into main 2026-10-05 18:31:28 +00:00
jochen 0581256905 build-agent serves its seat's verbs: current, kill, pause, resume (hq ADR 0219)
The node-build-agent seat now promises these verbs, and a holder that does not
name them cannot hold it. The binary serving them is mesh-controller's
cmd/mesh-builder; merge only once a controller carrying the seat's new verbs runs,
since an older one refuses a claim naming verbs its row lacks.
2026-10-05 19:23:30 +02:00
mesh-admin 082f8de32f Merge pull request 'distribution: collect for real now that every kept archive is held (hq issue 253)' (#67) from fix/the-collector-collects-for-real into main 2026-10-05 16:44:30 +00:00
jochen 428f5b8864 distribution: collect for real now that every kept archive is held
The dry run stood until the controller held each kept archive by a manifest
(mesh-controller #53) and an apply stopped reopening the collection window
(mesh-host #23, hq issue 224). The controller's collection command now
reports 134 of 134 kept archives held. hq issue 253.
2026-10-05 18:34:37 +02:00
mesh-admin 579d21a209 Merge pull request 'gitea: a merge poll looks only at repositories that moved, one pass at a time (hq issue 250)' (#66) from fix/the-merge-poll-looks-only-at-what-moved into main 2026-10-05 16:12:13 +00:00
jochen 52b81d524c gitea: a merge poll looks only at repositories that moved, one pass at a time
With the poll the only announcer of a merge, its cost showed: every 30 s it
asked every repository for its pull requests, a pass outlasted the tick, and
passes piled up beside each other — a merge was announced four and a half
minutes late, and two passes at once could each announce it. A pass now asks
only repositories updated since a minute before the last look, and the next
pass starts when this one ends. hq issue 250.
2026-10-05 18:12:11 +02:00
mesh-admin b651137d95 Merge pull request 'systemd: port to Go, and read a system unit's journal as root (hq issue 255)' (#65) from feat/systemd-in-go into main 2026-10-05 16:10:03 +00:00
jochen c42f1ce45b systemd: port to Go, and read a system unit's journal as root
The journal verb ran journalctl as the operator account, which outside the
journal's group sees only its own entries: every system service read
'-- No entries --', and a person reached for a shell. The read now
escalates with sudo -n like the acts; ported to Go with every test. hq
issue 255.
2026-10-05 18:09:42 +02:00
mesh-admin 6a42bafb1c Merge pull request 'records: port to Go, and keep the checkout in a directory it owns (hq issue 251)' (#64) from feat/records-in-go into main 2026-10-05 15:55:58 +00:00
mesh-admin 053eba6950 Merge pull request 'gitea: announce a merge once, and read every page of its changed files (hq issues 250, 252)' (#63) from fix/a-merge-is-announced-once into main 2026-10-05 15:55:54 +00:00
jochen 45507c3d5c gitea: test that a pull request's files are read past the forge's page cap 2026-10-05 17:47:36 +02:00
jochen 63e53f4622 gitea: read every page of a pull request's changed files
The forge caps a page at fifty when asked for a hundred, so a large merge's
file list was cut short and reported whole: a module whose own files moved
was not rebuilt. Read until a page comes back short. hq issue 252.
2026-10-05 17:46:54 +02:00
mesh-admin ebacf79c91 Merge pull request 'URGENT distribution: the nightly collector dry-runs until every kept bundle is held (first run tonight 03:30)' (#62) from fix/the-collector-dry-runs-until-bundles-are-held into main 2026-10-05 15:45:00 +00:00
jochen a66a23582f distribution: collect as a dry run until every kept bundle is held by a manifest
The stock collector keeps only what a manifest names, and the mesh's bundles
and archives are bare blobs no manifest names: its first run, tonight at
03:30, would delete every one, the current ones included. It now reports
what it would delete and deletes nothing, until the controller holds each
kept archive with a manifest. hq issue 253.
2026-10-05 17:43:11 +02:00
jochen db9a5bff0c records: port to Go, and keep the checkout in a directory it owns
Every sync had failed since a container that ran as root left the checkout
root's: git refused it as dubious ownership, and records answered from a
stale copy. The Go bundle clones into repository/ under its directory, clears
the old layout where it can and names what it cannot. Drops the container-
runtime capability the move into the runtime left behind. hq issue 251.
2026-10-05 17:42:08 +02:00
jochen 85352c8d6f gitea: announce a merge once, from the poll
The merge tool announced pull.merged and so did the poll added for issue
131, so every merge made through the tool reached the controller twice.
The poll sees every path and carries the clone url; it is now the only
emitter. hq issue 250.
2026-10-05 17:39:01 +02:00
mesh-admin f74e1f0309 Merge pull request 'claude-code: say what a registration wrote here, whichever took it first' (#58) from fix/register-answers-what-changed into main 2026-10-05 13:17:39 +00:00
mesh-admin 97b1b2b36b Merge pull request 'Tray applets as modules: openrazer, polychromatic, forticlient, nm-applet' (#60) from feat/tray-applets into main 2026-10-05 13:10:50 +00:00
jochen d0d5546088 Give the remaining tray applets their modules, each with one start
openrazer (the official driver, daemon and library), polychromatic (the
AUR tray, kept as found; its i3 line moves out of the i3 module into its
own node-display-session contribution), forticlient (the AUR VPN client:
its service declared, its configuration never read) and nm-applet (the
desktop half of NetworkManager, apart from the server-side module).

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

desktop.go learns to tell a program from another sharing its 15-character
command name, so polychromatic's tools never count or end themselves, and
a copies test holds the six carriers to one text.
2026-10-05 15:10:30 +02:00
mesh-admin 2a99b3c3e9 Merge pull request 'Remove jetbrains-toolbox: the operator retired JetBrains' (#61) from chore/remove-jetbrains into main 2026-10-05 13:09:53 +00:00
jochen ade4901d7f Remove jetbrains-toolbox: the operator retired JetBrains
The module goes, its link handler leaves xdg's default applications, and the shared desktop helper's
header names the three bundles left.
2026-10-05 15:09:14 +02:00
mesh-admin f2b5168647 Merge pull request 'Add slack and jetbrains-toolbox: one start each, the apps kept as found' (#59) from feat/slack-and-jetbrains-toolbox into main 2026-10-05 13:07:29 +00:00
jochen 75f25fca21 Add slack and jetbrains-toolbox: one start each, the apps kept as found
Slack comes from the AUR and Toolbox from JetBrains' self-updating tarball,
so neither is declared: the host installs official packages only, and a
pinned archive would fight Toolbox's own updates. Slack's start and the
operator's i3 window rules become one node-display-session contribution,
because Slack's own launch-on-login is a symlink in ~/.config/autostart.
Toolbox keeps its own autostart entry as its one start. The shared
desktop.go header now names all four bundles.
2026-10-05 15:05:55 +02:00
jochen 1fc10b0323 claude-code: say what a registration wrote here, whichever took it first
On g14 the node's own watch took a home registration before the tool did,
and the answer said 'already so' though the file had just been placed.
Compare the view before and after, and render whenever the item applies
here, as the MCP server registration already does.
2026-10-05 14:33:53 +02:00
mesh-admin 4f3e52b47a Merge pull request 'The store collects nightly again (hq ADR 0189)' (#57) from feat/the-store-collects-nightly-again into main 2026-10-05 12:33:35 +00:00
191 changed files with 18460 additions and 3309 deletions
+4
View File
@@ -1,2 +1,6 @@
node_modules/
dist/
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
modules/slack/cmd/slack-tools/slack-tools
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
+36 -9
View File
@@ -1,7 +1,8 @@
package main
// desktop.go is the same file in the nextcloud-client and blueman bundles: a tray application of the
// operator's graphical session, seen from the node's tool runtime (novox/hq ADR 0208).
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
@@ -251,6 +252,22 @@ func (m *Machine) procs(comm string) []Proc {
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
@@ -411,12 +428,19 @@ func (m *Machine) detach(s Session, unit string, argv ...string) error {
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var pids []int
var ps []Proc
for _, c := range comms {
for _, p := range m.procs(c) {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
@@ -451,10 +475,13 @@ func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []in
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc {
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procs(comm); len(p) > 0 || waited >= d {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
@@ -1,7 +1,7 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client and blueman bundles.
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
@@ -113,6 +113,23 @@ func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+2 -1
View File
@@ -8,7 +8,8 @@
"claims": [
{
"name": "node-build-agent",
"scope": "node"
"scope": "node",
"serves": ["current", "kill", "pause", "resume"]
}
],
"requires": [
+12 -5
View File
@@ -800,8 +800,10 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
}
}
}
// Compared before and after rather than read from Take: this node's own watch may take the same change
// first, and then Take here finds nothing new although this call made it.
before, _ := json.Marshal(v.Items())
var keys []string
changed := false
for _, n := range nodes {
key := it.Key(n)
keys = append(keys, key)
@@ -819,7 +821,12 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
op = "delete"
}
item := it
changed = v.Take(key, op, &item) || changed
v.Take(key, op, &item)
}
after, _ := json.Marshal(v.Items())
here := string(before) != string(after)
for _, k := range keys {
here = here || v.Applies(k)
}
verb := map[bool]string{false: "registered", true: "unregistered"}[unregister]
answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys}
@@ -835,15 +842,15 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
answer["offered as"] = "/" + Plugin + ":" + it.Name
}
}
if changed {
if here {
// Rendered whenever it applies here, so the answer says what this node wrote, whichever of the
// watch and this call took the change.
rendered, err := RenderNow(p, write)
answer["rendered here"] = rendered
if err != nil {
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
}
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
} else if v.Applies(keys[0]) || len(keys) > 1 {
answer["here"] = "already so"
} else {
answer["here"] = "not this node: each node it is for takes it from the bus"
}
@@ -455,3 +455,16 @@ func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
t.Fatalf("the other files were not written: %v", w)
}
}
// The answer says what this node wrote even when its own watch took the change first.
func TestTheAnswerSaysWhatWasWrittenWhenTheWatchWasFirst(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
it := Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}
first := it
view.Take(it.Key("laptop"), "put", &first) // the watch, first
answer, err := Register(p, it, nil, false, state, view, writer(w))
if err != nil || answer["rendered here"] == nil || answer["here"] != nil {
t.Fatalf("%v %v", answer, err)
}
}
+5 -1
View File
@@ -5,7 +5,11 @@
"provides": [
{
"name": "public-dns",
"scope": "mesh"
"scope": "mesh",
"identity": {
"max": 63,
"in": "a DNS label"
}
}
],
"serves": {
+19
View File
@@ -78,6 +78,25 @@ today), remove the unused vendor drivers, once:
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
the Canon queue is used.
## The print applet: not this module's
The desktop has `system-config-printer` (official, explicit, installed 2026-10-04). Its package ships
`/etc/xdg/autostart/print-applet.desktop`, so from the desktop's next login `dex` starts
`system-config-printer-applet`, a tray icon for print jobs and printer problems. The laptop does not
have the package.
This module does not take it:
- `cups` needs no display and declares nothing graphical. The applet requires `x11-display`, and a
GTK package in this module would put it on any machine that prints, the laptop included, where the
operator never installed it.
- The queues and the default printer are already this module's tools (`cups_printers`, `cups_queue`,
`cups_default`), which is most of what the applet's window offers.
So the applet is left as found on the desktop, started by its package's entry. If it is wanted on both
workstations, it becomes a `system-config-printer` desktop module of its own, beside `blueman` and
`nm-applet`, requiring `x11-display`. If not, removing the package on the desktop ends it.
## Leaves as found
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
+14 -8
View File
@@ -2,17 +2,24 @@
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
private network's interface alone. It never declares an interface, an address, a route, a
wireless network or its credentials — the link dhcpcd keeps is the only channel the mesh reaches
the machine over.
private network's interface alone — and it writes that resolver file itself (ADR 0223). It never
declares an interface, an address, a route, a wireless network or its credentials — the link
dhcpcd keeps is the only channel the mesh reaches the machine over.
## 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
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
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
never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so
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
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
still rewrites `/etc/resolv.conf`, and `resolv-conf` puts it back at the next push. Assign this
module before `resolv-conf` on such a machine, and restart dhcpcd once, by hand, when losing the
link for a moment is acceptable.
still rewrites `/etc/resolv.conf`, and this module puts it back at the next push. Restart dhcpcd
once, by hand, when losing the link for a moment is acceptable.
## 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
installs the package and writes the two lines, and starts nothing.
+10 -1
View File
@@ -1,6 +1,9 @@
{
"module": "dhcpcd",
"version": "1",
"requires": [
"wildcard-resolution"
],
"capabilities": [
"package-manager",
"service-manager",
@@ -12,6 +15,12 @@
"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": [
{
"id": "package",
@@ -25,7 +34,7 @@
"mode": "0644",
"into": "block",
"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"
}
]
}
File diff suppressed because one or more lines are too long
+78 -52
View File
@@ -14,6 +14,8 @@ tools below are the module's own.
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
| `daemon` | `live-restore` and the mesh's registry in `insecure-registries` written into `/etc/docker/daemon.json`, beside other keys | each key given back as found when the module goes; only the member this module added leaves the list |
| `runtime` | `docker.service` running, enabled at boot; reloaded, never restarted, when `daemon` changes | given back as found (ADR 0118) |
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
takes no volume, no container and no image a container uses, so it never touches a container the mesh
@@ -24,62 +26,60 @@ while the machine was off happens at the next boot.
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
the module that installs the daemon cannot require it.
## The runtime's own file and service (issue 190, hq ADR 0196, ADR 0222)
This module writes two keys into `/etc/docker/daemon.json` (`into: json`, ADR 0102): `live-restore`
and `insecure-registries`. `dnsmasq` used to write `live-restore`, beside `dns`; it no longer writes
either. Under ADR 0196 a container copies its machine's resolvers, so no module writes `dns`. The
controller's private network wrote `insecure-registries`; under ADR 0222 the controller writes
nothing into this file, and this module states the registry itself (the order below).
- `daemon`: `{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"]}`,
merged into the file beside the keys others write.
- `${seat:mesh-artifact-store:reach}` is where this machine reaches the mesh's artifact store
(host:port), filled in by the controller: no binding, no credential, the same address the mesh
composes into every image it built. Trusting it in the clear is ADR 0082's decision: every path to
it is inside the private network's encryption. While no machine on the network holds the store the
answer is empty, and the controller drops the empty member, so the list gets nothing.
- `insecure-registries` is a list, and the host adds to it rather than replacing it: a machine's own
trusted registries stay, and undeclaring takes out only the member this module added.
- `runtime`: `docker.service` running, enabled at boot, and **reloaded, never restarted**, when
`daemon` changes. A restart stops every container. A reload turns `live-restore` on and takes the
trusted registries, and with `live-restore` on a later restart keeps every container running.
In the apply that moves the key, the host first gives back `dnsmasq`'s resources, then applies this
module's: `live-restore` is set again in the same apply, and the daemon is reloaded once.
`dns` is removed from the file then, but the daemon reads it only at its next start, and a running
container keeps the resolvers it was created with. Each container pinned to a machine's own resolver
is restarted before that machine's `dnsmasq` goes (ADR 0194, step 4).
**The order it lands in.** The controller that fills `${seat:…:reach}` is deployed first: one that
does not know the placeholder would send it through unfilled. Then this module. Then the controller
stops generating the private network's `registry-trust` and `registry-trust-reload` and refuses a
generated resource that collides with a module's (issue 190, steps 2 and 5). In the apply that moves
the member, the host removes the private network's record first (the member leaves the list) and
then applies this module's (it is added back, recorded as this module's); the daemon is reloaded
once, for `daemon`. The address is the same one, so the runtime's trust does not change.
Still elsewhere:
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand; they are left
as they are until a size is chosen for every machine.
## What it does not declare yet, and why
Three things this module should own are already declared by other modules on every machine. The
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
make the module unassignable everywhere. The refusals were checked against the controller's own
One thing this module should own is still declared elsewhere. The controller refuses two modules
on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`,
mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module
unassignable everywhere. The refusals were checked against the controller's own
check:
```
zsh and docker both declare the name "${machine:account}"
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
dnsmasq and docker both declare the unit "docker.service"
```
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190)
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
service:
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
`insecure-registries`. The collision check does not see generated resources.
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
**The change proposed, in one merge:**
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
2. `docker` adds the two resources below:
```json
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
```
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
start keeps every container running.
**Why one merge, and only after this module is on every machine:**
- In one apply, the host first gives back the resources that are no longer declared, then applies
the new ones (mesh-host `apply.go`).
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
again in the same apply. The daemon is reloaded once, after both steps.
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
every container.
**Later:** the controller hands the registry to this module as a value, and the overlay stops
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
per resource.
### 2. The operator account's membership of the `docker` group
### The operator account's membership of the `docker` group
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
`usermod --append` and never takes a group away.
@@ -111,9 +111,8 @@ On the machine the mesh was first installed on, the foundation bundle declared `
never sees them (mesh-host `store.go`).
- So `docker.package` here is a **second record of the same package**. The apply says "already
installed", and neither record ever uninstalls it.
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
1 would add a second record of that unit. Its found state is *running*, because genesis started
it, so undeclaring this module would leave the daemon running.
- `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*,
because genesis started it, so undeclaring this module leaves the daemon running.
## Tools
@@ -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_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_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 |
@@ -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_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
```
@@ -159,6 +184,7 @@ The tests run against a fake runner and cover:
- 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`;
- the log merge;
- a printed secret found by name and never answered by value, in the scan and in `docker_logs`;
- size parsing;
- what the daemon has not yet taken;
- event filtering;
+39 -10
View File
@@ -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, 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...)
program := "docker"
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.
all := append(lines(r.Stdout), lines(r.Stderr)...)
sort.SliceStable(all, func(a, b int) bool { return all[a] < all[b] })
if len(all) > tail {
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
return all, nil
}
// Stat is one container's use of the machine now.
@@ -8,6 +8,7 @@ import (
"os"
"reflect"
"strings"
"sync"
"testing"
"time"
)
@@ -19,6 +20,7 @@ type call struct {
// fake answers each command by the first rule whose prefix matches "name arg arg…".
type fake struct {
mu sync.Mutex
rules []rule
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) run(_ context.Context, name string, args ...string) Ran {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, call{name, args})
line := strings.Join(append([]string{name}, args...), " ")
for _, r := range f.rules {
+24 -2
View File
@@ -71,8 +71,9 @@ func tools(c *Client) []stdio.Tool {
},
},
{
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).",
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). " +
"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{
"container": containerArg,
"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"))
},
},
{
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",
Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.",
+289
View File
@@ -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
}
+19
View File
@@ -16,6 +16,7 @@
"docker_list",
"docker_inspect",
"docker_logs",
"docker_secrets_in_logs",
"docker_stats",
"docker_start",
"docker_stop",
@@ -50,6 +51,24 @@
"state": "running",
"boot": "enabled"
},
{
"id": "daemon",
"type": "file",
"path": "/etc/docker/daemon.json",
"mode": "0644",
"into": "json",
"content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"]}\n"
},
{
"id": "runtime",
"type": "service",
"unit": "docker.service",
"state": "running",
"boot": "enabled",
"reload-on": [
"daemon"
]
},
{
"id": "prune-service",
"type": "file",
+94
View File
@@ -0,0 +1,94 @@
# forticlient
The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the
operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned
only where a display server is held on the same machine.
**This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no
credential, no gateway, no certificate is declared, read, printed or stored by the module or its
tools. The tools report running and connected state only.
## Owns
| what | where |
|---|---|
| the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled |
Nothing else. It holds no seat, makes no contribution and writes no file.
- **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on
both workstations it is a foreign (AUR) package that repackages the vendor's build, installed
explicitly. The host installs from the official repositories only, so the module cannot declare it.
ADR 0205's pinned archive does not fit: it is a vendor binary set with a root service, a firewall
helper and an install script. It waits for the mesh's package repository (research 027 question 1,
option P2). Until then a fresh workstation installs it by hand.
- **The service is declared, and so depended on.** `forticlient.service` is the package's unit, running
and enabled on both workstations. Declared running and enabled, it is held in that state, and on a
machine without the package the host refuses it by name (*does not exist on this machine*): loud,
never a silent pass. The `asus-zephyrus-g14` module does the same with its foreign daemons. The
module never restarts it: a change to nothing of the module's would, and nothing of the module's
changes.
- **The configuration stays the operator's, and unread.** `/etc/forticlient/`, the client's database
under `/opt/forticlient/`, the account's FortiClient settings, the VPN profiles, saved credentials
and certificates are set in the client's own window. They are found (ADR 0182), and unlike any other
found file, the tools do not even read them.
## How it starts: the vendor's autostart entry, and nothing else
The tray has two processes: `fortitraylauncher`, which starts and watches `fortitray`. The package's
install script links `/etc/xdg/autostart/Fortitray.desktop` to the package's
`/opt/forticlient/Fortitray.desktop` (`Exec=/opt/forticlient/fortitraylauncher`). The session runs it
once at login through the `i3` module's `dex --autostart --environment i3`. **That entry is the tray's
one start.** The module adds no `xinitrc` slot and no `node-display-session` exec, because either would
start it a second time. The link is the vendor's, made by its install script; the mesh does not make
or remove it.
The tunnel is not the tray's: the service's processes hold it, as root. Ending the tray leaves a
connected tunnel connected.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `forticlient_status` (r) | <ul><li>the installed version, and that it is from outside the official repositories</li><li>the service: active, enabled</li><li>whether the launcher and the tray run: pid, since, and the scope or unit they run in</li><li>what starts the tray at login</li><li>connected or not, as the number of the client's tunnel interfaces that are up</li></ul> |
| `forticlient_restart` (a) | asks the tray and its launcher to end (SIGTERM), forces them after 5 s, and starts the launcher in the operator's session as a transient user unit `mesh-forticlient-tray`, so it outlives the tools runtime. The launcher starts the tray. The service and the tunnel are not touched. Refused plainly when nobody is logged in to the desktop |
| `forticlient_check` (r) | <ul><li>the package is installed</li><li>the service is running and enabled</li><li>exactly one start: the vendor's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one launcher and one tray run in a desktop session</li></ul>Being connected is never a finding: that is the operator's to decide. Each finding says what to do |
**What the tools never touch**, held by the tests (a fake machine carries a profile, a gateway, an
address, a secret and a certificate where the client keeps them, and no answer may hold any of them):
- no file under `/etc/forticlient` or the account's FortiClient settings is opened, and under
`/opt/forticlient` only the tray's autostart entry, through its link (it names the launcher and
nothing else);
- the vendor's command-line client and `fortivpn` are never run, and its logs are never read;
- a process is named by its command name only, never by its arguments;
- *connected* is whether an interface named `fctvpn…` is up, from its flags. The interface's name
(it carries an identifier) and its addresses are never answered. A tunnel of a kind that brings up
no such interface (IPsec) is not seen, and the answer says *not connected*.
## What changes when it is assigned
| | laptop | desktop |
|---|---|---|
| package | none: `forticlient-vpn` 7.4.3.5411, explicit, foreign | the same |
| service | none: running and enabled | the same |
| tray | none: dex starts it from the vendor's entry, in the login session's scope | none on disk. **No tray runs now:** that session began before `dex` was installed, and the predecessor's window manager never started it. The next login is the first that starts it |
## Migration (ADR 0182)
Nothing is required on either machine. On the desktop, log out and in once, or run
`forticlient_restart`, and the tray runs from its one start. `forticlient_check` then answers `ok`.
## Leaves as found
Everything of the client's: its configuration and database, the VPN profiles and credentials, its
logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package itself.
## Relies on
- **The package, installed by hand.** Without it the host refuses the service by name.
- **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does
not start. `forticlient_check` says so.
- A display server on the same machine (`x11-display`, ADR 0208 §3).
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,205 @@
package main
// FortiClient as the tools see it: the vendor's scheduler service, the tray and the launcher that
// starts it, the tray's XDG autostart entry, and whether a tunnel is up. This is the operator's work
// VPN, so the tools never read its profiles, its credentials, its gateways or its certificates: no file
// under /etc/forticlient or the account's FortiClient settings is opened, and under /opt/forticlient
// only the tray's autostart entry (through its link in /etc/xdg/autostart); the vendor's command-line
// client is never run, its logs are never read, a process is named by its command name only (never its
// arguments), and a tunnel is counted, never named or addressed.
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
launcherComm = "fortitraylaunch" // the kernel keeps 15 characters of fortitraylauncher
launcherWord = "fortitraylauncher"
trayComm = "fortitray"
launcherBin = "/opt/forticlient/fortitraylauncher"
entryName = "Fortitray.desktop"
restartAs = "mesh-forticlient-tray"
packageFor = "forticlient-vpn"
serviceUnit = "forticlient.service"
// tunnelPrefix starts the name of the interface the client brings up for a connected tunnel.
tunnelPrefix = "fctvpn"
)
// named keeps a process's command name and drops its arguments.
func named(ps []Proc, comm string) []Proc {
out := []Proc{}
for _, p := range ps {
p.Command = comm
out = append(out, p)
}
return out
}
// Tunnel is whether the client holds a tunnel up: how many of its tunnel interfaces exist and are up.
// Their names and addresses are never answered.
type Tunnel struct {
Connected bool `json:"connected"`
Up int `json:"tunnels_up"`
}
func (m *Machine) tunnel() Tunnel {
t := Tunnel{}
entries, _ := os.ReadDir(m.path("/sys/class/net"))
for _, e := range entries {
if !strings.HasPrefix(e.Name(), tunnelPrefix) {
continue
}
flags, err := strconv.ParseUint(strings.TrimPrefix(readTrimmed(m.path(filepath.Join("/sys/class/net", e.Name(), "flags"))), "0x"), 16, 32)
if err == nil && flags&1 == 1 { // IFF_UP
t.Up++
}
}
t.Connected = t.Up > 0
return t
}
// Service is the vendor's scheduler, which holds the tunnel.
type Service struct {
Active string `json:"active"`
Enabled string `json:"enabled"`
}
func (m *Machine) service() Service {
word := func(verb string) string {
o := m.cmd(5*time.Second, nil, "systemctl", verb, serviceUnit)
if f := strings.Fields(o.Stdout); o.Err == nil && len(f) > 0 {
return f[0]
}
return "unknown"
}
return Service{Active: word("is-active"), Enabled: word("is-enabled")}
}
// foreign says whether the package came from outside the official repositories (pacman -Qm).
func (m *Machine) foreign() bool {
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
}
// StatusAnswer is what forticlient_status answers.
type StatusAnswer struct {
Installed string `json:"installed,omitempty"`
Foreign bool `json:"outside_official_repositories"`
Service Service `json:"service"`
Launcher []Proc `json:"launcher"`
Tray []Proc `json:"tray"`
StartedBy Autostart `json:"started_by"`
Tunnel Tunnel `json:"tunnel"`
}
// Status reads the client's running state, and nothing of its configuration.
func (m *Machine) Status() (StatusAnswer, error) {
s := StatusAnswer{Service: m.service(), Launcher: named(m.procs(launcherComm), launcherWord),
Tray: named(m.procs(trayComm), trayComm), StartedBy: m.autostart(entryName), Tunnel: m.tunnel()}
v, err := m.installed(packageFor)
if err != nil {
return s, err
}
s.Installed = v
if v != "" {
s.Foreign = m.foreign()
}
return s, nil
}
// RestartAnswer is what forticlient_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
// Tunnel is after the restart: the tray is only the client's face, the service holds the tunnel.
Tunnel Tunnel `json:"tunnel"`
}
// Restart ends the tray and its launcher and starts the launcher again in the operator's session,
// under the account's service manager. The launcher starts the tray. The tunnel is the service's,
// and is not touched.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(5*time.Second, launcherComm, trayComm)
if err := m.detach(s, restartAs, launcherBin); err != nil {
return a, err
}
a.Running = named(m.waitFor(launcherComm, 4*time.Second), launcherWord)
a.Tunnel = m.tunnel()
if len(a.Running) == 0 {
return a, fmt.Errorf("the launcher was started as %s but no %s process appeared within 4 s",
a.Unit, launcherWord)
}
return a, nil
}
// CheckAnswer is what forticlient_check answers. Being connected or not is never a finding: that is
// the operator's choice, and forticlient_status says which.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises and relies on: the package (found, not installed by the
// mesh); the service running and enabled; one start for the tray (the vendor's autostart entry, which
// the session's dex runs); and the tray running once in a desktop session.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("forticlient-vpn is not installed. It is outside the official repositories, so the mesh does not install it",
"install it from the AUR by hand")
}
if s := m.service(); s.Active != "active" || s.Enabled != "enabled" {
add(fmt.Sprintf("the service %s is %s and %s", serviceUnit, s.Active, s.Enabled),
"push the module, which declares it running and enabled")
}
entry := m.autostart(entryName)
if entry.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
if entry.From != "/etc/xdg/autostart/"+entryName {
add("the account's own "+entry.From+" replaces the vendor's entry", "remove it, so the vendor's entry is the one start")
}
} else {
add("the tray does not start with the session ("+entry.Because+")",
"remove ~/.config/autostart/"+entryName+" if it hides the vendor's entry; if the vendor's is gone, reinstall forticlient-vpn, whose install links it")
}
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
}
for _, word := range []string{launcherWord, trayComm} {
for _, l := range m.i3Starts(word) {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; the vendor's autostart entry is the tray's one start")
}
}
if _, err := m.session(); err == nil {
for _, p := range []struct{ comm, word string }{{launcherComm, launcherWord}, {trayComm, trayComm}} {
switch running := m.procs(p.comm); {
case len(running) == 0:
add("no "+p.word+" runs in the desktop session", "forticlient_restart")
case len(running) > 1:
add(fmt.Sprintf("%d of %s run", len(running), p.word), "forticlient_restart ends them all and starts one")
}
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
// secrets are what the operator's VPN configuration holds on a real machine. The fake machine carries
// them where the client keeps them, and no answer may.
var secrets = []string{"vpn.example.invalid", "192.0.2.10", "s3cret-psk", "work-profile", "BEGIN CERTIFICATE", "65d16a50"}
func newClient(t *testing.T, running, connected bool) *fake {
f := newFake(t)
f.write("/opt/forticlient/Fortitray.desktop", "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
f.write("/etc/forticlient/config.db", "work-profile vpn.example.invalid 192.0.2.10 s3cret-psk\n")
f.write(testHome+"/.config/FortiClient/state.json", `{"gateway":"vpn.example.invalid","cert":"-----BEGIN CERTIFICATE-----"}`)
f.write("/sys/class/net/wlp3s0/flags", "0x1003\n")
if connected {
f.write("/sys/class/net/fctvpn65d16a50/flags", "0x1091\n")
f.write("/sys/class/net/fctvpn65d16a50/address", "192.0.2.10\n")
}
if running {
f.proc(3857, 1000, launcherComm, []string{launcherBin, "--profile=work-profile"}, "session-c1.scope")
f.proc(4509, 1000, trayComm, []string{"/opt/forticlient/fortitray", "--gateway", "vpn.example.invalid"}, "session-c1.scope")
}
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman" && args[0] == "-Q":
return Output{Stdout: "forticlient-vpn 7.4.3.5411-1\n"}
case name == "pacman" && args[0] == "-Qqm":
return Output{Stdout: "forticlient-vpn\n"}
case name == "systemctl" && args[0] == "is-active":
return Output{Stdout: "active\n"}
case name == "systemctl" && args[0] == "is-enabled":
return Output{Stdout: "enabled\n"}
}
return Output{}
}
return f
}
// carries fails the test when an answer holds anything of the VPN's configuration.
func carries(t *testing.T, answer any) {
t.Helper()
raw, _ := json.Marshal(answer)
for _, s := range secrets {
if strings.Contains(string(raw), s) {
t.Fatalf("the answer carries %q: %s", s, raw)
}
}
}
func TestStatusIsRunningAndConnectedStateAndNothingOfTheConfiguration(t *testing.T) {
f := newClient(t, true, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed != "7.4.3.5411-1" || !s.Foreign || s.Service != (Service{"active", "enabled"}) || len(s.Launcher) != 1 || len(s.Tray) != 1 ||
!s.StartedBy.Starts || !s.Tunnel.Connected || s.Tunnel.Up != 1 {
t.Fatalf("%+v", s)
}
if s.Launcher[0].Command != launcherWord || s.Tray[0].Command != trayComm {
t.Fatalf("processes by command name only: %+v %+v", s.Launcher, s.Tray)
}
carries(t, s)
for _, c := range f.calls {
for _, never := range []string{"forticlient-cli", "fortivpn", "journalctl", "sqlite", "/etc/forticlient", "/opt/forticlient/.config"} {
if strings.Contains(c, never) {
t.Fatalf("ran %q", c)
}
}
}
down := newClient(t, true, false)
down.write("/sys/class/net/fctvpn0/flags", "0x1090\n")
if s, _ := down.Status(); s.Tunnel.Connected || s.Tunnel.Up != 0 {
t.Fatalf("an interface that is down is no tunnel: %+v", s.Tunnel)
}
}
func TestCheckPassesTheVendorsOneStartAndNeverJudgesTheConnection(t *testing.T) {
f := newClient(t, true, false)
f.desktopSession()
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
t.Fatalf("%+v %v", c, err)
}
carries(t, c)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id /opt/forticlient/fortitraylauncher\n")
f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=/opt/forticlient/fortitraylauncher\nHidden=true\n")
f.proc(3858, 1000, launcherComm, []string{launcherBin}, "session-c1.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "systemctl":
return Output{Stdout: "inactive\n", Code: 3}
case "dex":
return Output{Code: 127, Err: ErrNotInstalled}
}
return Output{}
}
c, _ = f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not installed", "forticlient.service is inactive", "does not start with the session (Hidden=true)", "dex",
"a second start: ~/.config/i3/config:1", "2 of fortitraylauncher run"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
carries(t, c)
}
func TestRestartStartsTheLauncherAndLeavesTheTunnel(t *testing.T) {
f := newClient(t, true, true)
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
f.desktopSession()
f.onStart = func(argv []string) { f.proc(9100, 1000, launcherComm, argv, "app.slice/"+restartAs+".service") }
a, err := f.Restart()
if err != nil || len(a.Ended) != 2 || len(a.Running) != 1 || !a.Tunnel.Connected {
t.Fatalf("%+v %v", a, err)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
t.Fatalf("%q", f.calls)
}
for _, c := range f.calls {
if strings.Contains(c, serviceUnit) {
t.Fatalf("the restart touched the service: %q", c)
}
}
carries(t, a)
}
@@ -0,0 +1,49 @@
// The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient
// VPN client's tray in the operator's session and the vendor's service behind it, served by the node's
// runtime as the operator account. The module holds no seat, so every tool is its own. The tools
// report running and connected state only: never a profile, a credential, a gateway or a certificate.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "forticlient_status",
Description: "The VPN client: the installed version and whether it came from outside the official " +
"repositories, the vendor's service (active, enabled), whether the tray and its launcher run " +
"(pid, since, scope or unit), what starts the tray at login, and whether a tunnel is up (a " +
"count; never a name, an address, a gateway or a profile). (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "forticlient_restart",
Description: "End the tray and its launcher (asked first, then forced after 5 s) and start the " +
"launcher again in the operator's desktop session, under the account's service manager. The " +
"tunnel is the service's and is not touched. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "forticlient_check",
Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
"outside the official repositories); the service runs and is enabled; the tray has exactly one " +
"start (the vendor's XDG autostart entry, which the session's dex runs; no window-manager exec); " +
"and the tray and its launcher run once in a desktop session. Being connected is not checked. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,106 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// forticlient's shape (novox/hq ADR 0205, ADR 0207, ADR 0208): no package (the client is outside the
// official repositories and kept as found), the vendor's service declared running and enabled, the X
// display on its own machine, no start of its own (the vendor's autostart entry is the tray's one
// start), nothing of the VPN's configuration, and the Go bundle serving exactly the listed forticlient_
// tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "forticlient_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed %s and described", tool.Name, "forticlient_")
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/forticlient-tools" || b["binary"] != "forticlient-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
!reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) {
t.Fatalf("%+v", m)
}
if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil {
t.Fatalf("resources: %v", m.Resources)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
t.Fatal("no seat, no environment, and no second start")
}
for _, never := range []string{"/etc/forticlient", "/opt/forticlient", "autostart", "\"package\"", "vpn.", "gateway", "profile"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %s", never)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module forticlient
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+39
View File
@@ -0,0 +1,39 @@
{
"module": "forticlient",
"version": "1",
"capabilities": [
"service-manager"
],
"requires": [
"x11-display"
],
"tools": [
"forticlient_status",
"forticlient_restart",
"forticlient_check"
],
"resources": [
{
"id": "scheduler",
"type": "service",
"unit": "forticlient.service",
"state": "running",
"boot": "enabled"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/forticlient-tools",
"binary": "forticlient-tools",
"loads": [
"forticlient-tools"
]
}
]
}
}
+29 -4
View File
@@ -17,6 +17,8 @@ export interface GiteaRepo {
description?: string;
html_url: string;
default_branch?: string;
/** When anything last moved in it — a push, and so a merge. */
updated_at?: string;
}
/** An issue, with its labels flattened to names. */
@@ -249,10 +251,24 @@ export class GiteaClient {
* `limit` is what is asked for, and a merge that changed more says so rather than being read
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
* so more pages would buy nothing. */
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
return { paths, truncated: paths.length >= limit };
/** Every file a pull request changed, page by page. **The forge caps a page below what is asked**
* (fifty, asked for a hundred), so one page read as the whole list dropped files silently, and a module
* whose own files moved was not rebuilt (novox/hq issue 252). Read until a page comes back short; past
* `most` files the list is cut and says so, and the mesh then rebuilds everything built from the
* repository, the safe direction. */
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; truncated: boolean }> {
const paths: string[] = [];
let pageSize = 0;
for (let page = 1; ; page++) {
const files = (await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=50&page=${page}`)) ?? [];
if (page === 1) pageSize = files.length;
for (const f of files) {
const name = String(f?.filename ?? "");
if (name !== "") paths.push(name);
}
if (files.length === 0 || files.length < pageSize) return { paths, truncated: false };
if (paths.length >= most) return { paths, truncated: true };
}
}
async createPullRequest(
@@ -342,6 +358,7 @@ export class GiteaClient {
description: r.description || undefined,
html_url: r.html_url,
default_branch: r.default_branch,
updated_at: r.updated_at ?? undefined,
};
}
@@ -584,3 +601,11 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/admin/users/${username}`, res);
}
}
/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose
* update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */
export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] {
if (!floor) return repos;
const at = Date.parse(floor);
return repos.filter((r) => !r.updated_at || !(Date.parse(r.updated_at) < at));
}
+21 -9
View File
@@ -3,18 +3,18 @@
//
// Emits (novox/hq ADR 0041/0042):
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
// module.gitea.pull.merged — a pull request was merged, however it was merged (web UI, API, or tool)
//
// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh
// takes that action — the natural point, and one process only. repo.created belongs here instead:
// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the
// repo list is the only way to catch every path — and keeping it out of the create-repo tool means
// the fact is never announced twice from two processes.
// issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
// repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
// polling is the only way to catch every path — and the only emitter, so a fact is never announced
// twice. The merge tool announced too until novox/hq issue 250, and every merge it made was heard twice.
//
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
// the forge for an immediacy nobody asked for is not.
import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient } from "./client.js";
import { GiteaClient, movedSince } from "./client.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -84,8 +84,16 @@ function keepAnnounced(): void {
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
renameSync(tmp, mergedRecord);
}
// **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the
// repository's update time; asking every repository for its pull requests on every tick took longer than the
// tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a
// minute before the last look is skipped — the minute absorbs the forge's clock against this one.
let lastLook = "";
const MARGIN_MS = 60_000;
async function pollMerged(client: GiteaClient): Promise<void> {
const repos = await client.listAllRepos();
const began = new Date().toISOString();
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
const repos = movedSince(await client.listAllRepos(), floor);
let changed = false;
for (const repo of repos) {
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
@@ -124,6 +132,7 @@ async function pollMerged(client: GiteaClient): Promise<void> {
if (!primedMerges) since = new Date().toISOString();
if (!primedMerges || changed) keepAnnounced();
primedMerges = true;
lastLook = began;
}
if (gitea) {
@@ -132,6 +141,9 @@ if (gitea) {
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
let failing: string | null = null;
const tick = (fn: () => Promise<void>, everyMs: number): void => {
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
// could each announce the same merge before either recorded it.
const run = (): void =>
void fn()
.then(() => {
@@ -142,8 +154,8 @@ if (gitea) {
const why = err instanceof Error ? err.message : String(err);
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
failing = why;
});
setInterval(run, everyMs);
})
.finally(() => setTimeout(run, everyMs));
run();
};
tick(() => pollRepos(client), 60_000);
+5 -1
View File
@@ -182,7 +182,11 @@
"provides": [
{
"name": "npm-package-registry",
"scope": "mesh"
"scope": "mesh",
"identity": {
"max": 40,
"in": "a Gitea user name"
}
},
{
"name": "git",
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
import { GiteaClient } from "../client.ts";
test("every page of a pull request's files is read, though the forge caps a page at fifty", async () => {
const total = 59;
const server = createServer((req, res) => {
const url = new URL(req.url ?? "", "http://x");
const page = Number(url.searchParams.get("page") ?? "1");
const start = (page - 1) * 50;
const files = Array.from({ length: Math.max(0, Math.min(50, total - start)) }, (_, i) => ({ filename: `modules/m${start + i}/x` }));
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(files));
});
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as any).port;
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
const got = await client.listPullFiles("novox", "mesh-catalog", 60);
server.close();
assert.equal(got.paths.length, total);
assert.equal(got.truncated, false);
assert.equal(new Set(got.paths).size, total);
});
test("a pass asks only the repositories that moved since the last look, every one before the first", async () => {
const { movedSince } = await import("../client.ts");
const repos = [
{ full_name: "a/old", name: "old", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T10:00:00Z" },
{ full_name: "a/new", name: "new", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T16:10:02Z" },
{ full_name: "a/unknown", name: "unknown", owner: "a", private: false, html_url: "" },
];
assert.deepEqual(movedSince(repos, "").map((r) => r.name), ["old", "new", "unknown"]);
assert.deepEqual(movedSince(repos, "2026-10-05T16:09:00.000Z").map((r) => r.name), ["new", "unknown"]);
assert.deepEqual(movedSince(repos, "2026-10-05T18:10:02+02:00").map((r) => r.name), ["new", "unknown"], "an offset is a moment, not a string");
});
+9 -27
View File
@@ -1,12 +1,12 @@
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client.
// They return structured data; the mesh serves them through the sdk's tool harness.
//
// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0041/0042):
// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on
// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos
// are far more often born from a `git push` or the web UI than from this tool, so the events
// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and
// the poll double-announcing the same repo from two processes.
// One tool emits an event at the natural point of the action it takes (novox/hq ADR 0041/0042):
// create-issue emits issue.opened. pull.merged and repo.created are deliberately NOT emitted here: a
// merge or a repository is as often made in the web UI or by a plain API call as by these tools, so the
// events entrypoint (index.ts) owns both by polling, which catches every path. Announcing a merge here
// as well announced every merge made through this tool twice — the tool's at once, the poll's moments
// later (novox/hq issue 250).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { emit } from "@novox/mesh-sdk/events";
@@ -228,29 +228,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
const number = Number(args.number);
const method = args.method ? String(args.method) : "merge";
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
// Read the PR first, so the merged event carries a title and branches, not just a number.
const pull = await gitea.getPullRequest(owner, repo, number);
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
// Read it again: the merge commit only exists now, and it is what a build is made from.
// The merge commit only exists now; answered so the caller can follow what is built from it.
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
const merged = await gitea.getPullRequest(owner, repo, number);
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
// every module built from the repository (novox/hq 04-ISSUES/131).
const changed = await gitea.listPullFiles(owner, repo, number);
await emit("pull.merged", {
owner,
repo,
number,
title: pull.title,
head: pull.head,
base: pull.base,
merge_commit_sha: merged.merge_commit_sha,
merged_at: merged.merged_at,
method,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
});
return { merged: true, number, method, deleted_branch: deleteBranch };
return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
},
},
+34
View File
@@ -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")) }),
}
}
+5
View File
@@ -0,0 +1,5 @@
module hostname
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+48
View File
@@ -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"
]
}
]
}
}
+4 -3
View File
@@ -91,6 +91,7 @@ file because each module carries them now:
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
| the peripherals' tray (`exec … polychromatic-tray-applet`) | `polychromatic`'s contribution to node-display-session |
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
@@ -98,9 +99,9 @@ because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDrop
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
key.
**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the
operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each
leaves when the module that owns it is written.
**Kept until their owners exist.** A marked section holds the operator's own scripts: volume, games
volume, the sessions launcher and the screenshot binding. Each leaves when the module that owns it is
written. The peripherals' tray applet left it for the `polychromatic` module.
## What it leaves found
+3 -1
View File
@@ -127,7 +127,9 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin
for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm",
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} {
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring",
// the peripherals' tray: polychromatic's contribution
"polychromatic"} {
if strings.Contains(code, gone) {
t.Errorf("the configuration still holds %q", gone)
}
+5 -6
View File
@@ -174,11 +174,9 @@ client.urgent #900000 #900000 #ffffff #900000 #900000
#########################################
# Each line below belongs to something other than i3, named on its line. When that module is written
# it contributes the line to node-display-session, and the line goes from here in the same change.
# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute
# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14).
# the peripherals' tray (the operator's application)
exec --no-startup-id polychromatic-tray-applet
# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'
# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,
# polychromatic).
# the operator's scripts: volume, games volume, sessions, screenshot
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
@@ -194,7 +192,8 @@ bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/script
###### Other modules' lines ####
#########################################
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.
# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's
# under a line naming it.
${contribution:node-display-session:config}
#########################################
###### Your own files ####
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -4,7 +4,10 @@
"provides": [
{
"name": "influxdb-api",
"scope": "mesh"
"scope": "mesh",
"identity": {
"in": "an InfluxDB v1 authorization"
}
}
],
"capabilities": [
+75
View File
@@ -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.
-317
View File
@@ -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
}),
}
}
+5
View File
@@ -0,0 +1,5 @@
module keycloak
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-44
View File
@@ -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");
+15 -12
View File
@@ -4,7 +4,11 @@
"provides": [
{
"name": "oidc-client",
"scope": "mesh"
"scope": "mesh",
"identity": {
"max": 255,
"in": "a Keycloak client id"
}
}
],
"requires": [
@@ -36,7 +40,9 @@
"password.reset",
"client.created",
"group.created",
"role.created"
"role.created",
"admin.repaired",
"admin.unrepaired"
],
"listens": [
{
@@ -150,22 +156,19 @@
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"language": "go",
"system": "arch",
"from": "cmd/keycloak-provider",
"binary": "keycloak-provider",
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
"keycloak-provider"
],
"env": {
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_KEYCLOAK_CONTAINER": "keycloak"
}
}
]
-185
View File
@@ -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";
}
}
-18
View File
@@ -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"
}
}
-73
View File
@@ -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);
},
});
-239
View File
@@ -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);
});
-378
View File
@@ -1,378 +0,0 @@
// keycloak's tools — moved here from the shared sdk (novox/hq ADR 0039), importing keycloak's own
// client. They return structured data (not the hal MCP `{content:[...]}` shape); the mesh serves
// them through the sdk's tool harness. Write actions announce themselves through the module's event
// surface at the point they succeed.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { KeycloakClient } from "../client.js";
import { events } from "../index.js";
export function getKeycloakTools(kc: KeycloakClient): ToolDefinition[] {
// Almost every tool is realm-scoped; an omitted realm falls back to the one the module resolved
// from its environment, so the common single-realm case needs no argument.
const realmOf = (args: Readonly<Record<string, unknown>>): string =>
args.realm ? String(args.realm) : kc.defaultRealm;
return [
// Realms & sessions
{
name: "keycloak_list_realms",
description: "List all Keycloak realms.",
input: {},
run: async () => {
const realms = await kc.listRealms();
return { realms: realms.map((r) => ({ id: r.id, realm: r.realm, displayName: r.displayName, enabled: r.enabled })) };
},
},
{
name: "keycloak_list_sessions",
description: "List active sessions for a user in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
},
run: async (args) => ({ sessions: await kc.getUserSessions(realmOf(args), String(args.user_id)) }),
},
// Users
{
name: "keycloak_list_users",
description: "List users in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
search: { type: "string", description: "search by username, email, first/last name" },
max: { type: "number", description: "maximum number of results" },
},
run: async (args) => ({
users: await kc.listUsers(realmOf(args), {
search: args.search ? String(args.search) : undefined,
max: args.max ? Number(args.max) : undefined,
}),
}),
},
{
name: "keycloak_create_user",
description: "Create a user in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
username: { type: "string", description: "username" },
email: { type: "string", description: "email address" },
password: { type: "string", description: "initial password" },
temporary_password: { type: "boolean", description: "require a password change on first login (default true)" },
},
run: async (args) => {
const realm = realmOf(args);
const username = String(args.username);
const email = args.email ? String(args.email) : undefined;
const credentials = args.password
? [{ type: "password", value: String(args.password), temporary: args.temporary_password !== false }]
: undefined;
await kc.createUser(realm, { username, email, credentials });
await events.userCreated(realm, username, email);
return { created: { realm, username, email } };
},
},
{
name: "keycloak_delete_user",
description: "Delete a user from a Keycloak realm (requires confirm).",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
confirm: { type: "boolean", description: "must be true to confirm deletion" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
if (args.confirm !== true) return { aborted: "confirm must be true to delete a user" };
await kc.deleteUser(realm, userId);
await events.userDeleted(realm, userId);
return { deleted: { realm, userId } };
},
},
{
name: "keycloak_update_user",
description: "Update a user's attributes in a Keycloak realm (enable/disable, change email, name).",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
enabled: { type: "boolean", description: "enable or disable the user" },
email: { type: "string", description: "new email address" },
firstName: { type: "string", description: "new first name" },
lastName: { type: "string", description: "new last name" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
const updates: Record<string, unknown> = {};
if (args.enabled !== undefined) updates.enabled = args.enabled === true;
if (args.email !== undefined) updates.email = String(args.email);
if (args.firstName !== undefined) updates.firstName = String(args.firstName);
if (args.lastName !== undefined) updates.lastName = String(args.lastName);
if (Object.keys(updates).length === 0) return { aborted: "no updates provided" };
await kc.updateUser(realm, userId, updates);
return { updated: { realm, userId, fields: Object.keys(updates) } };
},
},
{
name: "keycloak_reset_password",
description: "Reset a user's password in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
password: { type: "string", description: "new password" },
temporary: { type: "boolean", description: "require a password change on next login (default false)" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
await kc.resetPassword(realm, userId, String(args.password), args.temporary === true);
await events.passwordReset(realm, userId);
return { reset: { realm, userId } };
},
},
// Clients
{
name: "keycloak_list_clients",
description: "List OIDC clients in a Keycloak realm.",
input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } },
run: async (args) => {
const clients = (await kc.listClients(realmOf(args))) as Array<Record<string, unknown>>;
return {
clients: clients.map((c) => ({
id: c.id, clientId: c.clientId, name: c.name, enabled: c.enabled,
protocol: c.protocol, publicClient: c.publicClient, rootUrl: c.rootUrl,
})),
};
},
},
{
name: "keycloak_create_client",
description: "Create an OIDC client in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
client_id: { type: "string", description: "client ID (e.g. 'my-app')" },
name: { type: "string", description: "display name" },
root_url: { type: "string", description: "root URL of the application" },
redirect_uris: { type: "array", description: "allowed redirect URIs" },
public_client: { type: "boolean", description: "public client, no client secret (default true)" },
},
run: async (args) => {
const realm = realmOf(args);
const clientId = String(args.client_id);
const name = args.name ? String(args.name) : undefined;
await kc.createClient(realm, {
clientId,
name,
rootUrl: args.root_url ? String(args.root_url) : undefined,
redirectUris: Array.isArray(args.redirect_uris) ? args.redirect_uris.map(String) : undefined,
publicClient: args.public_client !== false,
});
await events.clientCreated(realm, clientId, name);
return { created: { realm, clientId, name } };
},
},
{
name: "keycloak_delete_client",
description: "Delete an OIDC client from a Keycloak realm (requires confirm).",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
client_id: { type: "string", description: "client ID (e.g. 'my-app')" },
confirm: { type: "boolean", description: "must be true to confirm deletion" },
},
run: async (args) => {
const realm = realmOf(args);
const clientId = String(args.client_id);
if (args.confirm !== true) return { aborted: "confirm must be true to delete a client" };
await kc.deleteClient(realm, clientId);
return { deleted: { realm, clientId } };
},
},
{
name: "keycloak_get_client_secret",
description: "Get the client secret for a confidential OIDC client.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
client_id: { type: "string", description: "client ID" },
},
run: async (args) => ({ secret: await kc.getClientSecret(realmOf(args), String(args.client_id)) }),
},
{
name: "keycloak_add_protocol_mapper",
description:
"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.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
client_id: { type: "string", description: "client ID (e.g. 'grafana')" },
name: { type: "string", description: "mapper name (e.g. 'realm roles')" },
mapper_type: { type: "string", description: "protocol mapper type (e.g. 'oidc-usermodel-realm-role-mapper')" },
claim_name: { type: "string", description: "token claim name (e.g. 'realm_access.roles')" },
claim_type: { type: "string", description: "JSON type: String, long, int, boolean (default String)" },
multivalued: { type: "boolean", description: "whether the claim has multiple values (default false)" },
id_token: { type: "boolean", description: "include in ID token (default true)" },
access_token: { type: "boolean", description: "include in access token (default true)" },
userinfo: { type: "boolean", description: "include in userinfo response (default true)" },
},
run: async (args) => {
const realm = realmOf(args);
const clientId = String(args.client_id);
const name = String(args.name);
await kc.addProtocolMapper(realm, clientId, {
name,
protocolMapper: String(args.mapper_type),
config: {
"claim.name": String(args.claim_name),
"jsonType.label": args.claim_type ? String(args.claim_type) : "String",
"multivalued": String(args.multivalued === true),
"id.token.claim": String(args.id_token !== false),
"access.token.claim": String(args.access_token !== false),
"userinfo.token.claim": String(args.userinfo !== false),
},
});
return { added: { realm, clientId, mapper: name } };
},
},
// Groups
{
name: "keycloak_list_groups",
description: "List groups in a Keycloak realm.",
input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } },
run: async (args) => ({ groups: await kc.listGroups(realmOf(args)) }),
},
{
name: "keycloak_create_group",
description: "Create a group in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
name: { type: "string", description: "group name" },
},
run: async (args) => {
const realm = realmOf(args);
const name = String(args.name);
await kc.createGroup(realm, name);
await events.groupCreated(realm, name);
return { created: { realm, group: name } };
},
},
{
name: "keycloak_get_user_groups",
description: "List the groups a user belongs to in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
},
run: async (args) => ({ groups: await kc.getUserGroups(realmOf(args), String(args.user_id)) }),
},
{
name: "keycloak_add_user_to_group",
description: "Add a user to a group in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
group_id: { type: "string", description: "group ID (UUID)" },
},
run: async (args) => {
const realm = realmOf(args);
await kc.addUserToGroup(realm, String(args.user_id), String(args.group_id));
return { added: { realm, userId: String(args.user_id), groupId: String(args.group_id) } };
},
},
{
name: "keycloak_remove_user_from_group",
description: "Remove a user from a group in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
group_id: { type: "string", description: "group ID (UUID)" },
},
run: async (args) => {
const realm = realmOf(args);
await kc.removeUserFromGroup(realm, String(args.user_id), String(args.group_id));
return { removed: { realm, userId: String(args.user_id), groupId: String(args.group_id) } };
},
},
// Roles
{
name: "keycloak_get_user_roles",
description: "List the realm roles assigned to a user in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
},
run: async (args) => ({ roles: await kc.getUserRealmRoles(realmOf(args), String(args.user_id)) }),
},
{
name: "keycloak_create_role",
description: "Create a realm role in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
role_name: { type: "string", description: "role name" },
description: { type: "string", description: "role description" },
},
run: async (args) => {
const realm = realmOf(args);
const name = String(args.role_name);
await kc.createRealmRole(realm, { name, description: args.description ? String(args.description) : undefined });
await events.roleCreated(realm, name);
return { created: { realm, role: name } };
},
},
{
name: "keycloak_assign_user_role",
description: "Assign an existing realm role to a user. Create it first with keycloak_create_role if needed.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
role_name: { type: "string", description: "role name to assign" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
const roleName = String(args.role_name);
// The mapping API needs the role's UUID, which only the "available" list carries; if the
// role is neither available nor already assigned it does not exist in this realm.
const available = await kc.getAvailableRealmRoles(realm, userId);
const role = available.find((r) => r.name === roleName);
if (!role) {
const assigned = await kc.getUserRealmRoles(realm, userId);
if (assigned.find((r) => r.name === roleName)) return { alreadyAssigned: { realm, userId, role: roleName } };
return { notFound: { realm, role: roleName } };
}
await kc.assignRealmRoles(realm, userId, [{ id: role.id, name: role.name }]);
return { assigned: { realm, userId, role: roleName } };
},
},
{
name: "keycloak_remove_user_role",
description: "Remove a realm role from a user in a Keycloak realm.",
input: {
realm: { type: "string", description: "realm name (defaults to the module's realm)" },
user_id: { type: "string", description: "user ID (UUID)" },
role_name: { type: "string", description: "role name to remove" },
},
run: async (args) => {
const realm = realmOf(args);
const userId = String(args.user_id);
const roleName = String(args.role_name);
const assigned = await kc.getUserRealmRoles(realm, userId);
const role = assigned.find((r) => r.name === roleName);
if (!role) return { notAssigned: { realm, userId, role: roleName } };
await kc.removeRealmRoles(realm, userId, [{ id: role.id, name: role.name }]);
return { removed: { realm, userId, role: roleName } };
},
},
];
}
// The tools exist only when the client can be configured; without an admin password, keycloak
// contributes none rather than failing the whole runtime.
registerModuleTools("keycloak", (env) => {
try {
return getKeycloakTools(KeycloakClient.fromEnv(env));
} catch {
return [];
}
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "oidc.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}
+36 -5
View File
@@ -10,7 +10,10 @@
],
"contributes": {
"postgres-database": {
"name": "letta"
"name": "letta",
"extensions": [
"vector"
]
},
"route": {
"label": "letta",
@@ -25,8 +28,14 @@
"postgres-database": "${dir:state}/database.secret"
},
"own-secrets": {
"server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret"
"server-password": {
"path": "${dir:state}/server-password.secret",
"taken": "at-start"
},
"openai-api-key": {
"path": "${dir:state}/openai-api-key.secret",
"taken": "at-start"
}
},
"listens": [
{
@@ -55,7 +64,21 @@
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n"
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nPGPASSFILE=/run/secrets/pgpass\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n"
},
{
"id": "pgpass",
"type": "file",
"path": "${dir:state}/pgpass",
"mode": "0600",
"content": "*:*:*:${bound:postgres-database:as}:${secret:postgres-database}\n"
},
{
"id": "start",
"type": "file",
"path": "${dir:state}/start.sh",
"mode": "0644",
"content": "#!/bin/sh\n# Generated by the mesh. Do not edit: module letta writes this file and replaces it at every push.\n#\n# letta 0.6.8 prints secrets it is given to its log (novox/hq issue 268):\n# letta/server/rest_api/app.py prints its server password when it starts in secure mode;\n# startup.sh, alembic/env.py and letta/server/server.py print LETTA_PG_URI whole.\n# The URI carries no password (libpq reads it from PGPASSFILE), and the one print of the server\n# password is rewritten before the server starts. Either one failing refuses the start: a letta\n# that does not start says why here, and one that leaks says nothing.\nset -e\napp=/app/letta/server/rest_api/app.py\nsed -i 's/Using secure mode with password: {random_password}/Using secure mode (the password is not printed)/' \"$app\"\nif grep -q 'print(.*random_password' \"$app\"; then\n echo \"letta: $app still prints the server password; not starting (novox/hq issue 268)\" >&2\n exit 1\nfi\ncase \"$LETTA_PG_URI\" in\n *://*:*@*)\n echo \"letta: LETTA_PG_URI carries a password, and letta prints that URI; not starting (novox/hq issue 268)\" >&2\n exit 1\n ;;\nesac\nexec ./letta/server/startup.sh\n"
},
{
"id": "net",
@@ -74,7 +97,15 @@
"ports": [
"8283"
],
"secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin), and its startup.sh starts an embedded PostgreSQL unless LETTA_PG_URI is set - so the database password travels inside that URI (startup.sh also echoes it to the log); LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source either"
"volumes": [
"${dir:state}/pgpass:/run/secrets/pgpass:ro",
"${dir:state}/start.sh:/run/letta/start.sh:ro"
],
"args": [
"sh",
"/run/letta/start.sh"
],
"secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin): LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source. The database password is not here: LETTA_PG_URI, which letta prints at start, names no password, and libpq reads it from the mounted pgpass file (PGPASSFILE)"
},
{
"id": "runtime-config",
+5 -1
View File
@@ -537,7 +537,11 @@
"provides": [
{
"name": "smtp",
"scope": "mesh"
"scope": "mesh",
"identity": {
"max": 64,
"in": "a mailbox's local part"
}
}
],
"serves": {
+4 -1
View File
@@ -4,7 +4,10 @@
"provides": [
{
"name": "secret",
"scope": "mesh"
"scope": "mesh",
"identity": {
"in": "a vault entry"
}
}
],
"capabilities": [
+5 -1
View File
@@ -4,7 +4,11 @@
"provides": [
{
"name": "s3-bucket",
"scope": "mesh"
"scope": "mesh",
"identity": {
"max": 20,
"in": "an S3 access key"
}
}
],
"requires": [
+5 -1
View File
@@ -4,7 +4,11 @@
"provides": [
{
"name": "mongodb-database",
"scope": "mesh"
"scope": "mesh",
"identity": {
"max": 63,
"in": "a MongoDB database name"
}
}
],
"capabilities": [
+4 -1
View File
@@ -5,7 +5,10 @@
"provides": [
{
"name": "mqtt-topic",
"scope": "mesh"
"scope": "mesh",
"identity": {
"in": "a Mosquitto client and topic prefix"
}
}
],
"capabilities": [
+5 -1
View File
@@ -4,7 +4,11 @@
"provides": [
{
"name": "mssql-database",
"scope": "mesh"
"scope": "mesh",
"identity": {
"max": 128,
"in": "a SQL Server login and database name"
}
}
],
"capabilities": [
+9
View File
@@ -7,6 +7,15 @@
# that names a manifest rather than the mistake. This is the index — `docker pull` reports the same
# one, and `RepoDigests` confirms it.
#
# **The release the digest is, said here because a digest does not say it:**
#
# upstream: nats 2.11.17-alpine
#
# Kept equal to the server version cmd/nats-tools tests against (its go.mod), and a test there fails
# when they differ: that test is what says the server delivers every message to a consumer with
# several filters. 2.10.29 did not — it moved such a consumer past a message now and then without
# handing it over, and the controller never heard of a merge (novox/hq issue 266).
#
# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image:
# the module's code is the server, which upstream already built. There is no BUILD_BASE here on
# purpose — nothing is compiled. The upstream image is declared in the manifest under build.on and
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"fmt"
"math/rand"
"os"
"regexp"
"runtime/debug"
"sync"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
)
// The server these tests run is the server the image runs. The image is pinned by a digest, which
// names no release, so the Dockerfile says which release it is, and this keeps the two equal: a
// test of the server below is only worth something about the server the mesh actually runs.
func TestTheImageIsTheServerTestedHere(t *testing.T) {
raw, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatal(err)
}
said := regexp.MustCompile(`(?m)^# upstream: nats (\d+\.\d+\.\d+)-alpine$`).FindSubmatch(raw)
if said == nil {
t.Fatal("the Dockerfile does not say which nats release its digest is (`# upstream: nats X.Y.Z-alpine`)")
}
info, ok := debug.ReadBuildInfo()
if !ok {
t.Fatal("no build information to read the tested server's version from")
}
for _, dep := range info.Deps {
if dep.Path == "github.com/nats-io/nats-server/v2" {
if dep.Version != "v"+string(said[1]) {
t.Fatalf("the image runs nats %s and these tests run %s: move them together", said[1], dep.Version)
}
return
}
}
t.Fatal("these tests run no nats-server")
}
// **A consumer with several filters is handed every message** (novox/hq issue 266).
//
// The controller follows the forge's merges, build outcomes and providers' standings on one durable
// consumer with seven filters, one message at a time. On nats 2.10.29 such a consumer was moved past
// a message now and then without handing it over: nothing pending, nothing redelivered, the message
// on the stream and its consumer never told. On 2026-10-06 that message was a merge, and the modules
// built from that repository were left behind with nobody told. This is that consumer, under traffic
// shaped like the mesh's — a stream mostly of build logs on ever-new subjects, the followed events
// few among them — and it fails on 2.10.29 (a few percent of the followed events never arrive) and
// passes on the release the image pins.
func TestAConsumerWithSeveralFiltersIsHandedEveryMessage(t *testing.T) {
if testing.Short() {
t.Skip("runs a server under load for seconds")
}
s, err := server.NewServer(&server.Options{Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(5 * time.Second) {
t.Fatal("the server did not start")
}
defer s.Shutdown()
admin, err := nats.Connect(s.ClientURL())
if err != nil {
t.Fatal(err)
}
defer admin.Close()
js, _ := admin.JetStream()
// The events stream and the controller's consumer on it, as the mesh declares them.
if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"},
MaxAge: 168 * time.Hour, MaxMsgsPerSubject: 10000, Storage: nats.FileStorage}); err != nil {
t.Fatal(err)
}
if _, err := js.AddConsumer("EVENTS", &nats.ConsumerConfig{Durable: "controller", DeliverSubject: "_DELIVER.controller.EVENTS",
FilterSubjects: []string{
"mesh.mod.mesh-catalog.event.upgraded",
"mesh.mod.mesh-catalog.event.catching-up",
"mesh.seat.node-build-agent.event.built",
"mesh.mod.gitea.event.pull.merged",
"mesh.seat.mesh-build-machine.event.built",
"mesh.mod.*.event.provisioner.failing",
"mesh.mod.*.event.provisioner.recovered",
},
AckPolicy: nats.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 5, MaxAckPending: 1,
DeliverPolicy: nats.DeliverNewPolicy}); err != nil {
t.Fatal(err)
}
controller, err := nats.Connect(s.ClientURL())
if err != nil {
t.Fatal(err)
}
defer controller.Close()
cjs, _ := controller.JetStream()
handed := make(chan *nats.Msg, 64)
if _, err := cjs.ChanSubscribe("", handed, nats.Bind("EVENTS", "controller")); err != nil {
t.Fatal(err)
}
var got sync.Map
go func() {
for m := range handed {
if meta, err := m.Metadata(); err == nil {
got.Store(meta.Sequence.Stream, true)
}
time.Sleep(time.Duration(rand.Intn(20)) * time.Millisecond)
_ = m.Ack()
}
}()
followed := []string{"mesh.mod.gitea.event.pull.merged", "mesh.seat.node-build-agent.event.built",
"mesh.mod.postgres.event.provisioner.recovered", "mesh.mod.mesh-catalog.event.catching-up"}
var mu sync.Mutex
var sent []uint64
stop := time.Now().Add(5 * time.Second)
var wg sync.WaitGroup
for w := 0; w < 4; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
nc, err := nats.Connect(s.ClientURL())
if err != nil {
t.Error(err)
return
}
defer nc.Close()
pjs, _ := nc.JetStream()
for i := 0; time.Now().Before(stop); i++ {
if rand.Intn(40) == 0 {
if ack, err := pjs.Publish(followed[rand.Intn(len(followed))], []byte(`{}`)); err == nil {
mu.Lock()
sent = append(sent, ack.Sequence)
mu.Unlock()
}
} else {
_, _ = pjs.Publish(fmt.Sprintf("mesh.seat.node-build-agent.event.log.build-%d-%d", w, i/50), make([]byte, 200))
}
if rand.Intn(100) == 0 {
time.Sleep(time.Duration(rand.Intn(300)) * time.Millisecond)
}
}
}(w)
}
wg.Wait()
// Every followed event handed over, given the consumer time to finish.
deadline := time.Now().Add(20 * time.Second)
for {
var missing []uint64
for _, seq := range sent {
if _, ok := got.Load(seq); !ok {
missing = append(missing, seq)
}
}
if len(missing) == 0 {
return
}
info, err := js.ConsumerInfo("EVENTS", "controller")
if time.Now().After(deadline) || (err == nil && info.NumPending == 0 && info.NumAckPending == 0) {
t.Fatalf("%d of %d followed events were never handed to the consumer (first at stream sequence %d), "+
"and it has nothing pending: the server moved past them", len(missing), len(sent), missing[0])
}
time.Sleep(200 * time.Millisecond)
}
}
+18 -1
View File
@@ -2,4 +2,21 @@ module nats-tools
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
require (
git.novox.be/novox/mesh-sdk/go v0.1.7
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.51.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+25
View File
@@ -1,2 +1,27 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+1 -1
View File
@@ -88,7 +88,7 @@
"on": [
{
"arg": "NATS_BASE",
"image": "nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927"
"image": "nats@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00"
}
],
"artifacts": [
+11 -1
View File
@@ -1,6 +1,10 @@
{
"module": "networkmanager",
"version": "1",
"slug": "nm",
"requires": [
"wildcard-resolution"
],
"capabilities": [
"package-manager",
"service-manager",
@@ -12,6 +16,12 @@
"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": [
{
"id": "package",
@@ -23,7 +33,7 @@
"type": "file",
"path": "/etc/NetworkManager/conf.d/50-mesh.conf",
"mode": "0644",
"content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory. NetworkManager reads the files here sorted by\n# name and a later one wins a key it sets again \u2014 so a file of the operator's\n# that sorts after this one (any name starting with a letter does) and sets dns=\n# or unmanaged-devices= overrides it. That is the operator's to decide, and the\n# reason this file sets nothing but the two keys it must.\n#\n# NetworkManager itself is the machine's: the mesh never starts, stops, enables\n# or disables it (its service is declared with no state), because stopping it\n# takes every link down, this machine's channel to the mesh included \u2014 and a\n# module unassigned by mistake must not be able to do that. When this file\n# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which\n# re-reads its configuration \u2014 NetworkManager(8)), never restarted.\n\n[main]\n# The resolver file is the mesh's: resolv-conf writes /etc/resolv.conf and names\n# the mesh's resolver. Without this line NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". The same page adds that device*.managed \"may be a better choice\" for\n# exactly those reasons \u2014 for an interface the operator might want to hand back\n# at runtime. For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n"
"content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory. NetworkManager reads the files here sorted by\n# name and a later one wins a key it sets again \u2014 so a file of the operator's\n# that sorts after this one (any name starting with a letter does) and sets dns=\n# or unmanaged-devices= overrides it. That is the operator's to decide, and the\n# reason this file sets nothing but the two keys it must.\n#\n# NetworkManager itself is the machine's: the mesh never starts, stops, enables\n# or disables it (its service is declared with no state), because stopping it\n# takes every link down, this machine's channel to the mesh included \u2014 and a\n# module unassigned by mistake must not be able to do that. When this file\n# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which\n# re-reads its configuration \u2014 NetworkManager(8)), never restarted.\n\n[main]\n# The resolver file is the mesh's: this module writes /etc/resolv.conf itself,\n# listing the mesh's resolvers (novox/hq ADR 0223). Without this line\n# NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\n#\n# Not NetworkManager's own global DNS ([global-dns-domain-*] with rc-manager=file):\n# it writes its own header and composes the options line itself, so it cannot\n# write the mesh's file byte for byte, and the three uplink modules would write\n# three different files for one fact. dns=none, and the file declared beside it.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". The same page adds that device*.managed \"may be a better choice\" for\n# exactly those reasons \u2014 for an interface the operator might want to hand back\n# at runtime. For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n"
},
{
"id": "service",
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -1,7 +1,8 @@
package main
// desktop.go is the same file in the nextcloud-client and blueman bundles: a tray application of the
// operator's graphical session, seen from the node's tool runtime (novox/hq ADR 0208).
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
@@ -251,6 +252,22 @@ func (m *Machine) procs(comm string) []Proc {
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
@@ -411,12 +428,19 @@ func (m *Machine) detach(s Session, unit string, argv ...string) error {
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var pids []int
var ps []Proc
for _, c := range comms {
for _, p := range m.procs(c) {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
@@ -451,10 +475,13 @@ func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []in
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc {
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procs(comm); len(p) > 0 || waited >= d {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
@@ -1,7 +1,7 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client and blueman bundles.
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
@@ -113,6 +113,23 @@ func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+78
View File
@@ -0,0 +1,78 @@
# nm-applet
NetworkManager's tray applet on the workstations, as a module (novox/hq ADR 0208). It requires
`x11-display`, so it is assigned only where a display server is held on the same machine.
## Owns
| what | where |
|---|---|
| the applet (and, as its dependencies, the connection editor and libnma) | package `network-manager-applet`, from the official repositories |
Nothing else. It holds no seat, makes no contribution and writes no file.
- **No AUR.** Both workstations run the official package (`extra`), installed explicitly.
- **NetworkManager is not this module's.** The `networkmanager` module holds the node's uplink
(`node-uplink`) with it, on servers as well as workstations. It declares the `networkmanager`
package, its one drop-in and its service. This module declares none of them (ADR 0210 §4: one
package, one module), and it does not declare the applet's own dependencies (`nm-connection-editor`,
`libnma`) either: the package brings them.
- **Why a module of its own, and not a part of `networkmanager`:** that module runs on machines with no
display, where a tray applet has nothing to draw on and the package would pull in GTK. The applet is
a desktop piece, beside `blueman` (for `bluetooth`) in the same way.
- **The connections stay the operator's.** The applet is NetworkManager's secret agent: it asks for a
network's password and can keep it in the keyring. Profiles, networks, secrets and addresses are
joined at the machine (the `networkmanager` module's README). The tools never ask for them.
- **The applet's settings are found.** It keeps its switches in dconf (`org.gnome.nm-applet`): the
notifications it shows and whether it shows itself. The module neither sets nor resets them.
## How it starts: the package's autostart entry, and nothing else
The package ships `/etc/xdg/autostart/nm-applet.desktop` (`nm-applet`, `NotShowIn=KDE;GNOME;`). The
session runs it once at login through the `i3` module's `dex --autostart --environment i3`. **That
entry is the applet's one start.** The module adds no `xinitrc` slot and no `node-display-session`
exec, because either would start it a second time.
- **Excluded:** the window manager's `exec … nm-applet`, which the `i3` module's configuration dropped.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `nm_applet_status` (r) | <ul><li>whether the applet runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and what starts it at login</li><li>its switches in `org.gnome.nm-applet`</li><li>NetworkManager's overall state: state, connectivity, Wi-Fi and networking on or off. Never a connection, a network, a secret or an address</li></ul> |
| `nm_applet_restart` (a) | asks the applet to end (SIGTERM), forces it after 5 s, and starts `nm-applet` in the operator's session as a transient user unit `mesh-nm-applet`, so it outlives the tools runtime. NetworkManager and its connections are not touched; for those few seconds no secret agent answers a password prompt. Refused plainly when nobody is logged in to the desktop |
| `nm_applet_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one applet runs in a desktop session</li><li>`NetworkManager.service` is active</li></ul>Each finding says what to do |
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment, as
`blueman` does. Every command has a timeout and capped output. Everything runs through an injected
runner and a fake root in the tests.
## What changes when it is assigned
| | laptop | desktop |
|---|---|---|
| package | none: `network-manager-applet` 1.36.0, explicit, from `extra` | the same |
| start | none: dex starts the applet from the package's entry, in the login session's scope | none on disk. **The applet running now came from the predecessor's window-manager line** (`nm-applet --sm-disable`, a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
## Migration (ADR 0182)
Nothing is required on either machine. On the desktop, log out and in once, or run
`nm_applet_restart`, and the applet runs from its one start. `nm_applet_check` then answers `ok`.
## Leaves as found
- The applet's dconf settings (`/org/gnome/nm-applet/`).
- `/etc/xdg/autostart/nm-applet.desktop`, the package's own file.
- Every connection profile and secret.
## Relies on
- **The `networkmanager` module, for NetworkManager.** There is no dependency mechanism between two
modules that hold no seat, so nothing refuses `nm-applet` without it; `nm_applet_check` reports it.
`networkmanager` holds `node-uplink`, but a module cannot depend on a seat without a resource or a
contribution that derives it (ADR 0207, ADR 0210 §3). **Assign both.**
- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat.
Assigned without `i3`, the applet is installed and does not start. `nm_applet_check` says so.
- A display server on the same machine (`x11-display`, ADR 0208 §3).
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,48 @@
// The nm-applet module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): NetworkManager's
// tray applet in the operator's session, served by the node's runtime as the operator account. The
// module holds no seat, so every tool is its own. NetworkManager itself is the networkmanager module's.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "nm_applet_status",
Description: "NetworkManager's applet: whether it runs (pid, since, and the unit or session scope it " +
"runs in), the installed version, what starts it at login, its notification switches, and " +
"NetworkManager's overall state (state, connectivity, Wi-Fi and networking on or off; never a " +
"connection, a network, a secret or an address). (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "nm_applet_restart",
Description: "End the applet (asked first, then forced after 5 s) and start it again in the " +
"operator's desktop session, under the account's service manager. NetworkManager and its " +
"connections are not touched. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "nm_applet_check",
Description: "Check what the module promises and relies on: the package is installed; the applet has " +
"exactly one start (the package's XDG autostart entry, which the session's dex runs; no " +
"window-manager exec); it runs once in a desktop session; and NetworkManager runs (the " +
"networkmanager module's). Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,104 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// nm-applet's shape (novox/hq ADR 0208, ADR 0210): the applet's one official package, no seat, the X
// display on its own machine, no start of its own (the package's autostart entry is the one start),
// nothing of the networkmanager module's (its package, its configuration, its service), and the Go
// bundle serving exactly the listed nm_applet_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "nm_applet_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed %s and described", tool.Name, "nm_applet_")
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/nm-applet-tools" || b["binary"] != "nm-applet-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestItInstallsTheAppletAndNothingOfNetworkManager(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "nm-applet" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
t.Fatalf("%+v", m)
}
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
t.Fatalf("resources: %v", m.Resources)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
t.Fatal("no seat, no environment, and no second start")
}
for _, never := range []string{"\"networkmanager\"", "nm-connection-editor", "libnma", "/etc/NetworkManager", "autostart", "service"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %s: the stack is the networkmanager module's, the start the package's", never)
}
}
}
@@ -0,0 +1,169 @@
package main
// NetworkManager's applet as the tools see it: its process, its XDG autostart entry (the package's),
// its settings in gsettings, and NetworkManager's overall state as nmcli gives it. The applet is also
// NetworkManager's secret agent, the program that asks for a network's password; the tools never ask
// NetworkManager for a connection, a secret or an address. Those are joined at the machine (the
// networkmanager module's README).
import (
"fmt"
"strings"
"time"
)
const (
appletComm = "nm-applet"
appletBin = "/usr/bin/nm-applet"
entryName = "nm-applet.desktop"
restartAs = "mesh-nm-applet"
packageFor = "network-manager-applet"
stackUnit = "NetworkManager.service"
settingsSet = "org.gnome.nm-applet"
)
// Manager is NetworkManager's overall state: no connection, network or address.
type Manager struct {
State string `json:"state"`
Connectivity string `json:"connectivity"`
Wifi string `json:"wifi"`
Networking string `json:"networking"`
}
func (m *Machine) manager() (*Manager, error) {
args := []string{"-t", "-f", "STATE,CONNECTIVITY,WIFI,NETWORKING", "general"}
o := m.cmd(5*time.Second, nil, "nmcli", args...)
if err := failed(o, "nmcli", args...); err != nil {
return nil, err
}
f := strings.Split(strings.TrimSpace(o.Stdout), ":")
if len(f) != 4 {
return nil, fmt.Errorf("nmcli's general state is not four fields: %q", tail(o.Stdout, 200))
}
return &Manager{State: f[0], Connectivity: f[1], Wifi: f[2], Networking: f[3]}, nil
}
// settings are the applet's switches in gsettings: the notifications it shows and whether it shows
// itself.
func (m *Machine) settings() map[string]string {
out := map[string]string{}
o := m.cmd(5*time.Second, m.bus(), "gsettings", "list-recursively", settingsSet)
if o.Err != nil || o.Code != 0 {
return out
}
for _, l := range strings.Split(o.Stdout, "\n") {
f := strings.Fields(l)
if len(f) == 3 && f[0] == settingsSet && f[1] != "stamp" {
out[f[1]] = f[2]
}
}
return out
}
// StatusAnswer is what nm_applet_status answers.
type StatusAnswer struct {
Installed string `json:"installed,omitempty"`
Applet []Proc `json:"applet"`
StartedBy Autostart `json:"started_by"`
Settings map[string]string `json:"settings"`
Manager *Manager `json:"network_manager,omitempty"`
Unanswered string `json:"unanswered,omitempty"`
}
// Status reads the applet and NetworkManager's overall state.
func (m *Machine) Status() (StatusAnswer, error) {
s := StatusAnswer{Applet: m.procs(appletComm), StartedBy: m.autostart(entryName), Settings: m.settings()}
if s.Applet == nil {
s.Applet = []Proc{}
}
v, err := m.installed(packageFor)
if err != nil {
return s, err
}
s.Installed = v
if s.Manager, err = m.manager(); err != nil {
s.Unanswered = err.Error()
}
return s, nil
}
// RestartAnswer is what nm_applet_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends the applet and starts it again in the operator's session, under the account's service
// manager. NetworkManager and its connections are not touched: the applet is only their face.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(5*time.Second, appletComm)
if err := m.detach(s, restartAs, appletBin); err != nil {
return a, err
}
a.Running = m.waitFor(appletComm, 4*time.Second)
if len(a.Running) == 0 {
return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+
"see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit)
}
return a, nil
}
// CheckAnswer is what nm_applet_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises and relies on: the package; one start (the package's
// autostart entry, which the session's dex runs); the applet running once in a session; and
// NetworkManager, which is the networkmanager module's, running.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("the package "+packageFor+" is not installed", "push the module to the node")
}
entry := m.autostart(entryName)
if entry.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
if entry.From != "/etc/xdg/autostart/"+entryName {
add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start")
}
} else {
add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry")
}
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
}
for _, l := range m.i3Starts(appletComm) {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start")
}
if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" {
add("NetworkManager ("+stackUnit+") is not running: the applet has nothing to show",
"assign the networkmanager module, which holds the node's uplink with it")
}
if _, err := m.session(); err == nil {
switch running := m.procs(appletComm); {
case len(running) == 0:
add("no applet runs in the desktop session", "nm_applet_restart")
case len(running) > 1:
add(fmt.Sprintf("%d applets run", len(running)), "nm_applet_restart ends them all and starts one")
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,107 @@
package main
import (
"strings"
"testing"
)
func newApplet(t *testing.T, running bool) *fake {
f := newFake(t)
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nName=Network\nExec=nm-applet\nNotShowIn=KDE;GNOME;\n")
if running {
f.proc(3860, 1000, appletComm, []string{"nm-applet"}, "session-c1.scope")
}
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman":
return Output{Stdout: "network-manager-applet 1.36.0-2\n"}
case name == "nmcli":
return Output{Stdout: "connected:full:enabled:enabled\n"}
case name == "gsettings":
return Output{Stdout: "org.gnome.nm-applet disable-connected-notifications false\norg.gnome.nm-applet stamp 0\n" +
"org.gnome.nm-applet show-applet true\n"}
case name == "systemctl":
return Output{Stdout: "active\n"}
}
return Output{}
}
return f
}
func TestStatusReadsTheAppletAndOnlyTheManagersOverallState(t *testing.T) {
f := newApplet(t, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed != "1.36.0-2" || len(s.Applet) != 1 || !s.StartedBy.Starts || s.Manager == nil ||
*s.Manager != (Manager{"connected", "full", "enabled", "enabled"}) || len(s.Settings) != 2 || s.Settings["show-applet"] != "true" {
t.Fatalf("%+v", s)
}
for _, c := range f.calls {
for _, never := range []string{"connection", "device", "secrets", "--show-secrets", " ip"} {
if strings.HasPrefix(c, "nmcli") && strings.Contains(c, never) {
t.Fatalf("asked NetworkManager for more than its overall state: %q", c)
}
}
}
f.answer = func(name string, args []string) Output {
if name == "nmcli" {
return Output{Code: 8, Stderr: "Error: NetworkManager is not running."}
}
return Output{Stdout: "x 1\n"}
}
if s, _ := f.Status(); s.Manager != nil || !strings.Contains(s.Unanswered, "not running") {
t.Fatalf("%+v", s)
}
}
func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) {
f := newApplet(t, true)
f.desktopSession()
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
t.Fatalf("%+v %v", c, err)
}
f.write(testHome+"/.config/i3/config", "exec --no-startup-id nm-applet --sm-disable\n")
f.proc(3861, 1000, appletComm, []string{"nm-applet"}, "session-c1.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "systemctl":
return Output{Stdout: "inactive\n", Code: 3}
}
return Output{}
}
c, _ = f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not installed", "a second start: ~/.config/i3/config:1", "NetworkManager.service", "2 applets run"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
}
func TestRestartEndsTheAppletAndStartsItUnderTheServiceManager(t *testing.T) {
f := newApplet(t, true)
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
f.desktopSession()
f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") }
a, err := f.Restart()
if err != nil || len(a.Ended) != 1 || len(a.Running) != 1 || a.Running[0].Command != appletBin {
t.Fatalf("%+v %v", a, err)
}
for _, c := range f.calls {
if strings.Contains(c, stackUnit) {
t.Fatalf("the restart touched NetworkManager: %q", c)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module nm-applet
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+37
View File
@@ -0,0 +1,37 @@
{
"module": "nm-applet",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"tools": [
"nm_applet_status",
"nm_applet_restart",
"nm_applet_check"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "network-manager-applet"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/nm-applet-tools",
"binary": "nm-applet-tools",
"loads": [
"nm-applet-tools"
]
}
]
}
}

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