Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d407248b3 | ||
|
|
6e4d12e5a4 | ||
|
|
1fd2914ae1 | ||
|
|
7f99fb4a85 | ||
|
|
08265a70ca | ||
|
|
419e82cded | ||
|
|
f144f6eee8 | ||
|
|
7524390cad | ||
|
|
685cb1cb1b | ||
|
|
8f5a75ab9b | ||
|
|
57d524f4ef | ||
|
|
368fa2f45e | ||
|
|
2c15074a0b | ||
|
|
e68ef88333 | ||
|
|
4554e18279 | ||
|
|
4238dd8616 | ||
|
|
2c151e7c21 | ||
|
|
12569ed085 | ||
|
|
b1bd5c861e | ||
|
|
8c2c9ace0e | ||
|
|
a4b92c1306 | ||
|
|
aabc8aa039 | ||
|
|
908d45864a | ||
|
|
c12d364a68 | ||
|
|
0ae7933d54 | ||
|
|
495bb88111 | ||
|
|
b87dc29706 | ||
|
|
e5cb7b071c | ||
|
|
7fad19a764 | ||
|
|
4769dadf63 | ||
|
|
bbb67e41a0 | ||
|
|
f9f27d4878 | ||
|
|
cc2f19123a | ||
|
|
0275c2eeac | ||
|
|
78328d4ab2 | ||
|
|
48d4188927 | ||
|
|
5c2157b81c | ||
|
|
77fb1ecfb2 | ||
|
|
6f1e2f5a0d | ||
|
|
ed6384feb0 | ||
|
|
0e072b05c0 | ||
|
|
60604fcd11 | ||
|
|
ff61578e3e | ||
|
|
138d9afd7b | ||
|
|
dd124966ad | ||
|
|
73d6a51325 | ||
|
|
7b0b80ee05 | ||
|
|
d8b4d20886 | ||
|
|
0515db043a | ||
|
|
7f491fd6bc | ||
|
|
b4b86c1452 | ||
|
|
737f42deb4 | ||
|
|
5c89eaf9a6 | ||
|
|
964a4fdfbc | ||
|
|
20603b63e6 | ||
|
|
4bf5eef2fd | ||
|
|
4cac44face | ||
|
|
875d2a0554 | ||
|
|
76707bbe0e | ||
|
|
076455ec78 | ||
|
|
f20c4b749b | ||
|
|
69d6b9066f | ||
|
|
2cc27e2a74 | ||
|
|
0581256905 | ||
|
|
082f8de32f | ||
|
|
428f5b8864 | ||
|
|
579d21a209 | ||
|
|
52b81d524c | ||
|
|
b651137d95 | ||
|
|
c42f1ce45b | ||
|
|
6a42bafb1c | ||
|
|
053eba6950 | ||
|
|
45507c3d5c | ||
|
|
63e53f4622 | ||
|
|
ebacf79c91 | ||
|
|
a66a23582f | ||
|
|
db9a5bff0c | ||
|
|
85352c8d6f | ||
|
|
f74e1f0309 | ||
|
|
97b1b2b36b | ||
|
|
d0d5546088 | ||
|
|
2a99b3c3e9 | ||
|
|
ade4901d7f | ||
|
|
f2b5168647 | ||
|
|
75f25fca21 | ||
|
|
1fc10b0323 | ||
|
|
4f3e52b47a | ||
|
|
1a03caf8a5 | ||
|
|
54ba97f901 | ||
|
|
92f78db970 | ||
|
|
1d8f1ceff8 | ||
|
|
83bd2afe66 | ||
|
|
46e3a5a6b7 | ||
|
|
d67e786bf9 | ||
|
|
7786507d45 | ||
|
|
a70227a40a | ||
|
|
08c7a79f79 | ||
|
|
d9120c6848 | ||
|
|
96ee0d7ad6 | ||
|
|
d43de93e49 | ||
|
|
3a88bca21a | ||
|
|
dd1035a27d | ||
|
|
37bb53ab95 | ||
|
|
45f27eb300 | ||
|
|
785d32407b | ||
|
|
170b3296e9 | ||
|
|
32cd92baeb | ||
|
|
9582208984 |
@@ -1,2 +1,11 @@
|
||||
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
|
||||
modules/messenger/cmd/messenger/messenger
|
||||
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
|
||||
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
|
||||
modules/messenger/messenger
|
||||
modules/mesh-watcher/mesh-watcher
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
// The audit handler — appends one line per event to an append-only audit log. It is the whole of
|
||||
// the module's code: an audit logger is not a privileged component, only a module that listens to
|
||||
// everything and writes it down (novox/hq ADR 0041).
|
||||
//
|
||||
// **Written once per event.** Delivery is at-least-once, and since novox/hq issue 276 a failed write
|
||||
// is asked for again and an event that keeps failing is replayed from the spool — so the same event can
|
||||
// reach the trail more than once. An append-only file has no ON CONFLICT; its equivalent is the SDK's
|
||||
// other answer ("Taking an event"): skip an event id already written, remembering it only once the line
|
||||
// is on disk. The ids remembered are the most recent ones, seeded from the end of the trail on start,
|
||||
// which covers every redelivery (seconds apart) and a restart between a write and its answer.
|
||||
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { mkdir, open, stat } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { Event } from "@novox/mesh-sdk/events";
|
||||
import { keyOf } from "./spool.js";
|
||||
|
||||
/** Where the trail is written. A directory the host applies; one file per node. */
|
||||
export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log";
|
||||
}
|
||||
|
||||
/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. The id
|
||||
* is the event's own x-event-id (ADR 0042) — the handle a reader dedups the at-least-once trail on. */
|
||||
export async function record(event: Event, path: string): Promise<void> {
|
||||
const line =
|
||||
/** Where an event that could not be written waits (see spool.ts). Beside the trail unless said. */
|
||||
export function auditSpoolPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.AUDIT_SPOOL ?? `${dirname(auditLogPath(env))}/spool`;
|
||||
}
|
||||
|
||||
/** One event as the trail's line, keeping the metadata an audit needs first. The id is the event's own
|
||||
* x-event-id (ADR 0042) — the handle a reader dedups the at-least-once trail on. */
|
||||
export function lineOf(event: Event): string {
|
||||
return (
|
||||
JSON.stringify({
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
@@ -23,8 +36,82 @@ export async function record(event: Event, path: string): Promise<void> {
|
||||
at: event.at,
|
||||
...(event.causationId ? { causationId: event.causationId } : {}),
|
||||
...(event.schema ? { schema: event.schema } : {}),
|
||||
body: event.body,
|
||||
}) + "\n";
|
||||
await mkdir(dirname(path), { recursive: true }).catch(() => {});
|
||||
await appendFile(path, line, { mode: 0o600 });
|
||||
body: event.body ?? null,
|
||||
}) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
/** How many recent event ids a trail remembers, and how much of its end it reads to seed them. */
|
||||
const REMEMBERED = 50_000;
|
||||
const SEED_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export class Trail {
|
||||
private readonly seen = new Map<string, true>();
|
||||
|
||||
private constructor(readonly path: string) {}
|
||||
|
||||
/** Open the trail at `path`, remembering the ids at its end. */
|
||||
static async open(path: string): Promise<Trail> {
|
||||
const t = new Trail(path);
|
||||
await t.seed();
|
||||
return t;
|
||||
}
|
||||
|
||||
has(event: Event): boolean {
|
||||
return this.seen.has(keyOf(event));
|
||||
}
|
||||
|
||||
/** Append the event as one line and sync it, unless this trail already holds it. Throws when the
|
||||
* line could not be written — the handler's cue to ask for the event again. */
|
||||
async record(event: Event): Promise<void> {
|
||||
const key = keyOf(event);
|
||||
if (this.seen.has(key)) return;
|
||||
await mkdir(dirname(this.path), { recursive: true }).catch(() => {});
|
||||
const fh = await open(this.path, "a", 0o600);
|
||||
try {
|
||||
await fh.appendFile(lineOf(event));
|
||||
await fh.datasync();
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
this.remember(key);
|
||||
}
|
||||
|
||||
private remember(key: string): void {
|
||||
this.seen.set(key, true);
|
||||
if (this.seen.size > REMEMBERED) {
|
||||
const first = this.seen.keys().next().value;
|
||||
if (first !== undefined) this.seen.delete(first);
|
||||
}
|
||||
}
|
||||
|
||||
private async seed(): Promise<void> {
|
||||
let size: number;
|
||||
try {
|
||||
size = (await stat(this.path)).size;
|
||||
} catch {
|
||||
return; // no trail yet
|
||||
}
|
||||
const from = Math.max(0, size - SEED_BYTES);
|
||||
const fh = await open(this.path, "r");
|
||||
let text: string;
|
||||
try {
|
||||
const buf = Buffer.alloc(size - from);
|
||||
await fh.read(buf, 0, buf.length, from);
|
||||
text = buf.toString("utf8");
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
const lines = text.split("\n");
|
||||
if (from > 0) lines.shift(); // the first is cut
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const l = JSON.parse(line);
|
||||
this.remember(keyOf({ id: l.id ?? "", type: l.type, source: l.source, node: l.node, at: l.at, body: l.body }));
|
||||
} catch {
|
||||
// a line cut short by a failed write: its event was asked for again
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
// The audit-logger's entrypoint. The per-node module runtime imports this once the broker is
|
||||
// bound; the on("#") subscription is the whole handshake — it consumes every event on the mesh
|
||||
// (module.*, mesh.*, node.*) and writes each to the trail.
|
||||
//
|
||||
// **No event is lost** (novox/hq issue 276). A line that cannot be written is thrown, so the bus offers
|
||||
// the event again; the first failure spools it on disk, its last delivery is taken from the spool, and
|
||||
// the spool is replayed into the trail once writing works again (spool.ts). The trail skips an event id
|
||||
// it already holds, so a redelivery or a replay never writes it twice (audit.ts).
|
||||
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
import { auditLogPath, record } from "./audit.js";
|
||||
import { auditLogPath, auditSpoolPath, Trail } from "./audit.js";
|
||||
import { replayEvery, Spool, takeOrSpool } from "./spool.js";
|
||||
|
||||
const path = auditLogPath();
|
||||
const say = (line: string) => console.error(`[audit-logger] ${line}`);
|
||||
const trail = await Trail.open(auditLogPath());
|
||||
const spool = await Spool.open(auditSpoolPath());
|
||||
const write = (event: Parameters<Trail["record"]>[0]) => trail.record(event);
|
||||
|
||||
await on("#", async (event) => {
|
||||
try {
|
||||
await record(event, path);
|
||||
} catch (err) {
|
||||
// A failure to write the audit trail is worth a loud line, never a swallowed one — but it must
|
||||
// not throw back into the broker and wedge the subscription.
|
||||
console.error(`[audit-logger] could not record ${event.type}: ${err}`);
|
||||
}
|
||||
});
|
||||
replayEvery(spool, write, 30_000, say);
|
||||
|
||||
console.log(`[audit-logger] recording all mesh events to ${path}`);
|
||||
await on("#", (event) => takeOrSpool(event, write, spool, say));
|
||||
|
||||
console.log(
|
||||
`[audit-logger] recording all mesh events to ${trail.path}` +
|
||||
(spool.size > 0 ? `; ${spool.size} spooled event(s) wait in ${spool.dir}` : ""),
|
||||
);
|
||||
|
||||
@@ -12,17 +12,36 @@
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js"
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"index.js"
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"env": {
|
||||
"AUDIT_LOG": "${dir:trail}/audit.log"
|
||||
"AUDIT_LOG": "${dir:trail}/audit.log",
|
||||
"AUDIT_SPOOL": "${dir:spool}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "trail",
|
||||
"path": "${dir:trail}",
|
||||
"class": "valuable",
|
||||
"why": "the audit trail, written nowhere else"
|
||||
},
|
||||
{
|
||||
"id": "spool",
|
||||
"path": "${dir:spool}",
|
||||
"class": "valuable",
|
||||
"why": "events the trail could not take yet; after the bus gives one up, the only copy"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
@@ -34,6 +53,11 @@
|
||||
"id": "trail",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "spool",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
"description": "audit-logger — records every event on the mesh (module, mesh and node) to an append-only trail.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc audit.ts spool.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --strict --skipLibCheck --outDir dist",
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// The spool — where an event this module could not write waits until it can (novox/hq issue 276).
|
||||
//
|
||||
// **A handler takes an event by returning and asks for it again by throwing** (mesh-sdk README,
|
||||
// "Taking an event"). A write that failed is therefore thrown, and the bus offers the event again five
|
||||
// seconds later, up to the consumer's maximum deliveries — after which the bus gives it up. For a
|
||||
// module whose whole job is to keep what it is sent, giving up is losing, so:
|
||||
//
|
||||
// - **The first failed write puts the event in the spool**, one file per event, written and synced
|
||||
// before the handler throws. From then on the event is on this machine's disk, whatever the bus does.
|
||||
// - **The spool counts the failed deliveries**, because the runtime does not hand a handler the bus's
|
||||
// delivery count. On the last one the event is **taken**: it is in the spool, and the bus giving it
|
||||
// up would only raise a condition about an event that is not lost.
|
||||
// - **A background pass replays the spool** into the store once writes work again, oldest first, and
|
||||
// stops at the first failure. A write that succeeds — by a redelivery or by a replay — removes the
|
||||
// event from the spool. The module's writes are idempotent by the event's id, so a replay and a
|
||||
// redelivery of the same event write it once.
|
||||
// - **Over its bound** — too many events, or one waiting too long — the spool still keeps every event,
|
||||
// but the last delivery is no longer taken: the bus gives it up and the controller raises
|
||||
// `max-deliveries` for this module's consumer (to-be 45, S9). That is the one condition the mesh
|
||||
// has today that names a consumer which cannot keep up; a module has no standing of its own to report
|
||||
// (ADR 0224's is a provider's), so the bound borrows that one rather than inventing a second.
|
||||
//
|
||||
// The same file lives in audit-logger and model-usage; a third copy belongs in the SDK instead.
|
||||
|
||||
import { mkdir, open, readdir, readFile, rename, unlink } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import type { Event } from "@novox/mesh-sdk/events";
|
||||
|
||||
/** The controller's maximum deliveries for a module's consumer (mesh-controller
|
||||
* internal/broker/derived.go). The spool takes an event on this, its last, failed delivery. */
|
||||
export const MAX_DELIVERIES = 5;
|
||||
/** More events spooled than this is over the bound. */
|
||||
export const BOUND_COUNT = 1000;
|
||||
/** An event spooled longer than this is over the bound. */
|
||||
export const BOUND_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
/** One event waiting in the spool. */
|
||||
export interface SpoolEntry {
|
||||
key: string;
|
||||
event: Event;
|
||||
/** Failed deliveries seen so far. */
|
||||
attempts: number;
|
||||
/** When it first failed, and last. */
|
||||
first: string;
|
||||
last: string;
|
||||
/** The last failure's words. */
|
||||
error: string;
|
||||
/** Taken on its last delivery: only the spool holds it now. */
|
||||
held: boolean;
|
||||
}
|
||||
|
||||
/** What the spool holds, for the module's status tool and its log. */
|
||||
export interface Standing {
|
||||
dir: string;
|
||||
spooled: number;
|
||||
held: number;
|
||||
oldest?: string;
|
||||
lastError?: string;
|
||||
overBound: boolean;
|
||||
why?: string;
|
||||
bound: { count: number; ageMinutes: number };
|
||||
}
|
||||
|
||||
export interface SpoolOptions {
|
||||
maxDeliveries?: number;
|
||||
boundCount?: number;
|
||||
boundAgeMs?: number;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
/** What a failed write became: asked for again, held and taken, or asked for again past the bound. */
|
||||
export type Verdict = "retry" | "held" | "over-bound";
|
||||
|
||||
/** The key an event is spooled and deduplicated by: its x-event-id when it is a safe file name, else a
|
||||
* hash of what identifies it (an event without an id is still one event). */
|
||||
export function keyOf(event: Pick<Event, "id" | "type" | "source" | "node" | "at" | "body">): string {
|
||||
if (event.id && /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(event.id)) return event.id;
|
||||
const h = createHash("sha256");
|
||||
for (const part of [event.id ?? "", event.type, event.source, event.node, event.at, JSON.stringify(event.body ?? null)]) {
|
||||
h.update(String(part));
|
||||
h.update("\0");
|
||||
}
|
||||
return "h-" + h.digest("hex");
|
||||
}
|
||||
|
||||
export class Spool {
|
||||
private readonly entries = new Map<string, SpoolEntry>();
|
||||
private chain: Promise<unknown> = Promise.resolve();
|
||||
private readonly max: number;
|
||||
private readonly boundCount: number;
|
||||
private readonly boundAgeMs: number;
|
||||
private readonly now: () => Date;
|
||||
|
||||
private constructor(readonly dir: string, opts: SpoolOptions) {
|
||||
this.max = opts.maxDeliveries ?? MAX_DELIVERIES;
|
||||
this.boundCount = opts.boundCount ?? BOUND_COUNT;
|
||||
this.boundAgeMs = opts.boundAgeMs ?? BOUND_AGE_MS;
|
||||
this.now = opts.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/** Open the spool in `dir`, creating it, and read what it already holds. */
|
||||
static async open(dir: string, opts: SpoolOptions = {}): Promise<Spool> {
|
||||
const s = new Spool(dir, opts);
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
await s.load(true);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Read a spool another process writes, without changing it — for the status tool. */
|
||||
static async read(dir: string, opts: SpoolOptions = {}): Promise<Spool> {
|
||||
const s = new Spool(dir, opts);
|
||||
await s.load(false);
|
||||
return s;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.entries.has(key);
|
||||
}
|
||||
|
||||
list(): SpoolEntry[] {
|
||||
return [...this.entries.values()].sort((a, b) => a.first.localeCompare(b.first));
|
||||
}
|
||||
|
||||
/** Record a failed write of `event`. Synced to disk before it returns; throws if it could not be. */
|
||||
failed(event: Event, err: unknown): Promise<Verdict> {
|
||||
return this.serial(async () => {
|
||||
const key = keyOf(event);
|
||||
const prev = this.entries.get(key);
|
||||
const at = this.now().toISOString();
|
||||
const entry: SpoolEntry = {
|
||||
key,
|
||||
event,
|
||||
attempts: (prev?.attempts ?? 0) + 1,
|
||||
first: prev?.first ?? at,
|
||||
last: at,
|
||||
error: String(err instanceof Error ? err.message : err).slice(0, 500),
|
||||
held: false,
|
||||
};
|
||||
const last = entry.attempts >= this.max;
|
||||
let oldest = entry.first;
|
||||
for (const e of this.entries.values()) if (e.first < oldest) oldest = e.first;
|
||||
const over = this.over(prev ? this.entries.size : this.entries.size + 1, oldest);
|
||||
entry.held = last && !over;
|
||||
await this.persist(entry);
|
||||
this.entries.set(key, entry);
|
||||
return last ? (over ? "over-bound" : "held") : "retry";
|
||||
});
|
||||
}
|
||||
|
||||
/** The event was written: it need not wait any longer. */
|
||||
done(key: string): Promise<void> {
|
||||
if (!this.entries.has(key)) return Promise.resolve();
|
||||
return this.serial(async () => {
|
||||
if (!this.entries.has(key)) return;
|
||||
await unlink(this.file(key)).catch((e: NodeJS.ErrnoException) => {
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
});
|
||||
this.entries.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
/** Write every spooled event again, oldest first, stopping at the first that still fails. */
|
||||
async replay(write: (event: Event) => Promise<void>): Promise<{ replayed: number; left: number; error?: string }> {
|
||||
let replayed = 0;
|
||||
for (const entry of this.list()) {
|
||||
try {
|
||||
await write(entry.event);
|
||||
} catch (err) {
|
||||
return { replayed, left: this.entries.size, error: String(err instanceof Error ? err.message : err) };
|
||||
}
|
||||
await this.done(entry.key);
|
||||
replayed++;
|
||||
}
|
||||
return { replayed, left: this.entries.size };
|
||||
}
|
||||
|
||||
standing(): Standing {
|
||||
const list = this.list();
|
||||
const oldest = list[0]?.first;
|
||||
const newest = list.reduce<SpoolEntry | undefined>((n, e) => (!n || e.last > n.last ? e : n), undefined);
|
||||
const over = this.over(list.length, oldest);
|
||||
return {
|
||||
dir: this.dir,
|
||||
spooled: list.length,
|
||||
held: list.filter((e) => e.held).length,
|
||||
...(oldest ? { oldest } : {}),
|
||||
...(newest ? { lastError: newest.error } : {}),
|
||||
overBound: over,
|
||||
...(over ? { why: this.why(list.length, oldest) } : {}),
|
||||
bound: { count: this.boundCount, ageMinutes: Math.round(this.boundAgeMs / 60000) },
|
||||
};
|
||||
}
|
||||
|
||||
private over(count: number, oldest: string | undefined): boolean {
|
||||
return this.why(count, oldest) !== undefined;
|
||||
}
|
||||
|
||||
private why(count: number, oldest: string | undefined): string | undefined {
|
||||
if (count > this.boundCount) return `${count} events spooled, more than ${this.boundCount}`;
|
||||
if (oldest && this.now().getTime() - Date.parse(oldest) > this.boundAgeMs) {
|
||||
return `an event has waited since ${oldest}, longer than ${Math.round(this.boundAgeMs / 60000)} minutes`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private file(key: string): string {
|
||||
return join(this.dir, `${key}.json`);
|
||||
}
|
||||
|
||||
private async load(tidy: boolean): Promise<void> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.dir);
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === "ENOENT") return;
|
||||
throw e;
|
||||
}
|
||||
for (const name of names) {
|
||||
if (name.endsWith(".tmp")) {
|
||||
// A write cut short: the event it was for was never answered as taken, so the bus has it.
|
||||
if (tidy) await unlink(join(this.dir, name)).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
if (!name.endsWith(".json")) continue;
|
||||
try {
|
||||
const entry = JSON.parse(await readFile(join(this.dir, name), "utf8")) as SpoolEntry;
|
||||
if (entry?.key && entry.event) this.entries.set(entry.key, entry);
|
||||
} catch (err) {
|
||||
// Kept on disk for a person, never deleted: it may be the only copy of an event.
|
||||
console.error(`[spool] ${join(this.dir, name)} cannot be read and is left as it is: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Written to a temporary file, synced, renamed over the entry, and the directory synced. */
|
||||
private async persist(entry: SpoolEntry): Promise<void> {
|
||||
const final = this.file(entry.key);
|
||||
const tmp = `${final}.tmp`;
|
||||
const fh = await open(tmp, "w", 0o600);
|
||||
try {
|
||||
await fh.writeFile(JSON.stringify(entry) + "\n");
|
||||
await fh.sync();
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
await rename(tmp, final);
|
||||
const dh = await open(this.dir, "r");
|
||||
try {
|
||||
await dh.sync();
|
||||
} catch {
|
||||
// Not every filesystem syncs a directory; the rename stands either way.
|
||||
} finally {
|
||||
await dh.close();
|
||||
}
|
||||
}
|
||||
|
||||
private serial<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = this.chain.then(fn, fn);
|
||||
this.chain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The handler's whole discipline: write the event; if that fails, spool it and ask for it again —
|
||||
* or, on its last delivery, take it, because the spool now holds it. A write that cannot even be
|
||||
* spooled is asked for again, which is all that is left.
|
||||
*/
|
||||
export async function takeOrSpool(
|
||||
event: Event,
|
||||
write: (event: Event) => Promise<void>,
|
||||
spool: Spool,
|
||||
say: (line: string) => void = (l) => console.error(l),
|
||||
): Promise<void> {
|
||||
const what = `${event.type} (${event.id || "no id"})`;
|
||||
try {
|
||||
await write(event);
|
||||
} catch (err) {
|
||||
let verdict: Verdict;
|
||||
try {
|
||||
verdict = await spool.failed(event, err);
|
||||
} catch (spoolErr) {
|
||||
say(`could not write ${what}: ${err}; and could not spool it either: ${spoolErr}; asking for it again`);
|
||||
throw err;
|
||||
}
|
||||
if (verdict === "held") {
|
||||
say(`could not write ${what} on its last delivery: ${err}; spooled and taken, written when writing works again`);
|
||||
return;
|
||||
}
|
||||
say(
|
||||
verdict === "over-bound"
|
||||
? `could not write ${what} on its last delivery: ${err}; spooled, and NOT taken because the spool is over its bound (${spool.standing().why}) — the bus gives it up and the controller raises max-deliveries; the spool still writes it when it can`
|
||||
: `could not write ${what}: ${err}; spooled, asking for it again`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
// Written: a copy spooled by an earlier failed delivery is no longer needed. Failing to remove it
|
||||
// costs one idempotent write on the next replay, so it is said, never thrown.
|
||||
await spool.done(keyOf(event)).catch((e) => say(`wrote ${what} but could not remove its spooled copy: ${e}`));
|
||||
}
|
||||
|
||||
/** Replay the spool every `everyMs` while it holds anything, and say loudly — every fifteen minutes —
|
||||
* while it is over its bound. Returns a stop. */
|
||||
export function replayEvery(
|
||||
spool: Spool,
|
||||
write: (event: Event) => Promise<void>,
|
||||
everyMs = 30_000,
|
||||
say: (line: string) => void = (l) => console.error(l),
|
||||
): () => void {
|
||||
let running = false;
|
||||
let loudAt = 0;
|
||||
const timer = setInterval(async () => {
|
||||
if (running || spool.size === 0) return;
|
||||
running = true;
|
||||
try {
|
||||
const r = await spool.replay(write);
|
||||
if (r.replayed > 0) say(`replayed ${r.replayed} spooled event(s); ${r.left} left`);
|
||||
const st = spool.standing();
|
||||
if (st.overBound && Date.now() - loudAt > 15 * 60_000) {
|
||||
loudAt = Date.now();
|
||||
say(`THE SPOOL IS OVER ITS BOUND: ${st.why}; ${st.spooled} event(s) wait in ${st.dir}, last error: ${r.error ?? st.lastError}`);
|
||||
}
|
||||
} catch (err) {
|
||||
say(`replaying the spool failed: ${err}`);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}, everyMs);
|
||||
timer.unref?.();
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
@@ -1,40 +1,188 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { useBroker } from "@novox/mesh-sdk/messaging";
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { record } from "../audit.ts";
|
||||
import { emit, on, type Event } from "@novox/mesh-sdk/events";
|
||||
import { Trail } from "../dist/audit.js";
|
||||
import { Spool, takeOrSpool } from "../dist/spool.js";
|
||||
import { getAuditTools } from "../dist/tools/index.js";
|
||||
|
||||
async function lines(path: string): Promise<Record<string, any>[]> {
|
||||
const text = await readFile(path, "utf8").catch(() => "");
|
||||
return text.trim() === "" ? [] : text.trim().split("\n").map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
function ev(id: string, body: unknown = { n: 1 }): Event {
|
||||
return { type: "umami.site.created", id, source: "umami", node: "anchor", at: "2026-10-06T10:00:00.000Z", body };
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const dir = await mkdtemp(join(tmpdir(), "audit-"));
|
||||
const trail = await Trail.open(join(dir, "audit.log"));
|
||||
const spool = await Spool.open(join(dir, "spool"));
|
||||
const store = { broken: false };
|
||||
const write = async (e: Event) => {
|
||||
if (store.broken) throw new Error("ENOSPC: no space left on device");
|
||||
await trail.record(e);
|
||||
};
|
||||
const said: string[] = [];
|
||||
const handle = (e: Event) => takeOrSpool(e, write, spool, (l) => said.push(l));
|
||||
return { dir, trail, spool, store, write, handle, said, path: join(dir, "audit.log") };
|
||||
}
|
||||
|
||||
test("audit-logger records every event to the trail as one line each", async () => {
|
||||
const broker = memBroker();
|
||||
useBroker(() => broker);
|
||||
const dir = await mkdtemp(join(tmpdir(), "audit-"));
|
||||
const path = join(dir, "audit.log");
|
||||
const { handle, path } = await setup();
|
||||
|
||||
// The audit-logger's whole behaviour: consume everything, record it.
|
||||
await on("#", async (event) => record(event, path)); // the pattern index.ts subscribes
|
||||
await on("#", handle); // the pattern index.ts subscribes
|
||||
|
||||
process.env.MESH_MODULE = "umami";
|
||||
process.env.MESH_NODE = "anchor";
|
||||
await emit("site.created", { domain: "my-app" });
|
||||
await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one
|
||||
|
||||
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(lines.length, 2);
|
||||
const got = await lines(path);
|
||||
assert.equal(got.length, 2);
|
||||
// A module names its events locally (design 29); the module is the `source`, which together with
|
||||
// the type says whose event it was. This broker does no namespacing, so the type is as emitted.
|
||||
assert.deepEqual(lines.map((l) => l.type), ["site.created", "node.anchor.joined"]);
|
||||
assert.equal(lines[0].source, "umami");
|
||||
assert.equal(lines[0].node, "anchor");
|
||||
assert.equal(lines[0].body.domain, "my-app");
|
||||
assert.deepEqual(got.map((l) => l.type), ["site.created", "node.anchor.joined"]);
|
||||
assert.equal(got[0].source, "umami");
|
||||
assert.equal(got[0].node, "anchor");
|
||||
assert.equal(got[0].body.domain, "my-app");
|
||||
|
||||
delete process.env.MESH_MODULE;
|
||||
delete process.env.MESH_NODE;
|
||||
});
|
||||
|
||||
test("a write that fails is thrown, so the bus offers the event again — and it is spooled at once", async () => {
|
||||
const broker = memBroker();
|
||||
useBroker(() => broker);
|
||||
const { handle, store, spool, path } = await setup();
|
||||
await on("#", handle);
|
||||
store.broken = true;
|
||||
await assert.rejects(emit("site.created", { domain: "x" }), /ENOSPC/);
|
||||
assert.equal(spool.size, 1, "spooled on the first failure");
|
||||
assert.equal(spool.list()[0].attempts, 1);
|
||||
assert.equal(spool.list()[0].held, false);
|
||||
assert.deepEqual(await lines(path), []);
|
||||
});
|
||||
|
||||
test("a redelivered event is written once — also after a restart, and also when its earlier failure was spooled", async () => {
|
||||
const { handle, store, spool, path, dir } = await setup();
|
||||
await handle(ev("e1"));
|
||||
await handle(ev("e1")); // the answer was lost; the bus offers it again
|
||||
assert.equal((await lines(path)).length, 1);
|
||||
|
||||
// A restart between writing and answering: the trail remembers the ids at its end.
|
||||
const again = await Trail.open(path);
|
||||
await again.record(ev("e1"));
|
||||
assert.equal((await lines(path)).length, 1);
|
||||
|
||||
// Failed once, then written: the spooled copy goes.
|
||||
store.broken = true;
|
||||
await assert.rejects(handle(ev("e2")));
|
||||
assert.equal(spool.size, 1);
|
||||
store.broken = false;
|
||||
await handle(ev("e2"));
|
||||
assert.equal(spool.size, 0);
|
||||
assert.deepEqual(await readdir(join(dir, "spool")), []);
|
||||
assert.deepEqual((await lines(path)).map((l) => l.id), ["e1", "e2"]);
|
||||
});
|
||||
|
||||
test("on its last delivery a failed event is spooled and taken", async () => {
|
||||
const { handle, store, spool, said } = await setup();
|
||||
store.broken = true;
|
||||
for (let i = 1; i < 5; i++) await assert.rejects(handle(ev("e3")), /ENOSPC/, `delivery ${i} asks again`);
|
||||
await handle(ev("e3")); // the fifth — the controller's max-deliver — returns: taken
|
||||
const [entry] = spool.list();
|
||||
assert.equal(entry.attempts, 5);
|
||||
assert.equal(entry.held, true);
|
||||
assert.match(said.at(-1)!, /spooled and taken/);
|
||||
});
|
||||
|
||||
test("the delivery count survives a restart, because it is the spool's", async () => {
|
||||
const { handle, store, dir } = await setup();
|
||||
store.broken = true;
|
||||
for (let i = 0; i < 3; i++) await assert.rejects(handle(ev("e4")));
|
||||
const reopened = await Spool.open(join(dir, "spool"));
|
||||
assert.equal(reopened.list()[0].attempts, 3);
|
||||
});
|
||||
|
||||
test("the spool replays into the trail once writing works, once each", async () => {
|
||||
const { handle, store, spool, write, path } = await setup();
|
||||
store.broken = true;
|
||||
for (let i = 0; i < 5; i++) await handle(ev("e5")).catch(() => {});
|
||||
for (let i = 0; i < 5; i++) await handle(ev("e6")).catch(() => {});
|
||||
assert.equal(spool.size, 2);
|
||||
|
||||
// Still broken: the pass stops at the first failure and keeps everything.
|
||||
let r = await spool.replay(write);
|
||||
assert.equal(r.replayed, 0);
|
||||
assert.equal(spool.size, 2);
|
||||
|
||||
store.broken = false;
|
||||
r = await spool.replay(write);
|
||||
assert.deepEqual(r, { replayed: 2, left: 0 });
|
||||
await handle(ev("e5")); // a late redelivery after the replay
|
||||
assert.deepEqual((await lines(path)).map((l) => l.id), ["e5", "e6"]);
|
||||
});
|
||||
|
||||
test("over its bound the spool keeps the event but does not take it, so the bus gives it up and says so", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "audit-"));
|
||||
const spool = await Spool.open(join(dir, "spool"), { boundCount: 1 });
|
||||
const failing = async () => {
|
||||
throw new Error("down");
|
||||
};
|
||||
for (let i = 0; i < 5; i++) await takeOrSpool(ev("a"), failing, spool, () => {}).catch(() => {});
|
||||
assert.equal(spool.list()[0].held, true, "within the bound: taken");
|
||||
for (let i = 1; i < 5; i++) await assert.rejects(takeOrSpool(ev("b"), failing, spool, () => {}));
|
||||
await assert.rejects(takeOrSpool(ev("b"), failing, spool, () => {}), /down/, "over the bound: asked again on its last");
|
||||
assert.equal(spool.size, 2, "and still spooled");
|
||||
const st = spool.standing();
|
||||
assert.equal(st.overBound, true);
|
||||
assert.equal(st.spooled, 2);
|
||||
assert.equal(st.held, 1);
|
||||
assert.match(st.why!, /more than 1/);
|
||||
});
|
||||
|
||||
test("an event without a body, or without an id, is recorded once", async () => {
|
||||
const { handle, path } = await setup();
|
||||
await handle(ev("e7", null));
|
||||
await handle(ev("", undefined));
|
||||
await handle(ev("", undefined));
|
||||
const got = await lines(path);
|
||||
assert.equal(got.length, 2);
|
||||
assert.equal(got[0].body, null);
|
||||
});
|
||||
|
||||
test("an event waiting longer than the bound puts the spool over it", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "audit-"));
|
||||
let now = new Date("2026-10-06T10:00:00Z");
|
||||
const spool = await Spool.open(join(dir, "spool"), { now: () => now });
|
||||
await takeOrSpool(ev("old"), async () => { throw new Error("down"); }, spool, () => {}).catch(() => {});
|
||||
assert.equal(spool.standing().overBound, false);
|
||||
now = new Date("2026-10-06T10:31:00Z");
|
||||
assert.match(spool.standing().why!, /longer than 30 minutes/);
|
||||
});
|
||||
|
||||
test("audit_status says what waits in the spool, read from disk by the tool's own process", async () => {
|
||||
const { handle, store, dir } = await setup();
|
||||
store.broken = true;
|
||||
for (let i = 0; i < 5; i++) await handle(ev("s1")).catch(() => {});
|
||||
await handle(ev("s2")).catch(() => {});
|
||||
const [tool] = getAuditTools({ AUDIT_LOG: join(dir, "audit.log"), AUDIT_SPOOL: join(dir, "spool") });
|
||||
assert.equal(tool.name, "audit_status");
|
||||
const got = (await tool.run({}, {} as never)) as { spool: { spooled: number; held: number; lastError: string } };
|
||||
assert.equal(got.spool.spooled, 2);
|
||||
assert.equal(got.spool.held, 1);
|
||||
assert.match(got.spool.lastError, /ENOSPC/);
|
||||
});
|
||||
|
||||
function memBroker() {
|
||||
const subs: { pattern: string; handler: (env: { key: string; node: string; body: unknown }) => Promise<void> }[] = [];
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// The audit-logger's one tool — its standing: where the trail is, and what waits in the spool
|
||||
// (novox/hq issue 276). Launched as its own process beside the handler, so it reads the spool from
|
||||
// disk rather than asking the handler.
|
||||
|
||||
import { stat } from "node:fs/promises";
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { auditLogPath, auditSpoolPath } from "../audit.js";
|
||||
import { Spool } from "../spool.js";
|
||||
|
||||
export function getAuditTools(env: NodeJS.ProcessEnv = process.env): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "audit_status",
|
||||
description:
|
||||
"The audit trail's standing: its path and size, and the events that could not be written yet — how many wait in the spool, how many were taken from the bus and are held only there, the oldest, the last error, and whether the spool is over its bound (then the bus gives events up and max-deliveries is raised).",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const path = auditLogPath(env);
|
||||
const size = await stat(path).then((s) => s.size, () => null);
|
||||
const spool = await Spool.read(auditSpoolPath(env));
|
||||
return { trail: { path, bytes: size }, spool: spool.standing() };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("audit-logger", (env) => getAuditTools(env));
|
||||
@@ -8,5 +8,10 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["audit.ts", "index.ts"]
|
||||
"include": [
|
||||
"audit.ts",
|
||||
"spool.ts",
|
||||
"index.ts",
|
||||
"tools/index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
"why": "the Baserow web UI and REST API, served by the image's own Caddy; a public name is the route's"
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "data",
|
||||
"path": "${dir:data}",
|
||||
"class": "valuable",
|
||||
"why": "uploaded files and media; the tables are in the database"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# blueman
|
||||
|
||||
The Bluetooth tray applet on the workstations, as a module (novox/hq ADR 0208). It requires
|
||||
`x11-display`, so it is assigned only where a display server is held on the same machine.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the applet, the manager window, send-to | package `blueman`, from the official repositories |
|
||||
|
||||
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||
|
||||
- **No AUR.** Both workstations run the official package (`extra`), installed explicitly.
|
||||
- **The Bluetooth stack is not this module's.** `bluez`, `bluez-utils` and `bluetooth.service` belong
|
||||
to the `bluetooth` module. This module declares none of them (ADR 0210 §4: one package, one module).
|
||||
The devices, pairing and power are that module's tools (`bluetooth_*`). This module's tools are
|
||||
about the applet.
|
||||
- **The operator's applet settings are found.** blueman keeps them in dconf (`org.blueman.*`): the
|
||||
plugin switches, the recent connections, auto-connect and auto-power-on. The module neither sets nor
|
||||
resets them. `blueman_status` shows the plugin switches.
|
||||
|
||||
## How it starts: the package's autostart entry, and nothing else
|
||||
|
||||
One process has one starter (the rule `picom` states for the desktop modules). The package ships
|
||||
`/etc/xdg/autostart/blueman.desktop` (`blueman-applet`). The session runs it once at login through
|
||||
the `i3` module's `dex --autostart --environment i3`. **That entry is the applet's one start.** The
|
||||
module adds no `xinitrc` slot and no `node-display-session` exec, because either would start it a
|
||||
second time.
|
||||
|
||||
- **Excluded:** the window manager's `exec … blueman-applet`, which the `i3` module's configuration
|
||||
dropped.
|
||||
- **Not a start:** the package's user unit `blueman-applet.service` is static. It exists for D-Bus
|
||||
activation of `org.blueman.Applet`. A program that calls the applet's bus name while no applet runs
|
||||
starts one through it. The applet is single-instance on that name, so this never makes a second
|
||||
one. The tools always ask the bus with `--auto-start=no`, so asking never starts it.
|
||||
- The applet starts its tray icon, `blueman-tray`, itself.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `blueman_status` (r) | <ul><li>whether the applet and its tray icon run: pid, since, and the scope or unit they run in</li><li>the installed version, and what starts it at login</li><li>the plugins the running applet has loaded and those it has not (asked of the applet on the session bus)</li><li>the plugin switches in `org.blueman.general plugin-list`</li><li>whether the applet sees Bluetooth on</li></ul> |
|
||||
| `blueman_restart` (a) | asks the applet and the tray icon to end (SIGTERM), forces them after 5 s, and starts `blueman-applet` in the operator's session. The start is a transient user unit `mesh-blueman-applet`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop |
|
||||
| `blueman_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one applet runs in a desktop session</li><li>`bluetooth.service` is active</li></ul>Each finding says what to do |
|
||||
|
||||
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment,
|
||||
as `clipmenu` and `screen-lock` do. Every command has a timeout and capped output. Everything runs
|
||||
through an injected runner and a fake root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
|
||||
| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
|
||||
| settings (dconf) | defaults, no plugin switched; recent connections only | no plugin switched; auto-connect for one headset, auto-power-on set |
|
||||
|
||||
The workstations are already in the state this module describes.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
|
||||
the applet runs from its one start. `blueman_check` then answers `ok`.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- The applet's dconf settings (`/org/blueman/`).
|
||||
- `/etc/xdg/autostart/blueman.desktop`, the package's own file.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The `bluetooth` module, for bluez and its daemon.** Without them the applet has nothing to
|
||||
manage. There is no dependency mechanism between two modules that hold no seat. So nothing refuses
|
||||
`blueman` on a node without `bluetooth`. `blueman_check` reports it instead, from
|
||||
`bluetooth.service`. **Assign both.** When the stack becomes a seat (`node-bluetooth`), this module
|
||||
depends on the seat.
|
||||
- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat.
|
||||
Assigned without `i3`, the applet is installed and does not start. `blueman_check` says so.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
// blueman's applet as the tools see it: its processes, its XDG autostart entry (the package's), the
|
||||
// applet's own answers on the session bus, and its settings in gsettings. Every bus call is made with
|
||||
// --auto-start=no: the applet is D-Bus activatable, and a question must never start it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
appletComm = "blueman-applet"
|
||||
trayComm = "blueman-tray"
|
||||
appletBin = "/usr/bin/blueman-applet"
|
||||
entryName = "blueman.desktop"
|
||||
restartAs = "mesh-blueman-applet"
|
||||
packageFor = "blueman"
|
||||
busName = "org.blueman.Applet"
|
||||
busPath = "/org/blueman/Applet"
|
||||
stackUnit = "bluetooth.service"
|
||||
)
|
||||
|
||||
// appletCall asks the running applet one question over the session bus and answers busctl's JSON.
|
||||
func (m *Machine) appletCall(method string) (json.RawMessage, error) {
|
||||
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, busPath, busName, method}
|
||||
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
|
||||
if err := failed(o, "busctl", args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
|
||||
return nil, fmt.Errorf("the applet's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
|
||||
}
|
||||
return doc.Data[0], nil
|
||||
}
|
||||
|
||||
func (m *Machine) appletStrings(method string) ([]string, error) {
|
||||
raw, err := m.appletCall(method)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("the applet's %s is not a list of names: %w", method, err)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// gvariantStrings reads gsettings' printed array of strings: "@as []" or "['a', '!b']".
|
||||
func gvariantStrings(s string) []string {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "@as"))
|
||||
s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]")
|
||||
out := []string{}
|
||||
for _, item := range strings.Split(s, ",") {
|
||||
item = strings.Trim(strings.TrimSpace(item), `'"`)
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Plugins is which applet plugins run, and what the operator's settings say about them.
|
||||
type Plugins struct {
|
||||
// Loaded are the plugins the running applet answers it has loaded.
|
||||
Loaded []string `json:"loaded"`
|
||||
// NotLoaded are the plugins it knows but did not load: switched off, or not fit for this machine.
|
||||
NotLoaded []string `json:"not_loaded"`
|
||||
// Switched is the operator's plugin-list setting: a name to load it, !name to keep it off. Empty
|
||||
// is blueman's defaults.
|
||||
Switched []string `json:"switched"`
|
||||
}
|
||||
|
||||
// AppletStatus is what blueman_status answers.
|
||||
type AppletStatus struct {
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Applet []Proc `json:"applet"`
|
||||
Tray []Proc `json:"tray"`
|
||||
StartedBy Autostart `json:"started_by"`
|
||||
// Bluetooth is the applet's own view of the adapter's power, when it runs.
|
||||
Bluetooth *bool `json:"bluetooth_on,omitempty"`
|
||||
Plugins Plugins `json:"plugins"`
|
||||
// Unanswered says why the running applet's view is missing.
|
||||
Unanswered string `json:"unanswered,omitempty"`
|
||||
}
|
||||
|
||||
// Status reads the applet: running or not, how it starts, and its plugins.
|
||||
func (m *Machine) Status() (AppletStatus, error) {
|
||||
s := AppletStatus{Applet: m.procs(appletComm), Tray: m.procs(trayComm), StartedBy: m.autostart(entryName),
|
||||
Plugins: Plugins{Loaded: []string{}, NotLoaded: []string{}, Switched: []string{}}}
|
||||
if s.Applet == nil {
|
||||
s.Applet = []Proc{}
|
||||
}
|
||||
if s.Tray == nil {
|
||||
s.Tray = []Proc{}
|
||||
}
|
||||
if v, err := m.installed(packageFor); err == nil {
|
||||
s.Installed = v
|
||||
}
|
||||
o := m.cmd(5*time.Second, m.bus(), "gsettings", "get", "org.blueman.general", "plugin-list")
|
||||
if o.Err == nil && o.Code == 0 {
|
||||
s.Plugins.Switched = gvariantStrings(o.Stdout)
|
||||
}
|
||||
if len(s.Applet) == 0 {
|
||||
s.Unanswered = "the applet is not running"
|
||||
return s, nil
|
||||
}
|
||||
loaded, err := m.appletStrings("QueryPlugins")
|
||||
if err != nil {
|
||||
s.Unanswered = err.Error()
|
||||
return s, nil
|
||||
}
|
||||
s.Plugins.Loaded = loaded
|
||||
if all, err := m.appletStrings("QueryAvailablePlugins"); err == nil {
|
||||
in := map[string]bool{}
|
||||
for _, p := range loaded {
|
||||
in[p] = true
|
||||
}
|
||||
for _, p := range all {
|
||||
if !in[p] {
|
||||
s.Plugins.NotLoaded = append(s.Plugins.NotLoaded, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, err := m.appletCall("GetBluetoothStatus"); err == nil {
|
||||
var on bool
|
||||
if json.Unmarshal(raw, &on) == nil {
|
||||
s.Bluetooth = &on
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what blueman_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Ended []int `json:"ended"`
|
||||
Killed []int `json:"killed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Session Session `json:"session"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// Restart ends the applet and its tray icon, and starts the applet again in the operator's session
|
||||
// under the account's service manager. The applet starts its tray icon itself.
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return RestartAnswer{}, err
|
||||
}
|
||||
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||
a.Ended, a.Killed = m.stop(5*time.Second, appletComm, trayComm)
|
||||
if err := m.detach(s, restartAs, appletBin); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Running = m.waitFor(appletComm, 4*time.Second)
|
||||
if len(a.Running) == 0 {
|
||||
return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+
|
||||
"see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what blueman_check answers.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies what the module promises and relies on: the package; one start (the package's
|
||||
// autostart entry, which the session's dex runs); the applet running once in a session; and the
|
||||
// Bluetooth daemon it manages, which is the bluetooth module's.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("the package blueman is not installed", "push the module to the node")
|
||||
}
|
||||
entry := m.autostart(entryName)
|
||||
if entry.Starts {
|
||||
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
||||
if entry.From != "/etc/xdg/autostart/"+entryName {
|
||||
add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start")
|
||||
}
|
||||
} else {
|
||||
add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry")
|
||||
}
|
||||
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
|
||||
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
|
||||
}
|
||||
for _, l := range m.i3Starts(appletComm) {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start")
|
||||
}
|
||||
if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" {
|
||||
add("the Bluetooth daemon ("+stackUnit+") is not running: the applet has nothing to manage",
|
||||
"assign the bluetooth module, which owns bluez and its daemon")
|
||||
}
|
||||
running := m.procs(appletComm)
|
||||
if _, err := m.session(); err == nil {
|
||||
switch {
|
||||
case len(running) == 0:
|
||||
add("no applet runs in the desktop session", "blueman_restart")
|
||||
case len(running) > 1:
|
||||
add(fmt.Sprintf("%d applets run", len(running)), "blueman_restart ends them all and starts one")
|
||||
}
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newApplet(t *testing.T, running bool) *fake {
|
||||
f := newFake(t)
|
||||
f.write("/etc/xdg/autostart/blueman.desktop", "[Desktop Entry]\nName=Blueman Applet\nExec=blueman-applet\nType=Application\n")
|
||||
if running {
|
||||
f.proc(3859, 1000, appletComm, []string{"/usr/bin/python", "/usr/bin/blueman-applet"}, "session-c1.scope")
|
||||
f.proc(4084, 1000, trayComm, []string{"/usr/bin/python", "/usr/bin/blueman-tray"}, "session-c1.scope")
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
call := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "pacman":
|
||||
return Output{Stdout: "blueman 2.4.6-2\n"}
|
||||
case name == "gsettings":
|
||||
return Output{Stdout: "['!NetUsage', 'DhcpClient']\n"}
|
||||
case strings.HasSuffix(call, " QueryPlugins"):
|
||||
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager"]]}`}
|
||||
case strings.HasSuffix(call, " QueryAvailablePlugins"):
|
||||
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager","NetUsage"]]}`}
|
||||
case strings.HasSuffix(call, " GetBluetoothStatus"):
|
||||
return Output{Stdout: `{"type":"b","data":[true]}`}
|
||||
case name == "systemctl" && len(args) > 1 && args[0] == "is-active":
|
||||
return Output{Stdout: "active\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestStatusAsksTheRunningAppletAndNeverStartsIt(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Installed != "2.4.6-2" || len(s.Applet) != 1 || len(s.Tray) != 1 || !s.StartedBy.Starts || s.Bluetooth == nil || !*s.Bluetooth {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if strings.Join(s.Plugins.Loaded, ",") != "AuthAgent,PowerManager,StatusIcon" || strings.Join(s.Plugins.NotLoaded, ",") != "NetUsage" ||
|
||||
strings.Join(s.Plugins.Switched, ",") != "!NetUsage,DhcpClient" {
|
||||
t.Fatalf("%+v", s.Plugins)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
|
||||
t.Fatalf("a bus call that could start the applet: %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
stopped := newApplet(t, false)
|
||||
s, err = stopped.Status()
|
||||
if err != nil || s.Unanswered != "the applet is not running" || len(s.Applet) != 0 || s.Bluetooth != nil {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
if stopped.called("busctl") {
|
||||
t.Fatalf("asked the bus with no applet running: %q", stopped.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsListsAreReadAsGSettingsPrintsThem(t *testing.T) {
|
||||
for in, want := range map[string]string{"@as []\n": "", "['a']": "a", "['!a', 'b']\n": "!a,b"} {
|
||||
if got := strings.Join(gvariantStrings(in), ","); got != want {
|
||||
t.Errorf("%q: %q, not %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartEndsAppletAndTrayAndStartsTheApplet(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("without a desktop: %v", err)
|
||||
}
|
||||
f.desktopSession()
|
||||
f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") }
|
||||
a, err := f.Restart()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Ended) != 2 || len(a.Running) != 1 || a.Running[0].PID != 9100 || a.Running[0].Command != appletBin {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) {
|
||||
f := newApplet(t, true)
|
||||
f.desktopSession()
|
||||
c, err := f.Check()
|
||||
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/blueman.desktop" {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id blueman-applet\n")
|
||||
f.write(testHome+"/.config/autostart/blueman.desktop", "[Desktop Entry]\nExec=blueman-applet\nHidden=true\n")
|
||||
f.proc(3860, 1000, appletComm, []string{"blueman-applet"}, "session-c1.scope")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch name {
|
||||
case "pacman":
|
||||
return Output{Code: 1}
|
||||
case "systemctl":
|
||||
return Output{Stdout: "inactive\n", Code: 3}
|
||||
case "dex":
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
c, _ = f.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1",
|
||||
"bluetooth.service", "2 applets run"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no finding %q in\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// The blueman module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Bluetooth tray
|
||||
// applet in the operator's session, served by the node's runtime as the operator account. The module
|
||||
// holds no seat, so every tool is its own. The devices themselves are the bluetooth module's tools.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "blueman_status",
|
||||
Description: "The Bluetooth applet: whether it and its tray icon run (pid, since, and the unit or " +
|
||||
"session scope they run in), the installed version, what starts it at login, the plugins the " +
|
||||
"running applet has loaded and those it has not, the plugin switches in the operator's settings, " +
|
||||
"and whether the applet sees Bluetooth on. Never starts the applet. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "blueman_restart",
|
||||
Description: "End the applet and its tray icon (asked first, then forced after 5 s) and start the " +
|
||||
"applet again in the operator's desktop session, under the account's service manager. Answers " +
|
||||
"the pids ended and the new one. Needs someone logged in to the desktop. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "blueman_check",
|
||||
Description: "Check what the module promises and relies on: the package is installed; the applet has " +
|
||||
"exactly one start (the package's XDG autostart entry, which the session's dex runs; no " +
|
||||
"window-manager exec); it runs once in a desktop session; and the Bluetooth daemon is running " +
|
||||
"(the bluetooth module's). Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// blueman's shape (novox/hq ADR 0208, ADR 0210): one official package, no seat, the X display on its
|
||||
// own machine, no start of its own (the package's autostart entry is the one start), nothing of the
|
||||
// bluetooth module's (bluez, its utilities, its daemon), and the Go bundle serving exactly the listed
|
||||
// blueman_ tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []any `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestItInstallsTheAppletAndNothingElse(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if m.Module != "blueman" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
|
||||
t.Fatalf("resources: %v", m.Resources)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil {
|
||||
t.Fatal("it holds no seat and sets no environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItAddsNoSecondStartAndDeclaresNothingOfTheBluetoothModule(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
// The package ships its XDG autostart entry, which the session's dex runs: an xinitrc slot or a
|
||||
// window-manager exec would start it twice.
|
||||
if m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions)
|
||||
}
|
||||
for _, never := range []string{"autostart", "service", "bluez", "/etc/bluetooth"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %q: the start is the package's, the stack the bluetooth module's", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "blueman_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed blueman_ and described", tool.Name)
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/blueman-tools" || b["binary"] != "blueman-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module blueman
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"module": "blueman",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"tools": [
|
||||
"blueman_status",
|
||||
"blueman_restart",
|
||||
"blueman_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "blueman"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/blueman-tools",
|
||||
"binary": "blueman-tools",
|
||||
"loads": [
|
||||
"blueman-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,6 @@ running. `bluez` becomes explicitly the mesh's.
|
||||
## Leaves as found
|
||||
|
||||
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
|
||||
- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
|
||||
That line is the `i3` module's to keep or drop.
|
||||
- `blueman` and its applet: the `blueman` module's, which relies on this one for the stack and
|
||||
declares none of its packages. The applet starts from the package's XDG autostart entry.
|
||||
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-build-agent",
|
||||
"scope": "node"
|
||||
"scope": "node",
|
||||
"serves": ["current", "kill", "pause", "resume"]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
@@ -24,6 +25,16 @@
|
||||
"own-secrets": {
|
||||
"broker": "${dir:mesh-state}/broker"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "workspace",
|
||||
"path": "${dir:workspace}",
|
||||
"class": "cache",
|
||||
"why": "a build's working copy, cloned again for every build"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
@@ -22,11 +22,17 @@ whenever the node's tool runtime collects the module's tools:
|
||||
| file | holds |
|
||||
|---|---|
|
||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, under the mesh's own: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
|
||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, then the settings registered through this module (the mesh's, then this node's), under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions — then the instruction sections registered for every node and for this one |
|
||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>`. Replaced whole, staged beside and swapped in |
|
||||
|
||||
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
|
||||
this node a subscription token. Nothing else under the home is read or written.
|
||||
Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
|
||||
subscription token; and what is registered at the **home** scope for this node — a skill, subagent,
|
||||
command, output style, or instructions as a rule file — each path recorded in the module's state
|
||||
(`home-placed.json`). It writes, changes and removes only those — never a path the person made, even one with the
|
||||
same content, and never through a directory that is a symbolic link. A placed file changed by hand is left
|
||||
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
|
||||
items to report them; nothing else is read or written.
|
||||
|
||||
## Over NATS
|
||||
|
||||
@@ -41,6 +47,7 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
||||
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
||||
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
||||
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
|
||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name |
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -49,6 +56,24 @@ manager), `claude_code_mcp_list`,
|
||||
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
|
||||
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
||||
|
||||
The agent's configuration (hq ADR 0216), each registered at a **scope** — `mesh` (the default), `node`
|
||||
(`nodes`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the
|
||||
operator account's own `~/.claude` on those nodes):
|
||||
|
||||
- for each kind — `skill`, `agent`, `command`, `hook`, `output_style`, `instructions` —
|
||||
`claude_code_<kind>_list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a
|
||||
lone SKILL.md); a hook is an `event`, a `matcher`, a `command` and its scripts as `files`, with
|
||||
`${HOOK_DIR}` in the command naming their directory. Hooks and settings take no home scope;
|
||||
- `claude_code_settings_get`, `_set` (merged into the scope, or `replace`), `_clear`;
|
||||
`claude_code_permission_add` and `_remove` for one allow, ask or deny rule. The agent refuses to loosen
|
||||
its own settings: these are the operator's to call;
|
||||
- `claude_code_config_list`, `_show` (one registration in full), `_status` (what applies here, the plugin
|
||||
as written, and the home's own items — which the mesh placed, which share a name with a mesh item, which
|
||||
call a tool server not loaded here) and `_import` (an item of this node's home, registered at a scope;
|
||||
the original stays).
|
||||
|
||||
A new session takes a change; a running one at `/reload-plugins`.
|
||||
|
||||
## Settings
|
||||
|
||||
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
|
||||
|
||||
@@ -69,19 +69,25 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
|
||||
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
|
||||
var a, b any
|
||||
_ = json.Unmarshal([]byte(got[file]), &a)
|
||||
// The plugin's two keys are new since the TypeScript (ADR 0216); everything else means the same.
|
||||
if m, ok := a.(map[string]any); ok && file == "managed-settings.json" {
|
||||
for k := range MarketplaceKeys() {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal([]byte(want[file]), &b)
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
|
||||
}
|
||||
}
|
||||
}
|
||||
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered), f.WithKey)
|
||||
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain)
|
||||
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered, Config{}), f.WithKey)
|
||||
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}, Config{}), f.Plain)
|
||||
}
|
||||
|
||||
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
|
||||
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
|
||||
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil)
|
||||
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil, Config{})
|
||||
var mcp struct {
|
||||
MCPServers map[string]map[string]any `json:"mcpServers"`
|
||||
}
|
||||
@@ -89,7 +95,7 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
|
||||
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
|
||||
t.Fatalf("%v", mcp.MCPServers)
|
||||
}
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) {
|
||||
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
|
||||
t.Fatal("rendering is not deterministic")
|
||||
}
|
||||
}
|
||||
@@ -104,7 +110,7 @@ func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
|
||||
}}
|
||||
read := func(binding *Binding) map[string]any {
|
||||
var m map[string]any
|
||||
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil)
|
||||
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil, Config{})
|
||||
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
package main
|
||||
|
||||
// The agent's configuration, registered through this module at three scopes (novox/hq ADR 0216, to-be 36 §8).
|
||||
//
|
||||
// Every registration is one key in the module's `config` state (ADR 0201):
|
||||
//
|
||||
// mesh.<kind>.<name> every node running the agent
|
||||
// node.<node>.<kind>.<name> one node — a list of nodes is one key each
|
||||
// home.<node>.<kind>.<name> the operator account's own agent directory on one node
|
||||
//
|
||||
// Every instance watches the state and takes what applies to it. What it takes lands in one place per kind,
|
||||
// the one place the vendor honours for it:
|
||||
//
|
||||
// skill, agent, command, hook, output-style the plugin `nox-mesh`, in a marketplace in the managed directory
|
||||
// (at the home scope: the home's own directories)
|
||||
// instructions sections of the managed instruction file (home: a rule file)
|
||||
// settings the managed settings file, mesh then node, under the mesh's keys
|
||||
//
|
||||
// Tool servers keep their own state and file (`servers`, the managed tool-server file): the exclusive file
|
||||
// would block a plugin's.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Plugin is the plugin's name, and its marketplace's: what its items are called in a session, `nox-mesh:<name>`.
|
||||
const Plugin = "nox-mesh"
|
||||
|
||||
// MarketplaceDir is where the marketplace is written, inside the managed directory.
|
||||
const MarketplaceDir = "marketplace"
|
||||
|
||||
// MaxItemBytes is the most one registration may carry, its files included: well under the bus's message limit.
|
||||
const MaxItemBytes = 256 << 10
|
||||
|
||||
// The kinds a registration may be.
|
||||
const (
|
||||
KindSkill = "skill"
|
||||
KindAgent = "agent"
|
||||
KindCommand = "command"
|
||||
KindHook = "hook"
|
||||
KindOutputStyle = "output-style"
|
||||
KindInstructions = "instructions"
|
||||
KindSettings = "settings"
|
||||
)
|
||||
|
||||
// Kinds is every kind, in the order a list shows them.
|
||||
var Kinds = []string{KindSkill, KindAgent, KindCommand, KindHook, KindOutputStyle, KindInstructions, KindSettings}
|
||||
|
||||
// The scopes.
|
||||
const (
|
||||
ScopeMesh = "mesh"
|
||||
ScopeNode = "node"
|
||||
ScopeHome = "home"
|
||||
)
|
||||
|
||||
// SettingsName is the one name a settings registration has: a scope holds one set of settings.
|
||||
const SettingsName = "settings"
|
||||
|
||||
var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
||||
|
||||
// nodeName is what a node may be called in a key.
|
||||
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// meshOwnedKeys are the settings a registration may not set: the mesh's own, and those that would deny
|
||||
// the mesh's console or its marketplace by another way.
|
||||
var meshOwnedKeys = []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins",
|
||||
"allowedMcpServers", "deniedMcpServers", "allowManagedMcpServersOnly", "strictKnownMarketplaces", "blockedMarketplaces"}
|
||||
|
||||
// hookEvents are the events a hook may be registered for.
|
||||
var hookEvents = map[string]bool{"PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, "Notification": true,
|
||||
"Stop": true, "SubagentStop": true, "SessionStart": true, "SessionEnd": true, "PreCompact": true}
|
||||
|
||||
// Item is one registration, as the state keeps it.
|
||||
type Item struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
// Files are the item's files by path relative to the item: a skill's SKILL.md and whatever sits beside
|
||||
// it; for the one-file kinds, `<name>.md`; a hook's scripts.
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
// A hook's event, matcher and command. In the command, ${HOOK_DIR} is the directory its files are in.
|
||||
Event string `json:"event,omitempty"`
|
||||
Matcher string `json:"matcher,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
// Settings, for the settings kind: keys of the vendor's settings file.
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
// Where it was registered from, and when.
|
||||
By string `json:"by,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
}
|
||||
|
||||
// Key is where an item lives in the state, for one node (ignored at the mesh scope).
|
||||
func (it Item) Key(node string) string {
|
||||
if it.Scope == ScopeMesh {
|
||||
return ScopeMesh + "." + it.Kind + "." + it.Name
|
||||
}
|
||||
return it.Scope + "." + node + "." + it.Kind + "." + it.Name
|
||||
}
|
||||
|
||||
// ParseKey reads a key back: its scope, the node it is for ("" at the mesh scope), the kind and the name.
|
||||
func ParseKey(key string) (scope, node, kind, name string, ok bool) {
|
||||
parts := strings.Split(key, ".")
|
||||
switch {
|
||||
case len(parts) == 3 && parts[0] == ScopeMesh:
|
||||
return ScopeMesh, "", parts[1], parts[2], true
|
||||
case len(parts) == 4 && (parts[0] == ScopeNode || parts[0] == ScopeHome):
|
||||
return parts[0], parts[1], parts[2], parts[3], true
|
||||
}
|
||||
return "", "", "", "", false
|
||||
}
|
||||
|
||||
func knownKind(k string) bool {
|
||||
for _, x := range Kinds {
|
||||
if x == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Problem says why an item cannot be registered, or "".
|
||||
func (it Item) Problem() string {
|
||||
if !knownKind(it.Kind) {
|
||||
return fmt.Sprintf("%q is not a kind: %s", it.Kind, strings.Join(Kinds, ", "))
|
||||
}
|
||||
if it.Scope != ScopeMesh && it.Scope != ScopeNode && it.Scope != ScopeHome {
|
||||
return fmt.Sprintf("%q is not a scope: mesh, node or home", it.Scope)
|
||||
}
|
||||
if it.Kind == KindSettings {
|
||||
if it.Scope == ScopeHome {
|
||||
return "settings take the mesh and node scopes only: the home's settings file is the person's"
|
||||
}
|
||||
if it.Name != SettingsName {
|
||||
return "a scope holds one set of settings, named " + SettingsName
|
||||
}
|
||||
if len(it.Settings) == 0 {
|
||||
return "no settings given"
|
||||
}
|
||||
for _, key := range meshOwnedKeys {
|
||||
if _, ok := it.Settings[key]; ok {
|
||||
return fmt.Sprintf("%q is one of the mesh's own keys, or would turn off the mesh's console or plugin; a registration cannot set it", key)
|
||||
}
|
||||
}
|
||||
} else if !itemName.MatchString(it.Name) {
|
||||
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
|
||||
}
|
||||
if it.Kind == KindHook {
|
||||
if it.Scope == ScopeHome {
|
||||
return "a hook takes the mesh and node scopes only: at home it would live in the person's settings file"
|
||||
}
|
||||
if !hookEvents[it.Event] {
|
||||
return fmt.Sprintf("%q is not a hook event the agent knows", it.Event)
|
||||
}
|
||||
if strings.TrimSpace(it.Command) == "" {
|
||||
return "a hook needs a command"
|
||||
}
|
||||
}
|
||||
for rel := range it.Files {
|
||||
if problem := pathProblem(rel); problem != "" {
|
||||
return problem
|
||||
}
|
||||
for other := range it.Files {
|
||||
if strings.HasPrefix(other, rel+"/") {
|
||||
return fmt.Sprintf("%q is a file and also the directory of %q", rel, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
if _, ok := it.Files["SKILL.md"]; !ok {
|
||||
return "a skill needs a SKILL.md"
|
||||
}
|
||||
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||
if len(it.Files) != 1 || strings.TrimSpace(it.Files[it.Name+".md"]) == "" {
|
||||
return "this kind is one file, " + it.Name + ".md, with content"
|
||||
}
|
||||
}
|
||||
if n := it.size(); n > MaxItemBytes {
|
||||
return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
|
||||
// segment a real name — never empty, `.` or `..`.
|
||||
func pathProblem(rel string) string {
|
||||
if rel == "" || path.IsAbs(rel) || strings.ContainsAny(rel, "\\\x00") {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (it Item) size() int {
|
||||
raw, _ := json.Marshal(it)
|
||||
return len(raw)
|
||||
}
|
||||
|
||||
// ---- the view -------------------------------------------------------------------------------------
|
||||
|
||||
// ConfigView is what this node takes from the `config` state: every mesh item, and the node and home items
|
||||
// for this node — kept in memory from the watch and written through to the module's own file, so the
|
||||
// managed directory renders without the bus.
|
||||
type ConfigView struct {
|
||||
p Paths
|
||||
mu sync.Mutex
|
||||
items map[string]Item
|
||||
}
|
||||
|
||||
// NewConfigView is the view as last written through, or empty.
|
||||
func NewConfigView(p Paths) *ConfigView {
|
||||
v := &ConfigView{p: p, items: map[string]Item{}}
|
||||
_ = readJSON(p.config(), &v.items)
|
||||
return v
|
||||
}
|
||||
|
||||
// Applies says whether a key is this node's to take.
|
||||
func (v *ConfigView) Applies(key string) bool {
|
||||
scope, node, _, _, ok := ParseKey(key)
|
||||
return ok && (scope == ScopeMesh || node == v.p.Node)
|
||||
}
|
||||
|
||||
// Take takes one change, and answers whether what applies to this node changed.
|
||||
func (v *ConfigView) Take(key, op string, item *Item) bool {
|
||||
if !v.Applies(key) {
|
||||
return false
|
||||
}
|
||||
_, node, _, _, _ := ParseKey(key)
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
// Only an item that is what its key says: a key decides which nodes take an item, the item where it
|
||||
// lands, and the two must agree — a mesh key holding a home item would land in every home.
|
||||
if op == "put" && item != nil && item.Problem() == "" && item.Key(node) == key {
|
||||
v.items[key] = *item
|
||||
} else {
|
||||
delete(v.items, key)
|
||||
}
|
||||
return v.writeThroughLocked()
|
||||
}
|
||||
|
||||
// Prune drops what the view holds and the state no longer does: a registration removed while this node
|
||||
// was away is never handed over by the watch, which hands over what is there, not what went.
|
||||
func (v *ConfigView) Prune(present []string) bool {
|
||||
keep := map[string]bool{}
|
||||
for _, k := range present {
|
||||
keep[k] = true
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
for k := range v.items {
|
||||
if !keep[k] {
|
||||
delete(v.items, k)
|
||||
}
|
||||
}
|
||||
return v.writeThroughLocked()
|
||||
}
|
||||
|
||||
// Items is what applies here, by key.
|
||||
func (v *ConfigView) Items() map[string]Item {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
out := make(map[string]Item, len(v.items))
|
||||
for k, it := range v.items {
|
||||
out[k] = it
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeThroughLocked writes the view to its file, with v.mu held: the snapshot and the write are one step, so
|
||||
// an older snapshot never lands after a newer one.
|
||||
func (v *ConfigView) writeThroughLocked() bool {
|
||||
now, _ := indented(v.items)
|
||||
before, _ := os.ReadFile(v.p.config())
|
||||
if string(before) == string(now) {
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(v.p.config(), now, 0o600)
|
||||
return true
|
||||
}
|
||||
|
||||
// Config is what applies to this node, sorted for rendering.
|
||||
type Config struct {
|
||||
Mesh, Node, Home []Item
|
||||
}
|
||||
|
||||
// ConfigOf sorts the items that apply here by scope, each scope by kind and name.
|
||||
func ConfigOf(items map[string]Item) Config {
|
||||
var c Config
|
||||
for _, it := range items {
|
||||
switch it.Scope {
|
||||
case ScopeMesh:
|
||||
c.Mesh = append(c.Mesh, it)
|
||||
case ScopeNode:
|
||||
c.Node = append(c.Node, it)
|
||||
case ScopeHome:
|
||||
c.Home = append(c.Home, it)
|
||||
}
|
||||
}
|
||||
for _, list := range [][]Item{c.Mesh, c.Node, c.Home} {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].Kind != list[j].Kind {
|
||||
return list[i].Kind < list[j].Kind
|
||||
}
|
||||
return list[i].Name < list[j].Name
|
||||
})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ---- rendering --------------------------------------------------------------------------------------
|
||||
|
||||
// PluginFile is one file of the marketplace: its content and whether it is run.
|
||||
type PluginFile struct {
|
||||
Content string
|
||||
Executable bool
|
||||
}
|
||||
|
||||
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
|
||||
// over a mesh item of the same kind and name: the node's wins.
|
||||
func Marketplace(c Config) map[string]PluginFile {
|
||||
root := "plugins/" + Plugin + "/"
|
||||
out := map[string]PluginFile{
|
||||
".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{
|
||||
"name": Plugin,
|
||||
"owner": map[string]any{"name": "the mesh"},
|
||||
"plugins": []any{map[string]any{"name": Plugin, "source": "./plugins/" + Plugin,
|
||||
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand."}},
|
||||
})},
|
||||
root + ".claude-plugin/plugin.json": {Content: jsonFile(map[string]any{
|
||||
"name": Plugin, "version": "1.0.0",
|
||||
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand.",
|
||||
"author": map[string]any{"name": "the mesh"},
|
||||
})},
|
||||
}
|
||||
chosen := map[string]Item{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
chosen[it.Kind+"/"+it.Name] = it
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(chosen))
|
||||
for k := range chosen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
hooks := map[string][]any{}
|
||||
for _, k := range keys {
|
||||
it := chosen[k]
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
for rel, content := range it.Files {
|
||||
out[root+"skills/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: isScript(rel, content)}
|
||||
}
|
||||
case KindAgent:
|
||||
out[root+"agents/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindCommand:
|
||||
out[root+"commands/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindOutputStyle:
|
||||
out[root+"output-styles/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindHook:
|
||||
for rel, content := range it.Files {
|
||||
out[root+"hooks/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: true}
|
||||
}
|
||||
command := strings.ReplaceAll(it.Command, "${HOOK_DIR}", `"${CLAUDE_PLUGIN_ROOT}/hooks/`+it.Name+`"`)
|
||||
entry := map[string]any{"hooks": []any{map[string]any{"type": "command", "command": command}}}
|
||||
if it.Matcher != "" {
|
||||
entry["matcher"] = it.Matcher
|
||||
}
|
||||
hooks[it.Event] = append(hooks[it.Event], entry)
|
||||
}
|
||||
}
|
||||
if len(hooks) > 0 {
|
||||
out[root+"hooks/hooks.json"] = PluginFile{Content: jsonFile(map[string]any{"hooks": hooks})}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isScript(rel, content string) bool {
|
||||
return strings.HasPrefix(content, "#!") || strings.HasSuffix(rel, ".sh")
|
||||
}
|
||||
|
||||
// MarketplaceKeys are the two managed settings keys that name the marketplace and enable the plugin.
|
||||
func MarketplaceKeys() map[string]any {
|
||||
return map[string]any{
|
||||
"extraKnownMarketplaces": map[string]any{Plugin: map[string]any{
|
||||
"source": map[string]any{"source": "directory", "path": filepath.Join(ManagedDir, MarketplaceDir)}}},
|
||||
"enabledPlugins": map[string]any{Plugin + "@" + Plugin: true},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredSettings is the settings registered for the mesh, with those registered for this node laid over.
|
||||
func RegisteredSettings(c Config) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
if it.Kind == KindSettings {
|
||||
out = mergeSettings(out, it.Settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSettings lays b over a: objects are merged key by key, lists are joined without repeats (a
|
||||
// permission rule or an auto-mode rule added for a node adds to the mesh's), and anything else is b's.
|
||||
func mergeSettings(a, b map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range a {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range b {
|
||||
switch nb := v.(type) {
|
||||
case map[string]any:
|
||||
if na, ok := out[k].(map[string]any); ok {
|
||||
out[k] = mergeSettings(na, nb)
|
||||
continue
|
||||
}
|
||||
case []any:
|
||||
if la, ok := out[k].([]any); ok {
|
||||
joined := append([]any{}, la...)
|
||||
seen := map[string]bool{}
|
||||
for _, x := range la {
|
||||
raw, _ := json.Marshal(x)
|
||||
seen[string(raw)] = true
|
||||
}
|
||||
for _, x := range nb {
|
||||
raw, _ := json.Marshal(x)
|
||||
if !seen[string(raw)] {
|
||||
seen[string(raw)] = true
|
||||
joined = append(joined, x)
|
||||
}
|
||||
}
|
||||
out[k] = joined
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InstructionSections is what the managed instruction file adds after the mesh's own text: the sections
|
||||
// registered for the mesh, then those for this node.
|
||||
func InstructionSections(c Config) string {
|
||||
var b strings.Builder
|
||||
for _, part := range []struct {
|
||||
title string
|
||||
items []Item
|
||||
}{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} {
|
||||
var sections []Item
|
||||
for _, it := range part.items {
|
||||
if it.Kind == KindInstructions {
|
||||
sections = append(sections, it)
|
||||
}
|
||||
}
|
||||
if len(sections) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title)
|
||||
for _, it := range sections {
|
||||
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// HomeFiles is what the home scope places in the operator account's agent directory, by path relative to
|
||||
// that directory.
|
||||
func HomeFiles(c Config) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, it := range c.Home {
|
||||
switch it.Kind {
|
||||
case KindSkill:
|
||||
for rel, content := range it.Files {
|
||||
out["skills/"+it.Name+"/"+rel] = content
|
||||
}
|
||||
case KindAgent:
|
||||
out["agents/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindCommand:
|
||||
out["commands/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindOutputStyle:
|
||||
out["output-styles/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
case KindInstructions:
|
||||
out["rules/"+it.Name+".md"] = it.Files[it.Name+".md"]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the home ---------------------------------------------------------------------------------------
|
||||
|
||||
// Placed is what this module placed in the home, by path relative to the agent directory, with the digest
|
||||
// of what it wrote (ADR 0182: the mesh owns what it places, and only that).
|
||||
type Placed map[string]string
|
||||
|
||||
func digest(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// deletedByHand marks, in the record, a path the mesh placed and the person then deleted: their choice,
|
||||
// kept until the item is unregistered.
|
||||
const deletedByHand = "deleted-by-hand"
|
||||
|
||||
// PlaceHome brings the home in line with what the home scope wants (ADR 0182): it writes what is wanted and
|
||||
// absent, or its own; it never writes a path the person made, nor through a directory that is a symbolic
|
||||
// link; it removes what it placed and is no longer wanted, unless the person changed it since; and a file it
|
||||
// placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what
|
||||
// it left alone, and why.
|
||||
func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
var placed Placed
|
||||
if !readJSON(p.placed(), &placed) {
|
||||
placed = Placed{}
|
||||
}
|
||||
paths := make([]string, 0, len(want))
|
||||
for rel := range want {
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, rel := range paths {
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
content := want[rel]
|
||||
if why := linkedParent(dir, rel); why != "" {
|
||||
left = append(left, rel+": "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
exists := err == nil
|
||||
ours, wasPlaced := placed[rel]
|
||||
switch {
|
||||
case !exists && wasPlaced && ours == deletedByHand:
|
||||
continue
|
||||
case !exists && wasPlaced:
|
||||
placed[rel] = deletedByHand
|
||||
left = append(left, rel+": deleted by hand, left deleted until the item is unregistered")
|
||||
continue
|
||||
case exists && !wasPlaced:
|
||||
left = append(left, rel+": the person's own, left alone")
|
||||
continue
|
||||
case exists && ours == deletedByHand:
|
||||
left = append(left, rel+": made again by hand after the mesh's was deleted, left alone")
|
||||
continue
|
||||
case exists && string(current) == content:
|
||||
continue
|
||||
case exists && digest(string(current)) != ours:
|
||||
left = append(left, rel+": changed by hand since it was placed, left alone")
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
left = append(left, rel+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if isScript(rel, content) {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||
left = append(left, rel+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
placed[rel] = digest(content)
|
||||
done = append(done, rel+": placed")
|
||||
}
|
||||
for rel, ours := range placed {
|
||||
if _, wanted := want[rel]; wanted {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if ours == deletedByHand {
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
if why := linkedParent(dir, rel); why != "" {
|
||||
left = append(left, rel+": no longer registered, but "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
if err == nil && digest(string(current)) != ours {
|
||||
left = append(left, rel+": no longer registered, but changed by hand, left alone")
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
left = append(left, rel+": no longer registered, and could not be removed: "+err.Error())
|
||||
continue
|
||||
}
|
||||
// Up to the kind's own directory, never it: `skills/<name>` goes when empty, `skills` stays.
|
||||
removeEmptyParents(filepath.Join(dir, strings.SplitN(rel, "/", 2)[0]), filepath.Dir(full))
|
||||
delete(placed, rel)
|
||||
done = append(done, rel+": removed")
|
||||
}
|
||||
raw, _ := indented(placed)
|
||||
if err := os.WriteFile(p.placed(), raw, 0o600); err != nil {
|
||||
left = append(left, "the record of what was placed could not be saved: "+err.Error())
|
||||
}
|
||||
return done, left
|
||||
}
|
||||
|
||||
// linkedParent says, when a directory between the agent directory and a file is a symbolic link, which one:
|
||||
// writing through it would write wherever it points.
|
||||
func linkedParent(dir, rel string) string {
|
||||
parts := strings.Split(rel, "/")
|
||||
at := dir
|
||||
for _, seg := range parts[:len(parts)-1] {
|
||||
at = filepath.Join(at, seg)
|
||||
if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return at + " is a symbolic link"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// removeEmptyParents removes empty directories from dir up to, not including, root.
|
||||
func removeEmptyParents(root, dir string) {
|
||||
for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) {
|
||||
if err := os.Remove(dir); err != nil {
|
||||
return
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// HomeConflict says whether registering an item at the home scope here would collide with something the
|
||||
// person made: "" when it would not.
|
||||
func HomeConflict(p Paths, it Item) string {
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
for rel := range HomeFiles(Config{Home: []Item{it}}) {
|
||||
if _, ours := placed[rel]; ours {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil {
|
||||
return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- what the module did not place ------------------------------------------------------------------
|
||||
|
||||
// HomeItem is one item found in the home.
|
||||
type HomeItem struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Placed bool `json:"placedByTheMesh"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`)
|
||||
|
||||
// HomeItems lists the home's skills, subagents, commands, output styles and rule files, says which the mesh
|
||||
// placed, and notes those that share a name with a mesh item or call a tool server that is not loaded here.
|
||||
func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
inPlugin := map[string]bool{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
inPlugin[it.Kind+"/"+it.Name] = true
|
||||
}
|
||||
}
|
||||
var out []HomeItem
|
||||
add := func(kind, name, rel, body string) {
|
||||
h := HomeItem{Kind: kind, Name: name, Path: filepath.Join(dir, filepath.FromSlash(rel))}
|
||||
_, h.Placed = placed[rel]
|
||||
if inPlugin[kind+"/"+name] {
|
||||
h.Notes = append(h.Notes, "the mesh also registers a "+kind+" of this name, offered as "+Plugin+":"+name)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range mcpToolRef.FindAllStringSubmatch(body, -1) {
|
||||
if server := m[1]; !seen[server] && server != meshEntry && loaded[server] == nil {
|
||||
seen[server] = true
|
||||
h.Notes = append(h.Notes, "calls tools of `"+server+"`, a tool server not loaded on this node")
|
||||
}
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md"))
|
||||
if err != nil {
|
||||
continue // the vendor's own synced folders carry none at their top
|
||||
}
|
||||
add(KindSkill, e.Name(), "skills/"+e.Name()+"/SKILL.md", string(body))
|
||||
}
|
||||
}
|
||||
for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} {
|
||||
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name()))
|
||||
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return out[i].Kind < out[j].Kind
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ImportFromHome reads one item of this node's home as an item to register. A rule file becomes instructions.
|
||||
func ImportFromHome(p Paths, kind, name string) (Item, error) {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
it := Item{Kind: kind, Name: name, Files: map[string]string{}}
|
||||
switch kind {
|
||||
case KindSkill:
|
||||
root := filepath.Join(dir, "skills", name)
|
||||
err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
raw, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
it.Files[filepath.ToSlash(rel)] = string(raw)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("the skill %s in this home: %w", name, err)
|
||||
}
|
||||
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
|
||||
sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind]
|
||||
raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md"))
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err)
|
||||
}
|
||||
it.Files[name+".md"] = string(raw)
|
||||
default:
|
||||
return Item{}, fmt.Errorf("a %s is not imported from a home", kind)
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// ---- registering ------------------------------------------------------------------------------------
|
||||
|
||||
// ConfigState is the `config` state as this module reaches it through the runtime.
|
||||
type ConfigState interface {
|
||||
Put(key string, value any) error
|
||||
Delete(key string) error
|
||||
Keys() ([]string, error)
|
||||
Get(key string) (json.RawMessage, bool, error)
|
||||
}
|
||||
|
||||
// Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the
|
||||
// node and home scopes one key per node — this node when none is given. Taken into this node's view at once,
|
||||
// so the answer says what it did here.
|
||||
func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) {
|
||||
if !unregister {
|
||||
it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339)
|
||||
if problem := it.Problem(); problem != "" {
|
||||
return map[string]any{"registered": false, "reason": problem}, nil
|
||||
}
|
||||
} else if !knownKind(it.Kind) {
|
||||
return map[string]any{"unregistered": false, "reason": fmt.Sprintf("%q is not a kind", it.Kind)}, nil
|
||||
}
|
||||
if it.Scope == ScopeMesh {
|
||||
nodes = []string{""}
|
||||
} else if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
} else if problem := nodesProblem(nodes); problem != "" {
|
||||
return map[string]any{verbOf(unregister): false, "reason": problem}, nil
|
||||
}
|
||||
if !unregister && it.Scope == ScopeHome {
|
||||
for _, n := range nodes {
|
||||
if n == p.Node {
|
||||
if conflict := HomeConflict(p, it); conflict != "" {
|
||||
return map[string]any{"registered": false, "reason": conflict}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compared before and after rather than read from Take: this node's own watch may take the same change
|
||||
// first, and then Take here finds nothing new although this call made it.
|
||||
before, _ := json.Marshal(v.Items())
|
||||
var keys []string
|
||||
for _, n := range nodes {
|
||||
key := it.Key(n)
|
||||
keys = append(keys, key)
|
||||
var err error
|
||||
if unregister {
|
||||
err = state.Delete(key)
|
||||
} else {
|
||||
err = state.Put(key, it)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op := "put"
|
||||
if unregister {
|
||||
op = "delete"
|
||||
}
|
||||
item := it
|
||||
v.Take(key, op, &item)
|
||||
}
|
||||
after, _ := json.Marshal(v.Items())
|
||||
here := string(before) != string(after)
|
||||
for _, k := range keys {
|
||||
here = here || v.Applies(k)
|
||||
}
|
||||
verb := map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||
answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys}
|
||||
if !unregister {
|
||||
switch {
|
||||
case it.Scope == ScopeHome && it.Kind == KindCommand:
|
||||
answer["offered as"] = "/" + it.Name
|
||||
case it.Scope == ScopeHome:
|
||||
answer["offered as"] = it.Name + ", from the home"
|
||||
case it.Kind == KindSkill || it.Kind == KindAgent || it.Kind == KindOutputStyle:
|
||||
answer["offered as"] = Plugin + ":" + it.Name
|
||||
case it.Kind == KindCommand:
|
||||
answer["offered as"] = "/" + Plugin + ":" + it.Name
|
||||
}
|
||||
}
|
||||
if here {
|
||||
// Rendered whenever it applies here, so the answer says what this node wrote, whichever of the
|
||||
// watch and this call took the change.
|
||||
rendered, err := RenderNow(p, write)
|
||||
answer["rendered here"] = rendered
|
||||
if err != nil {
|
||||
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
|
||||
}
|
||||
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
|
||||
} else {
|
||||
answer["here"] = "not this node: each node it is for takes it from the bus"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func verbOf(unregister bool) string {
|
||||
return map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||
}
|
||||
|
||||
// nodesProblem says why a list of nodes cannot name keys, or "". "all" has been expanded before this.
|
||||
func nodesProblem(nodes []string) string {
|
||||
for _, n := range nodes {
|
||||
if !nodeName.MatchString(n) {
|
||||
return fmt.Sprintf("%q is not a node's name", n)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExpandNodes turns `all` into every node running the module; anything else is kept.
|
||||
func ExpandNodes(nodes []string, running func() ([]string, error)) ([]string, error) {
|
||||
if len(nodes) != 1 || nodes[0] != "all" {
|
||||
return nodes, nil
|
||||
}
|
||||
all, err := running()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("which nodes run claude-code: %w", err)
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, fmt.Errorf("no node is known to run claude-code")
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state.
|
||||
func List(state ConfigState, kind string) (map[string]any, error) {
|
||||
keys, err := state.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := map[string]any{}
|
||||
for _, key := range keys {
|
||||
_, _, k, _, ok := ParseKey(key)
|
||||
if !ok || (kind != "" && k != kind) {
|
||||
continue
|
||||
}
|
||||
raw, found, err := state.Get(key)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
var it Item
|
||||
if json.Unmarshal(raw, &it) != nil {
|
||||
continue
|
||||
}
|
||||
files := make([]string, 0, len(it.Files))
|
||||
for f := range it.Files {
|
||||
files = append(files, f)
|
||||
}
|
||||
sort.Strings(files)
|
||||
summary := map[string]any{"by": it.By, "at": it.At}
|
||||
if len(files) > 0 {
|
||||
summary["files"] = files
|
||||
}
|
||||
if it.Kind == KindHook {
|
||||
summary["event"], summary["matcher"], summary["command"] = it.Event, it.Matcher, it.Command
|
||||
}
|
||||
if it.Kind == KindSettings {
|
||||
summary["settings"] = it.Settings
|
||||
}
|
||||
out[key] = summary
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Show is one registration in full: its files' content included.
|
||||
func Show(state ConfigState, key string) (any, error) {
|
||||
raw, found, err := state.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("nothing is registered at %s", key)
|
||||
}
|
||||
var it Item
|
||||
if err := json.Unmarshal(raw, &it); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package main
|
||||
|
||||
// The agent's configuration registered at three scopes (novox/hq ADR 0216): each test is one row of the
|
||||
// record's "How it is checked".
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// memConfig is the `config` state as a map, shared by the nodes of a test the way the bus shares it.
|
||||
type memConfig map[string]json.RawMessage
|
||||
|
||||
func (m memConfig) Put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
m[key] = raw
|
||||
return err
|
||||
}
|
||||
func (m memConfig) Delete(key string) error { delete(m, key); return nil }
|
||||
func (m memConfig) Keys() ([]string, error) {
|
||||
out := []string{}
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m memConfig) Get(key string) (json.RawMessage, bool, error) {
|
||||
raw, ok := m[key]
|
||||
return raw, ok, nil
|
||||
}
|
||||
|
||||
// deliver hands every key of the state to a node's view, as its watch would.
|
||||
func deliver(m memConfig, v *ConfigView) {
|
||||
for key, raw := range m {
|
||||
var it Item
|
||||
_ = json.Unmarshal(raw, &it)
|
||||
v.Take(key, "put", &it)
|
||||
}
|
||||
}
|
||||
|
||||
func one(name, content string) map[string]string { return map[string]string{name + ".md": content} }
|
||||
|
||||
func pluginOf(t *testing.T, w map[string]string) map[string]PluginFile {
|
||||
t.Helper()
|
||||
var files map[string]PluginFile
|
||||
if err := json.Unmarshal([]byte(w[MarketplaceDir+"/"]), &files); err != nil {
|
||||
t.Fatalf("no marketplace written: %v", err)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func TestEachKindLandsInItsOnePlace(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
register := func(it Item) {
|
||||
t.Helper()
|
||||
answer, err := Register(p, it, nil, false, state, view, writer(w))
|
||||
if err != nil || answer["registered"] == false {
|
||||
t.Fatalf("%s %s: %v %v", it.Kind, it.Name, answer, err)
|
||||
}
|
||||
}
|
||||
register(Item{Kind: KindSkill, Name: "review", Scope: ScopeMesh,
|
||||
Files: map[string]string{"SKILL.md": "---\nname: review\ndescription: d\n---\nbody", "scripts/run.sh": "#!/bin/sh\necho hi\n"}})
|
||||
register(Item{Kind: KindAgent, Name: "reviewer", Scope: ScopeMesh, Files: one("reviewer", "---\nname: reviewer\n---\nx")})
|
||||
register(Item{Kind: KindCommand, Name: "ship", Scope: ScopeMesh, Files: one("ship", "ship it")})
|
||||
register(Item{Kind: KindOutputStyle, Name: "terse", Scope: ScopeMesh, Files: one("terse", "---\nname: terse\n---\nshort")})
|
||||
register(Item{Kind: KindHook, Name: "guard", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
|
||||
Command: "${HOOK_DIR}/guard.sh", Files: map[string]string{"guard.sh": "#!/bin/sh\nexit 0\n"}})
|
||||
register(Item{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: one("conventions", "Commit in the imperative.")})
|
||||
register(Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh,
|
||||
Settings: map[string]any{"permissions": map[string]any{"deny": []any{"Bash(rm -rf:*)"}}}})
|
||||
|
||||
plugin := pluginOf(t, w)
|
||||
root := "plugins/" + Plugin + "/"
|
||||
for _, want := range []string{".claude-plugin/marketplace.json", root + ".claude-plugin/plugin.json",
|
||||
root + "skills/review/SKILL.md", root + "skills/review/scripts/run.sh", root + "agents/reviewer.md",
|
||||
root + "commands/ship.md", root + "output-styles/terse.md", root + "hooks/hooks.json", root + "hooks/guard/guard.sh"} {
|
||||
if _, ok := plugin[want]; !ok {
|
||||
t.Errorf("the plugin lacks %s", want)
|
||||
}
|
||||
}
|
||||
if !plugin[root+"skills/review/scripts/run.sh"].Executable || !plugin[root+"hooks/guard/guard.sh"].Executable {
|
||||
t.Error("a script is not executable")
|
||||
}
|
||||
var hooks struct {
|
||||
Hooks map[string][]struct {
|
||||
Matcher string `json:"matcher"`
|
||||
Hooks []struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"hooks"`
|
||||
} `json:"hooks"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks)
|
||||
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 1 || pre[0].Matcher != "Bash" ||
|
||||
pre[0].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
|
||||
t.Errorf("the hook's command does not name its directory, quoted: %s", plugin[root+"hooks/hooks.json"].Content)
|
||||
}
|
||||
if !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.") {
|
||||
t.Errorf("the instruction section is not in the managed instruction file:\n%s", w["CLAUDE.md"])
|
||||
}
|
||||
var managed map[string]any
|
||||
_ = json.Unmarshal([]byte(w["managed-settings.json"]), &managed)
|
||||
if managed["permissions"] == nil || managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
|
||||
t.Errorf("managed settings: %v", managed)
|
||||
}
|
||||
if _, inPlugin := plugin[root+"settings.json"]; inPlugin {
|
||||
t.Error("settings were put in the plugin, which drops them")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T) {
|
||||
a, wa := node(t, "laptop")
|
||||
b, wb := node(t, "server")
|
||||
state := memConfig{}
|
||||
va, vb := NewConfigView(a), NewConfigView(b)
|
||||
for _, r := range []struct {
|
||||
it Item
|
||||
nodes []string
|
||||
}{
|
||||
{Item{Kind: KindCommand, Name: "everywhere", Scope: ScopeMesh, Files: one("everywhere", "x")}, nil},
|
||||
{Item{Kind: KindCommand, Name: "server-only", Scope: ScopeNode, Files: one("server-only", "x")}, []string{"server"}},
|
||||
{Item{Kind: KindCommand, Name: "at-home", Scope: ScopeHome, Files: one("at-home", "x")}, []string{"laptop"}},
|
||||
} {
|
||||
if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
deliver(state, vb)
|
||||
if _, err := RenderNow(b, writer(wb)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := "plugins/" + Plugin + "/commands/"
|
||||
pa, pb := pluginOf(t, wa), pluginOf(t, wb)
|
||||
if _, ok := pa[root+"everywhere.md"]; !ok {
|
||||
t.Error("the mesh item is missing on the laptop")
|
||||
}
|
||||
if _, ok := pb[root+"everywhere.md"]; !ok {
|
||||
t.Error("the mesh item is missing on the server")
|
||||
}
|
||||
if _, ok := pa[root+"server-only.md"]; ok {
|
||||
t.Error("the server's item reached the laptop")
|
||||
}
|
||||
if _, ok := pb[root+"server-only.md"]; !ok {
|
||||
t.Error("the server's item is missing on the server")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(a.Home, ".claude", "commands", "at-home.md")); err != nil {
|
||||
t.Error("the home item was not placed in the laptop's home")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(b.Home, ".claude", "commands", "at-home.md")); err == nil {
|
||||
t.Error("the laptop's home item reached the server's home")
|
||||
}
|
||||
if _, ok := pa[root+"at-home.md"]; ok {
|
||||
t.Error("a home item went into the plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMeshsOwnKeysCannotBeSetOrReplaced(t *testing.T) {
|
||||
for _, key := range []string{"extraKnownMarketplaces", "enabledPlugins", "attribution", "apiKeyHelper"} {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{key: true}}
|
||||
if it.Problem() == "" {
|
||||
t.Errorf("a registration could set %s", key)
|
||||
}
|
||||
}
|
||||
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"enabledPlugins": map[string]any{Plugin + "@" + Plugin: false}}},
|
||||
nil, "/h", nil, Config{})
|
||||
var managed map[string]any
|
||||
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||
if managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
|
||||
t.Error("the operator's setting turned the mesh's plugin off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsLayMeshThenNodeAndJoinTheirLists(t *testing.T) {
|
||||
c := Config{
|
||||
Mesh: []Item{{Kind: KindSettings, Scope: ScopeMesh, Settings: map[string]any{
|
||||
"permissions": map[string]any{"deny": []any{"A"}}, "model": "mesh-model"}}},
|
||||
Node: []Item{{Kind: KindSettings, Scope: ScopeNode, Settings: map[string]any{
|
||||
"permissions": map[string]any{"deny": []any{"A", "B"}}, "model": "node-model"}}},
|
||||
}
|
||||
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"permissions": map[string]any{"allow": []any{"C"}}}},
|
||||
nil, "/h", nil, c)
|
||||
var managed struct {
|
||||
Permissions map[string][]string `json:"permissions"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||
if strings.Join(managed.Permissions["deny"], ",") != "A,B" || strings.Join(managed.Permissions["allow"], ",") != "C" || managed.Model != "node-model" {
|
||||
t.Fatalf("%+v", managed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHomeScopeOwnsOnlyWhatItPlaced(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
mine := filepath.Join(p.Home, ".claude", "agents", "mine.md")
|
||||
_ = os.MkdirAll(filepath.Dir(mine), 0o755)
|
||||
writeFile(t, mine, "the person's own")
|
||||
|
||||
answer, _ := Register(p, Item{Kind: KindAgent, Name: "mine", Scope: ScopeHome, Files: one("mine", "the mesh's")}, nil, false, state, view, writer(w))
|
||||
if answer["registered"] != false {
|
||||
t.Fatalf("a name the person uses was taken: %v", answer)
|
||||
}
|
||||
if raw, _ := os.ReadFile(mine); string(raw) != "the person's own" {
|
||||
t.Fatal("the person's file was overwritten")
|
||||
}
|
||||
|
||||
placed := filepath.Join(p.Home, ".claude", "agents", "placed.md")
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, _ := os.ReadFile(placed); string(raw) != "v1" {
|
||||
t.Fatal("the home item was not placed")
|
||||
}
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(placed); err == nil {
|
||||
t.Fatal("unregistering did not remove what the mesh placed")
|
||||
}
|
||||
if _, err := os.Stat(mine); err != nil {
|
||||
t.Fatal("unregistering removed the person's file")
|
||||
}
|
||||
|
||||
// Changed by hand after it was placed: left alone when unregistered.
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome, Files: one("edited", "v1")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edited := filepath.Join(p.Home, ".claude", "agents", "edited.md")
|
||||
writeFile(t, edited, "changed by hand")
|
||||
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw, _ := os.ReadFile(edited); string(raw) != "changed by hand" {
|
||||
t.Fatal("a placed file changed by hand was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnItemAboveTheLimitOrMisshapenIsRefused(t *testing.T) {
|
||||
big := Item{Kind: KindSkill, Name: "big", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": strings.Repeat("x", MaxItemBytes+1)}}
|
||||
cases := map[string]Item{
|
||||
"too big": big,
|
||||
"a bad name": {Kind: KindAgent, Name: "Bad Name", Scope: ScopeMesh, Files: one("Bad Name", "x")},
|
||||
"a skill without one": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"other.md": "x"}},
|
||||
"a path outside": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": "x", "../escape": "x"}},
|
||||
"a hook at home": {Kind: KindHook, Name: "h", Scope: ScopeHome, Event: "Stop", Command: "true"},
|
||||
"settings at home": {Kind: KindSettings, Name: SettingsName, Scope: ScopeHome, Settings: map[string]any{"model": "x"}},
|
||||
"an unknown event": {Kind: KindHook, Name: "h", Scope: ScopeMesh, Event: "Whenever", Command: "true"},
|
||||
}
|
||||
for label, it := range cases {
|
||||
if it.Problem() == "" {
|
||||
t.Errorf("%s was accepted", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\nuse mcp__gone__do_it")
|
||||
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
|
||||
it, err := ImportFromHome(p, KindSkill, "old")
|
||||
if err != nil || len(it.Files) != 2 || it.Files["scripts/a.sh"] == "" {
|
||||
t.Fatalf("%+v %v", it, err)
|
||||
}
|
||||
items := HomeItems(p, Config{Mesh: []Item{{Kind: KindSkill, Name: "old"}}}, Servers{})
|
||||
if len(items) != 1 || len(items[0].Notes) != 2 {
|
||||
t.Fatalf("%+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced.
|
||||
func TestTheManifestListsEveryToolServed(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
listed := map[string]bool{}
|
||||
for _, n := range m.Tools {
|
||||
listed[n] = true
|
||||
}
|
||||
p, _ := node(t, "laptop")
|
||||
served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p))
|
||||
for _, tool := range served {
|
||||
if !listed[tool.Name] {
|
||||
t.Errorf("%s is served and not in the manifest", tool.Name)
|
||||
}
|
||||
delete(listed, tool.Name)
|
||||
}
|
||||
for n := range listed {
|
||||
t.Errorf("%s is in the manifest and not served", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The tree writer's comparison: a directory holding exactly the files given, executable bits included.
|
||||
func TestATreeIsTheSameOnlyWhenEveryFileAndModeIs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]PluginFile{"a.md": {Content: "a"}, "s/run.sh": {Content: "#!/bin/sh\n", Executable: true}}
|
||||
_ = os.MkdirAll(filepath.Join(dir, "s"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "a.md"), "a")
|
||||
writeFile(t, filepath.Join(dir, "s", "run.sh"), "#!/bin/sh\n")
|
||||
if sameTree(dir, files) {
|
||||
t.Fatal("a script without its executable bit counted as the same")
|
||||
}
|
||||
_ = os.Chmod(filepath.Join(dir, "s", "run.sh"), 0o755)
|
||||
if !sameTree(dir, files) {
|
||||
t.Fatal("the same tree counted as different")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "extra.md"), "x")
|
||||
if sameTree(dir, files) {
|
||||
t.Fatal("an extra file counted as the same")
|
||||
}
|
||||
}
|
||||
|
||||
// A registration removed while a node was away is dropped when it is back: the watch hands over only what is
|
||||
// there, so the view is pruned to the keys the state still holds.
|
||||
func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
for _, name := range []string{"kept", "gone"} {
|
||||
if _, err := Register(p, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}, nil, false, state, view, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
delete(state, "mesh.command.gone") // removed from another node while this one was off
|
||||
back := NewConfigView(p) // the node starts again from what it last wrote
|
||||
if len(back.Items()) != 2 {
|
||||
t.Fatalf("the view did not start from what was last written: %v", back.Items())
|
||||
}
|
||||
keys, _ := state.Keys()
|
||||
if !back.Prune(keys) || len(back.Items()) != 1 {
|
||||
t.Fatalf("after pruning: %v", back.Items())
|
||||
}
|
||||
}
|
||||
|
||||
// The review's findings, each held by a test.
|
||||
|
||||
func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
|
||||
for label, files := range map[string]map[string]string{
|
||||
"a dot": {"SKILL.md": "x", ".": "x"},
|
||||
"an empty segment": {"SKILL.md": "x", "a//b": "x"},
|
||||
"a parent segment": {"SKILL.md": "x", "a/../b": "x"},
|
||||
"a file and directory": {"SKILL.md": "x", "a": "x", "a/b": "x"},
|
||||
} {
|
||||
if (Item{Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: files}).Problem() == "" {
|
||||
t.Errorf("%s was accepted", label)
|
||||
}
|
||||
}
|
||||
p, _ := node(t, "laptop")
|
||||
v := NewConfigView(p)
|
||||
home := Item{Kind: KindCommand, Name: "x", Scope: ScopeHome, Files: one("x", "y")}
|
||||
if v.Take("mesh.command.x", "put", &home); len(v.Items()) != 0 {
|
||||
t.Fatal("a mesh key holding a home item was taken")
|
||||
}
|
||||
for _, key := range []string{"mesh.command.x", "mesh.agent.x"} {
|
||||
mesh := Item{Kind: KindCommand, Name: "x", Scope: ScopeMesh, Files: one("x", "y")}
|
||||
v.Take(key, "put", &mesh)
|
||||
}
|
||||
if len(v.Items()) != 1 {
|
||||
t.Fatalf("an item under a key of another kind was taken: %v", v.Items())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
|
||||
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
|
||||
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
|
||||
t.Fatalf("%v %v", nodes, err)
|
||||
}
|
||||
p, w := node(t, "laptop")
|
||||
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
|
||||
if answer["registered"] != false {
|
||||
t.Fatalf("a node name with a dot was used in a key: %v", answer)
|
||||
}
|
||||
if (Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{"deniedMcpServers": []any{}}}).Problem() == "" {
|
||||
t.Fatal("a registration could deny the mesh's console")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOperatorsOwnPluginsAndMarketplacesAreKept(t *testing.T) {
|
||||
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{
|
||||
"enabledPlugins": map[string]any{"theirs@market": true},
|
||||
"extraKnownMarketplaces": map[string]any{"market": map[string]any{"source": map[string]any{"source": "github", "repo": "o/r"}}},
|
||||
}}, nil, "/h", nil, Config{})
|
||||
var managed struct {
|
||||
Enabled map[string]any `json:"enabledPlugins"`
|
||||
Known map[string]any `json:"extraKnownMarketplaces"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||
if managed.Enabled["theirs@market"] != true || managed.Enabled[Plugin+"@"+Plugin] != true || managed.Known["market"] == nil || managed.Known[Plugin] == nil {
|
||||
t.Fatalf("%+v", managed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHomeLeavesThePersonsChoicesAlone(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
// An identical file the person made is not taken over, so unregistering never removes it.
|
||||
_ = os.MkdirAll(filepath.Join(dir, "agents"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "agents", "same.md"), "x")
|
||||
if _, left := PlaceHome(p, map[string]string{"agents/same.md": "x"}); len(left) != 1 {
|
||||
t.Fatalf("an identical file of the person's was taken over: %v", left)
|
||||
}
|
||||
PlaceHome(p, map[string]string{})
|
||||
if _, err := os.Stat(filepath.Join(dir, "agents", "same.md")); err != nil {
|
||||
t.Fatal("the person's file was removed")
|
||||
}
|
||||
// A placed file the person deleted stays deleted while it is registered.
|
||||
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||
_ = os.Remove(filepath.Join(dir, "commands", "c.md"))
|
||||
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||
if _, err := os.Stat(filepath.Join(dir, "commands", "c.md")); err == nil {
|
||||
t.Fatal("a file the person deleted was placed again")
|
||||
}
|
||||
// The kind's own directory stays when the mesh's last item in it goes.
|
||||
PlaceHome(p, map[string]string{"skills/s/SKILL.md": "x"})
|
||||
PlaceHome(p, map[string]string{})
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); err == nil {
|
||||
t.Fatal("the item's own directory was left")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills")); err != nil {
|
||||
t.Fatal("the kind's directory was removed")
|
||||
}
|
||||
// Never through a symbolic link.
|
||||
elsewhere := t.TempDir()
|
||||
if err := os.Symlink(elsewhere, filepath.Join(dir, "output-styles")); err != nil {
|
||||
t.Skip("no symbolic links here")
|
||||
}
|
||||
PlaceHome(p, map[string]string{"output-styles/o.md": "x"})
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "o.md")); err == nil {
|
||||
t.Fatal("a file was written through a symbolic link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
w := map[string]string{}
|
||||
failing := func(name, content string) (string, error) {
|
||||
if name == MarketplaceDir+"/" {
|
||||
return "", os.ErrPermission
|
||||
}
|
||||
w[name] = content
|
||||
return name + ": written", nil
|
||||
}
|
||||
if _, err := RenderNow(p, failing); err == nil {
|
||||
t.Fatal("the failure was not reported")
|
||||
}
|
||||
if w["managed-settings.json"] == "" || w["managed-mcp.json"] == "" || w["CLAUDE.md"] == "" {
|
||||
t.Fatalf("the other files were not written: %v", w)
|
||||
}
|
||||
}
|
||||
|
||||
// The answer says what this node wrote even when its own watch took the change first.
|
||||
func TestTheAnswerSaysWhatWasWrittenWhenTheWatchWasFirst(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
it := Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}
|
||||
first := it
|
||||
view.Take(it.Key("laptop"), "put", &first) // the watch, first
|
||||
answer, err := Register(p, it, nil, false, state, view, writer(w))
|
||||
if err != nil || answer["rendered here"] == nil || answer["here"] != nil {
|
||||
t.Fatalf("%v %v", answer, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
// The tools that register the agent's configuration (novox/hq ADR 0216): for each kind a list, a register and an
|
||||
// unregister; for settings, a read, a set, a clear and the permission rules; and the status and import tools
|
||||
// for what the module did not place.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// kindTool is how one kind is named in its tools and described to whoever calls them.
|
||||
type kindTool struct {
|
||||
kind, tool, what, lands string
|
||||
}
|
||||
|
||||
var kindTools = []kindTool{
|
||||
{KindSkill, "skill", "a skill: a folder with a SKILL.md (front matter `name` and `description`) and any files beside it",
|
||||
"the plugin's skills/<name>/ (home: ~/.claude/skills/<name>/)"},
|
||||
{KindAgent, "agent", "a subagent: one markdown file with front matter (`name`, `description`, optionally `tools`, `model`)",
|
||||
"the plugin's agents/<name>.md (home: ~/.claude/agents/<name>.md)"},
|
||||
{KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)",
|
||||
"the plugin's commands/<name>.md, run as /" + Plugin + ":<name> (home: ~/.claude/commands/<name>.md, run as /<name>)"},
|
||||
{KindHook, "hook", "a hook: an event, an optional matcher, a command, and optionally the scripts it runs — ${HOOK_DIR} in the command is their directory",
|
||||
"the plugin's hooks/hooks.json, its scripts in hooks/<name>/ (mesh and node scopes only)"},
|
||||
{KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)",
|
||||
"the plugin's output-styles/<name>.md (home: ~/.claude/output-styles/<name>.md)"},
|
||||
{KindInstructions, "instructions", "a section of instructions every session reads",
|
||||
"a section of the managed instruction file, after the mesh's own text (home: a rule file, ~/.claude/rules/<name>.md)"},
|
||||
}
|
||||
|
||||
func boolArg(a map[string]any, k string) bool { b, _ := a[k].(bool); return b }
|
||||
func strArg(a map[string]any, k string) string {
|
||||
s, _ := a[k].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list.
|
||||
func targetNodes(a map[string]any) ([]string, error) {
|
||||
return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe)
|
||||
}
|
||||
|
||||
// scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for.
|
||||
func scopeOf(a map[string]any) string {
|
||||
if s := strArg(a, "scope"); s != "" {
|
||||
return s
|
||||
}
|
||||
return ScopeMesh
|
||||
}
|
||||
|
||||
// filesOf reads an item's files: `content` for a one-file kind, or `files`, a map of path to content.
|
||||
func filesOf(kind, name string, a map[string]any) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if raw, ok := a["files"].(map[string]any); ok {
|
||||
for rel, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("files.%s is not text", rel)
|
||||
}
|
||||
out[rel] = s
|
||||
}
|
||||
}
|
||||
if content, ok := a["content"].(string); ok && content != "" {
|
||||
switch kind {
|
||||
case KindSkill:
|
||||
out["SKILL.md"] = content
|
||||
case KindHook:
|
||||
return nil, errors.New("a hook's scripts are given as files")
|
||||
default:
|
||||
out[name+".md"] = content
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`)
|
||||
nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`)
|
||||
var out []stdio.Tool
|
||||
for _, k := range kindTools {
|
||||
k := k
|
||||
input := map[string]any{
|
||||
"name": str("the name: lower-case letters, digits and -"),
|
||||
"scope": scopeArg,
|
||||
"nodes": nodesArg,
|
||||
}
|
||||
switch k.kind {
|
||||
case KindSkill:
|
||||
input["content"] = str("the SKILL.md, when the skill is that one file")
|
||||
input["files"] = map[string]any{"type": "object", "description": "the skill's files by path inside it, SKILL.md among them, e.g. {\"SKILL.md\": \"...\", \"scripts/run.sh\": \"#!/bin/sh ...\"}"}
|
||||
case KindHook:
|
||||
input["event"] = str("PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart, SessionEnd or PreCompact")
|
||||
input["matcher"] = str("for tool events, which tools, e.g. Bash or Edit|Write; absent is every one")
|
||||
input["command"] = str("the shell command; ${HOOK_DIR} is the directory of the files given, e.g. ${HOOK_DIR}/check.sh")
|
||||
input["files"] = map[string]any{"type": "object", "description": "the scripts the command runs, by path, e.g. {\"check.sh\": \"#!/bin/sh ...\"}"}
|
||||
default:
|
||||
input["content"] = str("the file's content, front matter included")
|
||||
}
|
||||
out = append(out,
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_list",
|
||||
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
|
||||
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
|
||||
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, or at home where the person already has one of that name.",
|
||||
Input: input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
name := strArg(a, "name")
|
||||
files, err := filesOf(k.kind, name, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
|
||||
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_unregister",
|
||||
Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.",
|
||||
Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||
}},
|
||||
)
|
||||
}
|
||||
|
||||
settingsScope := str(`mesh (every node; the default) or node (the nodes given, or this one)`)
|
||||
out = append(out,
|
||||
stdio.Tool{Name: "claude_code_settings_get",
|
||||
Description: "Claude Code's managed settings on this node as written, and where each part came from: the operator's `managed_settings` setting (ADR 0213), the settings registered for the mesh and for this node, and the mesh's own keys, which always win.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
written := map[string]any{}
|
||||
_ = readJSON(filepath.Join(ManagedDir, "managed-settings.json"), &written)
|
||||
var settings Settings
|
||||
_ = readJSON(p.Settings, &settings)
|
||||
c := ConfigOf(view.Items())
|
||||
layers := map[string]any{"managed_settings setting": settings.ManagedSettings}
|
||||
for _, it := range c.Mesh {
|
||||
if it.Kind == KindSettings {
|
||||
layers["registered for the mesh"] = it.Settings
|
||||
}
|
||||
}
|
||||
for _, it := range c.Node {
|
||||
if it.Kind == KindSettings {
|
||||
layers["registered for this node"] = it.Settings
|
||||
}
|
||||
}
|
||||
return map[string]any{"written": written, "layers": layers,
|
||||
"order": "managed_settings setting, then mesh, then node — objects merged, lists joined — then the mesh's own keys"}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_set",
|
||||
Description: "Set Claude Code settings (the vendor's settings keys: permissions, autoMode, env, model, hooks, statusLine, …) for every node or some. Merged into what that scope holds — objects key by key, lists joined — unless replace is set. The mesh's own keys (attribution, the connectors key, the key-helper, the plugin's marketplace) cannot be set. The agent refuses to loosen its own settings: this is the operator's act.",
|
||||
Input: map[string]any{
|
||||
"settings": map[string]any{"type": "object", "description": "settings keys, e.g. {\"permissions\": {\"deny\": [\"Bash(rm -rf:*)\"]}}"},
|
||||
"replace": map[string]any{"type": "boolean", "description": "replace what the scope holds instead of merging into it"},
|
||||
"scope": settingsScope, "nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
given, _ := a["settings"].(map[string]any)
|
||||
if len(given) == 0 {
|
||||
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
if boolArg(a, "replace") {
|
||||
return given
|
||||
}
|
||||
return mergeSettings(held, given)
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_clear",
|
||||
Description: "Remove the Claude Code settings registered at a scope.",
|
||||
Input: map[string]any{"scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_permission_add",
|
||||
Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.",
|
||||
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
|
||||
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}})
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_permission_remove",
|
||||
Description: "Remove a permission rule added through this module, at its scope.",
|
||||
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
perms, _ := held["permissions"].(map[string]any)
|
||||
rules, _ := perms[list].([]any)
|
||||
if len(rules) == 0 {
|
||||
return held // nothing to remove: what the scope holds stays as it is
|
||||
}
|
||||
kept := []any{}
|
||||
for _, r := range rules {
|
||||
if r != rule {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
next := mergeSettings(map[string]any{}, held)
|
||||
np := mergeSettings(map[string]any{}, perms)
|
||||
np[list] = kept
|
||||
next["permissions"] = np
|
||||
return next
|
||||
}, state, view)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_config_list",
|
||||
Description: "Everything registered for Claude Code on the mesh through this module — skills, subagents, commands, hooks, output styles, instruction sections, settings — by key, with who registered each and its files.",
|
||||
Run: func(map[string]any) (any, error) { return List(state, "") }},
|
||||
stdio.Tool{Name: "claude_code_config_show",
|
||||
Description: "One registration in full, its files' content included, by its key as a list shows it (e.g. mesh.skill.review).",
|
||||
Input: map[string]any{"key": str("the key")},
|
||||
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
|
||||
stdio.Tool{Name: "claude_code_config_status",
|
||||
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles and rule files — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
c := ConfigOf(view.Items())
|
||||
names := func(items []Item) []string {
|
||||
out := []string{}
|
||||
for _, it := range items {
|
||||
out = append(out, it.Kind+" "+it.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
var mcp struct {
|
||||
MCPServers Servers `json:"mcpServers"`
|
||||
}
|
||||
_ = readJSON(filepath.Join(ManagedDir, "managed-mcp.json"), &mcp)
|
||||
var placed Placed
|
||||
_ = readJSON(p.placed(), &placed)
|
||||
plugin := []string{}
|
||||
root := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin)
|
||||
_ = filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
||||
if err == nil && !d.IsDir() {
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
plugin = append(plugin, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return map[string]any{
|
||||
"applies here": map[string]any{"mesh": names(c.Mesh), "node": names(c.Node), "home": names(c.Home)},
|
||||
"plugin": map[string]any{"name": Plugin, "files": plugin},
|
||||
"home": HomeItems(p, c, mcp.MCPServers),
|
||||
"placed": placed,
|
||||
}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_config_import",
|
||||
Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.",
|
||||
Input: map[string]any{
|
||||
"kind": str("skill, agent, command, output-style or instructions (a rule file)"),
|
||||
"name": str("its name in the home: the skill's folder, or the file without .md"),
|
||||
"scope": scopeArg, "nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it, err := ImportFromHome(p, strArg(a, "kind"), strArg(a, "name"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Scope = scopeOf(a)
|
||||
if it.Scope == ScopeHome {
|
||||
return nil, errors.New("it is already at home; import it to the mesh or node scope")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetSettings changes the settings registered at a scope, one key per node, by what change makes of what
|
||||
// the key holds. An empty result removes the key.
|
||||
func SetSettings(p Paths, scope string, nodes []string, change func(held map[string]any) map[string]any,
|
||||
state ConfigState, view *ConfigView) (map[string]any, error) {
|
||||
if scope != ScopeMesh && scope != ScopeNode {
|
||||
return map[string]any{"set": false, "reason": "settings take the mesh and node scopes only"}, nil
|
||||
}
|
||||
if scope == ScopeMesh {
|
||||
nodes = []string{""}
|
||||
} else if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
} else if problem := nodesProblem(nodes); problem != "" {
|
||||
return map[string]any{"set": false, "reason": problem}, nil
|
||||
}
|
||||
answers := map[string]any{}
|
||||
for _, n := range nodes {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scope}
|
||||
held := map[string]any{}
|
||||
if raw, found, err := state.Get(it.Key(n)); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
var was Item
|
||||
if json.Unmarshal(raw, &was) == nil && was.Settings != nil {
|
||||
held = was.Settings
|
||||
}
|
||||
}
|
||||
it.Settings = change(held)
|
||||
target := []string(nil)
|
||||
if n != "" {
|
||||
target = []string{n}
|
||||
}
|
||||
var answer map[string]any
|
||||
var err error
|
||||
if len(it.Settings) == 0 {
|
||||
answer, err = Register(p, it, target, true, state, view, writeManaged)
|
||||
} else {
|
||||
answer, err = Register(p, it, target, false, state, view, writeManaged)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer["settings"] = it.Settings
|
||||
answers[it.Key(n)] = answer
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
@@ -30,6 +30,9 @@ func say(format string, args ...any) {
|
||||
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
||||
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
||||
func writeManaged(name, content string) (string, error) {
|
||||
if strings.HasSuffix(name, "/") {
|
||||
return writeManagedTree(strings.TrimSuffix(name, "/"), content)
|
||||
}
|
||||
path := filepath.Join(ManagedDir, name)
|
||||
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
||||
return name + ": unchanged", nil
|
||||
@@ -54,6 +57,74 @@ func writeManaged(name, content string) (string, error) {
|
||||
return name + ": written", nil
|
||||
}
|
||||
|
||||
// writeManagedTree replaces one directory of the managed directory whole — the plugin's marketplace (ADR
|
||||
// 0216) — when what it holds differs from the files given (a JSON map of path to PluginFile). Staged
|
||||
// beside, then swapped in as root, so a session never reads half of it.
|
||||
func writeManagedTree(name, content string) (string, error) {
|
||||
var files map[string]PluginFile
|
||||
if err := json.Unmarshal([]byte(content), &files); err != nil {
|
||||
return "", err
|
||||
}
|
||||
target := filepath.Join(ManagedDir, name)
|
||||
if sameTree(target, files) {
|
||||
return name + "/: unchanged", nil
|
||||
}
|
||||
staged, err := os.MkdirTemp("", "claude-code-tree-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(staged)
|
||||
for rel, f := range files {
|
||||
full := filepath.Join(staged, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if f.Executable {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(f.Content), mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Chmod(full, mode)
|
||||
}
|
||||
_ = os.Chmod(staged, 0o755)
|
||||
script := `set -e; rm -rf "$2.next" "$2.old"; cp -r "$1" "$2.next"; chown -R root:root "$2.next"; chmod -R go-w,a+rX "$2.next";
|
||||
if [ -d "$2" ]; then mv "$2" "$2.old"; fi; mv "$2.next" "$2"; rm -rf "$2.old"`
|
||||
args := []string{"sh", "-c", script, "sh", staged, target}
|
||||
if os.Geteuid() != 0 {
|
||||
args = append([]string{"sudo", "-n"}, args...)
|
||||
}
|
||||
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("%s/: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
||||
name, ManagedDir, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return fmt.Sprintf("%s/: written, %d file(s)", name, len(files)), nil
|
||||
}
|
||||
|
||||
// sameTree says whether a directory holds exactly these files, with these contents and executable bits.
|
||||
func sameTree(dir string, files map[string]PluginFile) bool {
|
||||
found := 0
|
||||
err := filepath.WalkDir(dir, func(full string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, full)
|
||||
f, ok := files[filepath.ToSlash(rel)]
|
||||
if !ok {
|
||||
return errors.New("not wanted")
|
||||
}
|
||||
raw, err := os.ReadFile(full)
|
||||
info, ierr := d.Info()
|
||||
if err != nil || ierr != nil || string(raw) != f.Content || (info.Mode()&0o111 != 0) != f.Executable {
|
||||
return errors.New("differs")
|
||||
}
|
||||
found++
|
||||
return nil
|
||||
})
|
||||
return err == nil && found == len(files)
|
||||
}
|
||||
|
||||
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
||||
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
||||
|
||||
@@ -63,6 +134,13 @@ type stateOf struct{ s stdio.KeptState }
|
||||
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
||||
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
||||
e, err := s.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
|
||||
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
||||
// tool's question.
|
||||
@@ -152,9 +230,9 @@ func nodesOf(v any) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
||||
func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, configView *ConfigView) []stdio.Tool {
|
||||
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
||||
return []stdio.Tool{
|
||||
out := []stdio.Tool{
|
||||
{Name: "claude_code_status",
|
||||
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
||||
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
||||
@@ -239,6 +317,7 @@ func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
||||
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
||||
}},
|
||||
}
|
||||
return append(out, configTools(p, config, configView)...)
|
||||
}
|
||||
|
||||
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
||||
@@ -283,15 +362,53 @@ func main() {
|
||||
}
|
||||
servers := stateOf{stdio.State("servers")}
|
||||
view := NewServerView(p)
|
||||
go run(p, view)
|
||||
if err := stdio.Serve("", tools(p, servers, view)); err != nil {
|
||||
config := stateOf{stdio.State("config")}
|
||||
configView := NewConfigView(p)
|
||||
go run(p, view, configView)
|
||||
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run is the module's long-running half, beside the tools (ADR 0198).
|
||||
func run(p Paths, view *ServerView) {
|
||||
func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
// The agent's configuration, at every scope (ADR 0216): the whole current set first, then each change.
|
||||
go persist("watching the agent's configuration", func() error {
|
||||
keys, err := stdio.State("config").Keys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if configView.Prune(keys) {
|
||||
if _, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("rendering after what was removed while away: %v", err)
|
||||
}
|
||||
}
|
||||
return stdio.State("config").Watch("", func(c stdio.StateChange) error {
|
||||
var item *Item
|
||||
if c.Op == "put" {
|
||||
item = &Item{}
|
||||
if json.Unmarshal(c.Value, item) != nil {
|
||||
item = nil
|
||||
}
|
||||
}
|
||||
if !configView.Take(c.Key, c.Op, item) {
|
||||
return nil
|
||||
}
|
||||
if out, err := RenderNow(p, writeManaged); err != nil {
|
||||
say("taking %s %s: %v", c.Op, c.Key, err)
|
||||
} else {
|
||||
say("took %s %s", c.Op, c.Key)
|
||||
for _, line := range out {
|
||||
if !strings.HasSuffix(line, "unchanged") {
|
||||
say("%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("watching the agent's configuration%s", refusals(n)) })
|
||||
|
||||
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
||||
go persist("watching the MCP servers", func() error {
|
||||
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
||||
|
||||
@@ -62,6 +62,8 @@ func (p Paths) binding() string { return filepath.Join(p.State, "licence.jso
|
||||
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
|
||||
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
|
||||
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
|
||||
func (p Paths) config() string { return filepath.Join(p.State, "config.json") }
|
||||
func (p Paths) placed() string { return filepath.Join(p.State, "home-placed.json") }
|
||||
|
||||
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
|
||||
type Ask func(address string, args any) (json.RawMessage, error)
|
||||
@@ -102,9 +104,15 @@ func Registered(p Paths) Servers {
|
||||
return s
|
||||
}
|
||||
|
||||
var renderMu sync.Mutex
|
||||
|
||||
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
|
||||
// registered here.
|
||||
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
// One at a time: the watches and the tools all render, and the home's record of what was placed is
|
||||
// read and written whole.
|
||||
renderMu.Lock()
|
||||
defer renderMu.Unlock()
|
||||
var facts Facts
|
||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||
@@ -116,21 +124,44 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
if readJSON(p.binding(), &b) {
|
||||
binding = &b
|
||||
}
|
||||
files := Render(facts, settings, binding, p.helper(), Registered(p))
|
||||
var items map[string]Item
|
||||
_ = readJSON(p.config(), &items)
|
||||
config := ConfigOf(items)
|
||||
files := Render(facts, settings, binding, p.helper(), Registered(p), config)
|
||||
tree, _ := json.Marshal(Marketplace(config))
|
||||
files[MarketplaceDir+"/"] = string(tree)
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
// The marketplace first: the settings that enable its plugin must never name one that is not there yet.
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
if (names[i] == MarketplaceDir+"/") != (names[j] == MarketplaceDir+"/") {
|
||||
return names[i] == MarketplaceDir+"/"
|
||||
}
|
||||
return names[i] < names[j]
|
||||
})
|
||||
// Every file is attempted: one that cannot be written — a registration the vendor's layout refuses, a
|
||||
// failed escalation — must not keep the licence, the tool servers or the instructions from landing.
|
||||
var out []string
|
||||
var failed []error
|
||||
for _, n := range names {
|
||||
line, err := write(n, files[n])
|
||||
if err != nil {
|
||||
return out, err
|
||||
failed = append(failed, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out, nil
|
||||
// The home scope: what this module places in the account's own agent directory (ADR 0182).
|
||||
done, left := PlaceHome(p, HomeFiles(config))
|
||||
for _, line := range done {
|
||||
out = append(out, "home "+line)
|
||||
}
|
||||
for _, line := range left {
|
||||
out = append(out, "home "+line)
|
||||
}
|
||||
return out, errors.Join(failed...)
|
||||
}
|
||||
|
||||
// ---- the licence ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -96,8 +96,10 @@ func jsonFile(v any) string {
|
||||
}
|
||||
|
||||
// Render composes the three files. registered — what was registered through this module and applies
|
||||
// here — is laid over the servers the operator set in its settings.
|
||||
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string {
|
||||
// here — is laid over the servers the operator set in its settings; config is the rest of what was
|
||||
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
|
||||
// after the mesh's own text. The plugin itself is Marketplace's, and the home's PlaceHome's.
|
||||
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config) map[string]string {
|
||||
servers := map[string]any{}
|
||||
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
|
||||
for name, entry := range layer {
|
||||
@@ -109,14 +111,27 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
|
||||
}
|
||||
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
|
||||
|
||||
managed := map[string]any{}
|
||||
for key, value := range settings.ManagedSettings {
|
||||
managed[key] = value
|
||||
}
|
||||
// The operator's `managed_settings` (ADR 0213), then what was registered for the mesh, then for this node.
|
||||
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
|
||||
managed = mergeSettings(managed, RegisteredSettings(config))
|
||||
// The mesh's own keys are laid last: a setting never replaces them.
|
||||
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
|
||||
managed["allowAllClaudeAiMcps"] = true
|
||||
delete(managed, "apiKeyHelper")
|
||||
// The plugin's marketplace and the plugin itself, as entries in the operator's own maps, the mesh's
|
||||
// entry winning: the operator may know more marketplaces and enable more plugins.
|
||||
for key, value := range MarketplaceKeys() {
|
||||
entries := map[string]any{}
|
||||
if held, ok := managed[key].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
entries[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range value.(map[string]any) {
|
||||
entries[k] = v
|
||||
}
|
||||
managed[key] = entries
|
||||
}
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
}
|
||||
@@ -127,6 +142,6 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
|
||||
return map[string]string{
|
||||
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
|
||||
"managed-settings.json": jsonFile(managed),
|
||||
"CLAUDE.md": instructionsText(facts.Node, role),
|
||||
"CLAUDE.md": instructionsText(facts.Node, role) + InstructionSections(config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
},
|
||||
"state": [
|
||||
"servers",
|
||||
"holdings"
|
||||
"holdings",
|
||||
"config"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
@@ -26,8 +27,45 @@
|
||||
"claude_code_add_api_key",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister"
|
||||
"claude_code_mcp_unregister",
|
||||
"claude_code_skill_list",
|
||||
"claude_code_skill_register",
|
||||
"claude_code_skill_unregister",
|
||||
"claude_code_agent_list",
|
||||
"claude_code_agent_register",
|
||||
"claude_code_agent_unregister",
|
||||
"claude_code_command_list",
|
||||
"claude_code_command_register",
|
||||
"claude_code_command_unregister",
|
||||
"claude_code_hook_list",
|
||||
"claude_code_hook_register",
|
||||
"claude_code_hook_unregister",
|
||||
"claude_code_output_style_list",
|
||||
"claude_code_output_style_register",
|
||||
"claude_code_output_style_unregister",
|
||||
"claude_code_instructions_list",
|
||||
"claude_code_instructions_register",
|
||||
"claude_code_instructions_unregister",
|
||||
"claude_code_settings_get",
|
||||
"claude_code_settings_set",
|
||||
"claude_code_settings_clear",
|
||||
"claude_code_permission_add",
|
||||
"claude_code_permission_remove",
|
||||
"claude_code_config_list",
|
||||
"claude_code_config_show",
|
||||
"claude_code_config_status",
|
||||
"claude_code_config_import"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "agent-home",
|
||||
"path": "${dir:agent-home}",
|
||||
"class": "valuable",
|
||||
"why": "the operator's agent's own files: its memory, history and projects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
// cloudflare-dns's own code (novox/hq ADR 0039). It provides the mesh `public-dns` interface
|
||||
// (ADR 0044): a public name that resolves to the mesh's public ingress. Cloudflare is one registrar
|
||||
// behind the neutral interface — a consumer names `public-dns`, never Cloudflare — so this file is
|
||||
// the only place Cloudflare's API appears, and swapping registrars swaps only this module.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface PublicRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class CloudflareClient {
|
||||
constructor(
|
||||
private readonly token: string,
|
||||
private readonly zoneId: string,
|
||||
/** The zone this registers under, e.g. "example.com". */
|
||||
readonly domain: string,
|
||||
/** What every public name points at — the mesh's public ingress (the reverse proxy). */
|
||||
readonly ingress: string,
|
||||
) {}
|
||||
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): CloudflareClient {
|
||||
// Which zone, domain and ingress are a mesh's own facts, not this module's — so they are
|
||||
// settings, merged into a config file the mesh manages (novox/hq ADR 0046), read here. The
|
||||
// token is the one secret and stays an own-secret. Env is honoured as a fallback for a
|
||||
// hand-run instance, but the deployed path is the config file settings fill.
|
||||
const config = readConfig(env.MESH_CLOUDFLARE_CONFIG_FILE);
|
||||
const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE);
|
||||
const zoneId = config.zone ?? env.MESH_CLOUDFLARE_ZONE_ID;
|
||||
const domain = config.domain ?? env.MESH_PUBLIC_DOMAIN;
|
||||
const ingress = config.ingress ?? env.MESH_PUBLIC_INGRESS;
|
||||
if (!token || !zoneId || !domain || !ingress) {
|
||||
throw new Error(
|
||||
"cloudflare-dns is not configured — set its zone, domain and ingress in settings (and the " +
|
||||
"token as its own-secret); until then it registers nothing",
|
||||
);
|
||||
}
|
||||
return new CloudflareClient(token, zoneId, domain, ingress);
|
||||
}
|
||||
|
||||
/**
|
||||
* The public name a consumer gets: derived from its identity under the mesh's domain. Derived, not
|
||||
* contributed, for the same reason minio derives a bucket name — the harness hands `remove` only
|
||||
* the identity, so teardown must recompute exactly what creation made.
|
||||
*/
|
||||
nameFor(consumer: string): string {
|
||||
return `${consumer.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase()}.${this.domain}`;
|
||||
}
|
||||
|
||||
/** An IP points at itself (A/AAAA); a hostname points through a CNAME. */
|
||||
private recordType(): "A" | "AAAA" | "CNAME" {
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(this.ingress)) return "A";
|
||||
if (this.ingress.includes(":")) return "AAAA";
|
||||
return "CNAME";
|
||||
}
|
||||
|
||||
private async api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const json = (await res.json()) as { success?: boolean; result?: unknown; errors?: unknown };
|
||||
if (!res.ok || json.success === false) {
|
||||
throw new Error(`cloudflare ${method} ${path}: ${res.status} ${JSON.stringify(json.errors ?? json)}`);
|
||||
}
|
||||
return json.result as T;
|
||||
}
|
||||
|
||||
async findRecord(name: string): Promise<PublicRecord | undefined> {
|
||||
const records = await this.api<PublicRecord[]>(
|
||||
"GET",
|
||||
`/zones/${this.zoneId}/dns_records?name=${encodeURIComponent(name)}`,
|
||||
);
|
||||
return records[0];
|
||||
}
|
||||
|
||||
/** Point a public name at the mesh's ingress, idempotently — create it, or update one already there. */
|
||||
async upsert(name: string): Promise<PublicRecord> {
|
||||
const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false };
|
||||
const existing = await this.findRecord(name);
|
||||
if (existing) {
|
||||
return this.api<PublicRecord>("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body);
|
||||
}
|
||||
return this.api<PublicRecord>("POST", `/zones/${this.zoneId}/dns_records`, body);
|
||||
}
|
||||
|
||||
/** Remove a public name, idempotently — a record already gone is not an error on reconcile. */
|
||||
async remove(name: string): Promise<void> {
|
||||
const existing = await this.findRecord(name);
|
||||
if (existing) await this.api("DELETE", `/zones/${this.zoneId}/dns_records/${existing.id}`);
|
||||
}
|
||||
|
||||
/** Every record in the zone, for the diagnostic tool. */
|
||||
async records(): Promise<PublicRecord[]> {
|
||||
return this.api<PublicRecord[]>("GET", `/zones/${this.zoneId}/dns_records`);
|
||||
}
|
||||
}
|
||||
|
||||
function readSecret(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
try {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface Config {
|
||||
zone?: string;
|
||||
domain?: string;
|
||||
ingress?: string;
|
||||
}
|
||||
|
||||
/** The settings-managed config file (a JSON document the mesh merges settings into). Absent or
|
||||
* unparseable yields an empty config, which fromEnv then reports as unconfigured. */
|
||||
function readConfig(path: string | undefined): Config {
|
||||
if (!path) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Config;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"module": "cloudflare-dns",
|
||||
"version": "1",
|
||||
"slug": "cfdns",
|
||||
"provides": [
|
||||
{
|
||||
"name": "public-dns",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"public-dns": {}
|
||||
},
|
||||
"grants": {
|
||||
"public-dns": "${dir:grants}"
|
||||
},
|
||||
"receives": {
|
||||
"public-dns": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"own-secrets": {
|
||||
"token": "${dir:state}/token"
|
||||
},
|
||||
"emits": [
|
||||
"record.created",
|
||||
"record.removed"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/config.json",
|
||||
"merge": "json",
|
||||
"content": "{}",
|
||||
"mode": "0600"
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"loads": [
|
||||
"tools/index.js",
|
||||
"provisioner/index.js"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLOUDFLARE_TOKEN_FILE": "${dir:state}/token",
|
||||
"MESH_CLOUDFLARE_CONFIG_FILE": "${dir:state}/config.json",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "@novox/module-cloudflare-dns",
|
||||
"version": "0.1.0",
|
||||
"description": "cloudflare-dns — a public-dns provider (ADR 0044): registers public names at Cloudflare.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface
|
||||
// (novox/hq ADR 0044). The reconcile loop and the contributions file are the sdk harness's; this
|
||||
// writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing it
|
||||
// at the mesh's ingress, and remove it when the consumer is withdrawn (ADR 0048).
|
||||
//
|
||||
// The `public-dns` interface hands a consumer { fqdn, target, ttl } — a name that resolves publicly
|
||||
// and what it resolves to. Like umami's analytics it is a *data* provision, not a credential one:
|
||||
// nothing the mesh mints is set here (a DNS record is public, and the only secret is this module's
|
||||
// own Cloudflare token, which never leaves). So the password the harness carries is unused; the name
|
||||
// is derived from the login the mesh gave the consumer, which the consumer can derive too. Delivering
|
||||
// the record back to the consumer is the data-provision return path ADR 0048 leaves out of scope.
|
||||
|
||||
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { CloudflareClient } from "../client.js";
|
||||
|
||||
const cloudflare = CloudflareClient.fromEnv();
|
||||
|
||||
runProvisioner("public-dns", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
const fqdn = cloudflare.nameFor(p.as);
|
||||
await cloudflare.upsert(fqdn);
|
||||
await announce("record.created", {
|
||||
name: fqdn,
|
||||
target: cloudflare.ingress,
|
||||
consumer: p.consumer ?? "",
|
||||
});
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
const fqdn = cloudflare.nameFor(p.as);
|
||||
await cloudflare.remove(fqdn);
|
||||
await announce("record.removed", { name: fqdn, consumer: p.as });
|
||||
},
|
||||
});
|
||||
|
||||
/** Emit best-effort: a broker hiccup must never fail or reverse a DNS change that already happened. */
|
||||
async function announce(type: string, body: unknown): Promise<void> {
|
||||
try {
|
||||
await emit(type, body);
|
||||
} catch (err) {
|
||||
console.error(`[cloudflare-dns] could not emit ${type}: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// cloudflare-dns's tool — the diagnostic: what public names the mesh currently publishes here.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { CloudflareClient } from "../client.js";
|
||||
|
||||
export function getCloudflareDnsTools(cloudflare: CloudflareClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "cloudflare_dns_records",
|
||||
description: "The public DNS records in the mesh's zone — the names it currently publishes.",
|
||||
input: {},
|
||||
run: async () => ({ domain: cloudflare.domain, ingress: cloudflare.ingress, records: await cloudflare.records() }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("cloudflare-dns", (env) => {
|
||||
try {
|
||||
return getCloudflareDnsTools(CloudflareClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"client.ts",
|
||||
"tools/index.ts",
|
||||
"provisioner/index.ts"
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# dhcpcd
|
||||
|
||||
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.
|
||||
|
||||
## What it writes
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
**At the start of the file** (`at: start`). Both are global options, and dhcpcd reads every line
|
||||
after an `interface` or `ssid` line as that interface's own. A configured machine's file ends in
|
||||
exactly such a block (the interface, its static address), so appended at the end these two would
|
||||
quietly apply to one interface only.
|
||||
|
||||
## Why it declares no service
|
||||
|
||||
dhcpcd is the machine's, not the mesh's. The mesh never starts, stops or enables it: stopping it
|
||||
drops the address the machine is reached at, and a module unassigned by mistake must not be able
|
||||
to do that. And there is nothing to reload it with — `dhcpcd.service` reports `CanReload=no`, and
|
||||
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.
|
||||
|
||||
## One manager per machine
|
||||
|
||||
It claims `the-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.
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"module": "dhcpcd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager",
|
||||
"uplink-dhcpcd"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-uplink",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "dhcpcd"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/dhcpcd.conf",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -36,6 +36,18 @@
|
||||
"why": "every machine pulls images and artifacts from here"
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "registry",
|
||||
"path": "${dir:registry-data}",
|
||||
"class": "rebuildable",
|
||||
"backup": "none",
|
||||
"measure": "shallow",
|
||||
"why": "the artifact store: every image is built again from its source, and too large to copy every night (ADR 0214 left it out)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
@@ -63,6 +75,23 @@
|
||||
"env": {
|
||||
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "collect",
|
||||
"type": "container",
|
||||
"name": "mesh-registry-collect",
|
||||
"image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
||||
"volumes": [
|
||||
"/var/lib/mesh-registry:/var/lib/registry"
|
||||
],
|
||||
"args": [
|
||||
"garbage-collect",
|
||||
"/etc/docker/registry/config.yml"
|
||||
],
|
||||
"schedule": "30 3 * * *",
|
||||
"while-stopped": [
|
||||
"store"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+19
-24
File diff suppressed because one or more lines are too long
+78
-52
@@ -14,6 +14,8 @@ tools below are the module's own.
|
||||
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
|
||||
| `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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
// A container's secrets in its own output (novox/hq issue 268).
|
||||
//
|
||||
// **The leak this catches.** Software prints what it was given: a server that announces its
|
||||
// password when it starts in secure mode, a startup script that echoes the database URI it
|
||||
// connects with, password and all. The container's log is then a copy of the secret that every
|
||||
// reader of the log holds — this bundle's docker_logs, the journal where a container logs there,
|
||||
// and whatever kept a transcript of either. Nothing in the mesh noticed, because nothing looked.
|
||||
//
|
||||
// **What is known here, and what is not.** A container's environment is readable through the
|
||||
// runtime (docker inspect), so its values can be compared with what it printed: a variable named
|
||||
// like a secret (PASSWORD, SECRET, TOKEN, KEY, …) and the password inside any URI a variable holds.
|
||||
// Beside that, a credential-bearing URI anywhere in a line (`scheme://user:password@`) is caught
|
||||
// by its shape, whatever the source. A secret handed only as a mounted file and never in the
|
||||
// environment is not known to this bundle — it runs as the operator account, which cannot read
|
||||
// the files the host writes at 0600 — and is caught only if the program prints it inside a URI.
|
||||
//
|
||||
// **Never the value.** A finding names the container, the module and the variable; it carries
|
||||
// no value and no line. A tool that quoted the leak to report it would be a second leak.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// secretName is a variable name that says its value is a secret.
|
||||
var secretName = regexp.MustCompile(`(?i)(pass(word|wd|phrase)?|secret|token|api_?key|private_?key|access_?key|credential|auth)`)
|
||||
|
||||
// notAValue is a name that says its value is where a secret is, not the secret: a file or a path.
|
||||
var notAValue = regexp.MustCompile(`(?i)(_FILE|FILE|_PATH|_DIR)$`)
|
||||
|
||||
// uriPassword is a URI carrying a password in its userinfo: scheme://user:password@.
|
||||
var uriPassword = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^\s/:@'"]*:([^\s/@'"]+)@`)
|
||||
|
||||
// masked is a password a program already hid: ***, xxx, <redacted>, [REDACTED].
|
||||
var masked = regexp.MustCompile(`^(\*+|x+|X+|<[^>]*>|\[[^\]]*\]|%2A+)$`)
|
||||
|
||||
// ordinary is a value under a secret's name that is not one: a path, an address, a number, a switch.
|
||||
var ordinary = regexp.MustCompile(`^(/.*|[A-Za-z][A-Za-z0-9+.-]*://.*|[0-9.]+[a-z]?|(?i:true|false|yes|no|on|off|none|null))$`)
|
||||
|
||||
// leastSecret is the shortest value compared as a secret: a shorter one matches ordinary words.
|
||||
const leastSecret = 6
|
||||
|
||||
// knownSecret is one value a container was given, by the name it came under.
|
||||
type knownSecret struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// secretsIn are the values in a container's environment that must never appear in its output.
|
||||
func secretsIn(env []string) []knownSecret {
|
||||
var out []knownSecret
|
||||
seen := map[string]bool{}
|
||||
add := func(name, value string) {
|
||||
if len(value) < leastSecret || masked.MatchString(value) || seen[name+"\x00"+value] {
|
||||
return
|
||||
}
|
||||
seen[name+"\x00"+value] = true
|
||||
out = append(out, knownSecret{name, value})
|
||||
}
|
||||
for _, e := range env {
|
||||
name, value, ok := strings.Cut(e, "=")
|
||||
if !ok || value == "" {
|
||||
continue
|
||||
}
|
||||
for _, m := range uriPassword.FindAllStringSubmatch(value, -1) {
|
||||
add(name+" (the password in its URI)", m[1])
|
||||
if dec, err := url.PathUnescape(m[1]); err == nil && dec != m[1] {
|
||||
add(name+" (the password in its URI)", dec)
|
||||
}
|
||||
}
|
||||
if secretName.MatchString(name) && !notAValue.MatchString(name) && !ordinary.MatchString(value) {
|
||||
add(name, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// forms are the ways a value may appear printed: as given, and URL-encoded.
|
||||
func forms(value string) []string {
|
||||
out := []string{value}
|
||||
for _, f := range []string{url.QueryEscape(value), url.PathEscape(value)} {
|
||||
if f != value && !contains(out, f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaksIn is, per secret name, how many lines carry that secret; and how many carry a URI with a
|
||||
// password that is none of the known ones. The lines are read and forgotten.
|
||||
func leaksIn(lines []string, known []knownSecret) (byName map[string][]string, uris []string) {
|
||||
byName = map[string][]string{}
|
||||
for _, l := range lines {
|
||||
hit := false
|
||||
for _, s := range known {
|
||||
for _, f := range forms(s.Value) {
|
||||
if strings.Contains(l, f) {
|
||||
byName[s.Name] = append(byName[s.Name], stamp(l))
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
continue
|
||||
}
|
||||
for _, m := range uriPassword.FindAllStringSubmatch(l, -1) {
|
||||
if !masked.MatchString(m[1]) {
|
||||
uris = append(uris, stamp(l))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return byName, uris
|
||||
}
|
||||
|
||||
// redact is a line with every known secret, and every password inside a URI, replaced by a mark
|
||||
// naming what was there.
|
||||
func redact(line string, known []knownSecret) (string, int) {
|
||||
n := 0
|
||||
for _, s := range known {
|
||||
for _, f := range forms(s.Value) {
|
||||
if c := strings.Count(line, f); c > 0 {
|
||||
line = strings.ReplaceAll(line, f, "[redacted: "+s.Name+"]")
|
||||
n += c
|
||||
}
|
||||
}
|
||||
}
|
||||
line = uriPassword.ReplaceAllStringFunc(line, func(m string) string {
|
||||
sub := uriPassword.FindStringSubmatch(m)
|
||||
if masked.MatchString(sub[1]) {
|
||||
return m
|
||||
}
|
||||
n++
|
||||
return strings.TrimSuffix(m, sub[1]+"@") + "[redacted: a password in a URI]@"
|
||||
})
|
||||
return line, n
|
||||
}
|
||||
|
||||
// stamp is the timestamp leading a line `docker logs --timestamps` printed.
|
||||
func stamp(line string) string {
|
||||
t, _, _ := strings.Cut(line, " ")
|
||||
return t
|
||||
}
|
||||
|
||||
// envOf is one container's environment, values included — kept inside this process.
|
||||
func (c *Client) envOf(ctx context.Context, ref string) ([]string, error) {
|
||||
out, err := c.docker(ctx, "container", "inspect", "--format", "{{json .Config.Env}}", ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var env []string
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &env); err != nil {
|
||||
return nil, fmt.Errorf("docker inspect answered an environment that is not JSON")
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// Leak is one secret a container printed: by name, never by value.
|
||||
type Leak struct {
|
||||
Container string `json:"container"`
|
||||
HeldBy string `json:"held_by,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
Lines int `json:"lines"`
|
||||
First string `json:"first"`
|
||||
Last string `json:"last"`
|
||||
}
|
||||
|
||||
// ScanBudget is how long a scan may take: below the runtime's thirty-second call limit, so a scan of
|
||||
// a machine with many containers answers what it read rather than nothing. ScanWidth is how many
|
||||
// containers are read at once.
|
||||
const (
|
||||
ScanBudget = 25 * time.Second
|
||||
ScanWidth = 6
|
||||
)
|
||||
|
||||
// scanned is what one container's log held.
|
||||
type scanned struct {
|
||||
leaks []Leak
|
||||
lines int
|
||||
why string
|
||||
}
|
||||
|
||||
// scanOne reads one container's environment and log, and keeps only what was printed, by name.
|
||||
func (c *Client) scanOne(ctx context.Context, ct Container, tail int) scanned {
|
||||
env, err := c.envOf(ctx, ct.ID)
|
||||
if err != nil {
|
||||
return scanned{why: err.Error()}
|
||||
}
|
||||
lines, err := c.logLines(ctx, []string{"logs", "--timestamps", "--tail", strconv.Itoa(tail), ct.ID})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return scanned{why: "not read within the scan's " + ScanBudget.String()}
|
||||
}
|
||||
return scanned{why: err.Error()}
|
||||
}
|
||||
byName, uris := leaksIn(lines, secretsIn(env))
|
||||
if len(uris) > 0 {
|
||||
byName["a password inside a URI (not from its environment)"] = uris
|
||||
}
|
||||
names := make([]string, 0, len(byName))
|
||||
for n := range byName {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := scanned{lines: len(lines)}
|
||||
for _, n := range names {
|
||||
at := byName[n]
|
||||
sort.Strings(at)
|
||||
out.leaks = append(out.leaks, Leak{Container: ct.Name, HeldBy: ct.HeldBy, Module: ct.Module, Secret: n,
|
||||
Lines: len(at), First: at[0], Last: at[len(at)-1]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SecretsInLogs scans the last `tail` lines of each container — the mesh's, or every one — for the
|
||||
// secrets it was given. A container whose log could not be read is listed as unread, never as clean.
|
||||
func (c *Client) SecretsInLogs(ctx context.Context, held string, tail int) (map[string]any, error) {
|
||||
all, err := c.Containers(ctx, held, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, ScanBudget)
|
||||
defer cancel()
|
||||
results := make([]scanned, len(all))
|
||||
var wg sync.WaitGroup
|
||||
slots := make(chan struct{}, ScanWidth)
|
||||
for i, ct := range all {
|
||||
wg.Add(1)
|
||||
go func(i int, ct Container) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
defer func() { <-slots }()
|
||||
results[i] = c.scanOne(ctx, ct, tail)
|
||||
case <-ctx.Done():
|
||||
results[i] = scanned{why: "not read within the scan's " + ScanBudget.String()}
|
||||
}
|
||||
}(i, ct)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
leaks := []Leak{}
|
||||
unread := []map[string]string{}
|
||||
count, read := 0, 0
|
||||
for i, r := range results {
|
||||
if r.why != "" {
|
||||
unread = append(unread, map[string]string{"container": all[i].Name, "why": r.why})
|
||||
continue
|
||||
}
|
||||
count++
|
||||
read += r.lines
|
||||
leaks = append(leaks, r.leaks...)
|
||||
}
|
||||
verdict := "no container printed a secret it was given in the lines read"
|
||||
if len(leaks) > 0 {
|
||||
verdict = fmt.Sprintf("%d secret(s) printed into container logs: rotate each one after the program stops printing it, "+
|
||||
"and recreate the container to drop its old log", len(leaks))
|
||||
}
|
||||
if len(unread) > 0 {
|
||||
verdict += fmt.Sprintf("; %d container(s) not read, so not known to be clean", len(unread))
|
||||
}
|
||||
return map[string]any{
|
||||
"verdict": verdict, "leaks": leaks, "count": len(leaks), "containers_scanned": count, "lines_read": read,
|
||||
"unread": unread,
|
||||
"knows": "values in each container's environment named like a secret, the password in any URI it holds, and any " +
|
||||
"URI carrying a password; a secret delivered only as a mounted file is caught only inside a URI",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The shape of the leak in hq issue 268: a server password announced at start and a database URI
|
||||
// echoed whole. The values are made up for the test.
|
||||
const (
|
||||
serverPassword = "Zq8-server-pass_word"
|
||||
dbPassword = "Db_pa55-word-xyz"
|
||||
)
|
||||
|
||||
var lettaEnv = []string{
|
||||
"LETTA_PG_URI=postgresql://letta@db:5432/letta",
|
||||
"LETTA_SERVER_PASSWORD=" + serverPassword,
|
||||
"OTHER_URI=postgresql://other:" + dbPassword + "@db:5432/other",
|
||||
"PGPASSFILE=/run/secrets/pgpass",
|
||||
"SECURE=true",
|
||||
"AUTH_URL=https://id.example/auth",
|
||||
"TOKEN_TTL=3600",
|
||||
"POSTGRES_PASSWORD=letta",
|
||||
"TZ=Europe/Brussels",
|
||||
}
|
||||
|
||||
func TestOnlyValuesNamedAsSecretsAndPasswordsInURIsAreKnown(t *testing.T) {
|
||||
got := map[string]string{}
|
||||
for _, s := range secretsIn(lettaEnv) {
|
||||
got[s.Name] = s.Value
|
||||
}
|
||||
if got["LETTA_SERVER_PASSWORD"] != serverPassword || got["OTHER_URI (the password in its URI)"] != dbPassword {
|
||||
t.Fatalf("missed a secret: %v", keys(got))
|
||||
}
|
||||
for _, not := range []string{"LETTA_PG_URI (the password in its URI)", "PGPASSFILE", "SECURE", "AUTH_URL", "TOKEN_TTL", "POSTGRES_PASSWORD", "TZ"} {
|
||||
if _, ok := got[not]; ok {
|
||||
t.Errorf("%s taken for a secret", not)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scanMachine(logs string) *fake {
|
||||
env, _ := json.Marshal(lettaEnv)
|
||||
return (&fake{}).
|
||||
on("docker ps --all --quiet --no-trunc", Ran{Stdout: "aaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbb\n"}).
|
||||
on("docker container inspect aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbb", Ran{Stdout: "[" + held + "," + stray + "]"}).
|
||||
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: string(env) + "\n"}).
|
||||
on("docker logs --timestamps --tail 5000 aaaaaaaaaaaa", Ran{Stdout: logs})
|
||||
}
|
||||
|
||||
const leakyLog = "2026-10-05T19:16:40Z External Postgres configuration detected, using postgresql://letta@db:5432/letta\n" +
|
||||
"2026-10-05T19:16:41Z Creating engine postgresql://other:" + dbPassword + "@db:5432/other\n" +
|
||||
"2026-10-05T19:16:42Z ▶ Using secure mode with password: " + serverPassword + "\n" +
|
||||
"2026-10-05T19:16:43Z connecting to mongodb://app:s3cr3t-elsewhere@mongo:27017\n" +
|
||||
"2026-10-05T19:16:44Z Using database: postgresql://letta:***@db:5432/letta\n" +
|
||||
"2026-10-05T19:20:42Z ▶ Using secure mode with password: " + serverPassword + "\n"
|
||||
|
||||
func TestAScanNamesEachPrintedSecretAndNeverItsValue(t *testing.T) {
|
||||
got, err := client(scanMachine(leakyLog), 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
for _, v := range []string{serverPassword, dbPassword, "s3cr3t-elsewhere"} {
|
||||
if strings.Contains(string(raw), v) {
|
||||
t.Fatalf("the answer carries a secret's value: %s", raw)
|
||||
}
|
||||
}
|
||||
leaks := got["leaks"].([]Leak)
|
||||
byName := map[string]Leak{}
|
||||
for _, l := range leaks {
|
||||
byName[l.Secret] = l
|
||||
if l.Container != "mesh-web" || l.Module != "hello-web" || l.HeldBy != "hello-web.server" {
|
||||
t.Errorf("finding not named by its container and module: %+v", l)
|
||||
}
|
||||
}
|
||||
if l := byName["LETTA_SERVER_PASSWORD"]; l.Lines != 2 || l.First != "2026-10-05T19:16:42Z" || l.Last != "2026-10-05T19:20:42Z" {
|
||||
t.Errorf("server password: %+v", l)
|
||||
}
|
||||
if l := byName["OTHER_URI (the password in its URI)"]; l.Lines != 1 {
|
||||
t.Errorf("password in a URI from the environment: %+v", l)
|
||||
}
|
||||
if l := byName["a password inside a URI (not from its environment)"]; l.Lines != 1 {
|
||||
t.Errorf("a URI's password by its shape (and not a masked one): %+v", l)
|
||||
}
|
||||
if len(leaks) != 3 || got["containers_scanned"] != 1 {
|
||||
t.Errorf("leaks %d, scanned %v (only the mesh's)", len(leaks), got["containers_scanned"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACleanLogIsSaidToBeClean(t *testing.T) {
|
||||
got, err := client(scanMachine("2026-10-05T19:16:40Z started\n"), 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["count"] != 0 || !strings.HasPrefix(got["verdict"].(string), "no container") {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerWhoseLogCannotBeReadIsSaidSoRatherThanCalledClean(t *testing.T) {
|
||||
f := scanMachine("")
|
||||
f.rules = append([]rule{{"docker logs", Ran{Status: 1, Stderr: "Error response from daemon: configured logging driver does not support reading\n"}}}, f.rules...)
|
||||
got, err := client(f, 1000).SecretsInLogs(context.Background(), "mesh", 5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got["unread"].([]map[string]string)) != 1 || got["containers_scanned"] != 0 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsRedactWhatTheContainerPrintedOfItsSecrets(t *testing.T) {
|
||||
env, _ := json.Marshal(lettaEnv)
|
||||
f := (&fake{}).
|
||||
on("docker logs", Ran{Stdout: leakyLog}).
|
||||
on("docker container inspect --format {{json .Config.Env}} letta", Ran{Stdout: string(env)})
|
||||
got, err := client(f, 1000).Logs(context.Background(), "letta", 200, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
for _, v := range []string{serverPassword, dbPassword, "s3cr3t-elsewhere"} {
|
||||
if strings.Contains(string(raw), v) {
|
||||
t.Fatalf("docker_logs answered a secret: %s", raw)
|
||||
}
|
||||
}
|
||||
lines := got["lines"].([]string)
|
||||
if !strings.Contains(lines[2], "[redacted: LETTA_SERVER_PASSWORD]") ||
|
||||
!strings.Contains(lines[3], "mongodb://app:[redacted: a password in a URI]@mongo") ||
|
||||
!strings.Contains(lines[4], "letta:***@db") || got["redacted"] != 4 {
|
||||
t.Errorf("%v %v", lines, got["redacted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsWithoutTheEnvironmentStillHideAURIsPasswordAndSaySo(t *testing.T) {
|
||||
f := (&fake{}).on("docker logs", Ran{Stdout: leakyLog})
|
||||
got, err := client(f, 1000).Logs(context.Background(), "letta", 200, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
if strings.Contains(string(raw), dbPassword) || got["redaction"] == nil {
|
||||
t.Errorf("%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]string) []string {
|
||||
out := []string{}
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
"docker_list",
|
||||
"docker_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",
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from
|
||||
// the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept
|
||||
// running by one. This code exists only to read and steer the *live* state the daemon owns: who is
|
||||
// banned now and until when, and the ban or release an operator asks for — the node-intrusion-
|
||||
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
|
||||
// mesh composes the jails and never writes the ban list.
|
||||
//
|
||||
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
|
||||
// package this module declares on the machine, and the socket is root's: root is the module's
|
||||
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be
|
||||
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import { isIP } from "node:net";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
/** A command runner, so the verbs can be tested without a daemon. */
|
||||
export type Runner = (cmd: string, args: string[]) => Promise<string>;
|
||||
|
||||
/** The command as it is run: as given when this process is root, else through sudo without a
|
||||
* prompt. The daemon's socket answers only to root. */
|
||||
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
|
||||
if (uid === 0) return [cmd, args];
|
||||
return ["sudo", ["-n", cmd, ...args]];
|
||||
}
|
||||
|
||||
/** Whether a tool is on this machine: an executable of that name on the path, or where the
|
||||
* system keeps its administration. */
|
||||
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
|
||||
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
|
||||
return dirs.some((dir) => {
|
||||
try {
|
||||
accessSync(join(dir, tool), constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const execRunner: Runner = async (cmd, args) => {
|
||||
if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`);
|
||||
const [program, argv] = escalated(cmd, args);
|
||||
try {
|
||||
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
|
||||
return stdout;
|
||||
} catch (err) {
|
||||
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
|
||||
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
|
||||
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
|
||||
// on its own stderr line, and the rest is the client's own answer.
|
||||
if (program === "sudo") {
|
||||
if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
|
||||
if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
|
||||
}
|
||||
if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) {
|
||||
throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account");
|
||||
}
|
||||
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
|
||||
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`));
|
||||
}
|
||||
};
|
||||
|
||||
/** One jail as the daemon reports it. */
|
||||
export interface JailStatus {
|
||||
jail: string;
|
||||
/** What the jail is reading: files or journal matches, as fail2ban names them. */
|
||||
watching: string[];
|
||||
/** Addresses with failures counted against them right now, and all failures since the jail started. */
|
||||
failing: { now: number; total: number };
|
||||
/** Addresses held right now, and all bans since the jail started. */
|
||||
banned: { now: number; total: number; addresses: string[] };
|
||||
}
|
||||
|
||||
/** One ban as the daemon holds it. */
|
||||
export interface Ban {
|
||||
ip: string;
|
||||
jail: string;
|
||||
/** When the ban was placed, in the machine's local time as fail2ban prints it. */
|
||||
since: string;
|
||||
/** When the ban ends; "never" for a permanent ban. */
|
||||
until: string;
|
||||
}
|
||||
|
||||
export interface JailSettings {
|
||||
jail: string;
|
||||
bantime: string;
|
||||
findtime: string;
|
||||
maxretry: number;
|
||||
ignoreip: string[];
|
||||
actions: string[];
|
||||
/** The log files the jail reads, when it reads files. */
|
||||
logpath: string[];
|
||||
/** The journal match the jail reads, when it reads the journal. */
|
||||
journalmatch: string;
|
||||
}
|
||||
|
||||
export class Fail2banClient {
|
||||
private readonly run: Runner;
|
||||
|
||||
constructor(run: Runner = execRunner) {
|
||||
this.run = run;
|
||||
}
|
||||
|
||||
/** The daemon as this machine has it, through its own client. */
|
||||
static onThisMachine(): Fail2banClient {
|
||||
return new Fail2banClient();
|
||||
}
|
||||
|
||||
private client(...args: string[]): Promise<string> {
|
||||
return this.run("fail2ban-client", args);
|
||||
}
|
||||
|
||||
/** The jails the daemon runs, by name. */
|
||||
async jails(): Promise<string[]> {
|
||||
const out = await this.client("status");
|
||||
const m = out.match(/Jail list:\s*(.*)/);
|
||||
if (!m) return [];
|
||||
return m[1].split(",").map((j) => j.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Every jail with what it watches and holds, or one jail's detail. */
|
||||
async status(jail?: string): Promise<{ jails: JailStatus[] }> {
|
||||
const names = jail ? [jail] : await this.jails();
|
||||
const jails: JailStatus[] = [];
|
||||
for (const name of names) {
|
||||
jails.push(parseJailStatus(name, await this.client("status", name)));
|
||||
}
|
||||
return { jails };
|
||||
}
|
||||
|
||||
/** Every address banned now, with the jail holding it and when the ban ends. */
|
||||
async banned(jail?: string): Promise<{ banned: Ban[] }> {
|
||||
const names = jail ? [jail] : await this.jails();
|
||||
const banned: Ban[] = [];
|
||||
for (const name of names) {
|
||||
banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time")));
|
||||
}
|
||||
banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip));
|
||||
return { banned };
|
||||
}
|
||||
|
||||
/** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */
|
||||
async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> {
|
||||
address(ip);
|
||||
name(jail);
|
||||
const out = await this.client("set", jail, "banip", ip);
|
||||
const added = Number.parseInt(out.trim(), 10) || 0;
|
||||
const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null;
|
||||
return { banned: held, added };
|
||||
}
|
||||
|
||||
/** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */
|
||||
async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> {
|
||||
address(ip);
|
||||
let out: string;
|
||||
if (jail) {
|
||||
name(jail);
|
||||
out = await this.client("set", jail, "unbanip", ip);
|
||||
} else {
|
||||
out = await this.client("unban", ip);
|
||||
}
|
||||
return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" };
|
||||
}
|
||||
|
||||
/** One jail's effective settings — the module's own tool, beside the seat's verbs. */
|
||||
async settings(jail: string): Promise<JailSettings> {
|
||||
name(jail);
|
||||
const get = (key: string) => this.client("get", jail, key);
|
||||
const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([
|
||||
get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"),
|
||||
get("journalmatch"),
|
||||
]);
|
||||
return {
|
||||
jail,
|
||||
bantime: bantime.trim(),
|
||||
findtime: findtime.trim(),
|
||||
maxretry: Number.parseInt(maxretry.trim(), 10),
|
||||
ignoreip: listed(ignoreip),
|
||||
actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean),
|
||||
logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath),
|
||||
journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */
|
||||
function listed(out: string): string[] {
|
||||
return out
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/^[\s|`-]+/, "").trim())
|
||||
.filter((l, i) => i > 0 && l.length > 0);
|
||||
}
|
||||
|
||||
export function parseJailStatus(jail: string, out: string): JailStatus {
|
||||
const field = (label: string) => {
|
||||
const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)"));
|
||||
return m ? m[1].trim() : "";
|
||||
};
|
||||
const num = (label: string) => Number.parseInt(field(label), 10) || 0;
|
||||
const watching = [field("File list"), field("Journal matches")].filter(Boolean);
|
||||
return {
|
||||
jail,
|
||||
watching,
|
||||
failing: { now: num("Currently failed"), total: num("Total failed") },
|
||||
banned: {
|
||||
now: num("Currently banned"),
|
||||
total: num("Total banned"),
|
||||
addresses: field("Banned IP list").split(/\s+/).filter(Boolean),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** `get <jail> banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */
|
||||
export function parseBans(jail: string, out: string): Ban[] {
|
||||
const bans: Ban[] = [];
|
||||
for (const line of out.split("\n")) {
|
||||
const m = line.match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)/);
|
||||
if (!m) continue;
|
||||
bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] });
|
||||
}
|
||||
return bans;
|
||||
}
|
||||
|
||||
function address(ip: string): void {
|
||||
if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`);
|
||||
}
|
||||
|
||||
function name(jail: string): void {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from the
|
||||
// modules a machine runs (to-be 31) and written as declared resources; the daemon is kept running by
|
||||
// one. This code exists only to read and steer the *live* state the daemon owns: who is banned now
|
||||
// and until when, and the ban or release an operator asks for — the node-intrusion-prevention seat's
|
||||
// four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the mesh composes the
|
||||
// jails and never writes the ban list.
|
||||
//
|
||||
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
|
||||
// package this module declares on the machine, and the socket is root's: root is the module's
|
||||
// concern (ADR 0175 §4), and the runtime launching this binary runs as the operator's account (to-be
|
||||
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner runs one command and answers what it printed, so the verbs can be tested without a daemon.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root, else through sudo
|
||||
// without a prompt. The daemon's socket answers only to root.
|
||||
func escalated(uid int, name string, args []string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
// installed is whether a tool is on this machine: an executable of that name on the path, or where
|
||||
// the system keeps its administration.
|
||||
func installed(tool, path string) bool {
|
||||
dirs := append(filepath.SplitList(path), "/usr/sbin", "/sbin", "/usr/bin")
|
||||
for _, dir := range dirs {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, tool)); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var socketTrouble = regexp.MustCompile(`(?i)Failed to access socket path|Is fail2ban running|Permission denied to socket`)
|
||||
|
||||
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if !installed(name, os.Getenv("PATH")) {
|
||||
return "", fmt.Errorf("%s is not installed on this machine", name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
program, argv := escalated(os.Getuid(), name, args)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), nil
|
||||
}
|
||||
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks on
|
||||
// its own stderr line, and the rest is the client's own answer.
|
||||
if program == "sudo" {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
}
|
||||
if socketTrouble.MatchString(said) {
|
||||
return "", errors.New("fail2ban is not running on this machine, or its socket does not answer the runtime's account")
|
||||
}
|
||||
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
|
||||
var lines []string
|
||||
for _, l := range strings.Split(said, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
return "", errors.New(lines[len(lines)-1])
|
||||
}
|
||||
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||
}
|
||||
|
||||
// Counted is a jail's count now and since it started.
|
||||
type Counted struct {
|
||||
Now int `json:"now"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// Held is what a jail holds: the count now and since it started, and the addresses.
|
||||
type Held struct {
|
||||
Now int `json:"now"`
|
||||
Total int `json:"total"`
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
|
||||
// JailStatus is one jail as the daemon reports it.
|
||||
type JailStatus struct {
|
||||
Jail string `json:"jail"`
|
||||
// Watching is what the jail is reading: files or journal matches, as fail2ban names them.
|
||||
Watching []string `json:"watching"`
|
||||
// Failing is the addresses with failures counted against them now, and all failures since the
|
||||
// jail started.
|
||||
Failing Counted `json:"failing"`
|
||||
// Banned is the addresses held right now, and all bans since the jail started.
|
||||
Banned Held `json:"banned"`
|
||||
}
|
||||
|
||||
// Ban is one ban as the daemon holds it.
|
||||
type Ban struct {
|
||||
IP string `json:"ip"`
|
||||
Jail string `json:"jail"`
|
||||
// Since is when the ban was placed, in the machine's local time as fail2ban prints it.
|
||||
Since string `json:"since"`
|
||||
// Until is when the ban ends; "never" for a permanent ban.
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
// JailSettings is one jail's effective settings.
|
||||
type JailSettings struct {
|
||||
Jail string `json:"jail"`
|
||||
Bantime string `json:"bantime"`
|
||||
Findtime string `json:"findtime"`
|
||||
Maxretry int `json:"maxretry"`
|
||||
Ignoreip []string `json:"ignoreip"`
|
||||
Actions []string `json:"actions"`
|
||||
Logpath []string `json:"logpath"`
|
||||
Journal string `json:"journalmatch"`
|
||||
}
|
||||
|
||||
// Fail2ban is the daemon as this machine has it, through its own client.
|
||||
type Fail2ban struct {
|
||||
Run Runner
|
||||
}
|
||||
|
||||
func (f Fail2ban) client(ctx context.Context, args ...string) (string, error) {
|
||||
return f.Run(ctx, "fail2ban-client", args...)
|
||||
}
|
||||
|
||||
var jailList = regexp.MustCompile(`Jail list:[ \t]*(.*)`)
|
||||
|
||||
// Jails is the jails the daemon runs, by name.
|
||||
func (f Fail2ban) Jails(ctx context.Context) ([]string, error) {
|
||||
out, err := f.client(ctx, "status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := jailList.FindStringSubmatch(out)
|
||||
if m == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
var jails []string
|
||||
for _, j := range strings.Split(m[1], ",") {
|
||||
if j = strings.TrimSpace(j); j != "" {
|
||||
jails = append(jails, j)
|
||||
}
|
||||
}
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
func (f Fail2ban) named(ctx context.Context, jail string) ([]string, error) {
|
||||
if jail != "" {
|
||||
return []string{jail}, nil
|
||||
}
|
||||
return f.Jails(ctx)
|
||||
}
|
||||
|
||||
// Status is every jail with what it watches and holds, or one jail's detail.
|
||||
func (f Fail2ban) Status(ctx context.Context, jail string) (map[string][]JailStatus, error) {
|
||||
names, err := f.named(ctx, jail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jails := []JailStatus{}
|
||||
for _, name := range names {
|
||||
out, err := f.client(ctx, "status", name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jails = append(jails, parseJailStatus(name, out))
|
||||
}
|
||||
return map[string][]JailStatus{"jails": jails}, nil
|
||||
}
|
||||
|
||||
// Banned is every address banned now, with the jail holding it and when the ban ends, soonest to
|
||||
// end first.
|
||||
func (f Fail2ban) Banned(ctx context.Context, jail string) (map[string][]Ban, error) {
|
||||
names, err := f.named(ctx, jail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
banned := []Ban{}
|
||||
for _, name := range names {
|
||||
out, err := f.client(ctx, "get", name, "banip", "--with-time")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
banned = append(banned, parseBans(name, out)...)
|
||||
}
|
||||
sort.SliceStable(banned, func(a, b int) bool {
|
||||
if banned[a].Until != banned[b].Until {
|
||||
return banned[a].Until < banned[b].Until
|
||||
}
|
||||
return banned[a].IP < banned[b].IP
|
||||
})
|
||||
return map[string][]Ban{"banned": banned}, nil
|
||||
}
|
||||
|
||||
// BanOutcome is a ban as held, and how many addresses the daemon said it added.
|
||||
type BanOutcome struct {
|
||||
Banned *Ban `json:"banned"`
|
||||
Added int `json:"added"`
|
||||
}
|
||||
|
||||
// Ban bans one address in one jail now. The daemon's own answer is how many addresses it added.
|
||||
func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) {
|
||||
if err := address(ip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := jailName(jail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := f.client(ctx, "set", jail, "banip", ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
added, _ := strconv.Atoi(strings.TrimSpace(out))
|
||||
held, err := f.Banned(ctx, jail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome := &BanOutcome{Added: added}
|
||||
for _, b := range held["banned"] {
|
||||
if b.IP == ip {
|
||||
b := b
|
||||
outcome.Banned = &b
|
||||
}
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// Released is how many bans the daemon let go, of which address, from where.
|
||||
type Released struct {
|
||||
Released int `json:"released"`
|
||||
IP string `json:"ip"`
|
||||
Jail string `json:"jail"`
|
||||
}
|
||||
|
||||
// Unban lets one address go, from one jail or from every jail. The daemon's answer is how many it
|
||||
// released.
|
||||
func (f Fail2ban) Unban(ctx context.Context, ip, jail string) (*Released, error) {
|
||||
if err := address(ip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out string
|
||||
var err error
|
||||
if jail != "" {
|
||||
if err := jailName(jail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = f.client(ctx, "set", jail, "unbanip", ip)
|
||||
} else {
|
||||
out, err = f.client(ctx, "unban", ip)
|
||||
jail = "every jail"
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
released, _ := strconv.Atoi(strings.TrimSpace(out))
|
||||
return &Released{Released: released, IP: ip, Jail: jail}, nil
|
||||
}
|
||||
|
||||
// Settings is one jail's effective settings — the module's own tool, beside the seat's verbs.
|
||||
func (f Fail2ban) Settings(ctx context.Context, jail string) (*JailSettings, error) {
|
||||
if err := jailName(jail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, key := range []string{"bantime", "findtime", "maxretry", "ignoreip", "actions", "logpath", "journalmatch"} {
|
||||
out, err := f.client(ctx, "get", jail, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
got[key] = out
|
||||
}
|
||||
maxretry, _ := strconv.Atoi(strings.TrimSpace(got["maxretry"]))
|
||||
s := &JailSettings{
|
||||
Jail: jail,
|
||||
Bantime: strings.TrimSpace(got["bantime"]),
|
||||
Findtime: strings.TrimSpace(got["findtime"]),
|
||||
Maxretry: maxretry,
|
||||
Ignoreip: listed(got["ignoreip"]),
|
||||
Actions: afterHeading(got["actions"]),
|
||||
Logpath: []string{},
|
||||
Journal: strings.Join(afterHeading(got["journalmatch"]), " "),
|
||||
}
|
||||
if !strings.Contains(got["logpath"], "No file is currently monitored") {
|
||||
s.Logpath = listed(got["logpath"])
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
var treeMarks = regexp.MustCompile("^[\\s|`-]+")
|
||||
|
||||
// listed reads fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading.
|
||||
func listed(out string) []string {
|
||||
items := []string{}
|
||||
for i, l := range strings.Split(out, "\n") {
|
||||
l = strings.TrimSpace(treeMarks.ReplaceAllString(l, ""))
|
||||
if i > 0 && l != "" {
|
||||
items = append(items, l)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// afterHeading is every non-empty line after the first, trimmed.
|
||||
func afterHeading(out string) []string {
|
||||
items := []string{}
|
||||
for i, l := range strings.Split(out, "\n") {
|
||||
if l = strings.TrimSpace(l); i > 0 && l != "" {
|
||||
items = append(items, l)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func parseJailStatus(jail, out string) JailStatus {
|
||||
field := func(label string) string {
|
||||
m := regexp.MustCompile(regexp.QuoteMeta(label) + `:\t?[ \t]*(.*)`).FindStringSubmatch(out)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
num := func(label string) int {
|
||||
n, _ := strconv.Atoi(field(label))
|
||||
return n
|
||||
}
|
||||
watching := []string{}
|
||||
for _, w := range []string{field("File list"), field("Journal matches")} {
|
||||
if w != "" {
|
||||
watching = append(watching, w)
|
||||
}
|
||||
}
|
||||
addresses := strings.Fields(field("Banned IP list"))
|
||||
if addresses == nil {
|
||||
addresses = []string{}
|
||||
}
|
||||
return JailStatus{
|
||||
Jail: jail,
|
||||
Watching: watching,
|
||||
Failing: Counted{Now: num("Currently failed"), Total: num("Total failed")},
|
||||
Banned: Held{Now: num("Currently banned"), Total: num("Total banned"), Addresses: addresses},
|
||||
}
|
||||
}
|
||||
|
||||
var banLine = regexp.MustCompile(`^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)`)
|
||||
|
||||
// parseBans reads `get <jail> banip --with-time`, one ban per line: "IP \tsince + seconds = until".
|
||||
func parseBans(jail, out string) []Ban {
|
||||
bans := []Ban{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
m := banLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
until := m[4]
|
||||
if seconds, _ := strconv.Atoi(m[3]); seconds < 0 {
|
||||
until = "never"
|
||||
}
|
||||
bans = append(bans, Ban{IP: m[1], Jail: jail, Since: m[2], Until: until})
|
||||
}
|
||||
return bans
|
||||
}
|
||||
|
||||
func address(ip string) error {
|
||||
if net.ParseIP(ip) == nil {
|
||||
return fmt.Errorf("%q is not an address", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var jailNamed = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
func jailName(jail string) error {
|
||||
if !jailNamed.MatchString(jail) {
|
||||
return fmt.Errorf("%q is not a jail's name", jail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
|
||||
// on the control node on 2026-10-02 (novox/hq ADR 0179).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const statusAll = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n"
|
||||
const recidive = "Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
|
||||
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
|
||||
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n"
|
||||
const sshd = "Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
|
||||
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
|
||||
" |- Total banned:\t150\n `- Banned IP list:\t\n"
|
||||
const withTime = "195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
|
||||
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n"
|
||||
|
||||
func fake(answers map[string]string, calls *[][]string) Runner {
|
||||
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if calls != nil {
|
||||
*calls = append(*calls, append([]string{name}, args...))
|
||||
}
|
||||
if out, ok := answers[strings.Join(args, " ")]; ok {
|
||||
return out, nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected %s %s", name, strings.Join(args, " "))
|
||||
}
|
||||
}
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func TestAJailsStatusIsReadIntoNumbersWhatItWatchesAndWhoItHolds(t *testing.T) {
|
||||
got := parseJailStatus("recidive", recidive)
|
||||
want := JailStatus{Jail: "recidive", Watching: []string{"/var/log/fail2ban.log"}, Failing: Counted{36, 149},
|
||||
Banned: Held{9, 13, []string{"195.178.110.30", "45.148.10.240", "92.118.39.71"}}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
j := parseJailStatus("sshd", sshd)
|
||||
if !reflect.DeepEqual(j.Watching, []string{"_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}) {
|
||||
t.Errorf("watching %v", j.Watching)
|
||||
}
|
||||
if !reflect.DeepEqual(j.Banned, Held{0, 150, []string{}}) {
|
||||
t.Errorf("banned %+v", j.Banned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCoversEveryJailTheDaemonListsOrTheOneNamed(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{"status": statusAll, "status recidive": recidive, "status sshd": sshd}, &calls)}
|
||||
all, err := f.Status(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all["jails"]) != 2 || all["jails"][0].Jail != "recidive" || all["jails"][1].Jail != "sshd" {
|
||||
t.Errorf("%+v", all)
|
||||
}
|
||||
one, err := f.Status(ctx, "sshd")
|
||||
if err != nil || len(one["jails"]) != 1 {
|
||||
t.Fatalf("%+v %v", one, err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[len(calls)-1], []string{"fail2ban-client", "status", "sshd"}) {
|
||||
t.Errorf("last call %v", calls[len(calls)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansAreReadWithWhenTheyEndAPermanentOneAsNever(t *testing.T) {
|
||||
bans := parseBans("recidive", withTime+"203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n")
|
||||
if len(bans) != 3 {
|
||||
t.Fatalf("%+v", bans)
|
||||
}
|
||||
if bans[0] != (Ban{IP: "195.178.110.30", Jail: "recidive", Since: "2026-09-26 23:18:47", Until: "2026-10-03 23:18:47"}) {
|
||||
t.Errorf("%+v", bans[0])
|
||||
}
|
||||
if bans[2].Until != "never" {
|
||||
t.Errorf("a permanent ban ends %q", bans[2].Until)
|
||||
}
|
||||
if got := parseBans("sshd", "\n"); len(got) != 0 {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannedGathersEveryJailsBansSoonestToEndFirst(t *testing.T) {
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"status": statusAll,
|
||||
"get recidive banip --with-time": withTime,
|
||||
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
|
||||
}, nil)}
|
||||
got, err := f.Banned(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []string
|
||||
for _, b := range got["banned"] {
|
||||
order = append(order, b.IP+"@"+b.Jail)
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"}) {
|
||||
t.Errorf("%v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"set recidive banip 198.51.100.7": "1\n",
|
||||
"get recidive banip --with-time": withTime + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
|
||||
}, &calls)}
|
||||
r, err := f.Ban(ctx, "198.51.100.7", "recidive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Added != 1 || r.Banned == nil || r.Banned.Until != "2026-10-09 17:00:00" {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[0], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) {
|
||||
t.Errorf("first call %v", calls[0])
|
||||
}
|
||||
if _, err := f.Ban(ctx, "not-an-ip", "recidive"); err == nil || !strings.Contains(err.Error(), "is not an address") {
|
||||
t.Errorf("a non-address: %v", err)
|
||||
}
|
||||
if _, err := f.Ban(ctx, "198.51.100.7", "a jail; rm"); err == nil || !strings.Contains(err.Error(), "is not a jail's name") {
|
||||
t.Errorf("a non-name: %v", err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Errorf("a refused ban reached the daemon: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanReleasesFromOneJailOrFromEveryJail(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{"set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n"}, &calls)}
|
||||
one, err := f.Unban(ctx, "198.51.100.7", "sshd")
|
||||
if err != nil || *one != (Released{1, "198.51.100.7", "sshd"}) {
|
||||
t.Errorf("%+v %v", one, err)
|
||||
}
|
||||
every, err := f.Unban(ctx, "198.51.100.7", "")
|
||||
if err != nil || *every != (Released{2, "198.51.100.7", "every jail"}) {
|
||||
t.Errorf("%+v %v", every, err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[1], []string{"fail2ban-client", "unban", "198.51.100.7"}) {
|
||||
t.Errorf("%v", calls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJailsSettingsAreReadFromTheDaemonsListings(t *testing.T) {
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
|
||||
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
|
||||
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
|
||||
"get sshd logpath": "No file is currently monitored\n",
|
||||
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
|
||||
}, nil)}
|
||||
got, err := f.Settings(ctx, "sshd")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := &JailSettings{Jail: "sshd", Bantime: "86400", Findtime: "86400", Maxretry: 3,
|
||||
Ignoreip: []string{"127.0.0.0/8", "10.10.0.0/24", "::1"}, Actions: []string{"iptables-allports-dualchain"},
|
||||
Logpath: []string{}, Journal: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheClientRunsAsGivenByRootAndThroughSudoByAnyoneElse(t *testing.T) {
|
||||
if p, a := escalated(0, "fail2ban-client", []string{"status"}); p != "fail2ban-client" || !reflect.DeepEqual(a, []string{"status"}) {
|
||||
t.Errorf("as root: %s %v", p, a)
|
||||
}
|
||||
if p, a := escalated(1000, "fail2ban-client", []string{"set", "sshd", "banip", "198.51.100.7"}); p != "sudo" ||
|
||||
!reflect.DeepEqual(a, []string{"-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"}) {
|
||||
t.Errorf("as an account: %s %v", p, a)
|
||||
}
|
||||
if !installed("sh", "/bin:/usr/bin") || installed("no-such-client-of-the-mesh", "/bin:/usr/bin") {
|
||||
t.Error("installed is wrong about sh or about a tool nobody has")
|
||||
}
|
||||
}
|
||||
|
||||
// The tools carry the seat's four verbs under the seat's name, and the module's own under its own.
|
||||
func TestTheSeatsVerbsAndTheModulesOwnToolAreServed(t *testing.T) {
|
||||
var names []string
|
||||
for _, tool := range tools(Fail2ban{Run: fake(nil, nil)}) {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
want := []string{"node-intrusion-prevention.status", "node-intrusion-prevention.banned", "node-intrusion-prevention.ban",
|
||||
"node-intrusion-prevention.unban", "fail2ban_settings"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Errorf("%v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// The daemon on this machine, read only — status, bans and one jail's settings — when asked for with
|
||||
// FAIL2BAN_LIVE=1: the shapes above are what fail2ban-client printed once, and this is what it prints
|
||||
// now.
|
||||
func TestTheLiveDaemonReadsBack(t *testing.T) {
|
||||
if os.Getenv("FAIL2BAN_LIVE") != "1" {
|
||||
t.Skip("set FAIL2BAN_LIVE=1 to read the daemon on this machine")
|
||||
}
|
||||
f := Fail2ban{Run: execRunner}
|
||||
status, err := f.Status(ctx, "")
|
||||
if err != nil || len(status["jails"]) == 0 {
|
||||
t.Fatalf("status: %+v %v", status, err)
|
||||
}
|
||||
for _, j := range status["jails"] {
|
||||
t.Logf("%s: watching %v, failing %d, banned %d now of %d", j.Jail, j.Watching, j.Failing.Now, j.Banned.Now, j.Banned.Total)
|
||||
if len(j.Watching) == 0 {
|
||||
t.Errorf("%s watches nothing as read", j.Jail)
|
||||
}
|
||||
}
|
||||
banned, err := f.Banned(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("banned: %v", err)
|
||||
}
|
||||
t.Logf("%d bans held", len(banned["banned"]))
|
||||
settings, err := f.Settings(ctx, "sshd")
|
||||
if err != nil || settings.Maxretry == 0 || len(settings.Ignoreip) == 0 {
|
||||
t.Fatalf("settings: %+v %v", settings, err)
|
||||
}
|
||||
t.Logf("sshd: bantime %s, maxretry %d, ignores %v", settings.Bantime, settings.Maxretry, settings.Ignoreip)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// fail2ban-tools (novox/hq to-be 31, ADR 0179): the intrusion prevention's tools. One binary, launched
|
||||
// by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR
|
||||
// 0198): the node-intrusion-prevention seat's four verbs — who is banned, the jails' state, ban one,
|
||||
// let one go — and the module's own reading of a jail's settings. The jails themselves are composed
|
||||
// by the mesh from the modules a machine runs and written as declared resources; these touch only
|
||||
// what the running daemon holds.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-intrusion-prevention"
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(Fail2ban{Run: execRunner})); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
v, _ := a[k].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||
// subject. The module's own tools keep their bare names.
|
||||
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||
}
|
||||
|
||||
func tools(f Fail2ban) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
oneJail := map[string]any{"jail": str("one jail (optional)")}
|
||||
return []stdio.Tool{
|
||||
verb("status", "Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
|
||||
oneJail, func(a map[string]any) (any, error) { return f.Status(ctx, arg(a, "jail")) }),
|
||||
verb("banned", "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
|
||||
oneJail, func(a map[string]any) (any, error) { return f.Banned(ctx, arg(a, "jail")) }),
|
||||
verb("ban", "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
|
||||
map[string]any{"ip": str("the address"), "jail": str("the jail to hold it (recidive for the long ban)")},
|
||||
func(a map[string]any) (any, error) { return f.Ban(ctx, arg(a, "ip"), arg(a, "jail")) }),
|
||||
verb("unban", "Let one address go, from one jail or from every jail when none is named.",
|
||||
map[string]any{"ip": str("the address"), "jail": str("one jail (optional)")},
|
||||
func(a map[string]any) (any, error) { return f.Unban(ctx, arg(a, "ip"), arg(a, "jail")) }),
|
||||
{Name: "fail2ban_settings",
|
||||
Description: "One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
||||
Input: map[string]any{"jail": str("the jail")},
|
||||
Run: func(a map[string]any) (any, error) { return f.Settings(ctx, arg(a, "jail")) }},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module fail2ban
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -116,9 +116,12 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/fail2ban-tools",
|
||||
"binary": "fail2ban-tools",
|
||||
"loads": [
|
||||
"fail2ban-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@novox/module-fail2ban",
|
||||
"version": "0.1.0",
|
||||
"description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
|
||||
// on the control node on 2026-10-02 (novox/hq ADR 0179).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts";
|
||||
|
||||
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
|
||||
const RECIDIVE =
|
||||
"Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
|
||||
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
|
||||
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n";
|
||||
const SSHD =
|
||||
"Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
|
||||
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
|
||||
" |- Total banned:\t150\n `- Banned IP list:\t\n";
|
||||
const WITH_TIME =
|
||||
"195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
|
||||
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n";
|
||||
|
||||
function fake(answers: Record<string, string>, calls: string[][] = []): Runner {
|
||||
return async (cmd, args) => {
|
||||
calls.push([cmd, ...args]);
|
||||
const key = args.join(" ");
|
||||
if (key in answers) return answers[key];
|
||||
throw new Error(`unexpected ${cmd} ${key}`);
|
||||
};
|
||||
}
|
||||
|
||||
test("a jail's status is read into numbers, what it watches and who it holds", () => {
|
||||
const s = parseJailStatus("recidive", RECIDIVE);
|
||||
assert.deepEqual(s, {
|
||||
jail: "recidive",
|
||||
watching: ["/var/log/fail2ban.log"],
|
||||
failing: { now: 36, total: 149 },
|
||||
banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] },
|
||||
});
|
||||
const j = parseJailStatus("sshd", SSHD);
|
||||
assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]);
|
||||
assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] });
|
||||
});
|
||||
|
||||
test("status covers every jail the daemon lists, or the one named", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls));
|
||||
const all = await f.status();
|
||||
assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]);
|
||||
const one = await f.status("sshd");
|
||||
assert.equal(one.jails.length, 1);
|
||||
assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]);
|
||||
});
|
||||
|
||||
test("bans are read with when they were placed and when they end, a permanent one as never", () => {
|
||||
const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n");
|
||||
assert.equal(bans.length, 3);
|
||||
assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" });
|
||||
assert.equal(bans[2].until, "never");
|
||||
assert.deepEqual(parseBans("sshd", "\n"), []);
|
||||
});
|
||||
|
||||
test("banned gathers every jail's bans, soonest to end first", async () => {
|
||||
const f = new Fail2banClient(fake({
|
||||
status: STATUS,
|
||||
"get recidive banip --with-time": WITH_TIME,
|
||||
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
|
||||
}));
|
||||
const { banned } = await f.banned();
|
||||
assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]);
|
||||
});
|
||||
|
||||
test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({
|
||||
"set recidive banip 198.51.100.7": "1\n",
|
||||
"get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
|
||||
}, calls));
|
||||
const r = await f.ban("198.51.100.7", "recidive");
|
||||
assert.equal(r.added, 1);
|
||||
assert.equal(r.banned?.until, "2026-10-09 17:00:00");
|
||||
assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]);
|
||||
await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/);
|
||||
await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test("unban releases from one jail or from every jail", async () => {
|
||||
const calls: string[][] = [];
|
||||
const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls));
|
||||
assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" });
|
||||
assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" });
|
||||
assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]);
|
||||
});
|
||||
|
||||
test("a jail's settings are read from the daemon's listings", async () => {
|
||||
const f = new Fail2banClient(fake({
|
||||
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
|
||||
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
|
||||
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
|
||||
"get sshd logpath": "No file is currently monitored\n",
|
||||
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
|
||||
}));
|
||||
assert.deepEqual(await f.settings("sshd"), {
|
||||
jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3,
|
||||
ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"],
|
||||
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
|
||||
});
|
||||
});
|
||||
|
||||
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
|
||||
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
|
||||
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
|
||||
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
|
||||
assert.equal(installed("sh"), true);
|
||||
assert.equal(installed("no-such-client-of-the-mesh"), false);
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
// The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned,
|
||||
// the jails' state, ban one, let one go — and the module's own reading of a jail's settings
|
||||
// (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a
|
||||
// machine runs and written as declared resources; these touch only what the running daemon holds.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { Fail2banClient } from "../client.js";
|
||||
|
||||
export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "status",
|
||||
description:
|
||||
"Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
|
||||
input: { jail: { type: "string", description: "one jail (optional)" } },
|
||||
run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
{
|
||||
name: "banned",
|
||||
description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
|
||||
input: { jail: { type: "string", description: "one jail (optional)" } },
|
||||
run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
{
|
||||
name: "ban",
|
||||
description:
|
||||
"Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
|
||||
input: {
|
||||
ip: { type: "string", description: "the address" },
|
||||
jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" },
|
||||
},
|
||||
run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")),
|
||||
},
|
||||
{
|
||||
name: "unban",
|
||||
description: "Let one address go, from one jail or from every jail when none is named.",
|
||||
input: {
|
||||
ip: { type: "string", description: "the address" },
|
||||
jail: { type: "string", description: "one jail (optional)" },
|
||||
},
|
||||
run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "fail2ban_settings",
|
||||
description:
|
||||
"One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
||||
input: { jail: { type: "string", description: "the jail" } },
|
||||
run: async (args) => fail2ban.settings(String(args.jail ?? "")),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const fail2ban = Fail2banClient.onThisMachine();
|
||||
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
|
||||
// module holds it (ADR 0159, 0160). The module's own under its own.
|
||||
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
|
||||
registerModuleTools("fail2ban", () => getFail2banTools(fail2ban));
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"client.ts",
|
||||
"tools/index.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# forticlient
|
||||
|
||||
The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the
|
||||
operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned
|
||||
only where a display server is held on the same machine.
|
||||
|
||||
**This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no
|
||||
credential, no gateway, no certificate is declared, read, printed or stored by the module or its
|
||||
tools. The tools report running and connected state only.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled |
|
||||
|
||||
Nothing else. It holds no seat, makes no contribution and writes no file.
|
||||
|
||||
- **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on
|
||||
both workstations it is a foreign (AUR) package that repackages the vendor's build, installed
|
||||
explicitly. The host installs from the official repositories only, so the module cannot declare it.
|
||||
ADR 0205's pinned archive does not fit: it is a vendor binary set with a root service, a firewall
|
||||
helper and an install script. It waits for the mesh's package repository (research 027 question 1,
|
||||
option P2). Until then a fresh workstation installs it by hand.
|
||||
- **The service is declared, and so depended on.** `forticlient.service` is the package's unit, running
|
||||
and enabled on both workstations. Declared running and enabled, it is held in that state, and on a
|
||||
machine without the package the host refuses it by name (*does not exist on this machine*): loud,
|
||||
never a silent pass. The `asus-zephyrus-g14` module does the same with its foreign daemons. The
|
||||
module never restarts it: a change to nothing of the module's would, and nothing of the module's
|
||||
changes.
|
||||
- **The configuration stays the operator's, and unread.** `/etc/forticlient/`, the client's database
|
||||
under `/opt/forticlient/`, the account's FortiClient settings, the VPN profiles, saved credentials
|
||||
and certificates are set in the client's own window. They are found (ADR 0182), and unlike any other
|
||||
found file, the tools do not even read them.
|
||||
|
||||
## How it starts: the vendor's autostart entry, and nothing else
|
||||
|
||||
The tray has two processes: `fortitraylauncher`, which starts and watches `fortitray`. The package's
|
||||
install script links `/etc/xdg/autostart/Fortitray.desktop` to the package's
|
||||
`/opt/forticlient/Fortitray.desktop` (`Exec=/opt/forticlient/fortitraylauncher`). The session runs it
|
||||
once at login through the `i3` module's `dex --autostart --environment i3`. **That entry is the tray's
|
||||
one start.** The module adds no `xinitrc` slot and no `node-display-session` exec, because either would
|
||||
start it a second time. The link is the vendor's, made by its install script; the mesh does not make
|
||||
or remove it.
|
||||
|
||||
The tunnel is not the tray's: the service's processes hold it, as root. Ending the tray leaves a
|
||||
connected tunnel connected.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `forticlient_status` (r) | <ul><li>the installed version, and that it is from outside the official repositories</li><li>the service: active, enabled</li><li>whether the launcher and the tray run: pid, since, and the scope or unit they run in</li><li>what starts the tray at login</li><li>connected or not, as the number of the client's tunnel interfaces that are up</li></ul> |
|
||||
| `forticlient_restart` (a) | asks the tray and its launcher to end (SIGTERM), forces them after 5 s, and starts the launcher in the operator's session as a transient user unit `mesh-forticlient-tray`, so it outlives the tools runtime. The launcher starts the tray. The service and the tunnel are not touched. Refused plainly when nobody is logged in to the desktop |
|
||||
| `forticlient_check` (r) | <ul><li>the package is installed</li><li>the service is running and enabled</li><li>exactly one start: the vendor's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one launcher and one tray run in a desktop session</li></ul>Being connected is never a finding: that is the operator's to decide. Each finding says what to do |
|
||||
|
||||
**What the tools never touch**, held by the tests (a fake machine carries a profile, a gateway, an
|
||||
address, a secret and a certificate where the client keeps them, and no answer may hold any of them):
|
||||
|
||||
- no file under `/etc/forticlient` or the account's FortiClient settings is opened, and under
|
||||
`/opt/forticlient` only the tray's autostart entry, through its link (it names the launcher and
|
||||
nothing else);
|
||||
- the vendor's command-line client and `fortivpn` are never run, and its logs are never read;
|
||||
- a process is named by its command name only, never by its arguments;
|
||||
- *connected* is whether an interface named `fctvpn…` is up, from its flags. The interface's name
|
||||
(it carries an identifier) and its addresses are never answered. A tunnel of a kind that brings up
|
||||
no such interface (IPsec) is not seen, and the answer says *not connected*.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | laptop | desktop |
|
||||
|---|---|---|
|
||||
| package | none: `forticlient-vpn` 7.4.3.5411, explicit, foreign | the same |
|
||||
| service | none: running and enabled | the same |
|
||||
| tray | none: dex starts it from the vendor's entry, in the login session's scope | none on disk. **No tray runs now:** that session began before `dex` was installed, and the predecessor's window manager never started it. The next login is the first that starts it |
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
Nothing is required on either machine. On the desktop, log out and in once, or run
|
||||
`forticlient_restart`, and the tray runs from its one start. `forticlient_check` then answers `ok`.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
Everything of the client's: its configuration and database, the VPN profiles and credentials, its
|
||||
logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package itself.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The package, installed by hand.** Without it the host refuses the service by name.
|
||||
- **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does
|
||||
not start. `forticlient_check` says so.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
// FortiClient as the tools see it: the vendor's scheduler service, the tray and the launcher that
|
||||
// starts it, the tray's XDG autostart entry, and whether a tunnel is up. This is the operator's work
|
||||
// VPN, so the tools never read its profiles, its credentials, its gateways or its certificates: no file
|
||||
// under /etc/forticlient or the account's FortiClient settings is opened, and under /opt/forticlient
|
||||
// only the tray's autostart entry (through its link in /etc/xdg/autostart); the vendor's command-line
|
||||
// client is never run, its logs are never read, a process is named by its command name only (never its
|
||||
// arguments), and a tunnel is counted, never named or addressed.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
launcherComm = "fortitraylaunch" // the kernel keeps 15 characters of fortitraylauncher
|
||||
launcherWord = "fortitraylauncher"
|
||||
trayComm = "fortitray"
|
||||
launcherBin = "/opt/forticlient/fortitraylauncher"
|
||||
entryName = "Fortitray.desktop"
|
||||
restartAs = "mesh-forticlient-tray"
|
||||
packageFor = "forticlient-vpn"
|
||||
serviceUnit = "forticlient.service"
|
||||
// tunnelPrefix starts the name of the interface the client brings up for a connected tunnel.
|
||||
tunnelPrefix = "fctvpn"
|
||||
)
|
||||
|
||||
// named keeps a process's command name and drops its arguments.
|
||||
func named(ps []Proc, comm string) []Proc {
|
||||
out := []Proc{}
|
||||
for _, p := range ps {
|
||||
p.Command = comm
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Tunnel is whether the client holds a tunnel up: how many of its tunnel interfaces exist and are up.
|
||||
// Their names and addresses are never answered.
|
||||
type Tunnel struct {
|
||||
Connected bool `json:"connected"`
|
||||
Up int `json:"tunnels_up"`
|
||||
}
|
||||
|
||||
func (m *Machine) tunnel() Tunnel {
|
||||
t := Tunnel{}
|
||||
entries, _ := os.ReadDir(m.path("/sys/class/net"))
|
||||
for _, e := range entries {
|
||||
if !strings.HasPrefix(e.Name(), tunnelPrefix) {
|
||||
continue
|
||||
}
|
||||
flags, err := strconv.ParseUint(strings.TrimPrefix(readTrimmed(m.path(filepath.Join("/sys/class/net", e.Name(), "flags"))), "0x"), 16, 32)
|
||||
if err == nil && flags&1 == 1 { // IFF_UP
|
||||
t.Up++
|
||||
}
|
||||
}
|
||||
t.Connected = t.Up > 0
|
||||
return t
|
||||
}
|
||||
|
||||
// Service is the vendor's scheduler, which holds the tunnel.
|
||||
type Service struct {
|
||||
Active string `json:"active"`
|
||||
Enabled string `json:"enabled"`
|
||||
}
|
||||
|
||||
func (m *Machine) service() Service {
|
||||
word := func(verb string) string {
|
||||
o := m.cmd(5*time.Second, nil, "systemctl", verb, serviceUnit)
|
||||
if f := strings.Fields(o.Stdout); o.Err == nil && len(f) > 0 {
|
||||
return f[0]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
return Service{Active: word("is-active"), Enabled: word("is-enabled")}
|
||||
}
|
||||
|
||||
// foreign says whether the package came from outside the official repositories (pacman -Qm).
|
||||
func (m *Machine) foreign() bool {
|
||||
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
|
||||
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
|
||||
}
|
||||
|
||||
// StatusAnswer is what forticlient_status answers.
|
||||
type StatusAnswer struct {
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Foreign bool `json:"outside_official_repositories"`
|
||||
Service Service `json:"service"`
|
||||
Launcher []Proc `json:"launcher"`
|
||||
Tray []Proc `json:"tray"`
|
||||
StartedBy Autostart `json:"started_by"`
|
||||
Tunnel Tunnel `json:"tunnel"`
|
||||
}
|
||||
|
||||
// Status reads the client's running state, and nothing of its configuration.
|
||||
func (m *Machine) Status() (StatusAnswer, error) {
|
||||
s := StatusAnswer{Service: m.service(), Launcher: named(m.procs(launcherComm), launcherWord),
|
||||
Tray: named(m.procs(trayComm), trayComm), StartedBy: m.autostart(entryName), Tunnel: m.tunnel()}
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Installed = v
|
||||
if v != "" {
|
||||
s.Foreign = m.foreign()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what forticlient_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Ended []int `json:"ended"`
|
||||
Killed []int `json:"killed,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Session Session `json:"session"`
|
||||
Unit string `json:"unit"`
|
||||
// Tunnel is after the restart: the tray is only the client's face, the service holds the tunnel.
|
||||
Tunnel Tunnel `json:"tunnel"`
|
||||
}
|
||||
|
||||
// Restart ends the tray and its launcher and starts the launcher again in the operator's session,
|
||||
// under the account's service manager. The launcher starts the tray. The tunnel is the service's,
|
||||
// and is not touched.
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
s, err := m.session()
|
||||
if err != nil {
|
||||
return RestartAnswer{}, err
|
||||
}
|
||||
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
||||
a.Ended, a.Killed = m.stop(5*time.Second, launcherComm, trayComm)
|
||||
if err := m.detach(s, restartAs, launcherBin); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Running = named(m.waitFor(launcherComm, 4*time.Second), launcherWord)
|
||||
a.Tunnel = m.tunnel()
|
||||
if len(a.Running) == 0 {
|
||||
return a, fmt.Errorf("the launcher was started as %s but no %s process appeared within 4 s",
|
||||
a.Unit, launcherWord)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what forticlient_check answers. Being connected or not is never a finding: that is
|
||||
// the operator's choice, and forticlient_status says which.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies what the module promises and relies on: the package (found, not installed by the
|
||||
// mesh); the service running and enabled; one start for the tray (the vendor's autostart entry, which
|
||||
// the session's dex runs); and the tray running once in a desktop session.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
v, err := m.installed(packageFor)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("forticlient-vpn is not installed. It is outside the official repositories, so the mesh does not install it",
|
||||
"install it from the AUR by hand")
|
||||
}
|
||||
if s := m.service(); s.Active != "active" || s.Enabled != "enabled" {
|
||||
add(fmt.Sprintf("the service %s is %s and %s", serviceUnit, s.Active, s.Enabled),
|
||||
"push the module, which declares it running and enabled")
|
||||
}
|
||||
entry := m.autostart(entryName)
|
||||
if entry.Starts {
|
||||
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
|
||||
if entry.From != "/etc/xdg/autostart/"+entryName {
|
||||
add("the account's own "+entry.From+" replaces the vendor's entry", "remove it, so the vendor's entry is the one start")
|
||||
}
|
||||
} else {
|
||||
add("the tray does not start with the session ("+entry.Because+")",
|
||||
"remove ~/.config/autostart/"+entryName+" if it hides the vendor's entry; if the vendor's is gone, reinstall forticlient-vpn, whose install links it")
|
||||
}
|
||||
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
|
||||
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
|
||||
}
|
||||
for _, word := range []string{launcherWord, trayComm} {
|
||||
for _, l := range m.i3Starts(word) {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
add("a second start: "+l, "remove the line; the vendor's autostart entry is the tray's one start")
|
||||
}
|
||||
}
|
||||
if _, err := m.session(); err == nil {
|
||||
for _, p := range []struct{ comm, word string }{{launcherComm, launcherWord}, {trayComm, trayComm}} {
|
||||
switch running := m.procs(p.comm); {
|
||||
case len(running) == 0:
|
||||
add("no "+p.word+" runs in the desktop session", "forticlient_restart")
|
||||
case len(running) > 1:
|
||||
add(fmt.Sprintf("%d of %s run", len(running), p.word), "forticlient_restart ends them all and starts one")
|
||||
}
|
||||
}
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// secrets are what the operator's VPN configuration holds on a real machine. The fake machine carries
|
||||
// them where the client keeps them, and no answer may.
|
||||
var secrets = []string{"vpn.example.invalid", "192.0.2.10", "s3cret-psk", "work-profile", "BEGIN CERTIFICATE", "65d16a50"}
|
||||
|
||||
func newClient(t *testing.T, running, connected bool) *fake {
|
||||
f := newFake(t)
|
||||
f.write("/opt/forticlient/Fortitray.desktop", "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
|
||||
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
|
||||
f.write("/etc/forticlient/config.db", "work-profile vpn.example.invalid 192.0.2.10 s3cret-psk\n")
|
||||
f.write(testHome+"/.config/FortiClient/state.json", `{"gateway":"vpn.example.invalid","cert":"-----BEGIN CERTIFICATE-----"}`)
|
||||
f.write("/sys/class/net/wlp3s0/flags", "0x1003\n")
|
||||
if connected {
|
||||
f.write("/sys/class/net/fctvpn65d16a50/flags", "0x1091\n")
|
||||
f.write("/sys/class/net/fctvpn65d16a50/address", "192.0.2.10\n")
|
||||
}
|
||||
if running {
|
||||
f.proc(3857, 1000, launcherComm, []string{launcherBin, "--profile=work-profile"}, "session-c1.scope")
|
||||
f.proc(4509, 1000, trayComm, []string{"/opt/forticlient/fortitray", "--gateway", "vpn.example.invalid"}, "session-c1.scope")
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch {
|
||||
case name == "pacman" && args[0] == "-Q":
|
||||
return Output{Stdout: "forticlient-vpn 7.4.3.5411-1\n"}
|
||||
case name == "pacman" && args[0] == "-Qqm":
|
||||
return Output{Stdout: "forticlient-vpn\n"}
|
||||
case name == "systemctl" && args[0] == "is-active":
|
||||
return Output{Stdout: "active\n"}
|
||||
case name == "systemctl" && args[0] == "is-enabled":
|
||||
return Output{Stdout: "enabled\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// carries fails the test when an answer holds anything of the VPN's configuration.
|
||||
func carries(t *testing.T, answer any) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(answer)
|
||||
for _, s := range secrets {
|
||||
if strings.Contains(string(raw), s) {
|
||||
t.Fatalf("the answer carries %q: %s", s, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsRunningAndConnectedStateAndNothingOfTheConfiguration(t *testing.T) {
|
||||
f := newClient(t, true, true)
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Installed != "7.4.3.5411-1" || !s.Foreign || s.Service != (Service{"active", "enabled"}) || len(s.Launcher) != 1 || len(s.Tray) != 1 ||
|
||||
!s.StartedBy.Starts || !s.Tunnel.Connected || s.Tunnel.Up != 1 {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if s.Launcher[0].Command != launcherWord || s.Tray[0].Command != trayComm {
|
||||
t.Fatalf("processes by command name only: %+v %+v", s.Launcher, s.Tray)
|
||||
}
|
||||
carries(t, s)
|
||||
for _, c := range f.calls {
|
||||
for _, never := range []string{"forticlient-cli", "fortivpn", "journalctl", "sqlite", "/etc/forticlient", "/opt/forticlient/.config"} {
|
||||
if strings.Contains(c, never) {
|
||||
t.Fatalf("ran %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
down := newClient(t, true, false)
|
||||
down.write("/sys/class/net/fctvpn0/flags", "0x1090\n")
|
||||
if s, _ := down.Status(); s.Tunnel.Connected || s.Tunnel.Up != 0 {
|
||||
t.Fatalf("an interface that is down is no tunnel: %+v", s.Tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassesTheVendorsOneStartAndNeverJudgesTheConnection(t *testing.T) {
|
||||
f := newClient(t, true, false)
|
||||
f.desktopSession()
|
||||
c, err := f.Check()
|
||||
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
carries(t, c)
|
||||
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id /opt/forticlient/fortitraylauncher\n")
|
||||
f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=/opt/forticlient/fortitraylauncher\nHidden=true\n")
|
||||
f.proc(3858, 1000, launcherComm, []string{launcherBin}, "session-c1.scope")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch name {
|
||||
case "pacman":
|
||||
return Output{Code: 1}
|
||||
case "systemctl":
|
||||
return Output{Stdout: "inactive\n", Code: 3}
|
||||
case "dex":
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
c, _ = f.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
for _, want := range []string{"not installed", "forticlient.service is inactive", "does not start with the session (Hidden=true)", "dex",
|
||||
"a second start: ~/.config/i3/config:1", "2 of fortitraylauncher run"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no finding %q in\n%s", want, got)
|
||||
}
|
||||
}
|
||||
carries(t, c)
|
||||
}
|
||||
|
||||
func TestRestartStartsTheLauncherAndLeavesTheTunnel(t *testing.T) {
|
||||
f := newClient(t, true, true)
|
||||
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("without a desktop: %v", err)
|
||||
}
|
||||
f.desktopSession()
|
||||
f.onStart = func(argv []string) { f.proc(9100, 1000, launcherComm, argv, "app.slice/"+restartAs+".service") }
|
||||
a, err := f.Restart()
|
||||
if err != nil || len(a.Ended) != 2 || len(a.Running) != 1 || !a.Tunnel.Connected {
|
||||
t.Fatalf("%+v %v", a, err)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if strings.Contains(c, serviceUnit) {
|
||||
t.Fatalf("the restart touched the service: %q", c)
|
||||
}
|
||||
}
|
||||
carries(t, a)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient
|
||||
// VPN client's tray in the operator's session and the vendor's service behind it, served by the node's
|
||||
// runtime as the operator account. The module holds no seat, so every tool is its own. The tools
|
||||
// report running and connected state only: never a profile, a credential, a gateway or a certificate.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "forticlient_status",
|
||||
Description: "The VPN client: the installed version and whether it came from outside the official " +
|
||||
"repositories, the vendor's service (active, enabled), whether the tray and its launcher run " +
|
||||
"(pid, since, scope or unit), what starts the tray at login, and whether a tunnel is up (a " +
|
||||
"count; never a name, an address, a gateway or a profile). (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "forticlient_restart",
|
||||
Description: "End the tray and its launcher (asked first, then forced after 5 s) and start the " +
|
||||
"launcher again in the operator's desktop session, under the account's service manager. The " +
|
||||
"tunnel is the service's and is not touched. Needs someone logged in to the desktop. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "forticlient_check",
|
||||
Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
|
||||
"outside the official repositories); the service runs and is enabled; the tray has exactly one " +
|
||||
"start (the vendor's XDG autostart entry, which the session's dex runs; no window-manager exec); " +
|
||||
"and the tray and its launcher run once in a desktop session. Being connected is not checked. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// forticlient's shape (novox/hq ADR 0205, ADR 0207, ADR 0208): no package (the client is outside the
|
||||
// official repositories and kept as found), the vendor's service declared running and enabled, the X
|
||||
// display on its own machine, no start of its own (the vendor's autostart entry is the tray's one
|
||||
// start), nothing of the VPN's configuration, and the Go bundle serving exactly the listed forticlient_
|
||||
// tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "forticlient_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed %s and described", tool.Name, "forticlient_")
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/forticlient-tools" || b["binary"] != "forticlient-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
|
||||
!reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
|
||||
m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil {
|
||||
t.Fatalf("resources: %v", m.Resources)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatal("no seat, no environment, and no second start")
|
||||
}
|
||||
for _, never := range []string{"/etc/forticlient", "/opt/forticlient", "autostart", "\"package\"", "vpn.", "gateway", "profile"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %s", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module forticlient
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"module": "forticlient",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"tools": [
|
||||
"forticlient_status",
|
||||
"forticlient_restart",
|
||||
"forticlient_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "scheduler",
|
||||
"type": "service",
|
||||
"unit": "forticlient.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/forticlient-tools",
|
||||
"binary": "forticlient-tools",
|
||||
"loads": [
|
||||
"forticlient-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+36
-4
@@ -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,31 @@ 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. */
|
||||
/** Every file a pull request changes, and which of them it deleted: a module whose manifest the merge
|
||||
* deleted is gone from its source, and the mesh forgets it rather than asking its build (novox/hq ADR
|
||||
* 0236). The forge says `deleted`; `removed` is read the same. */
|
||||
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; removed: string[]; truncated: boolean }> {
|
||||
const paths: string[] = [];
|
||||
const removed: 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 === "") continue;
|
||||
paths.push(name);
|
||||
const status = String(f?.status ?? "");
|
||||
if (status === "deleted" || status === "removed") removed.push(name);
|
||||
}
|
||||
if (files.length === 0 || files.length < pageSize) return { paths, removed, truncated: false };
|
||||
if (paths.length >= most) return { paths, removed, truncated: true };
|
||||
}
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
@@ -342,6 +365,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 +608,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));
|
||||
}
|
||||
|
||||
+24
-9
@@ -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" });
|
||||
@@ -112,6 +120,9 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
html_url: pull.html_url,
|
||||
paths: changed.paths,
|
||||
paths_truncated: changed.truncated,
|
||||
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
|
||||
// not built.
|
||||
removed: changed.removed,
|
||||
});
|
||||
// Said, because a trigger that fires silently is indistinguishable from one that did not
|
||||
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
|
||||
@@ -124,6 +135,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 +144,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 +157,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);
|
||||
|
||||
@@ -85,6 +85,23 @@
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "data",
|
||||
"path": "${dir:data}",
|
||||
"class": "valuable",
|
||||
"why": "every repository, its issues and attachments, and the package registry"
|
||||
}
|
||||
],
|
||||
"consumers": {
|
||||
"npm-package-registry": {
|
||||
"class": "rebuildable",
|
||||
"in": "data",
|
||||
"why": "a consumer's packages are published again from its source"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
@@ -182,7 +199,11 @@
|
||||
"provides": [
|
||||
{
|
||||
"name": "npm-package-registry",
|
||||
"scope": "mesh"
|
||||
"scope": "mesh",
|
||||
"identity": {
|
||||
"max": 40,
|
||||
"in": "a Gitea user name"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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`, status: start + i === 3 ? "deleted" : "changed" }));
|
||||
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);
|
||||
assert.deepEqual(got.removed, ["modules/m3/x"], "a file the merge deleted is said as deleted");
|
||||
});
|
||||
|
||||
test("a pass asks only the repositories that moved since the last look, every one before the first", async () => {
|
||||
const { movedSince } = await import("../client.ts");
|
||||
const repos = [
|
||||
{ full_name: "a/old", name: "old", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T10:00:00Z" },
|
||||
{ full_name: "a/new", name: "new", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T16:10:02Z" },
|
||||
{ full_name: "a/unknown", name: "unknown", owner: "a", private: false, html_url: "" },
|
||||
];
|
||||
assert.deepEqual(movedSince(repos, "").map((r) => r.name), ["old", "new", "unknown"]);
|
||||
assert.deepEqual(movedSince(repos, "2026-10-05T16:09:00.000Z").map((r) => r.name), ["new", "unknown"]);
|
||||
assert.deepEqual(movedSince(repos, "2026-10-05T18:10:02+02:00").map((r) => r.name), ["new", "unknown"], "an offset is a moment, not a string");
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client.
|
||||
// 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 };
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
"why": "the dashboards. Also 3000 inside, like the forge - which is the mesh's port assignment earning its keep"
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "data",
|
||||
"path": "${dir:data}",
|
||||
"class": "valuable",
|
||||
"why": "dashboards, users and alert rules"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
@@ -34,6 +34,17 @@
|
||||
"why": "the bundled go2rtc's WebRTC port, which camera streams to a browser use"
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "config",
|
||||
"path": "${dir:config}",
|
||||
"class": "valuable",
|
||||
"active": "1d",
|
||||
"why": "the house's configuration, automations and history; written all the time"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# hostname
|
||||
|
||||
The machine's names (novox/hq ADR 0199, ADR 0223): it holds the node seat `node-hostname` and owns
|
||||
the two files that say what a machine is called — `/etc/hostname` and the machine's own lines in
|
||||
`/etc/hosts`. It was `hosts`, holding `node-hosts-file`; the seat was renamed, and the old name
|
||||
resolves to it as an alias.
|
||||
|
||||
## What it writes
|
||||
|
||||
- **`/etc/hostname`, whole**: the module's `hostname` setting, and nothing else. There is no
|
||||
default. A machine's name is the operator's: the mesh's name for a machine and the name it calls
|
||||
itself need not be the same, and writing the mesh's name silently would rename a machine. Set it
|
||||
per machine — `settings set hostname '{"hostname": "<name>"}' --node <node>` — before the module
|
||||
is assigned there; without it the module is left out of that machine's declaration, naming the
|
||||
key. A mesh-wide `{"hostname": "${machine:name}"}` makes every machine call itself by its mesh
|
||||
name, and a machine's own setting still overrides it.
|
||||
- **The machine's own lines in `/etc/hosts`**, as the mesh's marked region at the start of the file:
|
||||
`localhost` and `127.0.1.1` with the machine's mesh name. Every other line is the operator's, kept
|
||||
byte for byte and given back when the module goes.
|
||||
|
||||
## When a new name takes effect
|
||||
|
||||
At the machine's **next boot**. The kernel's name is set from `/etc/hostname` when the machine
|
||||
starts; writing the file changes what `hostnamectl` reports as the static name and nothing that is
|
||||
running. The module declares nothing that would set it live: a graphical session's X authority is
|
||||
keyed by the name the session started under, so changing it underneath a running session refuses
|
||||
every new window until the person logs in again. Reboot when that is acceptable, or run
|
||||
`hostnamectl hostname <name>` by hand.
|
||||
|
||||
## Its verbs
|
||||
|
||||
`<node>/node-hostname.entries`, `.add` and `.remove`: the hosts file's lines, each marked whose it
|
||||
is; add one address and its names to the operator's lines; remove a name or an address from them.
|
||||
They change the machine's file and nothing else. `/etc/hostname` has no verb — it is the setting.
|
||||
@@ -0,0 +1,331 @@
|
||||
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
|
||||
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
|
||||
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
|
||||
//
|
||||
// Root is the module's concern (ADR 0175 §4): the runtime launching this binary runs as the operator's
|
||||
// account, so the file is written through sudo without a prompt where the account is not root, as the
|
||||
// packet filter's is.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two
|
||||
// together.
|
||||
const HostsPath = "/etc/hosts"
|
||||
|
||||
// Operator is the owner of every line outside a block.
|
||||
const Operator = "operator"
|
||||
|
||||
// Runner runs one command as root and answers what it printed, so the writes can be tested without a
|
||||
// machine.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root, else through sudo
|
||||
// without a prompt.
|
||||
func escalated(uid int, name string, args []string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
program, argv := escalated(os.Getuid(), name, args)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), nil
|
||||
}
|
||||
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||
if program == "sudo" {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
}
|
||||
if said != "" {
|
||||
return "", fmt.Errorf("%s: %s", name, said)
|
||||
}
|
||||
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||
}
|
||||
|
||||
// Line is one line of the file, as a reader sees it.
|
||||
type Line struct {
|
||||
// Text is the line exactly as it is in the file.
|
||||
Text string `json:"text"`
|
||||
// Owner is whose it is: the block's id (`mesh hostname.own`, or another tool's) or "operator".
|
||||
Owner string `json:"owner"`
|
||||
// Address and Names are an entry's; absent for a comment or a blank line.
|
||||
Address string `json:"address,omitempty"`
|
||||
Names []string `json:"names,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`)
|
||||
end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`)
|
||||
)
|
||||
|
||||
// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be.
|
||||
func isAddress(s string) bool {
|
||||
_, err := netip.ParseAddr(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Parse is every line of a hosts file, each marked whose it is.
|
||||
func Parse(text string) []Line {
|
||||
out := []Line{}
|
||||
block := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if block == "" {
|
||||
if m := begin.FindStringSubmatch(raw); m != nil {
|
||||
block = m[1]
|
||||
out = append(out, Line{Text: raw, Owner: block})
|
||||
continue
|
||||
}
|
||||
}
|
||||
owner := block
|
||||
if owner == "" {
|
||||
owner = Operator
|
||||
}
|
||||
line := Line{Text: raw, Owner: owner}
|
||||
entry, _, _ := strings.Cut(raw, "#")
|
||||
if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) {
|
||||
line.Address, line.Names = fields[0], fields[1:]
|
||||
}
|
||||
out = append(out, line)
|
||||
if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block {
|
||||
block = ""
|
||||
}
|
||||
}
|
||||
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
|
||||
if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") {
|
||||
out = out[:n-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var label = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
|
||||
// Refused input says why, so a caller is one edit from right.
|
||||
func checkAddress(address string) error {
|
||||
if !isAddress(address) {
|
||||
return fmt.Errorf("%q is not an IPv4 or IPv6 address", address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkName(name string) error {
|
||||
bad := fmt.Errorf("%q is not a host name", name)
|
||||
if len(name) < 1 || len(name) > 253 {
|
||||
return bad
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") {
|
||||
if !label.MatchString(l) {
|
||||
return bad
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAdded is the file with one address and its names added to the operator's lines; unchanged when
|
||||
// they are already there.
|
||||
func WithAdded(text, address string, names []string) (string, error) {
|
||||
if err := checkAddress(address); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", errors.New("add names at least one name for the address")
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := checkName(n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, l := range Parse(text) {
|
||||
if l.Owner == Operator && l.Address == address {
|
||||
for _, n := range l.Names {
|
||||
have[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var missing []string
|
||||
for _, n := range names {
|
||||
if !have[n] && !slices.Contains(missing, n) {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
body := text
|
||||
if body != "" && !strings.HasSuffix(body, "\n") {
|
||||
body += "\n"
|
||||
}
|
||||
return body + address + "\t" + strings.Join(missing, " ") + "\n", nil
|
||||
}
|
||||
|
||||
// WithRemoved is the file with one name, or every line of one address, taken out of the operator's
|
||||
// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool
|
||||
// writes is refused, naming whose it is.
|
||||
func WithRemoved(text, what string) (string, int, error) {
|
||||
byAddress := isAddress(what)
|
||||
if !byAddress {
|
||||
if err := checkName(what); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
matches := func(l Line) bool {
|
||||
if byAddress {
|
||||
return l.Address == what
|
||||
}
|
||||
return slices.Contains(l.Names, what)
|
||||
}
|
||||
lines := Parse(text)
|
||||
removed := 0
|
||||
kept := []string{}
|
||||
for _, l := range lines {
|
||||
if l.Owner != Operator || l.Address == "" || !matches(l) {
|
||||
kept = append(kept, l.Text)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
if byAddress {
|
||||
continue
|
||||
}
|
||||
var rest []string
|
||||
for _, n := range l.Names {
|
||||
if n != what {
|
||||
rest = append(rest, n)
|
||||
}
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
kept = append(kept, l.Address+"\t"+strings.Join(rest, " "))
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
for _, l := range lines {
|
||||
if l.Owner != Operator && matches(l) {
|
||||
return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "\n") + "\n", removed, nil
|
||||
}
|
||||
|
||||
// HostsFile is the machine's hosts file.
|
||||
type HostsFile struct {
|
||||
Path string
|
||||
Run Runner
|
||||
}
|
||||
|
||||
// Entries is the file's lines, each marked whose.
|
||||
type Entries struct {
|
||||
Path string `json:"path"`
|
||||
Lines []Line `json:"lines"`
|
||||
}
|
||||
|
||||
func (h HostsFile) read() (string, error) {
|
||||
b, err := os.ReadFile(h.Path)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// Entries is every line of the file, each marked whose it is.
|
||||
func (h HostsFile) Entries() (*Entries, error) {
|
||||
text, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Entries{Path: h.Path, Lines: Parse(text)}, nil
|
||||
}
|
||||
|
||||
// Added is whether add changed the file, and the line it wrote.
|
||||
type Added struct {
|
||||
Added bool `json:"added"`
|
||||
Line string `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// Add adds one address and its names to the operator's lines.
|
||||
func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) {
|
||||
before, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := WithAdded(before, address, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if after == before {
|
||||
return &Added{Added: false}, nil
|
||||
}
|
||||
if err := h.write(ctx, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil
|
||||
}
|
||||
|
||||
// Removed is how many of the operator's lines remove touched.
|
||||
type Removed struct {
|
||||
Removed int `json:"removed"`
|
||||
}
|
||||
|
||||
// Remove takes one name, or every line of one address, out of the operator's lines.
|
||||
func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) {
|
||||
before, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, removed, err := WithRemoved(before, what)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if removed > 0 {
|
||||
if err := h.write(ctx, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Removed{Removed: removed}, nil
|
||||
}
|
||||
|
||||
// write puts the file in place whole, so a reader never sees half of it: the content is staged in a
|
||||
// private copy, installed as root beside the file — the same directory, so the same filesystem — and
|
||||
// renamed over it.
|
||||
func (h HostsFile) write(ctx context.Context, content string) error {
|
||||
dir, err := os.MkdirTemp("", "hosts-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
staged := filepath.Join(dir, "hosts")
|
||||
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hostname-tools")
|
||||
if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil {
|
||||
_, _ = h.Run(ctx, "rm", "-f", beside)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
|
||||
// distribution lines, an operator's development names, the mesh's block and another tool's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const file = "# Static table lookup for hostnames.\n" +
|
||||
"127.0.0.1\tlocaldev.example.com\n" +
|
||||
"127.0.0.1 a.example.com b.example.com\n" +
|
||||
"# BEGIN mesh hostname.own\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\tlocalhost\n" +
|
||||
"# END mesh hostname.own\n" +
|
||||
"# BEGIN other-tool\n" +
|
||||
"192.0.2.7\tproject.test\n" +
|
||||
"# END other-tool\n"
|
||||
|
||||
func blocks(text string) []string {
|
||||
var out []string
|
||||
for _, l := range Parse(text) {
|
||||
if l.Owner != Operator {
|
||||
out = append(out, l.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEveryLineSaysWhoseItIs(t *testing.T) {
|
||||
lines := Parse(file)
|
||||
if len(lines) != 10 {
|
||||
t.Fatalf("%d lines: %+v", len(lines), lines)
|
||||
}
|
||||
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
|
||||
if !reflect.DeepEqual(lines[1], want) {
|
||||
t.Errorf("%+v", lines[1])
|
||||
}
|
||||
if lines[0].Address != "" || lines[0].Owner != Operator {
|
||||
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
|
||||
}
|
||||
if lines[3].Owner != "mesh hostname.own" || lines[4].Owner != "mesh hostname.own" || lines[6].Owner != "mesh hostname.own" {
|
||||
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
|
||||
}
|
||||
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
|
||||
t.Errorf("%+v", lines[8])
|
||||
}
|
||||
if lines[5].Address != "::1" {
|
||||
t.Errorf("an IPv6 entry: %+v", lines[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
|
||||
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
|
||||
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
|
||||
t.Errorf("%+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
|
||||
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
|
||||
for _, l := range lines {
|
||||
if l.Owner != "x" {
|
||||
t.Errorf("%+v", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
|
||||
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
|
||||
t.Errorf("%q", after)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(after), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
|
||||
t.Errorf("a name already there changed the file")
|
||||
}
|
||||
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
|
||||
t.Errorf("%q", some)
|
||||
}
|
||||
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
|
||||
t.Errorf("a file without a last newline: %q", ended)
|
||||
}
|
||||
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
|
||||
t.Errorf("an empty file: %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
|
||||
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
|
||||
t.Errorf("%q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
address string
|
||||
names []string
|
||||
says string
|
||||
}{
|
||||
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
|
||||
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
|
||||
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
|
||||
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
|
||||
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
|
||||
{"192.0.2.9", []string{}, "at least one name"},
|
||||
} {
|
||||
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%q %q: %v", c.address, c.names, err)
|
||||
}
|
||||
}
|
||||
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
|
||||
t.Errorf("an IPv6 address and a rooted name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
|
||||
one, n, err := WithRemoved(file, "a.example.com")
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
|
||||
t.Errorf("%q", one)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(one), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
all, n, err := WithRemoved(file, "127.0.0.1")
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if !strings.Contains(all, "# BEGIN mesh hostname.own\n127.0.0.1\tlocalhost\n") {
|
||||
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(all), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
|
||||
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hostname.own") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
|
||||
t.Errorf("%d %v", n, err)
|
||||
}
|
||||
if _, _, err := WithRemoved(file, "bad name"); err == nil {
|
||||
t.Errorf("a name that is neither an address nor a host name is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
|
||||
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
}
|
||||
|
||||
// runPlain runs a command as given, unescalated: the test's file is the test's own.
|
||||
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hosts")
|
||||
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls [][]string
|
||||
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
calls = append(calls, append([]string{name}, args...))
|
||||
return runPlain(ctx, name, args...)
|
||||
}}
|
||||
ctx := context.Background()
|
||||
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
|
||||
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
|
||||
t.Fatalf("%+v %v", added, err)
|
||||
}
|
||||
beside := filepath.Join(dir, ".hosts.hostname-tools")
|
||||
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
|
||||
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
|
||||
t.Errorf("%v", calls)
|
||||
}
|
||||
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
|
||||
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
|
||||
}
|
||||
got, err := h.Entries()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
last := got.Lines[len(got.Lines)-1]
|
||||
if last.Owner != Operator || last.Address != "192.0.2.9" {
|
||||
t.Errorf("add then entries shows the line as the operator's: %+v", last)
|
||||
}
|
||||
removed, err := h.Remove(ctx, "lab.test")
|
||||
if err != nil || removed.Removed != 1 {
|
||||
t.Fatalf("%+v %v", removed, err)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != file {
|
||||
t.Errorf("add then remove gives the file back: %q", b)
|
||||
}
|
||||
if _, err := os.Stat(beside); !os.IsNotExist(err) {
|
||||
t.Errorf("the staged copy beside the file is left: %v", err)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
|
||||
t.Errorf("mode %v", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r.ID == "own" {
|
||||
found = true
|
||||
if r.Path != HostsPath {
|
||||
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no resource own")
|
||||
}
|
||||
var served []string
|
||||
for _, tool := range tools(HostsFile{}) {
|
||||
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
|
||||
if tool.Description == "" || tool.Run == nil {
|
||||
t.Errorf("%s", tool.Name)
|
||||
}
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
|
||||
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
|
||||
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
if got := namesArg(map[string]any{}); len(got) != 0 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// /etc/hostname is the module's whole file (novox/hq ADR 0223), and what it says is the operator's
|
||||
// `hostname` setting — never the mesh's name for the machine written silently: on the mesh this was
|
||||
// built for, three of four machines call themselves something else, and renaming a machine is the
|
||||
// operator's to decide.
|
||||
func TestTheMachinesNameIsTheOperatorsSetting(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["path"] != "/etc/hostname" {
|
||||
continue
|
||||
}
|
||||
if r["id"] != "name" || r["into"] != nil || r["content"] != "${setting:hostname}\n" {
|
||||
t.Errorf("/etc/hostname is not written whole from the hostname setting: %v", r)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("the module does not write /etc/hostname")
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// hostname-tools (novox/hq ADR 0199, ADR 0223): the tools of the machine's names. One binary,
|
||||
// launched by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR
|
||||
// 0193, ADR 0198): the node-hostname seat's three verbs — the hosts file's lines with whose each
|
||||
// is, add an operator's line, remove one. They change the machine's file and nothing else; the
|
||||
// controller holds none of it. /etc/hostname has no verb: it is the module's resource, set by the
|
||||
// module's `hostname` setting.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-hostname"
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[hostname] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
v, _ := a[k].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
var separators = regexp.MustCompile(`[\s,]+`)
|
||||
|
||||
// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list,
|
||||
// the list.
|
||||
func namesArg(a map[string]any) []string {
|
||||
var raw []string
|
||||
switch v := a["names"].(type) {
|
||||
case string:
|
||||
raw = separators.Split(v, -1)
|
||||
case []any:
|
||||
for _, n := range v {
|
||||
if s, ok := n.(string); ok {
|
||||
raw = append(raw, separators.Split(s, -1)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
names := []string{}
|
||||
for _, n := range raw {
|
||||
if n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||
// subject, as <node>/node-hostname.<verb>.
|
||||
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||
}
|
||||
|
||||
func tools(h HostsFile) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
return []stdio.Tool{
|
||||
verb("entries", "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
|
||||
nil, func(map[string]any) (any, error) { return h.Entries() }),
|
||||
verb("add", "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
|
||||
map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")},
|
||||
func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }),
|
||||
verb("remove", "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||||
map[string]any{"name": str("a host name, or an address to remove every line of")},
|
||||
func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module hostname
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"module": "hostname",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-hostname",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"entries",
|
||||
"add",
|
||||
"remove"
|
||||
]
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "own",
|
||||
"type": "file",
|
||||
"path": "/etc/hosts",
|
||||
"mode": "0644",
|
||||
"into": "block",
|
||||
"at": "start",
|
||||
"content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
|
||||
},
|
||||
{
|
||||
"id": "name",
|
||||
"type": "file",
|
||||
"path": "/etc/hostname",
|
||||
"mode": "0644",
|
||||
"content": "${setting:hostname}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/hostname-tools",
|
||||
"binary": "hostname-tools",
|
||||
"loads": [
|
||||
"hostname-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ file because each module carries them now:
|
||||
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -37,6 +37,16 @@
|
||||
"why": "streams in from sources (HTTP PUT) and out to listeners, plus the status and admin pages; a public name is its route"
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "logs",
|
||||
"path": "${dir:logs}",
|
||||
"class": "cache",
|
||||
"why": "the streaming server's logs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"provides": [
|
||||
{
|
||||
"name": "influxdb-api",
|
||||
"scope": "mesh"
|
||||
"scope": "mesh",
|
||||
"identity": {
|
||||
"in": "an InfluxDB v1 authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"capabilities": [
|
||||
@@ -37,6 +40,29 @@
|
||||
"grants": {
|
||||
"influxdb-api": "${dir:grants}"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "data",
|
||||
"path": "${dir:data}",
|
||||
"class": "valuable",
|
||||
"why": "every consumer's time series"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"path": "${dir:config}",
|
||||
"class": "valuable",
|
||||
"why": "the server's own configuration and its operator token, made at its first start"
|
||||
}
|
||||
],
|
||||
"consumers": {
|
||||
"influxdb-api": {
|
||||
"class": "valuable",
|
||||
"in": "data",
|
||||
"why": "a consumer's measurements are the only copy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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`.
|
||||
|
||||
A consumer the mesh no longer asks for is **retired**, not deleted (novox/hq ADR 0230): its client is
|
||||
disabled — Keycloak refuses its authorization and token requests — and marked with `mesh.retired` and
|
||||
`mesh.retired-why`; its secret, redirects and mappers are kept, and asked for again it is enabled as it
|
||||
was. Retiring waits five passes and ten minutes, and for a person's `retire approve` when it is more
|
||||
than three clients or more than half of those held. Only `cleanup delete` removes a client, and only a disabled one the
|
||||
mesh made. The tools `provisioner_retirement`, `provisioner_retire_approve`, `provisioner_retire_reject`
|
||||
and `provisioner_delete` are what the controller's `retire` and `cleanup` verbs ask.
|
||||
`MESH_KEYCLOAK_LIVE_URL` and `MESH_KEYCLOAK_LIVE_PASSWORD` run `live_retire_test.go` against a throwaway
|
||||
server.
|
||||
|
||||
## Tools
|
||||
|
||||
The realm, user, client, group and role tools (`keycloak_list_realms`, `keycloak_create_user`,
|
||||
`keycloak_list_clients`, `keycloak_assign_user_role`, …), and `keycloak_admin_check`. A tool that
|
||||
writes something announces it: `user.created`, `user.deleted`, `password.reset`, `client.created`,
|
||||
`group.created`, `role.created`.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
One Go bundle, `cmd/keycloak-provider`, launched by the node's runtime. It speaks MCP over stdio
|
||||
through the Go SDK, and was ported from TypeScript in 2026-10. It reaches the server on
|
||||
`MESH_KEYCLOAK_URL`, reads the mesh's admin secret from `MESH_KEYCLOAK_PASSWORD_FILE` at every check,
|
||||
and reaches the container named by `MESH_KEYCLOAK_CONTAINER` through the `container-runtime`
|
||||
capability.
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...` runs against a fake Keycloak and a fake container. It covers:
|
||||
|
||||
- repair on refusal, with no password in argv;
|
||||
- no repair while the server is unreachable;
|
||||
- the brake, and the operator overriding it;
|
||||
- the provisioner going quiet while the admin is refused;
|
||||
- the OIDC client rules;
|
||||
- the harness and its standing;
|
||||
- `harness_same_test.go`, which fails when this module's `harness.go` and postgres's differ.
|
||||
|
||||
`live_test.go` runs the repair against a real, throwaway Keycloak 26 container whose admin keeps an
|
||||
older password. The file's comment has the commands.
|
||||
@@ -1,317 +0,0 @@
|
||||
// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0039).
|
||||
// Moved out of the shared hal sdk, where a change to Keycloak's admin API rebuilt everything; here
|
||||
// it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and
|
||||
// nothing outside keycloak does.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
/** A secret file's value, trailing newline trimmed; undefined when unset or unreadable. */
|
||||
function secretFile(file?: string): string | undefined {
|
||||
if (!file) return undefined;
|
||||
try { return readFileSync(file, "utf8").replace(/\n$/, "") || undefined; }
|
||||
catch { return undefined; }
|
||||
}
|
||||
|
||||
/** A client as the admin API represents it — only the fields this module reads or writes are typed;
|
||||
* the rest travel through untouched, so an update never drops what somebody else set. */
|
||||
export interface ClientRepresentation {
|
||||
id?: string;
|
||||
clientId: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
protocol?: string;
|
||||
publicClient?: boolean;
|
||||
clientAuthenticatorType?: string;
|
||||
secret?: string;
|
||||
rootUrl?: string;
|
||||
baseUrl?: string;
|
||||
redirectUris?: string[];
|
||||
webOrigins?: string[];
|
||||
standardFlowEnabled?: boolean;
|
||||
implicitFlowEnabled?: boolean;
|
||||
directAccessGrantsEnabled?: boolean;
|
||||
serviceAccountsEnabled?: boolean;
|
||||
attributes?: Record<string, string>;
|
||||
protocolMappers?: ProtocolMapperRepresentation[];
|
||||
[other: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProtocolMapperRepresentation {
|
||||
id?: string;
|
||||
name: string;
|
||||
protocol: string;
|
||||
protocolMapper: string;
|
||||
config: Record<string, string>;
|
||||
}
|
||||
|
||||
export class KeycloakClient {
|
||||
readonly baseUrl: string;
|
||||
readonly defaultRealm: string;
|
||||
// The admin token is short-lived; caching it (minus a safety margin) spares every call a fresh
|
||||
// password grant, and a 401 mid-flight refreshes it once rather than failing the request.
|
||||
private tokenCache: { token: string; expiresAt: number } | null = null;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly adminUser: string,
|
||||
private readonly adminPass: string,
|
||||
defaultRealm = "master",
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/+$/, "");
|
||||
this.defaultRealm = defaultRealm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. Admin URL, credentials and the fallback realm are
|
||||
* read from MESH_KEYCLOAK_* — the names the mesh sets — falling back to the container's own
|
||||
* KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD so a co-located server needs nothing configured twice.
|
||||
* Throws when no admin password can be found: without it the client can do nothing, so failing
|
||||
* here lets the tool runtime expose no keycloak tools rather than tools that always error.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient {
|
||||
const cfg = meshConfig(env.MESH_KEYCLOAK_CONFIG_FILE);
|
||||
const url = cfg.url ?? env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`;
|
||||
const adminUser = cfg.user ?? env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin";
|
||||
// The admin password reaches the runtime as a file (novox/hq ADR 0086): the module's own `admin`
|
||||
// secret, mounted read-only. The environment forms stay for a co-located server that has them.
|
||||
const adminPass = cfg.password ?? secretFile(env.MESH_KEYCLOAK_PASSWORD_FILE)
|
||||
?? env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD;
|
||||
if (!adminPass) {
|
||||
throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)");
|
||||
}
|
||||
const realm = cfg.realm ?? env.MESH_KEYCLOAK_REALM ?? "master";
|
||||
return new KeycloakClient(url, adminUser, adminPass, realm);
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) return this.tokenCache.token;
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/realms/master/protocol/openid-connect/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "password",
|
||||
client_id: "admin-cli",
|
||||
username: this.adminUser,
|
||||
password: this.adminPass,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Keycloak token request failed: ${res.status} ${await res.text()}`);
|
||||
|
||||
const data = (await res.json()) as { access_token: string; expires_in: number };
|
||||
this.tokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 30) * 1000 };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const doRequest = async (token: string): Promise<Response> =>
|
||||
fetch(`${this.baseUrl}/admin/realms${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(options.headers as Record<string, string>),
|
||||
},
|
||||
});
|
||||
|
||||
let res = await doRequest(await this.getToken());
|
||||
// A cached token that expired against the server's clock reads as 401; drop it and retry once.
|
||||
if (res.status === 401) {
|
||||
this.tokenCache = null;
|
||||
res = await doRequest(await this.getToken());
|
||||
}
|
||||
if (!res.ok) throw new Error(`Keycloak API error ${res.status}: ${await res.text()}`);
|
||||
// 201/204 carry no body — the admin API's create/update/delete answer with an empty response.
|
||||
if (res.status === 201 || res.status === 204) return null as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Realms
|
||||
async listRealms(): Promise<Array<{ id: string; realm: string; displayName?: string; enabled: boolean }>> {
|
||||
return this.request("/");
|
||||
}
|
||||
|
||||
// Users
|
||||
async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise<unknown[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.max) qs.set("max", String(params.max));
|
||||
const query = qs.toString();
|
||||
return this.request(`/${realm}/users${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async createUser(realm: string, data: {
|
||||
username: string;
|
||||
email?: string;
|
||||
enabled?: boolean;
|
||||
credentials?: Array<{ type: string; value: string; temporary: boolean }>;
|
||||
}): Promise<void> {
|
||||
await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) });
|
||||
}
|
||||
|
||||
async updateUser(realm: string, userId: string, data: Record<string, unknown>): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async deleteUser(realm: string, userId: string): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/reset-password`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ type: "password", value: password, temporary }),
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSessions(realm: string, userId: string): Promise<unknown[]> {
|
||||
return this.request(`/${realm}/users/${userId}/sessions`);
|
||||
}
|
||||
|
||||
// Clients
|
||||
async listClients(realm: string): Promise<unknown[]> {
|
||||
return this.request(`/${realm}/clients`);
|
||||
}
|
||||
|
||||
async createClient(realm: string, data: {
|
||||
clientId: string;
|
||||
name?: string;
|
||||
rootUrl?: string;
|
||||
redirectUris?: string[];
|
||||
publicClient?: boolean;
|
||||
protocol?: string;
|
||||
}): Promise<void> {
|
||||
await this.request(`/${realm}/clients`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ protocol: "openid-connect", enabled: true, ...data }),
|
||||
});
|
||||
}
|
||||
|
||||
// The admin API addresses a client by its internal UUID, not the human clientId a caller knows;
|
||||
// every client-scoped call resolves the one to the other first.
|
||||
private async resolveClientId(realm: string, clientId: string): Promise<string> {
|
||||
const clients = (await this.listClients(realm)) as Array<Record<string, unknown>>;
|
||||
const client = clients.find((c) => c.clientId === clientId);
|
||||
if (!client) throw new Error(`Client '${clientId}' not found in realm '${realm}'`);
|
||||
return client.id as string;
|
||||
}
|
||||
|
||||
/** The one client with exactly this clientId, or undefined. The admin API's `clientId` filter is an
|
||||
* exact match unless `search=true` is asked for. */
|
||||
async findClient(realm: string, clientId: string): Promise<ClientRepresentation | undefined> {
|
||||
const found = await this.request<ClientRepresentation[]>(
|
||||
`/${realm}/clients?clientId=${encodeURIComponent(clientId)}`);
|
||||
return found.find((c) => c.clientId === clientId);
|
||||
}
|
||||
|
||||
async createClientFrom(realm: string, rep: ClientRepresentation): Promise<void> {
|
||||
await this.request(`/${realm}/clients`, { method: "POST", body: JSON.stringify(rep) });
|
||||
}
|
||||
|
||||
/** Replace a client's representation, addressed by its internal id. */
|
||||
async updateClient(realm: string, id: string, rep: ClientRepresentation): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${id}`, { method: "PUT", body: JSON.stringify(rep) });
|
||||
}
|
||||
|
||||
async deleteClientById(realm: string, id: string): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async clientSecretById(realm: string, id: string): Promise<string | undefined> {
|
||||
const result = await this.request<{ value?: string }>(`/${realm}/clients/${id}/client-secret`);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async listClientMappers(realm: string, id: string): Promise<ProtocolMapperRepresentation[]> {
|
||||
return this.request(`/${realm}/clients/${id}/protocol-mappers/models`);
|
||||
}
|
||||
|
||||
async addClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(mapper),
|
||||
});
|
||||
}
|
||||
|
||||
async updateClientMapper(realm: string, id: string, mapper: ProtocolMapperRepresentation): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models/${mapper.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(mapper),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteClient(realm: string, clientId: string): Promise<void> {
|
||||
await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async getClientSecret(realm: string, clientId: string): Promise<string> {
|
||||
const id = await this.resolveClientId(realm, clientId);
|
||||
const result = await this.request<{ value: string }>(`/${realm}/clients/${id}/client-secret`);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async addProtocolMapper(realm: string, clientId: string, mapper: {
|
||||
name: string;
|
||||
protocolMapper: string;
|
||||
config: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const id = await this.resolveClientId(realm, clientId);
|
||||
await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ protocol: "openid-connect", ...mapper }),
|
||||
});
|
||||
}
|
||||
|
||||
// Roles
|
||||
async listRealmRoles(realm: string): Promise<Array<{ id: string; name: string; description?: string; composite: boolean }>> {
|
||||
return this.request(`/${realm}/roles`);
|
||||
}
|
||||
|
||||
async createRealmRole(realm: string, data: { name: string; description?: string }): Promise<void> {
|
||||
await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async getUserRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
|
||||
return this.request(`/${realm}/users/${userId}/role-mappings/realm`);
|
||||
}
|
||||
|
||||
async getAvailableRealmRoles(realm: string, userId: string): Promise<Array<{ id: string; name: string; description?: string }>> {
|
||||
return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`);
|
||||
}
|
||||
|
||||
async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "POST", body: JSON.stringify(roles) });
|
||||
}
|
||||
|
||||
async removeRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) });
|
||||
}
|
||||
|
||||
// Groups
|
||||
async listGroups(realm: string): Promise<Array<{ id: string; name: string; path: string; subGroupCount?: number }>> {
|
||||
return this.request(`/${realm}/groups`);
|
||||
}
|
||||
|
||||
async createGroup(realm: string, name: string): Promise<void> {
|
||||
await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
async getUserGroups(realm: string, userId: string): Promise<Array<{ id: string; name: string; path: string }>> {
|
||||
return this.request(`/${realm}/users/${userId}/groups`);
|
||||
}
|
||||
|
||||
async addUserToGroup(realm: string, userId: string, groupId: string): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" });
|
||||
}
|
||||
|
||||
async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise<void> {
|
||||
await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package main
|
||||
|
||||
// The admin guard: Keycloak's admin must log in with the password the mesh minted, and when it does
|
||||
// not, the module makes it — itself, inside the container, and says so (novox/hq issue 179).
|
||||
//
|
||||
// **Why it is needed.** The manifest mints an `admin` own-secret and renders it into the server's
|
||||
// environment, and Keycloak applies that environment only when it creates its master realm. A
|
||||
// database that was adopted, restored or moved already has a master realm, whose `admin` keeps the
|
||||
// password it had. Every admin call then fails with *401 invalid_grant*. It happened twice: an
|
||||
// adopted database on 2026-10-01, and a moved one on 2026-10-05, when the provisioner failed every
|
||||
// five seconds for twenty-three hours — about 31,000 times — and nothing but the journal said so.
|
||||
// Both times the fix was the same by hand. This is that fix, run by the module.
|
||||
//
|
||||
// **Where it runs, and why here.** In the module's own bundle, which already holds the two things the
|
||||
// repair needs: the mesh's admin secret (the file the provisioner reads) and the container runtime
|
||||
// (the `container-runtime` capability; the nats and nextcloud bundles reach their containers the
|
||||
// same way). A declared host step would have to be handed the secret a second time and could not tell
|
||||
// the provisioner to stop; the bundle can, and it is the process that sees the 401 first.
|
||||
//
|
||||
// **What it does.** Checks the admin's login at start, every five minutes, and at once when the
|
||||
// admin API refuses the credentials. On a refusal — and only a refusal: an unreachable server is
|
||||
// waited for, never repaired — it runs Keycloak's own recovery inside the container: a temporary
|
||||
// admin through `kc.sh bootstrap-admin`, which sets the mesh's admin password (creating or enabling
|
||||
// that admin if it must), and is removed again. Then it checks again. Both passwords travel on the
|
||||
// exec's standard input, never on a command line, and neither is ever printed.
|
||||
//
|
||||
// **When it cannot.** It says so loudly (`admin.unrepaired`, and the provisioner's standing names the
|
||||
// consumers it fails), and it brakes: the next automatic attempt is ten minutes on, doubling to six
|
||||
// hours. While the admin is refused, the provisioner does not call Keycloak at all — each attempt
|
||||
// would be one more failed login against the admin, and enough of those lock it out.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AdminState is what the last check found.
|
||||
type AdminState string
|
||||
|
||||
const (
|
||||
AdminUnknown AdminState = "unknown"
|
||||
AdminOK AdminState = "ok"
|
||||
AdminRejected AdminState = "rejected"
|
||||
AdminUnreachable AdminState = "unreachable"
|
||||
// AdminUnchecked is a check that could not be made for a reason of the module's own — the
|
||||
// mesh's secret unreadable, say. Nothing to repair in Keycloak.
|
||||
AdminUnchecked AdminState = "unchecked"
|
||||
)
|
||||
|
||||
// Events the guard emits.
|
||||
const (
|
||||
EventRepaired = "admin.repaired"
|
||||
EventUnrepaired = "admin.unrepaired"
|
||||
)
|
||||
|
||||
// ErrAdminRejected is what the provisioner is answered while the admin is refused: fast, and without
|
||||
// asking Keycloak.
|
||||
var ErrAdminRejected = fmt.Errorf("the admin is refused by Keycloak and not yet repaired (%w); not asking it again until it is", ErrRejected)
|
||||
|
||||
// Executor runs one command with something on its standard input, answering its combined output.
|
||||
type Executor interface {
|
||||
Run(ctx context.Context, argv []string, stdin []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// DockerExec runs commands on this machine.
|
||||
type DockerExec struct{}
|
||||
|
||||
func (DockerExec) Run(ctx context.Context, argv []string, stdin []byte) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// Repair is what one repair did.
|
||||
type Repair struct {
|
||||
At time.Time `json:"at"`
|
||||
Outcome string `json:"outcome"` // "repaired" | "unrepaired"
|
||||
Step string `json:"step,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
TempUser string `json:"temporaryAdmin,omitempty"`
|
||||
// TempLeft says the temporary admin may still be in the master realm, for a person to delete.
|
||||
TempLeft bool `json:"temporaryAdminLeft,omitempty"`
|
||||
}
|
||||
|
||||
// Guard keeps the admin's login true.
|
||||
type Guard struct {
|
||||
KC *Client
|
||||
Exec Executor
|
||||
Container string
|
||||
Every time.Duration // 5m
|
||||
Waiting time.Duration // 15s: how often to look for a server not yet seen
|
||||
BrakeFrom time.Duration // 10m
|
||||
BrakeMax time.Duration // 6h
|
||||
Timeout time.Duration // 5m: the whole repair
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
Announce func(event string, body map[string]any)
|
||||
|
||||
once sync.Once
|
||||
mu sync.Mutex // one check-and-repair at a time: the background's or an operator's
|
||||
state atomic.Value
|
||||
last *Repair
|
||||
brakeTill time.Time
|
||||
brakeWait time.Duration
|
||||
nudge chan struct{}
|
||||
}
|
||||
|
||||
func (g *Guard) init() {
|
||||
g.once.Do(func() {
|
||||
if g.Every == 0 {
|
||||
g.Every = 5 * time.Minute
|
||||
}
|
||||
if g.Waiting == 0 {
|
||||
g.Waiting = 15 * time.Second
|
||||
}
|
||||
if g.BrakeFrom == 0 {
|
||||
g.BrakeFrom = 10 * time.Minute
|
||||
}
|
||||
if g.BrakeMax == 0 {
|
||||
g.BrakeMax = 6 * time.Hour
|
||||
}
|
||||
if g.Timeout == 0 {
|
||||
g.Timeout = 5 * time.Minute
|
||||
}
|
||||
if g.Now == nil {
|
||||
g.Now = time.Now
|
||||
}
|
||||
if g.Log == nil {
|
||||
g.Log = func(string, ...any) {}
|
||||
}
|
||||
if g.Container == "" {
|
||||
g.Container = "keycloak"
|
||||
}
|
||||
if g.Exec == nil {
|
||||
g.Exec = DockerExec{}
|
||||
}
|
||||
g.nudge = make(chan struct{}, 1)
|
||||
g.state.Store(AdminUnknown)
|
||||
})
|
||||
}
|
||||
|
||||
// State is what the last check found.
|
||||
func (g *Guard) State() AdminState {
|
||||
g.init()
|
||||
return g.state.Load().(AdminState)
|
||||
}
|
||||
|
||||
// Refused says the admin was refused at the last check and has not been repaired since: what the
|
||||
// provisioner asks before calling Keycloak.
|
||||
func (g *Guard) Refused() bool { return g.State() == AdminRejected }
|
||||
|
||||
// Nudge asks for a check now; never blocks.
|
||||
func (g *Guard) Nudge() {
|
||||
g.init()
|
||||
select {
|
||||
case g.nudge <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Check asks Keycloak for a token as the admin, with the mesh's secret as it is now.
|
||||
func (g *Guard) Check(ctx context.Context) (AdminState, error) {
|
||||
g.init()
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
_, _, err := g.KC.Login(cctx)
|
||||
state := classifyLogin(err)
|
||||
g.state.Store(state)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func classifyLogin(err error) AdminState {
|
||||
if err == nil {
|
||||
return AdminOK
|
||||
}
|
||||
if errors.Is(err, ErrRejected) {
|
||||
return AdminRejected
|
||||
}
|
||||
var terr *TokenError
|
||||
if errors.As(err, &terr) {
|
||||
if terr.Status >= 500 || terr.Status == 404 {
|
||||
return AdminUnreachable // starting, or not Keycloak yet
|
||||
}
|
||||
return AdminUnchecked
|
||||
}
|
||||
var nerr net.Error
|
||||
if errors.As(err, &nerr) || errors.Is(err, context.DeadlineExceeded) ||
|
||||
strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "EOF") {
|
||||
return AdminUnreachable
|
||||
}
|
||||
return AdminUnchecked
|
||||
}
|
||||
|
||||
// Report is the guard's account of itself, for the tool.
|
||||
type Report struct {
|
||||
State AdminState `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Repaired bool `json:"repaired,omitempty"`
|
||||
LastRepair *Repair `json:"lastRepair,omitempty"`
|
||||
BrakeUntil string `json:"brakeUntil,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Ensure checks the admin and repairs a refused one. operator is a person asking: the brake is
|
||||
// theirs to override. Without repair, it only checks.
|
||||
func (g *Guard) Ensure(ctx context.Context, repair, operator bool) Report {
|
||||
g.init()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
state, err := g.Check(ctx)
|
||||
r := g.report(state, err)
|
||||
if state != AdminRejected || !repair {
|
||||
if state == AdminOK {
|
||||
g.brakeTill, g.brakeWait = time.Time{}, 0
|
||||
r.BrakeUntil = ""
|
||||
}
|
||||
return r
|
||||
}
|
||||
if !operator && g.Now().Before(g.brakeTill) {
|
||||
r.Note = "the last repair failed; the next automatic attempt is at brakeUntil (keycloak_admin_check with repair: true tries now)"
|
||||
return r
|
||||
}
|
||||
|
||||
g.Log("[keycloak] the admin is refused by Keycloak (invalid_grant) with the mesh's password; repairing it inside %s", g.Container)
|
||||
done := g.repair(ctx)
|
||||
state, err = g.Check(ctx)
|
||||
if state == AdminOK && done.Outcome == "repaired" {
|
||||
g.brakeTill, g.brakeWait = time.Time{}, 0
|
||||
g.last = &done
|
||||
g.Log("[keycloak] REPAIRED the admin: the realm's admin did not take the mesh's password (invalid_grant) — " +
|
||||
"the database was adopted or moved and kept an older one; set to the mesh's through a temporary " +
|
||||
"bootstrap admin, which was removed (novox/hq issue 179)")
|
||||
if done.TempLeft {
|
||||
g.Log("[keycloak] the temporary admin %s could not be removed: delete it from the master realm", done.TempUser)
|
||||
}
|
||||
g.announce(EventRepaired, map[string]any{
|
||||
"cause": "the master realm's admin kept a password older than the mesh's — an adopted, restored or moved database",
|
||||
"at": done.At.UTC().Format(time.RFC3339), "temporaryAdminLeft": done.TempLeft,
|
||||
})
|
||||
r = g.report(state, err)
|
||||
r.Repaired = true
|
||||
return r
|
||||
}
|
||||
if done.Outcome == "repaired" {
|
||||
// The script finished and the login still fails: say what the check found.
|
||||
done.Outcome, done.Step = "unrepaired", "verify"
|
||||
done.Error = fmt.Sprintf("after the repair the admin still cannot log in: %s", errText(err))
|
||||
}
|
||||
g.last = &done
|
||||
if g.brakeWait == 0 {
|
||||
g.brakeWait = g.BrakeFrom
|
||||
} else if g.brakeWait *= 2; g.brakeWait > g.BrakeMax {
|
||||
g.brakeWait = g.BrakeMax
|
||||
}
|
||||
g.brakeTill = g.Now().Add(g.brakeWait)
|
||||
left := ""
|
||||
if done.TempLeft {
|
||||
left = fmt.Sprintf(" A temporary admin %s may be left in the master realm: delete it.", done.TempUser)
|
||||
}
|
||||
g.Log("[keycloak] COULD NOT REPAIR the admin at step %s: %s. Every consumer's client is unmanaged until it is; "+
|
||||
"the next automatic attempt is in %s. By hand: novox/hq issue 179.%s",
|
||||
done.Step, done.Error, g.brakeWait, left)
|
||||
g.announce(EventUnrepaired, map[string]any{
|
||||
"step": done.Step, "error": done.Error, "temporaryAdminLeft": done.TempLeft,
|
||||
"next": g.brakeTill.UTC().Format(time.RFC3339),
|
||||
})
|
||||
r = g.report(state, err)
|
||||
return r
|
||||
}
|
||||
|
||||
func (g *Guard) report(state AdminState, err error) Report {
|
||||
r := Report{State: state, LastRepair: g.last}
|
||||
if err != nil {
|
||||
r.Error = errText(err)
|
||||
}
|
||||
if g.Now().Before(g.brakeTill) {
|
||||
r.BrakeUntil = g.brakeTill.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func errText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (g *Guard) announce(event string, body map[string]any) {
|
||||
if g.Announce != nil {
|
||||
g.Announce(event, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks until ctx ends: often until the server has been seen, then every Every, and at once
|
||||
// when nudged.
|
||||
func (g *Guard) Run(ctx context.Context) {
|
||||
g.init()
|
||||
seen := false
|
||||
for {
|
||||
r := g.Ensure(ctx, true, false)
|
||||
if r.State != AdminUnreachable && r.State != AdminUnknown {
|
||||
seen = true
|
||||
}
|
||||
wait := g.Every
|
||||
if !seen {
|
||||
wait = g.Waiting
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-g.nudge:
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repairScript is Keycloak's own recovery, run inside its container (Keycloak 26). It reads the
|
||||
// temporary admin's password and then the mesh's admin password from standard input; nothing secret
|
||||
// is on its command line or in its environment as docker sees it. Every step announces itself on
|
||||
// stderr, so a failure names the step it failed at.
|
||||
const repairScript = `set -eu
|
||||
umask 077
|
||||
IFS= read -r TMP_PW
|
||||
IFS= read -r NEW_PW
|
||||
export TMP_PW
|
||||
bin=/opt/keycloak/bin
|
||||
cfg=/tmp/mesh-kcadm.$$.config
|
||||
bootstrapped=
|
||||
logged_in=
|
||||
step() { echo "mesh-repair-step: $1" >&2; }
|
||||
user_id() {
|
||||
"$bin/kcadm.sh" get users -r master --config "$cfg" -q username="$1" -q exact=true --fields id --format csv --noquotes
|
||||
}
|
||||
cleanup() {
|
||||
rc=$?
|
||||
set +e
|
||||
if [ -z "$logged_in" ] && [ -n "$bootstrapped" ]; then
|
||||
# The bootstrap may have made the temporary admin and failed after (it did once, on a held port):
|
||||
# log in as it anyway, so it is removed rather than left behind.
|
||||
"$bin/kcadm.sh" config credentials --config "$cfg" --server http://localhost:8080 --realm master \
|
||||
--user "$TMP_USER" --password "$TMP_PW" >/dev/null 2>&1 && logged_in=1
|
||||
fi
|
||||
if [ -n "$logged_in" ]; then
|
||||
tid=$(user_id "$TMP_USER" 2>/dev/null)
|
||||
if [ -n "$tid" ] && "$bin/kcadm.sh" delete "users/$tid" -r master --config "$cfg" >&2; then
|
||||
echo "mesh-repair-removed: $TMP_USER" >&2
|
||||
else
|
||||
echo "mesh-repair-left: $TMP_USER" >&2
|
||||
fi
|
||||
elif [ -n "$bootstrapped" ]; then
|
||||
echo "mesh-repair-left: $TMP_USER" >&2
|
||||
fi
|
||||
rm -f "$cfg"
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
step bootstrap-admin
|
||||
bootstrapped=1
|
||||
"$bin/kc.sh" bootstrap-admin user --username "$TMP_USER" --password:env TMP_PW --http-management-port="$MGMT_PORT" >&2
|
||||
step login
|
||||
"$bin/kcadm.sh" config credentials --config "$cfg" --server http://localhost:8080 --realm master --user "$TMP_USER" --password "$TMP_PW" >&2
|
||||
logged_in=1
|
||||
step find-admin
|
||||
id=$(user_id "$ADMIN_USER")
|
||||
if [ -z "$id" ]; then
|
||||
step create-admin
|
||||
"$bin/kcadm.sh" create users -r master --config "$cfg" -s username="$ADMIN_USER" -s enabled=true >&2
|
||||
"$bin/kcadm.sh" add-roles -r master --config "$cfg" --uusername "$ADMIN_USER" --rolename admin >&2
|
||||
id=$(user_id "$ADMIN_USER")
|
||||
fi
|
||||
step enable-admin
|
||||
"$bin/kcadm.sh" update "users/$id" -r master --config "$cfg" -s enabled=true >&2
|
||||
step set-password
|
||||
"$bin/kcadm.sh" set-password -r master --config "$cfg" --username "$ADMIN_USER" --new-password "$NEW_PW" >&2
|
||||
step remove-temporary-admin
|
||||
echo "mesh-repair-done" >&2
|
||||
`
|
||||
|
||||
// repair runs the script once.
|
||||
func (g *Guard) repair(ctx context.Context) Repair {
|
||||
done := Repair{At: g.Now(), Outcome: "unrepaired", Step: "prepare"}
|
||||
meshPW, err := g.KC.Password()
|
||||
if err != nil {
|
||||
done.Error = "the mesh's admin password cannot be read: " + err.Error()
|
||||
return done
|
||||
}
|
||||
if strings.ContainsAny(meshPW, "\n\r") {
|
||||
done.Error = "the mesh's admin password spans lines and cannot be handed over on one"
|
||||
return done
|
||||
}
|
||||
tmpPW, user, port, err := temporaries()
|
||||
if err != nil {
|
||||
done.Error = err.Error()
|
||||
return done
|
||||
}
|
||||
done.TempUser = user
|
||||
argv := []string{"docker", "exec", "-i",
|
||||
"-e", "TMP_USER=" + user, "-e", "MGMT_PORT=" + port, "-e", "ADMIN_USER=" + g.KC.AdminUser,
|
||||
g.Container, "bash", "-c", repairScript}
|
||||
rctx, cancel := context.WithTimeout(ctx, g.Timeout)
|
||||
defer cancel()
|
||||
out, runErr := g.Exec.Run(rctx, argv, []byte(tmpPW+"\n"+meshPW+"\n"))
|
||||
text := scrubAll(string(out), tmpPW, meshPW)
|
||||
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
finished := false
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "mesh-repair-step: "):
|
||||
done.Step = strings.TrimPrefix(line, "mesh-repair-step: ")
|
||||
case strings.HasPrefix(line, "mesh-repair-left: "):
|
||||
done.TempLeft = true
|
||||
case line == "mesh-repair-done":
|
||||
finished = true
|
||||
}
|
||||
}
|
||||
if runErr == nil && finished {
|
||||
done.Outcome, done.Step = "repaired", ""
|
||||
return done
|
||||
}
|
||||
why := "the script did not finish"
|
||||
if runErr != nil {
|
||||
why = scrubAll(runErr.Error(), tmpPW, meshPW)
|
||||
}
|
||||
done.Error = why + ": " + tail(text, 20)
|
||||
return done
|
||||
}
|
||||
|
||||
// temporaries are the temporary admin's password and name, and a management port for the second
|
||||
// server bootstrap-admin starts (the running server holds the default one).
|
||||
func temporaries() (pw, user, port string, err error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err = rand.Read(raw); err != nil {
|
||||
return "", "", "", fmt.Errorf("no randomness for a temporary password: %w", err)
|
||||
}
|
||||
id := make([]byte, 4)
|
||||
if _, err = rand.Read(id); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1000))
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), "mesh-repair-" + hex.EncodeToString(id),
|
||||
fmt.Sprint(19000 + n.Int64()), nil
|
||||
}
|
||||
|
||||
func scrubAll(text string, secrets ...string) string {
|
||||
for _, s := range secrets {
|
||||
if s != "" {
|
||||
text = strings.ReplaceAll(text, s, "***")
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// tail is the last n non-empty lines of text, on one line each joined by " | ".
|
||||
func tail(text string, n int) string {
|
||||
var lines []string
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, " | ")
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
// The guard (novox/hq issue 179): an admin refused with the mesh's password is repaired inside the
|
||||
// container, without a secret on any command line, verified, and said; one it cannot repair is said
|
||||
// loudly and braked; one it cannot reach is waited for; and while it is refused the provisioner does
|
||||
// not ask Keycloak.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeExec is the container: it records what it was asked, and — when it works — does what the
|
||||
// script does, setting the fake server's admin password to the second line of its input.
|
||||
type fakeExec struct {
|
||||
f *fakeKeycloak
|
||||
runs []execRun
|
||||
fails bool
|
||||
output string
|
||||
noEffect bool
|
||||
}
|
||||
|
||||
type execRun struct {
|
||||
argv []string
|
||||
stdin string
|
||||
}
|
||||
|
||||
func (x *fakeExec) Run(_ context.Context, argv []string, stdin []byte) ([]byte, error) {
|
||||
x.runs = append(x.runs, execRun{argv, string(stdin)})
|
||||
if x.fails {
|
||||
lines := strings.Split(string(stdin), "\n")
|
||||
return []byte("mesh-repair-step: bootstrap-admin\nERROR: boom " + lines[0] + " " + lines[1] + "\nmesh-repair-left: x\n"), errors.New("exit status 1")
|
||||
}
|
||||
if !x.noEffect {
|
||||
x.f.set(func() { x.f.password = strings.Split(string(stdin), "\n")[1] })
|
||||
}
|
||||
return []byte("mesh-repair-step: set-password\nmesh-repair-step: remove-temporary-admin\nmesh-repair-removed: x\nmesh-repair-done\n"), nil
|
||||
}
|
||||
|
||||
type guardWorld struct {
|
||||
f *fakeKeycloak
|
||||
x *fakeExec
|
||||
g *Guard
|
||||
now time.Time
|
||||
events []string
|
||||
said []string
|
||||
}
|
||||
|
||||
func newGuardWorld(t *testing.T) *guardWorld {
|
||||
w := &guardWorld{now: time.Date(2026, 10, 5, 23, 55, 0, 0, time.UTC)}
|
||||
w.f = newFakeKeycloak(t, "Novox", "old-password-from-2022")
|
||||
w.x = &fakeExec{f: w.f}
|
||||
w.g = &Guard{KC: w.f.client("the-mesh-minted-this"), Exec: w.x,
|
||||
Now: func() time.Time { return w.now },
|
||||
Log: func(f string, a ...any) { w.said = append(w.said, f) },
|
||||
Announce: func(e string, _ map[string]any) { w.events = append(w.events, e) }}
|
||||
return w
|
||||
}
|
||||
|
||||
func TestARefusedAdminIsRepairedInsideTheContainerAndSaid(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
r := w.g.Ensure(ctx, true, false)
|
||||
if !r.Repaired || r.State != AdminOK || w.f.password != "the-mesh-minted-this" {
|
||||
t.Fatalf("%+v, server password %q", r, w.f.password)
|
||||
}
|
||||
if strings.Join(w.events, ",") != EventRepaired {
|
||||
t.Fatal(w.events)
|
||||
}
|
||||
if !strings.Contains(strings.Join(w.said, "\n"), "REPAIRED the admin") {
|
||||
t.Fatal(w.said)
|
||||
}
|
||||
run := w.x.runs[0]
|
||||
if run.argv[0] != "docker" || run.argv[1] != "exec" || run.argv[2] != "-i" || !contains(run.argv, "keycloak") ||
|
||||
!contains(run.argv, "ADMIN_USER=admin") {
|
||||
t.Fatal(run.argv)
|
||||
}
|
||||
// Both passwords on standard input — the temporary one, then the mesh's — and on no command line.
|
||||
lines := strings.Split(run.stdin, "\n")
|
||||
if len(lines) != 3 || lines[1] != "the-mesh-minted-this" || len(lines[0]) < 40 {
|
||||
t.Fatalf("stdin has %d lines", len(lines))
|
||||
}
|
||||
for _, a := range run.argv {
|
||||
if strings.Contains(a, "the-mesh-minted-this") || strings.Contains(a, lines[0]) {
|
||||
t.Fatalf("a password is on the command line: %q", a)
|
||||
}
|
||||
}
|
||||
if w.g.Refused() {
|
||||
t.Fatal("still refused after a repair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheScriptIsKeycloaksOwnRecovery(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
`kc.sh" bootstrap-admin user --username "$TMP_USER" --password:env TMP_PW --http-management-port="$MGMT_PORT"`,
|
||||
`set-password -r master --config "$cfg" --username "$ADMIN_USER" --new-password "$NEW_PW"`,
|
||||
`umask 077`, `trap cleanup EXIT`, `rm -f "$cfg"`, `delete "users/$tid"`,
|
||||
} {
|
||||
if !strings.Contains(repairScript, want) {
|
||||
t.Errorf("the script lacks %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(repairScript, "--cache") {
|
||||
t.Error("bootstrap-admin takes no --cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdminThatLogsInIsLeftAlone(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
w.f.password = "the-mesh-minted-this"
|
||||
if r := w.g.Ensure(ctx, true, false); r.State != AdminOK || r.Repaired || len(w.x.runs) != 0 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAServerNotAnsweringIsWaitedForNeverRepaired(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
w.f.down = true
|
||||
if r := w.g.Ensure(ctx, true, false); r.State != AdminUnreachable || len(w.x.runs) != 0 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
w.f.srv.Close()
|
||||
if r := w.g.Ensure(ctx, true, false); r.State != AdminUnreachable || len(w.x.runs) != 0 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARepairThatFailsIsSaidLoudlyAndBraked(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
w.x.fails = true
|
||||
r := w.g.Ensure(ctx, true, false)
|
||||
if r.State != AdminRejected || r.LastRepair == nil || r.LastRepair.Step != "bootstrap-admin" || !r.LastRepair.TempLeft || r.BrakeUntil == "" {
|
||||
t.Fatalf("%+v %+v", r, r.LastRepair)
|
||||
}
|
||||
// Neither password survives into what is said.
|
||||
if strings.Contains(r.LastRepair.Error, "the-mesh-minted-this") || strings.Contains(r.LastRepair.Error, strings.Split(w.x.runs[0].stdin, "\n")[0]) {
|
||||
t.Fatal(r.LastRepair.Error)
|
||||
}
|
||||
if strings.Join(w.events, ",") != EventUnrepaired || !strings.Contains(strings.Join(w.said, "\n"), "COULD NOT REPAIR") {
|
||||
t.Fatal(w.events, w.said)
|
||||
}
|
||||
if !w.g.Refused() {
|
||||
t.Fatal("not refused")
|
||||
}
|
||||
|
||||
// Inside the brake: checked, not repaired.
|
||||
w.now = w.now.Add(9 * time.Minute)
|
||||
w.g.Ensure(ctx, true, false)
|
||||
if len(w.x.runs) != 1 {
|
||||
t.Fatalf("repaired inside the brake: %d runs", len(w.x.runs))
|
||||
}
|
||||
// Past it: tried again, and the next brake is twice as long.
|
||||
w.now = w.now.Add(2 * time.Minute)
|
||||
r = w.g.Ensure(ctx, true, false)
|
||||
if len(w.x.runs) != 2 {
|
||||
t.Fatalf("%d runs", len(w.x.runs))
|
||||
}
|
||||
if until, _ := time.Parse(time.RFC3339, r.BrakeUntil); until.Sub(w.now) != 20*time.Minute {
|
||||
t.Fatal(r.BrakeUntil)
|
||||
}
|
||||
|
||||
// An operator asking repairs now, brake or not.
|
||||
w.x.fails = false
|
||||
if r := w.g.Ensure(ctx, true, true); !r.Repaired || len(w.x.runs) != 3 || r.BrakeUntil != "" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAScriptThatFinishesButChangesNothingIsNotARepair(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
w.x.noEffect = true
|
||||
r := w.g.Ensure(ctx, true, false)
|
||||
if r.Repaired || r.LastRepair.Step != "verify" || strings.Join(w.events, ",") != EventUnrepaired {
|
||||
t.Fatalf("%+v %+v %v", r, r.LastRepair, w.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyCheckingRepairsNothing(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
if r := w.g.Ensure(ctx, false, true); r.State != AdminRejected || len(w.x.runs) != 0 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhileTheAdminIsRefusedTheProvisionerDoesNotAskKeycloak(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
w.x.fails = true
|
||||
w.g.Ensure(ctx, true, false)
|
||||
a := provisioner{clients: OidcClients{KC: w.g.KC, Realm: "Novox"}, guard: w.g,
|
||||
announce: func(string, map[string]any) {}, log: func(string, ...any) {}}
|
||||
logins := w.f.logins
|
||||
err := a.Create(ctx, grafana("s", nil))
|
||||
if !errors.Is(err, ErrRejected) || a.Class(err) != ClassCredentials {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.Holds(ctx, grafana("s", nil)); !errors.Is(err, ErrRejected) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.f.logins != logins {
|
||||
t.Fatal("Keycloak was asked while the admin is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusalSeenByTheAdminAPINudgesTheGuard(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
w.g.init()
|
||||
w.g.KC.onRejected = w.g.Nudge
|
||||
if _, err := w.g.KC.ListRealms(ctx); !errors.Is(err, ErrRejected) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-w.g.nudge:
|
||||
default:
|
||||
t.Fatal("not nudged")
|
||||
}
|
||||
// The guard's own check does not nudge it: that would be a guard checking in a loop.
|
||||
w.g.Check(ctx)
|
||||
select {
|
||||
case <-w.g.nudge:
|
||||
t.Fatal("the guard's own check nudged it")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGuardReadsTheMeshsSecretEachTime(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
secret := "first"
|
||||
w.g.KC.password = func() (string, error) { return secret, nil }
|
||||
w.f.password = "second"
|
||||
if s, _ := w.g.Check(ctx); s != AdminRejected {
|
||||
t.Fatal(s)
|
||||
}
|
||||
secret = "second"
|
||||
if s, _ := w.g.Check(ctx); s != AdminOK {
|
||||
t.Fatal(s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRepairsWhenNudged(t *testing.T) {
|
||||
w := newGuardWorld(t)
|
||||
w.f.password = "the-mesh-minted-this"
|
||||
w.g.Every, w.g.Waiting = time.Hour, time.Hour
|
||||
c, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go w.g.Run(c)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for w.g.State() != AdminOK {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no first check")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
w.f.set(func() { w.f.password = "moved-database" })
|
||||
w.g.Nudge()
|
||||
for {
|
||||
w.f.mu.Lock()
|
||||
done := w.f.password == "the-mesh-minted-this"
|
||||
w.f.mu.Unlock()
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("not repaired when nudged")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package main
|
||||
|
||||
// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0039).
|
||||
// Ported from client.ts: the same environment, the same token cache, the same one retry on a 401.
|
||||
//
|
||||
// A representation is a map rather than a struct, so an update carries back every field the server
|
||||
// sent — including ones this module does not know — and never drops what somebody else set.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rep is a representation as the admin API sends and takes it.
|
||||
type Rep = map[string]any
|
||||
|
||||
// ErrRejected marks a token request the server refused for the credentials: 401, or an
|
||||
// `invalid_grant` — wrong password, missing or disabled user. The guard repairs exactly this.
|
||||
var ErrRejected = errors.New("credentials rejected: invalid_grant")
|
||||
|
||||
// Client speaks to one Keycloak server's admin API as one admin.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
AdminUser string
|
||||
DefaultRealm string
|
||||
// password is read each time a token is needed, so a rotated secret is used without a restart.
|
||||
password func() (string, error)
|
||||
http *http.Client
|
||||
// onRejected is told when the server refuses the admin's credentials (the guard's nudge).
|
||||
onRejected func()
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// meshConfig is the settings-merged config the mesh delivers (novox/hq ADR 0046).
|
||||
func meshConfig(file string) map[string]any {
|
||||
out := map[string]any{}
|
||||
if file == "" {
|
||||
return out
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func cfgString(cfg map[string]any, key string) string {
|
||||
if s, ok := cfg[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstOf(values ...string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// secretFile is a secret file's value with its trailing newline trimmed.
|
||||
func secretFile(file string) (string, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := strings.TrimSuffix(string(raw), "\n")
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("%s is empty", file)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ClientFromEnv builds the client from the module's resolved environment, as client.ts did: the
|
||||
// config file first, then MESH_KEYCLOAK_*, then the container's own KEYCLOAK_ADMIN* names.
|
||||
// Refused when no admin password can be found at all: without one there is nothing to serve.
|
||||
func ClientFromEnv(getenv func(string) string) (*Client, error) {
|
||||
cfg := meshConfig(getenv("MESH_KEYCLOAK_CONFIG_FILE"))
|
||||
port := firstOf(getenv("KEYCLOAK_PORT"), "8080")
|
||||
base := firstOf(cfgString(cfg, "url"), getenv("MESH_KEYCLOAK_URL"), "http://127.0.0.1:"+port)
|
||||
user := firstOf(cfgString(cfg, "user"), getenv("MESH_KEYCLOAK_ADMIN"), getenv("KEYCLOAK_ADMIN"), "admin")
|
||||
realm := firstOf(cfgString(cfg, "realm"), getenv("MESH_KEYCLOAK_REALM"), "master")
|
||||
|
||||
// The admin password reaches the runtime as a file (novox/hq ADR 0086): the module's own `admin`
|
||||
// secret. Read on every token request, so the guard and the provisioner always use the mesh's
|
||||
// current one.
|
||||
fixed := firstOf(cfgString(cfg, "password"))
|
||||
file := getenv("MESH_KEYCLOAK_PASSWORD_FILE")
|
||||
env := firstOf(getenv("MESH_KEYCLOAK_PASSWORD"), getenv("KEYCLOAK_ADMIN_PASSWORD"))
|
||||
password := func() (string, error) {
|
||||
if fixed != "" {
|
||||
return fixed, nil
|
||||
}
|
||||
if file != "" {
|
||||
if s, err := secretFile(file); err == nil {
|
||||
return s, nil
|
||||
} else if env == "" {
|
||||
return "", fmt.Errorf("the admin password cannot be read: %w", err)
|
||||
}
|
||||
}
|
||||
if env != "" {
|
||||
return env, nil
|
||||
}
|
||||
return "", errors.New("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)")
|
||||
}
|
||||
if fixed == "" && file == "" && env == "" {
|
||||
return nil, errors.New("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD_FILE (or MESH_KEYCLOAK_PASSWORD)")
|
||||
}
|
||||
return NewClient(base, user, password, realm), nil
|
||||
}
|
||||
|
||||
// NewClient is a client for one server and admin.
|
||||
func NewClient(base, user string, password func() (string, error), realm string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(base, "/"), AdminUser: user, DefaultRealm: realm,
|
||||
password: password, http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Password is the admin password the mesh holds now.
|
||||
func (c *Client) Password() (string, error) { return c.password() }
|
||||
|
||||
// TokenError is a token request the server answered with something other than a token.
|
||||
type TokenError struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *TokenError) Error() string {
|
||||
return fmt.Sprintf("Keycloak token request failed: %d %s", e.Status, e.Body)
|
||||
}
|
||||
|
||||
// Unwrap makes a refusal of the credentials an ErrRejected.
|
||||
func (e *TokenError) Unwrap() error {
|
||||
if e.Status == http.StatusUnauthorized || strings.Contains(e.Body, "invalid_grant") {
|
||||
return ErrRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login asks for a fresh token with the admin's credentials, bypassing the cache: what the guard
|
||||
// checks with. It tells nobody about a refusal; getToken does.
|
||||
func (c *Client) Login(ctx context.Context) (string, time.Duration, error) {
|
||||
pw, err := c.password()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
form := url.Values{"grant_type": {"password"}, "client_id": {"admin-cli"},
|
||||
"username": {c.AdminUser}, "password": {pw}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.BaseURL+"/realms/master/protocol/openid-connect/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return "", 0, &TokenError{Status: res.StatusCode, Body: strings.TrimSpace(string(body))}
|
||||
}
|
||||
var data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &data); err != nil || data.AccessToken == "" {
|
||||
return "", 0, fmt.Errorf("Keycloak token response unreadable: %v", err)
|
||||
}
|
||||
return data.AccessToken, time.Duration(data.ExpiresIn) * time.Second, nil
|
||||
}
|
||||
|
||||
// getToken is a cached token, valid for at least thirty seconds more.
|
||||
func (c *Client) getToken(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
if c.token != "" && time.Now().Before(c.expiresAt) {
|
||||
t := c.token
|
||||
c.mu.Unlock()
|
||||
return t, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
token, life, err := c.Login(ctx)
|
||||
if err != nil {
|
||||
// The guard is told, so a refused admin is repaired now rather than at its next check. Only
|
||||
// here, never in Login: the guard checks with Login, and a check that nudged the guard
|
||||
// would be a guard checking in a loop.
|
||||
if errors.Is(err, ErrRejected) && c.onRejected != nil {
|
||||
c.onRejected()
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.token, c.expiresAt = token, time.Now().Add(life-30*time.Second)
|
||||
c.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c *Client) dropToken() {
|
||||
c.mu.Lock()
|
||||
c.token = ""
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// APIError is an admin API answer that was not a success.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return fmt.Sprintf("Keycloak API error %d: %s", e.Status, e.Body) }
|
||||
|
||||
// request calls the admin API under /admin/realms, decoding the answer into out when there is one.
|
||||
func (c *Client) request(ctx context.Context, method, path string, in, out any) error {
|
||||
var payload []byte
|
||||
if in != nil {
|
||||
var err error
|
||||
if payload, err = json.Marshal(in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
do := func(token string) (*http.Response, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
body = bytes.NewReader(payload)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/admin/realms"+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return c.http.Do(req)
|
||||
}
|
||||
token, err := c.getToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := do(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A cached token that expired against the server's clock reads as 401; drop it and retry once.
|
||||
if res.StatusCode == http.StatusUnauthorized {
|
||||
res.Body.Close()
|
||||
c.dropToken()
|
||||
if token, err = c.getToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if res, err = do(token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer res.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(res.Body, 16<<20))
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
return &APIError{Status: res.StatusCode, Body: strings.TrimSpace(string(raw))}
|
||||
}
|
||||
// 201/204 carry no body — the admin API's create/update/delete answer with an empty response.
|
||||
if out == nil || res.StatusCode == http.StatusCreated || res.StatusCode == http.StatusNoContent || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func esc(s string) string { return url.PathEscape(s) }
|
||||
|
||||
// Realms
|
||||
|
||||
func (c *Client) ListRealms(ctx context.Context) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/", nil, &out)
|
||||
}
|
||||
|
||||
// Users
|
||||
|
||||
func (c *Client) ListUsers(ctx context.Context, realm, search string, max int) ([]Rep, error) {
|
||||
q := url.Values{}
|
||||
if search != "" {
|
||||
q.Set("search", search)
|
||||
}
|
||||
if max > 0 {
|
||||
q.Set("max", fmt.Sprint(max))
|
||||
}
|
||||
path := "/" + esc(realm) + "/users"
|
||||
if len(q) > 0 {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, path, nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) CreateUser(ctx context.Context, realm string, rep Rep) error {
|
||||
body := Rep{"enabled": true}
|
||||
for k, v := range rep {
|
||||
body[k] = v
|
||||
}
|
||||
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/users", body, nil)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateUser(ctx context.Context, realm, id string, rep Rep) error {
|
||||
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id), rep, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteUser(ctx context.Context, realm, id string) error {
|
||||
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ResetPassword(ctx context.Context, realm, id, password string, temporary bool) error {
|
||||
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id)+"/reset-password",
|
||||
Rep{"type": "password", "value": password, "temporary": temporary}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) UserSessions(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/sessions", nil, &out)
|
||||
}
|
||||
|
||||
// Clients
|
||||
|
||||
func (c *Client) ListClients(ctx context.Context, realm string) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) CreateClient(ctx context.Context, realm string, rep Rep) error {
|
||||
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/clients", rep, nil)
|
||||
}
|
||||
|
||||
// FindClient is the one client with exactly this clientId, or nil. The admin API's `clientId`
|
||||
// filter is an exact match unless `search=true` is asked for.
|
||||
func (c *Client) FindClient(ctx context.Context, realm, clientID string) (Rep, error) {
|
||||
var found []Rep
|
||||
if err := c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients?clientId="+url.QueryEscape(clientID), nil, &found); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range found {
|
||||
if f["clientId"] == clientID {
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// resolveClientID is a client's internal id from the clientId a caller knows.
|
||||
func (c *Client) resolveClientID(ctx context.Context, realm, clientID string) (string, error) {
|
||||
clients, err := c.ListClients(ctx, realm)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, cl := range clients {
|
||||
if cl["clientId"] == clientID {
|
||||
id, _ := cl["id"].(string)
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("Client '%s' not found in realm '%s'", clientID, realm)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateClient(ctx context.Context, realm, id string, rep Rep) error {
|
||||
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/clients/"+esc(id), rep, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteClientByID(ctx context.Context, realm, id string) error {
|
||||
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/clients/"+esc(id), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ClientSecretByID(ctx context.Context, realm, id string) (string, error) {
|
||||
var out struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
err := c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients/"+esc(id)+"/client-secret", nil, &out)
|
||||
return out.Value, err
|
||||
}
|
||||
|
||||
func (c *Client) ListClientMappers(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) AddClientMapper(ctx context.Context, realm, id string, mapper Rep) error {
|
||||
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models", mapper, nil)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateClientMapper(ctx context.Context, realm, id string, mapper Rep) error {
|
||||
mid, _ := mapper["id"].(string)
|
||||
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/clients/"+esc(id)+"/protocol-mappers/models/"+esc(mid), mapper, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteClient(ctx context.Context, realm, clientID string) error {
|
||||
id, err := c.resolveClientID(ctx, realm, clientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.DeleteClientByID(ctx, realm, id)
|
||||
}
|
||||
|
||||
func (c *Client) GetClientSecret(ctx context.Context, realm, clientID string) (string, error) {
|
||||
id, err := c.resolveClientID(ctx, realm, clientID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.ClientSecretByID(ctx, realm, id)
|
||||
}
|
||||
|
||||
func (c *Client) AddProtocolMapper(ctx context.Context, realm, clientID string, mapper Rep) error {
|
||||
id, err := c.resolveClientID(ctx, realm, clientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := Rep{"protocol": "openid-connect"}
|
||||
for k, v := range mapper {
|
||||
body[k] = v
|
||||
}
|
||||
return c.AddClientMapper(ctx, realm, id, body)
|
||||
}
|
||||
|
||||
// Roles
|
||||
|
||||
func (c *Client) ListRealmRoles(ctx context.Context, realm string) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/roles", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) CreateRealmRole(ctx context.Context, realm string, rep Rep) error {
|
||||
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/roles", rep, nil)
|
||||
}
|
||||
|
||||
func (c *Client) UserRealmRoles(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) AvailableRealmRoles(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm/available", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) AssignRealmRoles(ctx context.Context, realm, id string, roles []Rep) error {
|
||||
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", roles, nil)
|
||||
}
|
||||
|
||||
func (c *Client) RemoveRealmRoles(ctx context.Context, realm, id string, roles []Rep) error {
|
||||
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id)+"/role-mappings/realm", roles, nil)
|
||||
}
|
||||
|
||||
// Groups
|
||||
|
||||
func (c *Client) ListGroups(ctx context.Context, realm string) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/groups", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) CreateGroup(ctx context.Context, realm, name string) error {
|
||||
return c.request(ctx, http.MethodPost, "/"+esc(realm)+"/groups", Rep{"name": name}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) UserGroups(ctx context.Context, realm, id string) ([]Rep, error) {
|
||||
var out []Rep
|
||||
return out, c.request(ctx, http.MethodGet, "/"+esc(realm)+"/users/"+esc(id)+"/groups", nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) AddUserToGroup(ctx context.Context, realm, id, group string) error {
|
||||
return c.request(ctx, http.MethodPut, "/"+esc(realm)+"/users/"+esc(id)+"/groups/"+esc(group), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) RemoveUserFromGroup(ctx context.Context, realm, id, group string) error {
|
||||
return c.request(ctx, http.MethodDelete, "/"+esc(realm)+"/users/"+esc(id)+"/groups/"+esc(group), nil, nil)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
// A fake Keycloak: the token endpoint, accepting one password that a test may change, and the admin
|
||||
// routes this module touches on one realm's clients, answering with the status codes and shapes
|
||||
// Keycloak gives.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
type fakeKeycloak struct {
|
||||
mu sync.Mutex
|
||||
srv *httptest.Server
|
||||
realm string
|
||||
password string // what the admin's login accepts
|
||||
down bool // answer 503, as a starting server does
|
||||
logins int
|
||||
clients map[string]Rep
|
||||
calls []string
|
||||
}
|
||||
|
||||
func newFakeKeycloak(t *testing.T, realm, password string) *fakeKeycloak {
|
||||
f := &fakeKeycloak{realm: realm, password: password, clients: map[string]Rep{}}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func uuid() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func send(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeKeycloak) set(fn func()) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
func (f *fakeKeycloak) serve(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, r.Method+" "+r.URL.Path)
|
||||
if f.down {
|
||||
send(w, 503, nil)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/realms/master/protocol/openid-connect/token" {
|
||||
_ = r.ParseForm()
|
||||
f.logins++
|
||||
if r.PostForm.Get("password") != f.password {
|
||||
send(w, 401, map[string]string{"error": "invalid_grant", "error_description": "Invalid user credentials"})
|
||||
return
|
||||
}
|
||||
send(w, 200, map[string]any{"access_token": "t", "expires_in": 300})
|
||||
return
|
||||
}
|
||||
base := "/admin/realms/" + f.realm + "/clients"
|
||||
if !strings.HasPrefix(r.URL.Path, base) {
|
||||
send(w, 404, map[string]string{"error": "Realm not found."})
|
||||
return
|
||||
}
|
||||
var rest []string
|
||||
for _, p := range strings.Split(strings.TrimPrefix(r.URL.Path, base), "/") {
|
||||
if p != "" {
|
||||
rest = append(rest, p)
|
||||
}
|
||||
}
|
||||
body := func() Rep {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var v Rep
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
return v
|
||||
}
|
||||
if len(rest) == 0 && r.Method == "GET" {
|
||||
want := r.URL.Query().Get("clientId")
|
||||
out := []Rep{}
|
||||
for _, c := range f.clients {
|
||||
if want == "" || c["clientId"] == want {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
send(w, 200, out)
|
||||
return
|
||||
}
|
||||
if len(rest) == 0 && r.Method == "POST" {
|
||||
rep := body()
|
||||
for _, c := range f.clients {
|
||||
if c["clientId"] == rep["clientId"] {
|
||||
send(w, 409, map[string]string{"errorMessage": "exists"})
|
||||
return
|
||||
}
|
||||
}
|
||||
id := uuid()
|
||||
mappers := []any{}
|
||||
if list, ok := rep["protocolMappers"].([]any); ok {
|
||||
for _, m := range list {
|
||||
mm := m.(map[string]any)
|
||||
mm["id"] = uuid()
|
||||
mappers = append(mappers, mm)
|
||||
}
|
||||
}
|
||||
rep["id"], rep["protocolMappers"] = id, mappers
|
||||
f.clients[id] = rep
|
||||
send(w, 201, nil)
|
||||
return
|
||||
}
|
||||
c := f.clients[rest[0]]
|
||||
if c == nil {
|
||||
send(w, 404, map[string]string{"error": "Could not find client"})
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case len(rest) == 1 && r.Method == "PUT":
|
||||
// Keycloak ignores protocolMappers on a client update: they have their own endpoints.
|
||||
rep := body()
|
||||
rep["id"], rep["protocolMappers"] = c["id"], c["protocolMappers"]
|
||||
f.clients[rest[0]] = rep
|
||||
send(w, 204, nil)
|
||||
case len(rest) == 1 && r.Method == "DELETE":
|
||||
delete(f.clients, rest[0])
|
||||
send(w, 204, nil)
|
||||
case rest[1] == "client-secret" && r.Method == "GET":
|
||||
send(w, 200, map[string]any{"type": "secret", "value": c["secret"]})
|
||||
case rest[1] == "protocol-mappers" && r.Method == "GET":
|
||||
send(w, 200, c["protocolMappers"])
|
||||
case rest[1] == "protocol-mappers" && r.Method == "POST":
|
||||
m := body()
|
||||
m["id"] = uuid()
|
||||
list, _ := c["protocolMappers"].([]any)
|
||||
c["protocolMappers"] = append(list, m)
|
||||
send(w, 201, nil)
|
||||
case rest[1] == "protocol-mappers" && r.Method == "PUT":
|
||||
m := body()
|
||||
list, _ := c["protocolMappers"].([]any)
|
||||
for i, x := range list {
|
||||
if x.(map[string]any)["id"] == rest[4] {
|
||||
list[i] = m
|
||||
}
|
||||
}
|
||||
send(w, 204, nil)
|
||||
default:
|
||||
send(w, 405, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeKeycloak) client(password string) *Client {
|
||||
return NewClient(f.srv.URL, "admin", func() (string, error) { return password, nil }, "master")
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package main
|
||||
|
||||
// The reconcile loop every provider shares, as the TypeScript SDK's runProvisioner runs it
|
||||
// (@novox/mesh-sdk/provisioner, 0.1.12). 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; retire 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).
|
||||
//
|
||||
// **A consumer the mesh stops asking for is retired, not withdrawn, and deleted only by a person
|
||||
// (novox/hq ADR 0230, the operator's model of 2026-10-06).** Retiring disables its access — reversibly —
|
||||
// and marks its login and data "to delete" with when and why; nothing is deleted. Asked for again, the
|
||||
// ordinary create re-enables it as it was. It is retired only once the same set has gone unasked in
|
||||
// StablePasses consecutive passes and for StableFor, and a set larger than the bound waits for a
|
||||
// person. retirement.go.
|
||||
//
|
||||
// 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 (this file and
|
||||
// retirement.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"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 brings the consumer into being under the mesh's login and password — and, for one that
|
||||
// was retired, re-enables it as it was and clears its mark to delete.
|
||||
Create(ctx context.Context, p Provision) error
|
||||
// Retire disables the consumer's access, reversibly, and marks its login and data to delete with
|
||||
// when and why. It deletes nothing (novox/hq ADR 0230). derived is what the mesh last derived,
|
||||
// remembered here; nil for a consumer this process did not make.
|
||||
Retire(ctx context.Context, as string, derived map[string]any, why string, at time.Time) error
|
||||
// Holds says whether the backend still holds the consumer exactly as p says. Read-only.
|
||||
Holds(ctx context.Context, p Provision) (bool, error)
|
||||
// Inventory is what the backend holds that the mesh made: consumers active and retired, read from
|
||||
// the backend itself so a restart forgets nothing. Read-only.
|
||||
Inventory(ctx context.Context) (Inventory, error)
|
||||
// Delete removes one retired consumer — its login and its data — for good. Only ever asked by a
|
||||
// person, through `cleanup delete`; the harness has checked it is retired and not asked for.
|
||||
Delete(ctx context.Context, r Retired) (freedBytes int64, err 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
|
||||
// StablePasses (5) is how many consecutive passes must see the same set no longer asked for, and
|
||||
// StableFor (10m) how long it must have held since the first of them, before it is retired. RetireAtOnce (3) and RetireFraction (0.5) bound what is retired without a person:
|
||||
// more consumers than RetireAtOnce, or — where more than one is held — a larger share of those held
|
||||
// than RetireFraction, waits for `retire approve` (novox/hq ADR 0230).
|
||||
StablePasses int
|
||||
StableFor time.Duration
|
||||
RetireAtOnce int
|
||||
RetireFraction float64
|
||||
|
||||
// mu is held by a pass and by every tool a person asks, so the two never interleave.
|
||||
mu sync.Mutex
|
||||
verifiedAt time.Time
|
||||
r retirement
|
||||
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
|
||||
node string
|
||||
}
|
||||
|
||||
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.StablePasses == 0 {
|
||||
h.StablePasses = 5
|
||||
}
|
||||
if h.StableFor == 0 {
|
||||
h.StableFor = StableFor
|
||||
}
|
||||
if h.RetireAtOnce == 0 {
|
||||
h.RetireAtOnce = 3
|
||||
}
|
||||
if h.RetireFraction == 0 {
|
||||
h.RetireFraction = 0.5
|
||||
}
|
||||
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{}
|
||||
h.r.retired = map[string]retiredEntry{}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 retire anything.
|
||||
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.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.init()
|
||||
given := h.readContributions()
|
||||
if given == nil {
|
||||
// Not a result: the passes that must agree before anything is retired start again.
|
||||
h.r.key, h.r.count = "", 0
|
||||
return
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, g := range given {
|
||||
want[g.As] = true
|
||||
}
|
||||
h.seed(ctx, want)
|
||||
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, node: g.Node}
|
||||
h.reenabled(g.As, g.Node)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retire what the mesh has stably stopped asking for — within the bound, or with a person.
|
||||
h.retireUnasked(ctx, want)
|
||||
h.r.lastWant = want
|
||||
for as := range h.failing {
|
||||
if !want[as] {
|
||||
delete(h.failing, as)
|
||||
}
|
||||
}
|
||||
// A consumer the mesh stopped asking for that this provider holds nothing 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. One still held is said recovered when it is retired.
|
||||
for as := range h.trouble {
|
||||
if _, held := h.applied[as]; !want[as] && !held {
|
||||
h.recovered(as, "no longer asked for")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user