Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66106d93ac | ||
|
|
c74f407abd | ||
|
|
f87f4cdfe5 | ||
|
|
79b384ec17 | ||
|
|
c0f9039695 | ||
|
|
f4c6efaadb | ||
|
|
33857626be | ||
|
|
e61fc6aede | ||
|
|
d1de0edd4a | ||
|
|
46481baab7 | ||
|
|
13b7562c47 | ||
|
|
950e52ff64 | ||
|
|
b6f0bc309b | ||
|
|
88135ad0e7 | ||
|
|
81641a4b22 | ||
|
|
cc1305a354 | ||
|
|
dba71a97a8 | ||
|
|
bd7b757dd9 | ||
|
|
b792bf4ba2 | ||
|
|
9951718623 | ||
|
|
1ba2c0513f | ||
|
|
1178db279e | ||
|
|
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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
70
|
||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||
resource without `health` is refused.
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
# mesh-check-toolchain: go
|
||||
#
|
||||
# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge
|
||||
# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain.
|
||||
#
|
||||
# The gate — the manifests the change touches through the running controller's module check, every machine
|
||||
# of the facts snapshot composed with them and validated by the node-engine's own validator, the replays
|
||||
# against this tree — is the build seat's first layer (`mesh/merge-gate`), run because the mesh's module
|
||||
# graph builds these modules from here. It is not repeated here. This is the repository's own:
|
||||
#
|
||||
# 1. every manifest through the controller's module check, so one module's change cannot leave another
|
||||
# it shares a rule with refused (MESH_GATE is the controller the mesh runs) — and the count of
|
||||
# long-running resources without `health` held to the number in health-undeclared, which only goes down;
|
||||
# 2. the Go tests of every module the change touches that has them, under the race detector when the
|
||||
# toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is
|
||||
# written in TypeScript, is said as not tested, never passed silently.
|
||||
set -eu
|
||||
|
||||
if [ -n "${MESH_GATE:-}" ]; then
|
||||
checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; }
|
||||
# **The count only goes down** (novox/hq ADR 0240 rule 8): the long-running resources that do not say how
|
||||
# they are ready, as the controller counts them, against the number kept in health-undeclared. A change
|
||||
# that raises it fails; one that lowers it writes the new number there, so it can never rise again.
|
||||
kept=$(sed -n 's/^\([0-9][0-9]*\).*/\1/p' health-undeclared | head -n 1)
|
||||
counted=$(printf '%s\n' "$checked" | sed -n 's/^long-running resources without health: \([0-9][0-9]*\)$/\1/p')
|
||||
if [ -z "$counted" ]; then
|
||||
echo "NOT COUNTED: the controller the mesh runs is older than the count of resources without health (ADR 0240 Phase B)"
|
||||
elif [ "$counted" -gt "$kept" ]; then
|
||||
echo "the long-running resources without health rose from $kept to $counted: declare how each new one is ready (ADR 0240 rule 8)"
|
||||
exit 1
|
||||
elif [ "$counted" -lt "$kept" ]; then
|
||||
echo "the long-running resources without health fell from $kept to $counted: write $counted in health-undeclared, so the count cannot rise again"
|
||||
exit 1
|
||||
else
|
||||
echo "long-running resources without health: $counted, as kept"
|
||||
fi
|
||||
else
|
||||
echo "NOT CHECKED: no controller was built beside this check, so the manifests were not read by one"
|
||||
fi
|
||||
|
||||
race=""
|
||||
if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi
|
||||
|
||||
# The modules the change reaches are the controller's planner's answer (MESH_CHECK_MODULES, hq ADR 0238):
|
||||
# a module by its name, a new one by its directory. By hand, without it, the directories the changed files
|
||||
# are in.
|
||||
if [ -n "${MESH_CHECK_MODULES:-}" ]; then
|
||||
touched=$(printf '%s\n' "$MESH_CHECK_MODULES" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d' | sort -u)
|
||||
else
|
||||
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
|
||||
fi
|
||||
for m in $touched; do
|
||||
[ -d "modules/$m" ] || continue
|
||||
if [ ! -f "modules/$m/go.mod" ]; then
|
||||
[ -f "modules/$m/package.json" ] && echo "NOT TESTED HERE: modules/$m is TypeScript; the gate judges its manifest"
|
||||
continue
|
||||
fi
|
||||
if ! (cd "modules/$m" && GOPRIVATE=git.novox.be go mod download >/dev/null 2>&1); then
|
||||
echo "NOT TESTED: modules/$m — its dependencies cannot be fetched by the build seat"
|
||||
continue
|
||||
fi
|
||||
echo "testing modules/$m"
|
||||
unformatted=$(cd "modules/$m" && gofmt -l .)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "modules/$m is not gofmt'd: $unformatted"
|
||||
exit 1
|
||||
fi
|
||||
cgo=0
|
||||
[ -n "$race" ] && cgo=1
|
||||
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ./... &&
|
||||
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test $race -count=1 ./...)
|
||||
done
|
||||
@@ -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",
|
||||
@@ -72,6 +82,9 @@
|
||||
"type": "container",
|
||||
"name": "baserow",
|
||||
"image": "baserow/baserow@sha256:263ea6c4b72c9eccabcd975ffe9fdebf23913a293a514bec6a3897a5e0a5a080",
|
||||
"health": {
|
||||
"kind": "runtime"
|
||||
},
|
||||
"network": "baserow",
|
||||
"env-file": [
|
||||
"${dir:state}/server.env"
|
||||
|
||||
@@ -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
+110
-54
@@ -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
|
||||
|
||||
@@ -127,8 +126,10 @@ A failure is an error naming how it failed, never an empty answer.
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `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_inspect` | r | one container whole, **environment values left out** (names kept), and its command line redacted as an exec's is |
|
||||
| `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_secrets_in_events` | r | which secrets exec command lines carried, as the runtime recorded them in its events, **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 |
|
||||
@@ -137,12 +138,66 @@ A failure is an error naming how it failed, never an empty answer.
|
||||
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
|
||||
| `docker_networks` | r | networks, subnets, and the containers on each |
|
||||
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
|
||||
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise |
|
||||
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise unless asked; **an exec's command line is shown with any secret it carried as `[redacted: <what it was>]`** |
|
||||
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
|
||||
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
|
||||
| `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).
|
||||
|
||||
## Secrets on an exec's command line (hq issue 282)
|
||||
|
||||
The runtime records the command line of every exec — a `docker exec`, and a health check, which is one
|
||||
— in its event stream (`exec_create: <argv joined by spaces>`). A program that passes a password to a
|
||||
tool as an argument has given it to everyone who may ask the runtime what happened, for as long as the
|
||||
runtime keeps its events, and to every transcript of a `docker_events` call. The mosquitto module did
|
||||
that with the broker's admin password on every administrative call, until it handed it over on stdin.
|
||||
|
||||
`docker_events` redacts, in every exec's command line, before it answers:
|
||||
|
||||
- the values of that container's environment named like a secret, and the passwords in its URIs —
|
||||
the same values `docker_logs` redacts;
|
||||
- any URI carrying a password;
|
||||
- by shape, whatever the source: the word after a flag that takes a password (`-P`, `--password`,
|
||||
`--secret-key`, `--token`, …; `-a` for `redis-cli`; `-p` for `mosquitto_ctrl`, whose connect `-p`
|
||||
is a port and is left alone as a number), a `NAME=value` whose name says secret, and the password a
|
||||
`mosquitto_ctrl dynsec` command sets as an argument (`setClientPassword`, `init`).
|
||||
|
||||
`docker_secrets_in_events` reads the exec events of a window ending now (60 minutes by default, at most
|
||||
24 hours) and names each secret found by container, module, what it was and the program, with how many
|
||||
execs carried it and the first and last time — **never the value or the command line**. A finding is
|
||||
code to change first (the secret handed over as a file or on stdin), then a secret to rotate. The
|
||||
runtime keeps a bounded number of events, so on a busy machine a long window reads only what it still
|
||||
holds — which is also why a leaked value ages out of the event stream quickly, and not out of a
|
||||
transcript that already copied it.
|
||||
|
||||
`docker_inspect` shows a container's own command line (`Path`/`Args`, `Cmd`, `Entrypoint`) redacted
|
||||
the same way.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
@@ -159,6 +214,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;
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package main
|
||||
|
||||
// A secret on a command line (novox/hq issue 282).
|
||||
//
|
||||
// **The leak this catches.** The runtime records the command line of every exec — `docker exec`, and
|
||||
// a health check, which is one — in its event stream, as the event's action (`exec_create: <argv
|
||||
// joined by spaces>`). A program that hands a password to a tool as an argument (`-P <password>`,
|
||||
// `--password <password>`, `PGPASSWORD=<password> psql`) has therefore given it to everyone who may
|
||||
// ask the runtime what happened, for as long as the runtime keeps its events — and, through
|
||||
// docker_events, to every transcript of an agent that asked. The mosquitto module did exactly that
|
||||
// with the broker's admin password, on every administrative call.
|
||||
//
|
||||
// **What is known here.** As for a log: the values of the container's environment named like a
|
||||
// secret and the passwords inside its URIs, by name; and, whatever their source, the values a
|
||||
// command line carries by its shape — the word after a flag that takes a password, a NAME=value
|
||||
// whose name says secret, the password a dynsec command sets. A secret given as a file and passed by
|
||||
// a flag the shapes do not know is not caught.
|
||||
//
|
||||
// **Never the value.** What is shown carries `[redacted: <what it was>]` in its place, and a finding
|
||||
// names the container, the module and what it was, by name.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// passwordFlags take a secret as their next word, whatever the program.
|
||||
var passwordFlags = map[string]bool{
|
||||
"-P": true, "--password": true, "--pass": true, "--passwd": true, "--secret": true, "--secret-key": true,
|
||||
"--token": true, "--api-key": true, "--apikey": true, "--auth": true,
|
||||
}
|
||||
|
||||
// programFlags take a secret as their next word for one program only: elsewhere the same flag means
|
||||
// something else (redis-cli's -a is its password; nft's -a is not).
|
||||
var programFlags = map[string]map[string]bool{
|
||||
"redis-cli": {"-a": true},
|
||||
"keydb-cli": {"-a": true},
|
||||
"valkey-cli": {"-a": true},
|
||||
"mosquitto_ctrl": {"-p": true}, // createClient -p <password>; the connect -p is a port, and a port is ordinary
|
||||
}
|
||||
|
||||
// positionalSecret is where a dynsec command carries a password as an argument: the word that many
|
||||
// places after the command's name.
|
||||
var positionalSecret = map[string]int{"setClientPassword": 2, "init": 3}
|
||||
|
||||
// commandSecret is one secret a command line carried, by what it was.
|
||||
type commandSecret struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// secretsOnCommandLine are the values a command line carries by their shape, by what each one is.
|
||||
func secretsOnCommandLine(words []string) []commandSecret {
|
||||
var out []commandSecret
|
||||
add := func(name, value string) {
|
||||
if len(value) < leastSecret || masked.MatchString(value) || ordinary.MatchString(value) {
|
||||
return
|
||||
}
|
||||
out = append(out, commandSecret{name, value})
|
||||
}
|
||||
program := ""
|
||||
for i, w := range words {
|
||||
base := path.Base(w)
|
||||
if _, known := programFlags[base]; known || base == "mosquitto_ctrl" {
|
||||
program = base
|
||||
}
|
||||
if flag, value, ok := strings.Cut(w, "="); ok && strings.HasPrefix(flag, "-") {
|
||||
if passwordFlags[flag] || programFlags[program][flag] {
|
||||
add("the value of "+flag, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name, value, ok := strings.Cut(w, "="); ok && name != "" && !strings.HasPrefix(name, "-") &&
|
||||
secretName.MatchString(name) && !notAValue.MatchString(name) && !strings.ContainsAny(name, "/:") {
|
||||
add("the value of "+name, value)
|
||||
continue
|
||||
}
|
||||
if i+1 < len(words) && (passwordFlags[w] || programFlags[program][w]) {
|
||||
add("the word after "+w+" in a "+orProgram(program, words)+" command line", words[i+1])
|
||||
}
|
||||
if program == "mosquitto_ctrl" {
|
||||
if at, ok := positionalSecret[w]; ok && i+at < len(words) && dynsecVerb(words, i) {
|
||||
add("the password given to dynsec "+w, words[i+at])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dynsecVerb says the word at i is a dynsec command's name: it follows "dynsec".
|
||||
func dynsecVerb(words []string, i int) bool {
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
if words[j] == "dynsec" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func orProgram(program string, words []string) string {
|
||||
if program != "" {
|
||||
return program
|
||||
}
|
||||
if len(words) > 0 {
|
||||
return path.Base(words[0])
|
||||
}
|
||||
return "program's"
|
||||
}
|
||||
|
||||
// redactCommand is a command line with every known secret, every password inside a URI and every
|
||||
// value its shape says is a secret replaced by a mark naming what was there; and what was replaced,
|
||||
// by name.
|
||||
func redactCommand(command string, known []knownSecret) (string, []string) {
|
||||
var names []string
|
||||
for _, s := range known {
|
||||
for _, f := range forms(s.Value) {
|
||||
if strings.Contains(command, f) {
|
||||
command = strings.ReplaceAll(command, f, "[redacted: "+s.Name+"]")
|
||||
names = append(names, s.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range secretsOnCommandLine(strings.Fields(command)) {
|
||||
if strings.Contains(command, s.Value) {
|
||||
command = strings.ReplaceAll(command, s.Value, "[redacted: "+s.Name+"]")
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
}
|
||||
if line, n := redact(command, nil); n > 0 {
|
||||
command = line
|
||||
names = append(names, "a password in a URI")
|
||||
}
|
||||
return command, names
|
||||
}
|
||||
|
||||
// execCommand is the command line an exec event carries, and whether it carries one.
|
||||
func execCommand(action string) (verb, command string, ok bool) {
|
||||
verb, command, ok = strings.Cut(action, ": ")
|
||||
if !ok || !strings.HasPrefix(verb, "exec_") {
|
||||
return "", "", false
|
||||
}
|
||||
return verb, command, true
|
||||
}
|
||||
|
||||
// envCache reads each container's environment once per call.
|
||||
type envCache struct {
|
||||
c *Client
|
||||
ctx context.Context
|
||||
seen map[string][]knownSecret
|
||||
}
|
||||
|
||||
func (e *envCache) of(id string) []knownSecret {
|
||||
if e.seen == nil {
|
||||
e.seen = map[string][]knownSecret{}
|
||||
}
|
||||
if k, ok := e.seen[id]; ok {
|
||||
return k
|
||||
}
|
||||
env, _ := e.c.envOf(e.ctx, id) // a container gone since: its shapes are still caught
|
||||
e.seen[id] = secretsIn(env)
|
||||
return e.seen[id]
|
||||
}
|
||||
|
||||
// CommandLeak is one secret the runtime recorded on exec command lines: by name, never by value.
|
||||
type CommandLeak struct {
|
||||
Container string `json:"container"`
|
||||
HeldBy string `json:"held_by,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
Execs int `json:"execs"`
|
||||
Program string `json:"program"`
|
||||
First string `json:"first"`
|
||||
Last string `json:"last"`
|
||||
}
|
||||
|
||||
// SecretsInEvents reads the runtime's exec events in a window ending now and says which secrets
|
||||
// their command lines carried, by container and name.
|
||||
func (c *Client) SecretsInEvents(ctx context.Context, minutes int) (map[string]any, error) {
|
||||
out, err := c.docker(ctx, "events", "--since", fmt.Sprintf("%dm", minutes), "--until", "0s",
|
||||
"--filter", "type=container", "--filter", "event=exec_create", "--format", "{{json .}}")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := jsonLines[runtimeEvent](out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
envs := &envCache{c: c, ctx: ctx}
|
||||
type key struct{ container, secret string }
|
||||
found := map[key]*CommandLeak{}
|
||||
execs := 0
|
||||
for _, e := range raw {
|
||||
verb, command, ok := execCommand(e.Action)
|
||||
if !ok || verb != "exec_create" {
|
||||
continue // an exec_start repeats its exec_create's command line
|
||||
}
|
||||
execs++
|
||||
_, names := redactCommand(command, envs.of(e.Actor.ID))
|
||||
if len(names) == 0 {
|
||||
continue
|
||||
}
|
||||
at := time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339)
|
||||
held := e.Actor.Attributes[MeshLabel]
|
||||
module, _, _ := strings.Cut(held, ".")
|
||||
program := ""
|
||||
if f := strings.Fields(command); len(f) > 0 {
|
||||
program = path.Base(f[0])
|
||||
}
|
||||
for _, n := range names {
|
||||
k := key{e.Actor.Attributes["name"], n}
|
||||
l, ok := found[k]
|
||||
if !ok {
|
||||
l = &CommandLeak{Container: k.container, HeldBy: held, Module: module, Secret: n, Program: program, First: at}
|
||||
found[k] = l
|
||||
}
|
||||
l.Execs++
|
||||
l.Last = at
|
||||
}
|
||||
}
|
||||
leaks := []CommandLeak{}
|
||||
for _, l := range found {
|
||||
leaks = append(leaks, *l)
|
||||
}
|
||||
sort.Slice(leaks, func(i, j int) bool {
|
||||
if leaks[i].Container != leaks[j].Container {
|
||||
return leaks[i].Container < leaks[j].Container
|
||||
}
|
||||
return leaks[i].Secret < leaks[j].Secret
|
||||
})
|
||||
verdict := fmt.Sprintf("no exec in the last %d minutes carried a secret on its command line", minutes)
|
||||
if len(leaks) > 0 {
|
||||
verdict = fmt.Sprintf("%d secret(s) on exec command lines the runtime recorded: the code that runs the exec must hand "+
|
||||
"them over another way (a file, stdin), and each is rotated once it does (novox/hq issue 282)", len(leaks))
|
||||
}
|
||||
return map[string]any{
|
||||
"verdict": verdict, "leaks": leaks, "count": len(leaks), "execs_read": execs, "minutes": minutes,
|
||||
"knows": "values of each container's environment named like a secret, passwords in URIs, and by shape: the word after " +
|
||||
"a password flag, a NAME=value named like a secret, and the password a dynsec command sets",
|
||||
"history": "the runtime keeps a bounded number of events, so a window longer than what it holds reads only what it still has",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runtimeEvent is one line of `docker events --format '{{json .}}'`.
|
||||
type runtimeEvent struct {
|
||||
Type, Action string
|
||||
Actor struct {
|
||||
ID string
|
||||
Attributes map[string]string
|
||||
}
|
||||
TimeNano int64 `json:"timeNano"`
|
||||
}
|
||||
@@ -345,6 +345,27 @@ func (c *Client) Inspect(ctx context.Context, ref string) (map[string]any, error
|
||||
return nil, fmt.Errorf("docker inspect answered something that is not one container")
|
||||
}
|
||||
obj := got[0]
|
||||
// A container's command line is shown without the secrets it carries, as an exec's is (novox/hq
|
||||
// issue 282): known from its environment, and by shape.
|
||||
var known []knownSecret
|
||||
if cfg, ok := obj["Config"].(map[string]any); ok {
|
||||
if env, ok := cfg["Env"].([]any); ok {
|
||||
list := make([]string, 0, len(env))
|
||||
for _, e := range env {
|
||||
list = append(list, fmt.Sprint(e))
|
||||
}
|
||||
known = secretsIn(list)
|
||||
}
|
||||
for _, k := range []string{"Cmd", "Entrypoint"} {
|
||||
if words, ok := cfg[k].([]any); ok {
|
||||
cfg[k] = redactWords(words, known)
|
||||
}
|
||||
}
|
||||
}
|
||||
if words, ok := obj["Args"].([]any); ok {
|
||||
path, _ := obj["Path"].(string)
|
||||
obj["Args"] = redactWords(append([]any{path}, words...), known)[1:]
|
||||
}
|
||||
if cfg, ok := obj["Config"].(map[string]any); ok {
|
||||
if env, ok := cfg["Env"].([]any); ok {
|
||||
names := []string{}
|
||||
@@ -380,6 +401,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 +451,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.
|
||||
@@ -950,24 +1000,29 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := jsonLines[struct {
|
||||
Type, Action string
|
||||
Actor struct {
|
||||
ID string
|
||||
Attributes map[string]string
|
||||
}
|
||||
TimeNano int64 `json:"timeNano"`
|
||||
}](out)
|
||||
raw, err := jsonLines[runtimeEvent](out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// An exec's command line is shown without the secrets it carried (novox/hq issue 282): this answer
|
||||
// is read by agents and kept in their transcripts, which would make it a second copy of the leak.
|
||||
envs := &envCache{c: c, ctx: ctx}
|
||||
redacted := map[string]bool{}
|
||||
events := []map[string]any{}
|
||||
for _, e := range raw {
|
||||
if !execs && strings.HasPrefix(e.Action, "exec_") {
|
||||
continue
|
||||
}
|
||||
action := e.Action
|
||||
if verb, command, ok := execCommand(action); ok {
|
||||
shown, names := redactCommand(command, envs.of(e.Actor.ID))
|
||||
action = verb + ": " + shown
|
||||
for _, n := range names {
|
||||
redacted[n] = true
|
||||
}
|
||||
}
|
||||
_, held := e.Actor.Attributes[MeshLabel]
|
||||
ev := map[string]any{"time": time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339), "type": e.Type, "action": e.Action,
|
||||
ev := map[string]any{"time": time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339), "type": e.Type, "action": action,
|
||||
"id": shortID(e.Actor.ID), "name": e.Actor.Attributes["name"]}
|
||||
if e.Type == "container" {
|
||||
ev["mesh_held"] = held
|
||||
@@ -982,7 +1037,41 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
|
||||
if len(events) > limit {
|
||||
events = events[len(events)-limit:]
|
||||
}
|
||||
return map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}, nil
|
||||
answer := map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}
|
||||
if len(redacted) > 0 {
|
||||
answer["redacted"] = sortedSet(redacted)
|
||||
answer["leak"] = "exec command lines carried secrets, which the runtime keeps in its events; docker_secrets_in_events names them (novox/hq issue 282)"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func sortedSet(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// redactWords is a command's words with what redactCommand hides hidden, word by word.
|
||||
func redactWords(words []any, known []knownSecret) []any {
|
||||
list := make([]string, len(words))
|
||||
for i, w := range words {
|
||||
list[i] = fmt.Sprint(w)
|
||||
}
|
||||
shapes := secretsOnCommandLine(list)
|
||||
out := make([]any, len(list))
|
||||
for i, w := range list {
|
||||
for _, s := range shapes {
|
||||
if strings.Contains(w, s.Value) {
|
||||
w = strings.ReplaceAll(w, s.Value, "[redacted: "+s.Name+"]")
|
||||
}
|
||||
}
|
||||
w, _ = redact(w, known)
|
||||
out[i] = w
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// restartOnly are the daemon keys the runtime reads only when it starts: a reload leaves them as
|
||||
|
||||
@@ -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,43 @@ 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_secrets_in_events",
|
||||
Description: "Which secrets exec command lines carried in a window ending now (default the last 60 minutes, at most 24 hours) — the runtime records every exec's command line in its events, " +
|
||||
"so a password passed as an argument is kept there for anyone who may ask it. By container, module and the secret's name, never its value. " +
|
||||
"A finding is code to change (hand the secret over as a file or on stdin) and then a secret to rotate (novox/hq issue 282).",
|
||||
Input: map[string]any{
|
||||
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
minutes, err := bounded(args, "minutes", 60, 1440)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.SecretsInEvents(ctx, minutes)
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
@@ -189,8 +227,9 @@ func tools(c *Client) []stdio.Tool {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_events",
|
||||
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked.",
|
||||
Name: "docker_events",
|
||||
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
|
||||
"an exec's command line is shown with any secret it carried as [redacted: <what it was>] — a value of the container's environment named like a secret, a password in a URI, the word after a password flag.",
|
||||
Input: map[string]any{
|
||||
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
|
||||
"type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"},
|
||||
|
||||
@@ -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,262 @@
|
||||
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
|
||||
}
|
||||
|
||||
// The shape of the leak in hq issue 282: a broker's admin password on an exec's command line. The
|
||||
// values are made up for the test.
|
||||
const (
|
||||
adminPassword = "Adm1n-pass_word-xyz"
|
||||
clientPassword = "Cl1ent-pass_word-abc"
|
||||
)
|
||||
|
||||
func TestACommandLineIsShownWithoutTheSecretsItCarried(t *testing.T) {
|
||||
for _, tc := range []struct{ command, mark string }{
|
||||
{"mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P " + adminPassword + " dynsec listClients", "the word after -P"},
|
||||
{"mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec createClient alice -p " + clientPassword, "the word after -p in a mosquitto_ctrl"},
|
||||
{"mosquitto_ctrl -o /tmp/x dynsec setClientPassword alice " + clientPassword, "the password given to dynsec setClientPassword"},
|
||||
{"redis-cli -a " + adminPassword + " ping", "the word after -a"},
|
||||
{"env PGPASSWORD=" + adminPassword + " psql -U app", "the value of PGPASSWORD"},
|
||||
{"tool --password=" + adminPassword, "the value of --password"},
|
||||
{"psql postgresql://app:" + adminPassword + "@db/app", "a password in a URI"},
|
||||
} {
|
||||
shown, names := redactCommand(tc.command, nil)
|
||||
if strings.Contains(shown, adminPassword) || strings.Contains(shown, clientPassword) {
|
||||
t.Errorf("%q: still carries the value: %q", tc.command, shown)
|
||||
}
|
||||
if len(names) == 0 || !strings.Contains(strings.Join(names, "|"), tc.mark) {
|
||||
t.Errorf("%q: named %v, want %q", tc.command, names, tc.mark)
|
||||
}
|
||||
}
|
||||
// A port, a path and a plain command are not secrets.
|
||||
for _, plain := range []string{
|
||||
"mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec listClients",
|
||||
"/usr/bin/lavinmqctl status",
|
||||
"sh -c umask 077\nf=$(mktemp) || exit 1 mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec getClient alice",
|
||||
"pg_dump -Fc -f /dumps/app.dump app",
|
||||
} {
|
||||
if shown, names := redactCommand(plain, nil); shown != plain || len(names) > 0 {
|
||||
t.Errorf("%q: redacted %v as %q", plain, names, shown)
|
||||
}
|
||||
}
|
||||
// What the container's environment holds is known by its name, wherever it appears.
|
||||
known := []knownSecret{{"SERVER_PASSWORD", adminPassword}}
|
||||
if shown, names := redactCommand("app login "+adminPassword, known); strings.Contains(shown, adminPassword) ||
|
||||
len(names) != 1 || names[0] != "SERVER_PASSWORD" {
|
||||
t.Errorf("an environment secret on a command line: %q %v", shown, names)
|
||||
}
|
||||
}
|
||||
|
||||
const execEvents = `{"Type":"container","Action":"exec_create: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec listClients","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e1"}},"timeNano":1791320000000000000}
|
||||
{"Type":"container","Action":"exec_start: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec listClients","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e1"}},"timeNano":1791320000000100000}
|
||||
{"Type":"container","Action":"exec_die","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","exitCode":"0"}},"timeNano":1791320000000200000}
|
||||
{"Type":"container","Action":"exec_create: /usr/bin/healthcheck","Actor":{"ID":"bbbbbbbbbbbbbbbb","Attributes":{"name":"other"}},"timeNano":1791320060000000000}
|
||||
{"Type":"container","Action":"exec_create: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec getClient a","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e2"}},"timeNano":1791320120000000000}
|
||||
`
|
||||
|
||||
func TestEventsShowAnExecsCommandLineWithoutItsSecrets(t *testing.T) {
|
||||
f := (&fake{}).
|
||||
on("docker events", Ran{Stdout: execEvents}).
|
||||
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: "[]\n"})
|
||||
got, err := client(f, 1000).Events(context.Background(), 30, "", 100, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(got)
|
||||
if strings.Contains(string(b), adminPassword) {
|
||||
t.Fatalf("the answer carries the value: %s", b)
|
||||
}
|
||||
if !strings.Contains(string(b), "[redacted: the word after -P") || got["leak"] == nil {
|
||||
t.Fatalf("not marked as redacted: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAScanOfExecEventsNamesEachSecretAndNeverItsValue(t *testing.T) {
|
||||
f := (&fake{}).
|
||||
on("docker events", Ran{Stdout: execEvents}).
|
||||
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: "[]\n"})
|
||||
got, err := client(f, 1000).SecretsInEvents(context.Background(), 60)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(got)
|
||||
if strings.Contains(string(b), adminPassword) {
|
||||
t.Fatalf("the finding carries the value: %s", b)
|
||||
}
|
||||
leaks := got["leaks"].([]CommandLeak)
|
||||
if len(leaks) != 1 || leaks[0].Container != "mosquitto" || leaks[0].Module != "mosquitto" || leaks[0].Execs != 2 ||
|
||||
leaks[0].Program != "mosquitto_ctrl" || !strings.Contains(leaks[0].Secret, "-P") {
|
||||
t.Fatalf("leaks: %+v", leaks)
|
||||
}
|
||||
if got["execs_read"] != 3 {
|
||||
t.Fatalf("read %v exec_create events, want 3 (a start repeats a create and is not counted)", got["execs_read"])
|
||||
}
|
||||
if !f.ran("docker events --since 60m --until 0s --filter type=container --filter event=exec_create") {
|
||||
t.Fatalf("not asked for exec creations only: %+v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectShowsACommandLineWithoutItsSecrets(t *testing.T) {
|
||||
obj := `[{"Path":"mosquitto_ctrl","Args":["-P","` + adminPassword + `","dynsec","listClients"],"Config":{"Env":["A=b"],"Cmd":["mosquitto_ctrl","-P","` + adminPassword + `"],"Labels":{}}}]`
|
||||
f := (&fake{}).on("docker container inspect", Ran{Stdout: obj})
|
||||
got, err := client(f, 1000).Inspect(context.Background(), "mosquitto")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(got)
|
||||
if strings.Contains(string(b), adminPassword) || !strings.Contains(string(b), "[redacted:") {
|
||||
t.Fatalf("inspect: %s", b)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
"docker_list",
|
||||
"docker_inspect",
|
||||
"docker_logs",
|
||||
"docker_secrets_in_logs",
|
||||
"docker_secrets_in_events",
|
||||
"docker_stats",
|
||||
"docker_start",
|
||||
"docker_stop",
|
||||
@@ -50,6 +52,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+161
-4
@@ -5,6 +5,8 @@
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
||||
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
|
||||
export type { BranchProtection, ProtectionWanted } from "./protection.js";
|
||||
|
||||
/** A repository, trimmed to what the mesh cares about. */
|
||||
export interface GiteaRepo {
|
||||
@@ -17,6 +19,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. */
|
||||
@@ -41,8 +45,21 @@ export interface GiteaPull {
|
||||
merged_at?: string;
|
||||
user?: string;
|
||||
head?: string;
|
||||
/** The commit the pull request's head is at now: what is checked before it merges (novox/hq to-be 45 §9). */
|
||||
head_sha?: string;
|
||||
base?: string;
|
||||
updated_at?: string;
|
||||
html_url: string;
|
||||
/** The description: where a pull request says it goes after another (`after: <repository>`, novox/hq ADR 0239). */
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */
|
||||
export interface CommitStatus {
|
||||
state: "pending" | "success" | "error" | "failure" | "warning";
|
||||
context: string;
|
||||
description: string;
|
||||
target_url?: string;
|
||||
}
|
||||
|
||||
export interface GiteaComment {
|
||||
@@ -249,10 +266,120 @@ 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 };
|
||||
}
|
||||
}
|
||||
|
||||
/** The directories holding these files that hold a `module.json` at a commit (novox/hq issue 278).
|
||||
*
|
||||
* **Whether a directory is a module is a fact of the repository, not of the merge.** The mesh read a
|
||||
* changed file as shared code unless its directory was a module it held or the merge also changed
|
||||
* that directory's manifest; a change to the catalogue's reference module, which no machine holds,
|
||||
* rebuilt all 103 modules built from the repository. So every directory above a changed file — never
|
||||
* the root — is looked up at the merge commit, and the ones holding a manifest are said.
|
||||
*
|
||||
* `null` when there are more than `most` directories to look at: not said, and the mesh keeps its old
|
||||
* rule, which rebuilds too much rather than too little. A failed lookup throws, for the same reason. */
|
||||
async moduleDirsAt(owner: string, repo: string, sha: string, paths: string[], most = 300): Promise<string[] | null> {
|
||||
const dirs = directoriesAbove(paths);
|
||||
if (dirs.length > most) return null;
|
||||
const out: string[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (await this.exists(`/repos/${owner}/${repo}/contents/${encodePath(dir + "/module.json")}?ref=${encodeURIComponent(sha)}`)) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
||||
async holdsFile(owner: string, repo: string, sha: string, file: string): Promise<boolean> {
|
||||
return this.exists(`/repos/${owner}/${repo}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`);
|
||||
}
|
||||
|
||||
/** Whether the forge has something at a path: true for an answer, false for a 404, thrown otherwise. */
|
||||
private async exists(path: string): Promise<boolean> {
|
||||
let token = await this.tokens.current();
|
||||
let res = await this.send(path, {}, token);
|
||||
if (res.status === 401) {
|
||||
token = await this.tokens.renew(token);
|
||||
res = await this.send(path, {}, token);
|
||||
}
|
||||
if (res.status === 404) return false;
|
||||
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
|
||||
await res.body?.cancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Set a commit's status — what a pull request whose head it is shows beside it (novox/hq to-be 45 §9).
|
||||
* The forge keeps one per context, the newest, so setting it again replaces it. */
|
||||
async setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/statuses/${sha}`, { method: "POST", body: JSON.stringify(status) });
|
||||
}
|
||||
|
||||
/** A commit's statuses by context, the newest of each: what a merge's head was checked as (novox/hq ADR 0239). */
|
||||
async commitStatuses(owner: string, repo: string, sha: string): Promise<Record<string, string>> {
|
||||
const raw = await this.request<any>(`/repos/${owner}/${repo}/commits/${sha}/status`);
|
||||
const out: Record<string, string> = {};
|
||||
for (const s of raw?.statuses ?? []) if (s?.context && !out[s.context]) out[s.context] = String(s.status ?? s.state ?? "");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */
|
||||
async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> {
|
||||
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
|
||||
return { id: Number(c?.id ?? id), html_url: String(c?.html_url ?? "") };
|
||||
}
|
||||
|
||||
// ---- Branch protection ----
|
||||
|
||||
/** A repository's branch protection rules. */
|
||||
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
return (await this.request<BranchProtection[]>(`/repos/${owner}/${repo}/branch_protections`)) ?? [];
|
||||
}
|
||||
|
||||
/** The rule named for a branch, null when it has none. */
|
||||
async branchProtection(owner: string, repo: string, rule: string): Promise<BranchProtection | null> {
|
||||
return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null;
|
||||
}
|
||||
|
||||
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
|
||||
* branch is edited — its required statuses replaced by these, everything else it says kept unless
|
||||
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
|
||||
async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> {
|
||||
const had = await this.branchProtection(owner, repo, branch);
|
||||
const body = protectionBody(want, !had);
|
||||
if (had) {
|
||||
const rule = await this.request<BranchProtection>(`/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(branch)}`,
|
||||
{ method: "PATCH", body: JSON.stringify(body) });
|
||||
return { created: false, rule };
|
||||
}
|
||||
const rule = await this.request<BranchProtection>(`/repos/${owner}/${repo}/branch_protections`,
|
||||
{ method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) });
|
||||
return { created: true, rule };
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
@@ -342,6 +469,7 @@ export class GiteaClient {
|
||||
description: r.description || undefined,
|
||||
html_url: r.html_url,
|
||||
default_branch: r.default_branch,
|
||||
updated_at: r.updated_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,8 +495,11 @@ export class GiteaClient {
|
||||
merged_at: p.merged_at ?? undefined,
|
||||
user: p.user?.login,
|
||||
head: p.head?.ref,
|
||||
head_sha: p.head?.sha ?? undefined,
|
||||
base: p.base?.ref,
|
||||
updated_at: p.updated_at ?? undefined,
|
||||
html_url: p.html_url,
|
||||
body: typeof p.body === "string" ? p.body : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -584,3 +715,29 @@ 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));
|
||||
}
|
||||
|
||||
/** Every directory above these files, from the repository's root, the root itself left out; sorted. */
|
||||
export function directoriesAbove(paths: string[]): string[] {
|
||||
const dirs = new Set<string>();
|
||||
for (const raw of paths) {
|
||||
const parts = raw.replace(/^\/+/, "").split("/");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const dir = parts.slice(0, i).join("/");
|
||||
if (dir !== "") dirs.add(dir);
|
||||
}
|
||||
}
|
||||
return [...dirs].sort();
|
||||
}
|
||||
|
||||
/** A repository path for the forge's URL: each segment escaped, the slashes kept. */
|
||||
function encodePath(path: string): string {
|
||||
return path.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// What the forge's holder does for a delivery (novox/hq ADR 0239): the commit's note under
|
||||
// refs/notes/mesh-plan, the delivery's view on its pull request, and its statuses — asked by mesh-delivery,
|
||||
// the delivery's owner, through this module's tools. The forge is this module's; mesh-delivery never writes
|
||||
// to it itself.
|
||||
//
|
||||
// **The note is written in the forge's own repository, as the forge's own user.** The forge's API reads a
|
||||
// note and writes none, and a push needs a clone and a credential nobody else should hold; the forge's
|
||||
// container holds the bare repository and git. So the line is appended there, by `git notes append`, as the
|
||||
// account the forge runs as — the mesh's own forge, never a person's or an agent's hand. Appending is
|
||||
// idempotent here: a line the note already has is not added again, so asking twice adds nothing.
|
||||
//
|
||||
// Pure functions and an injectable runner, so this is tested without a forge.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
/** How a command is run: docker on the forge's machine, or a test's. */
|
||||
export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>;
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
export const run: Runner = async (file, args) => {
|
||||
try {
|
||||
const { stdout } = await execFileP(file, args, { maxBuffer: 4 << 20 });
|
||||
return { stdout, code: 0 };
|
||||
} catch (err) {
|
||||
const e = err as { code?: number; stdout?: string };
|
||||
return { stdout: e.stdout ?? "", code: typeof e.code === "number" ? e.code : 1 };
|
||||
}
|
||||
};
|
||||
|
||||
const name = /^[A-Za-z0-9_.-]+$/;
|
||||
const sha = /^[0-9a-fA-F]{7,64}$/;
|
||||
const ref = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** A note line as it may be written: one line, bounded, nothing that is not text. */
|
||||
export function noteLine(line: string): string {
|
||||
const one = String(line ?? "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!one) throw new Error("an empty line is no note");
|
||||
return one.length > 2000 ? one.slice(0, 1999) + "…" : one;
|
||||
}
|
||||
|
||||
/** Where the forge keeps a repository, inside its container. */
|
||||
export function gitDir(owner: string, repo: string): string {
|
||||
if (!name.test(owner) || !name.test(repo)) throw new Error(`${owner}/${repo} is not a repository's name`);
|
||||
return `/data/git/repositories/${owner.toLowerCase()}/${repo.toLowerCase()}.git`;
|
||||
}
|
||||
|
||||
/** The commands the note is read and appended with, in the forge's container, as its user. */
|
||||
export function noteArgs(container: string, owner: string, repo: string, commit: string, notesRef: string, line?: string): string[] {
|
||||
if (!name.test(container)) throw new Error(`${container} is not a container's name`);
|
||||
if (!sha.test(commit)) throw new Error(`${commit} is not a commit`);
|
||||
if (!ref.test(notesRef)) throw new Error(`${notesRef} is not a notes ref`);
|
||||
const base = ["exec", "-u", "git", container, "git", "--git-dir", gitDir(owner, repo),
|
||||
"-c", "user.name=mesh", "-c", "user.email=mesh@mesh.invalid", "notes", `--ref=${notesRef}`];
|
||||
return line === undefined ? [...base, "show", commit] : [...base, "append", "-m", noteLine(line), commit];
|
||||
}
|
||||
|
||||
/** Append a line to a commit's note, unless the note already holds it. Answers whether it was added. */
|
||||
export async function appendNote(runner: Runner, container: string, owner: string, repo: string, commit: string,
|
||||
notesRef: string, line: string): Promise<{ added: boolean; lines: number }> {
|
||||
const wanted = noteLine(line);
|
||||
const shown = await runner("docker", noteArgs(container, owner, repo, commit, notesRef));
|
||||
// No note yet is git's exit 1 with nothing on stdout; anything else unreadable is said.
|
||||
const lines = shown.code === 0 ? shown.stdout.split("\n").filter((l) => l.trim() !== "") : [];
|
||||
if (lines.includes(wanted)) return { added: false, lines: lines.length };
|
||||
const appended = await runner("docker", noteArgs(container, owner, repo, commit, notesRef, wanted));
|
||||
if (appended.code !== 0) throw new Error(`the note on ${commit.slice(0, 8)} could not be appended (git exited ${appended.code})`);
|
||||
return { added: true, lines: lines.length + 1 };
|
||||
}
|
||||
|
||||
/** The marker that makes one comment of a pull request the delivery's view. */
|
||||
export const VIEW_MARKER = "<!-- mesh-delivery:view -->";
|
||||
|
||||
/** The view's body, marked: mesh-delivery writes it, this keeps exactly one of them per pull request. */
|
||||
export function viewBody(body: string): string {
|
||||
const b = String(body ?? "");
|
||||
return b.includes(VIEW_MARKER) ? b : `${VIEW_MARKER}\n${b}`;
|
||||
}
|
||||
|
||||
/** Which comment is the view: the first that carries the marker, or none yet. */
|
||||
export function viewComment<T extends { id: number; body: string }>(comments: T[]): T | undefined {
|
||||
return comments.find((c) => c.body.includes(VIEW_MARKER));
|
||||
}
|
||||
|
||||
const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
|
||||
/** The merge check's contexts (pulls.ts): a branch's protection may require them. */
|
||||
const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]);
|
||||
|
||||
/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a
|
||||
* bounded description. */
|
||||
export function deliveryStatus(context: string, state: string, description: string, target?: string) {
|
||||
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
|
||||
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
|
||||
// them: never set by hand, so nothing passes — or blocks — a merge past the check (novox/hq issue 293).
|
||||
if (mergeCheckContexts.has(context)) throw new Error(`${context} is the merge check's status, set by its verdict only`);
|
||||
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
|
||||
let d = String(description ?? "").replace(/\s+/g, " ").trim();
|
||||
if (d.length > 140) d = d.slice(0, 139) + "…";
|
||||
return { state: state as "pending" | "success" | "error" | "failure" | "warning", context, description: d,
|
||||
...(target && /^https?:\/\//.test(target) ? { target_url: target } : {}) };
|
||||
}
|
||||
+209
-10
@@ -3,18 +3,32 @@
|
||||
//
|
||||
// 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)
|
||||
// module.gitea.pull.updated — an open pull request's head moved, opened or pushed to: the mesh checks it
|
||||
// before it merges (novox/hq to-be 45 §9)
|
||||
//
|
||||
// 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.
|
||||
// module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239)
|
||||
//
|
||||
// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head
|
||||
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
|
||||
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
|
||||
// saying why when the gate is not a pass or the repository's own check failed.
|
||||
//
|
||||
// Every pull request the forge holds is announced, whatever its repository: the controller holds the
|
||||
// module graph and decides what is checked — a repository is never asked to opt in.
|
||||
//
|
||||
// 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 { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient, movedSince } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
||||
type RepoCheckFacts } from "./pulls.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,12 +98,32 @@ 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" });
|
||||
for (const pull of pulls) {
|
||||
// Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops.
|
||||
const closedKey = `closed:${repo.full_name}#${pull.number}`;
|
||||
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
|
||||
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) {
|
||||
await emit("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
|
||||
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
|
||||
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
|
||||
}
|
||||
announced.add(closedKey);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
|
||||
// Announced only if merged since the watching began; recorded either way, so it is looked
|
||||
// at once.
|
||||
@@ -99,11 +133,36 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
// directory moved, and without this every module built from a repository is rebuilt for a
|
||||
// change to any of them (novox/hq 04-ISSUES/131).
|
||||
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
||||
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
|
||||
// change inside one is that module's, held or not, and only a file in none is shared code. Not
|
||||
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
|
||||
let moduleDirs: string[] | null = null;
|
||||
if (!changed.truncated) {
|
||||
try {
|
||||
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
|
||||
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
|
||||
// from the forge's word when its owner never heard the head.
|
||||
let headChecks: Record<string, string> | null = null;
|
||||
if (pull.head_sha) {
|
||||
try {
|
||||
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
await emit("pull.merged", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
body: pull.body,
|
||||
head_sha: pull.head_sha,
|
||||
...(headChecks ? { head_checks: headChecks } : {}),
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: pull.merge_commit_sha,
|
||||
@@ -112,6 +171,10 @@ 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,
|
||||
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
|
||||
});
|
||||
// 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 +187,137 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
if (!primedMerges) since = new Date().toISOString();
|
||||
if (!primedMerges || changed) keepAnnounced();
|
||||
primedMerges = true;
|
||||
lastLook = began;
|
||||
}
|
||||
|
||||
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
|
||||
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
|
||||
// announces nothing twice; the first look on a machine with no record announces only what moved in the
|
||||
// last day, so a forge's whole backlog is not checked at once.
|
||||
const pullsRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-announced.json") : null;
|
||||
let heads: Announced = {};
|
||||
let primedPulls = false;
|
||||
if (pullsRecord && existsSync(pullsRecord)) {
|
||||
try {
|
||||
heads = (JSON.parse(readFileSync(pullsRecord, "utf8")) as { heads: Announced }).heads ?? {};
|
||||
primedPulls = true;
|
||||
} catch {
|
||||
// An unreadable record is no record: the first look announces only the last day's.
|
||||
}
|
||||
}
|
||||
function keepHeads(): void {
|
||||
if (!pullsRecord) return;
|
||||
mkdirSync(join(pullsRecord, ".."), { recursive: true });
|
||||
const tmp = pullsRecord + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify({ heads }));
|
||||
renameSync(tmp, pullsRecord);
|
||||
}
|
||||
let lastPullLook = "";
|
||||
async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
const began = new Date().toISOString();
|
||||
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
|
||||
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
|
||||
let changed = false;
|
||||
for (const repo of movedSince(await client.listAllRepos(), floor)) {
|
||||
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", sort: "recentupdate", limit: "20" });
|
||||
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
|
||||
const key = `${repo.full_name}#${pull.number}`;
|
||||
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
||||
if (fresh) {
|
||||
const files = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
||||
const head = String(pull.head_sha);
|
||||
// What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as
|
||||
// amended): which changed directories hold a module at the head — the merge's rule (issue 278) —
|
||||
// and whether the head holds the repository's own merge-check.sh. Not said when it could not be
|
||||
// read; the controller then reads the change as touching everything built from the repository.
|
||||
let moduleDirs: string[] | null = null;
|
||||
let mergeCheck: boolean | null = null;
|
||||
try {
|
||||
if (!files.truncated) moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, head, files.paths);
|
||||
mergeCheck = await client.holdsFile(repo.owner, repo.name, head, "merge-check.sh");
|
||||
} catch (err) {
|
||||
console.error(`[gitea] ${key}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
await emit("pull.updated", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
body: pull.body,
|
||||
base: pull.base,
|
||||
head: pull.head,
|
||||
head_sha: pull.head_sha,
|
||||
clone_url: repo.clone_url,
|
||||
html_url: pull.html_url,
|
||||
paths: files.paths,
|
||||
paths_truncated: files.truncated,
|
||||
removed: files.removed,
|
||||
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
|
||||
...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}),
|
||||
});
|
||||
// Said, so an operator knows the mesh was asked to check it.
|
||||
console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`);
|
||||
// Pending until the verdict comes, so the pull request says a check is running rather than nothing.
|
||||
await client
|
||||
.setCommitStatus(repo.owner, repo.name, String(pull.head_sha), {
|
||||
state: "pending", context: CHECK_CONTEXT, description: "the mesh is mapping this head onto its module graph",
|
||||
})
|
||||
.catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`));
|
||||
}
|
||||
heads[key] = String(pull.head_sha);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!primedPulls || changed) keepHeads();
|
||||
primedPulls = true;
|
||||
lastPullLook = began;
|
||||
}
|
||||
|
||||
// **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one
|
||||
// that is not a pass also leaves the check's own account as a comment, so the reason is read where the
|
||||
// change is reviewed. An error — the check could not run — is the forge's `error`, never a success.
|
||||
async function setVerdict(client: GiteaClient, event: { body: unknown }): Promise<void> {
|
||||
const c = (event.body ?? {}) as Checked;
|
||||
if (c.group) {
|
||||
// A delivery group's composed check (novox/hq ADR 0239): its verdict is the group owner's to say, on each
|
||||
// member's head, as mesh/delivery-group — never this head's merge gate.
|
||||
return;
|
||||
}
|
||||
if (!c.owner || !c.repo || !c.commit || !c.verdict) {
|
||||
console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored");
|
||||
return;
|
||||
}
|
||||
// Each status links to the pull request, where the delivery's view is kept (novox/hq ADR 0239).
|
||||
let target: string | undefined;
|
||||
let base: string | undefined;
|
||||
if (c.number) {
|
||||
const pull = await client.getPullRequest(c.owner, c.repo, c.number).catch(() => undefined);
|
||||
target = pull?.html_url || undefined;
|
||||
base = pull?.base || undefined;
|
||||
}
|
||||
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
|
||||
for (const status of statusesFor(c, facts)) {
|
||||
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
|
||||
}
|
||||
const comment = commentFor(c, facts);
|
||||
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
|
||||
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
|
||||
}
|
||||
|
||||
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
|
||||
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
|
||||
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
|
||||
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
|
||||
// required would let an untested repository merge.
|
||||
async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
|
||||
if (c["repo-check"]?.verdict !== "warning") return {};
|
||||
const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
|
||||
if (defined !== false) return { defined };
|
||||
const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined);
|
||||
if (!rules) return { defined };
|
||||
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main"));
|
||||
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
|
||||
return { defined, required };
|
||||
}
|
||||
|
||||
if (gitea) {
|
||||
@@ -132,6 +326,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,11 +339,13 @@ 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);
|
||||
tick(() => pollMerged(client), 30_000);
|
||||
tick(() => pollPulls(client), 30_000);
|
||||
await on("mesh-controller.checked", (event) => setVerdict(client, event));
|
||||
console.log("[gitea] watching for new repositories and merged pull requests");
|
||||
}
|
||||
|
||||
@@ -40,7 +40,12 @@
|
||||
"emits": [
|
||||
"repo.created",
|
||||
"issue.opened",
|
||||
"pull.merged"
|
||||
"pull.merged",
|
||||
"pull.updated",
|
||||
"pull.closed"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-controller.checked"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
@@ -85,6 +90,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 +204,11 @@
|
||||
"provides": [
|
||||
{
|
||||
"name": "npm-package-registry",
|
||||
"scope": "mesh"
|
||||
"scope": "mesh",
|
||||
"identity": {
|
||||
"max": 40,
|
||||
"in": "a Gitea user name"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's merge check —
|
||||
// `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it
|
||||
// may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
|
||||
|
||||
/** A branch protection rule, as the forge keeps it — the fields the mesh reads; the forge sends more. */
|
||||
export interface BranchProtection {
|
||||
rule_name?: string;
|
||||
branch_name?: string;
|
||||
enable_push?: boolean;
|
||||
enable_status_check?: boolean;
|
||||
status_check_contexts?: string[] | null;
|
||||
required_approvals?: number;
|
||||
block_admin_merge_override?: boolean;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
/** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's
|
||||
* default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */
|
||||
export interface ProtectionWanted {
|
||||
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/merge-gate. Empty: none. */
|
||||
statusChecks: string[];
|
||||
/** Whether a person may push to the branch directly. */
|
||||
push?: boolean;
|
||||
/** Whether an administrator is stopped from merging past a status that has not succeeded. */
|
||||
blockAdminOverride?: boolean;
|
||||
}
|
||||
|
||||
/** The forge's body for a wanted protection. */
|
||||
export function protectionBody(want: ProtectionWanted, creating: boolean): Record<string, unknown> {
|
||||
const checks = [...new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean))];
|
||||
const body: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
|
||||
if (want.push !== undefined) body.enable_push = want.push;
|
||||
else if (creating) body.enable_push = false;
|
||||
if (want.blockAdminOverride !== undefined) body.block_admin_merge_override = want.blockAdminOverride;
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull
|
||||
// request's head moves, and what it sets as the pull request's status when the mesh says the verdict.
|
||||
//
|
||||
// **Before merge, never after.** Every check the mesh had ran after a merge, on a machine: a manifest the
|
||||
// node-engine refuses (issue 236), an identity a real machine's name made too long (263). So each new head
|
||||
// of an open pull request is announced as `pull.updated`; the controller asks the build seat to check it
|
||||
// against every machine of the mesh's facts; and the verdict comes back as the controller's `checked`,
|
||||
// which this sets as the head commit's status — and, when it is not a pass, as a comment saying why.
|
||||
//
|
||||
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
|
||||
|
||||
import type { CommitStatus, GiteaPull } from "./client.js";
|
||||
|
||||
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
|
||||
export const CHECK_CONTEXT = "mesh/merge-gate";
|
||||
|
||||
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
|
||||
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
|
||||
|
||||
/** One layer of a check, judged. */
|
||||
export interface Layer {
|
||||
verdict: string;
|
||||
summary: string;
|
||||
/** The modules a merge of the change would move, as the controller's planner reckons it… */
|
||||
modules?: string[];
|
||||
/** …and those it would build after them because they stand on them. */
|
||||
dependents?: string[];
|
||||
}
|
||||
|
||||
/** What the controller says as `checked` (mesh-controller internal/link, Checked). */
|
||||
export interface Checked {
|
||||
owner: string;
|
||||
repo: string;
|
||||
number?: number;
|
||||
commit: string;
|
||||
verdict: string;
|
||||
summary: string;
|
||||
report?: string;
|
||||
id: string;
|
||||
on?: string;
|
||||
/** The gate — the modules of the mesh's graph the change touches — and the repository's own check. A
|
||||
* controller from before the layers says neither, and its verdict is the gate's. */
|
||||
gate?: Layer;
|
||||
"repo-check"?: Layer;
|
||||
/** The change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build and send. */
|
||||
plan?: ChangePlan;
|
||||
/** Set on a delivery group's composed check (novox/hq ADR 0239): not this head's merge gate. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/** A change plan, as the controller says it (mesh-controller internal/link, ChangePlan). */
|
||||
export interface ChangePlan {
|
||||
repository: string;
|
||||
base: string;
|
||||
head: string;
|
||||
moved?: string[];
|
||||
dependents?: string[];
|
||||
new?: string[];
|
||||
unread?: string[];
|
||||
tiers?: string[][];
|
||||
machines?: { machine: string; receives?: string[]; waits?: string[] }[];
|
||||
steps?: string[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** Whether a plan builds anything: a change that touches the mesh's graph. */
|
||||
function builds(plan?: ChangePlan): boolean {
|
||||
return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
/** A change plan as a person reads it on the pull request. */
|
||||
export function planText(plan: ChangePlan): string {
|
||||
const lines = [`**Change plan** — ${plan.summary}`];
|
||||
(plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`));
|
||||
for (const m of plan.machines ?? []) {
|
||||
const parts: string[] = [];
|
||||
if (m.receives?.length) parts.push(`receives ${m.receives.join(", ")}`);
|
||||
if (m.waits?.length) parts.push(`waits for a person: ${m.waits.join(", ")}`);
|
||||
lines.push(`- ${m.machine}: ${parts.join("; ")}`);
|
||||
}
|
||||
for (const step of plan.steps ?? []) lines.push(`- ${step}`);
|
||||
if (plan.unread?.length) lines.push(`- read by no module's build: ${plan.unread.join(", ")}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
|
||||
export type Announced = Record<string, string>;
|
||||
|
||||
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not
|
||||
* open and is never asked about. */
|
||||
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
|
||||
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
|
||||
}
|
||||
|
||||
// **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a
|
||||
// failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no
|
||||
// administrator override — will not merge past one. So the controller's `warning`, a note and never a
|
||||
// question, is set as `success` with the note in its description; only what a person must decide is a
|
||||
// `failure`, with why. The mapping, verdict by verdict:
|
||||
//
|
||||
// pass → success
|
||||
// warning (a note: rebuild width, a problem
|
||||
// already so on the base, a script's own) → success, "pass, with a note: …"
|
||||
// repo-check, no merge-check.sh, not required → success, "no repository check defined"
|
||||
// repo-check, no merge-check.sh, required (or
|
||||
// the protection unreadable) → failure: a person adds one, or lifts the requirement
|
||||
// fail → failure
|
||||
// error, or no verdict → error — never a success
|
||||
|
||||
/** The forge's state for a verdict: an error is the forge's `error`, never a success; a warning is a note. */
|
||||
function stateOf(verdict: string): CommitStatus["state"] {
|
||||
return verdict === "pass" || verdict === "warning" ? "success" : verdict === "fail" ? "failure" : "error";
|
||||
}
|
||||
|
||||
/** How a verdict is said in a status's description: a warning as the pass with a note it is. */
|
||||
function saidAs(verdict: string): string {
|
||||
return verdict === "warning" ? "pass, with a note" : verdict || "error";
|
||||
}
|
||||
|
||||
function described(verdict: string, summary: string, modules?: string[], dependents?: string[]): string {
|
||||
// The forge keeps a short description; the rest is the comment's.
|
||||
let d = `${saidAs(verdict)}: ${summary}`;
|
||||
if (modules?.length) d += ` [${modules.join(", ")}${dependents?.length ? ` +${dependents.length} dependent(s)` : ""}]`;
|
||||
return clipped(d);
|
||||
}
|
||||
|
||||
function clipped(d: string): string {
|
||||
d = d.replace(/\s+/g, " ").trim();
|
||||
return d.length > 140 ? d.slice(0, 139) + "…" : d;
|
||||
}
|
||||
|
||||
/** What the forge says of a repository's own check beyond the controller's verdict, asked by index.ts only
|
||||
* when the verdict is a warning: whether the head holds a merge-check.sh, and whether the base branch's
|
||||
* protection requires mesh/repo-check. Unknown is undefined. */
|
||||
export interface RepoCheckFacts {
|
||||
defined?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** The description of a required repository check that is not defined. */
|
||||
export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " +
|
||||
"a person adds one, or lifts the requirement from the branch's protection";
|
||||
|
||||
/** The repository check's status: its verdict, unless the repository defines no check at all — then
|
||||
* success where the check is not required, and a failure for a person where it is (or may be). */
|
||||
function repoStatus(repo: Layer, facts: RepoCheckFacts): CommitStatus {
|
||||
if (repo.verdict === "warning" && facts.defined === false) {
|
||||
return facts.required === false
|
||||
? { state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" }
|
||||
: { state: "failure", context: REPO_CHECK_CONTEXT, description: clipped(UNDEFINED_REQUIRED) };
|
||||
}
|
||||
return { state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) };
|
||||
}
|
||||
|
||||
/** Whether the repository check is a failure a person must read: failed, could not run, or required and
|
||||
* not defined. */
|
||||
function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean {
|
||||
return !!repo && repoStatus(repo, facts).state !== "success";
|
||||
}
|
||||
|
||||
/** The forge's status for the gate. */
|
||||
export function statusFor(c: Checked): CommitStatus {
|
||||
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||
// With a plan, the status says what the change does and how it was judged: "pass: builds gitea → anchor;
|
||||
// no bus step; every machine composes…".
|
||||
const description = builds(c.plan)
|
||||
? described(gate.verdict, `${c.plan!.summary}; ${gate.summary}`)
|
||||
: described(gate.verdict, gate.summary, gate.modules, gate.dependents);
|
||||
return { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description };
|
||||
}
|
||||
|
||||
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */
|
||||
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}): CommitStatus[] {
|
||||
const out = [statusFor(c)];
|
||||
const repo = c["repo-check"];
|
||||
if (repo) out.push(repoStatus(repo, facts));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The comment a verdict leaves on its pull request, with the check's own account: the change plan of a
|
||||
* change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the
|
||||
* repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing,
|
||||
* is said by its statuses alone, not by a comment on every push. */
|
||||
export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | null {
|
||||
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
|
||||
const repo = c["repo-check"];
|
||||
const wrong = repoWrong(repo, facts);
|
||||
if (gate.verdict === "pass" && !wrong && !builds(c.plan)) return null;
|
||||
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
|
||||
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
|
||||
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
|
||||
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
|
||||
if (repo) {
|
||||
const st = repoStatus(repo, facts);
|
||||
lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false
|
||||
? `**${st.state === "success" ? "PASS" : "FAIL"}** — ${st.description.replace(/^fail: /, "")}`
|
||||
: `**${saidAs(repo.verdict).toUpperCase()}** — ${repo.summary}`));
|
||||
}
|
||||
if (builds(c.plan)) lines.push("", planText(c.plan!));
|
||||
const ran = c.on ? `\n\nRun by the build seat on ${c.on} as \`${c.id}\` (\`builds --log ${c.id}\`).` : "";
|
||||
const report = c.report ? `\n\n\`\`\`\n${c.report.replace(/```/g, "'''")}\n\`\`\`` : "";
|
||||
return lines.join("\n") + ran + report;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
// What the forge's holder does for a delivery (novox/hq ADR 0239): a note appended once, in the forge's own
|
||||
// repository as its own user; one view per pull request; only the mesh's statuses.
|
||||
|
||||
test("a note line is appended once, as the forge's user, in its own repository", async () => {
|
||||
const { appendNote, noteArgs } = await import("../delivery.ts");
|
||||
const notes: Record<string, string[]> = {};
|
||||
const calls: string[][] = [];
|
||||
const runner = async (file: string, args: string[]) => {
|
||||
calls.push([file, ...args]);
|
||||
const commit = args[args.length - 1];
|
||||
if (args.includes("show")) {
|
||||
const lines = notes[commit];
|
||||
return lines ? { stdout: lines.join("\n") + "\n", code: 0 } : { stdout: "", code: 1 };
|
||||
}
|
||||
const line = args[args.indexOf("-m") + 1];
|
||||
(notes[commit] ??= []).push(line);
|
||||
return { stdout: "", code: 0 };
|
||||
};
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
assert.deepEqual(await appendNote(runner, "gitea", "Novox", "Mesh-Catalog", sha, "mesh-plan", "a -> b\n(merged)"),
|
||||
{ added: true, lines: 1 });
|
||||
assert.deepEqual(await appendNote(runner, "gitea", "Novox", "Mesh-Catalog", sha, "mesh-plan", "a -> b (merged)"),
|
||||
{ added: false, lines: 1 }, "the same line twice adds nothing");
|
||||
assert.deepEqual(await appendNote(runner, "gitea", "Novox", "Mesh-Catalog", sha, "mesh-plan", "b -> c"),
|
||||
{ added: true, lines: 2 });
|
||||
const append = calls.find((c) => c.includes("append"))!;
|
||||
assert.deepEqual(append.slice(0, 7), ["docker", "exec", "-u", "git", "gitea", "git", "--git-dir"]);
|
||||
assert.equal(append[7], "/data/git/repositories/novox/mesh-catalog.git");
|
||||
assert.ok(append.includes("--ref=mesh-plan"));
|
||||
assert.throws(() => noteArgs("gitea", "novox", "x; rm -rf /", sha, "mesh-plan"), /not a repository/);
|
||||
assert.throws(() => noteArgs("gitea", "novox", "x", "HEAD", "mesh-plan"), /not a commit/);
|
||||
assert.throws(() => noteArgs("gitea", "novox", "x", sha, "../commits"), /not a notes ref/);
|
||||
});
|
||||
|
||||
test("a note that cannot be written is said, never read as written", async () => {
|
||||
const { appendNote } = await import("../delivery.ts");
|
||||
const runner = async (_file: string, args: string[]) => ({ stdout: "", code: args.includes("append") ? 128 : 1 });
|
||||
await assert.rejects(appendNote(runner, "gitea", "novox", "x", "abcdef1234567", "mesh-plan", "l"), /could not be appended/);
|
||||
});
|
||||
|
||||
test("one view per pull request, found by its marker; only the mesh's statuses", async () => {
|
||||
const { viewBody, viewComment, deliveryStatus, VIEW_MARKER } = await import("../delivery.ts");
|
||||
assert.ok(viewBody("**Delivery**").startsWith(VIEW_MARKER));
|
||||
assert.equal(viewBody(`${VIEW_MARKER}\nx`), `${VIEW_MARKER}\nx`, "a marked body is kept as it is");
|
||||
const comments = [{ id: 1, body: "a review" }, { id: 2, body: `${VIEW_MARKER}\nold` }, { id: 3, body: `${VIEW_MARKER}\nlater` }];
|
||||
assert.equal(viewComment(comments)?.id, 2);
|
||||
assert.equal(viewComment([{ id: 1, body: "x" }]), undefined);
|
||||
const s = deliveryStatus("mesh/delivery", "pending", "y".repeat(300), "https://forge.invalid/novox/x/pulls/1");
|
||||
assert.ok(s.description.length <= 140 && s.target_url);
|
||||
assert.throws(() => deliveryStatus("ci/other", "success", "x"), /mesh's own/);
|
||||
assert.throws(() => deliveryStatus("mesh/delivery", "green", "x"), /not a status/);
|
||||
// The merge check's statuses are its verdict's alone: required by a branch, they are never set by hand.
|
||||
assert.throws(() => deliveryStatus("mesh/merge-gate", "success", "x"), /merge check's status/);
|
||||
assert.throws(() => deliveryStatus("mesh/repo-check", "warning", "x"), /merge check's status/);
|
||||
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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");
|
||||
});
|
||||
|
||||
test("the directories above a merge's files that hold a module at the commit are said, the root never", async () => {
|
||||
const { directoriesAbove } = await import("../client.ts");
|
||||
assert.deepEqual(directoriesAbove(["modules/showcase/index.ts", "modules/showcase/daemon/x.ts", "README.md", "/modules/lib/a.go"]),
|
||||
["modules", "modules/lib", "modules/showcase", "modules/showcase/daemon"]);
|
||||
const asked: string[] = [];
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "", "http://x");
|
||||
asked.push(`${url.pathname}@${url.searchParams.get("ref")}`);
|
||||
const held = ["/api/v1/repos/novox/mesh-catalog/contents/modules/showcase/module.json"];
|
||||
res.statusCode = held.includes(url.pathname) ? 200 : 404;
|
||||
res.end(res.statusCode === 200 ? "{}" : '{"message":"not found"}');
|
||||
});
|
||||
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.moduleDirsAt("novox", "mesh-catalog", "abc", ["modules/showcase/index.ts", "modules/lib/x.go"]);
|
||||
const capped = await client.moduleDirsAt("novox", "mesh-catalog", "abc", ["a/b/c/d.ts"], 2);
|
||||
server.close();
|
||||
assert.deepEqual(got, ["modules/showcase"], "a directory holding a manifest is a module; one holding none is not");
|
||||
assert.ok(asked.every((a) => a.endsWith("@abc")), "looked up at the merge commit");
|
||||
assert.equal(capped, null, "past the bound it is not said, and the mesh keeps its old rule");
|
||||
});
|
||||
|
||||
test("a lookup the forge refuses is thrown, not read as no module", async () => {
|
||||
const server = createServer((_req, res) => {
|
||||
res.statusCode = 500;
|
||||
res.end("down");
|
||||
});
|
||||
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");
|
||||
await assert.rejects(client.moduleDirsAt("novox", "mesh-catalog", "abc", ["modules/x/y.ts"]));
|
||||
server.close();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
// A branch's protection (novox/hq ADR 0237): what the operator's agent sets so a pull request waits for the
|
||||
// mesh's merge check before it merges.
|
||||
|
||||
test("the statuses asked for replace the rule's, and an empty list requires none", async () => {
|
||||
const { protectionBody } = await import("../protection.ts");
|
||||
assert.deepEqual(protectionBody({ statusChecks: ["mesh/merge-gate", " mesh/repo-check", "", "mesh/merge-gate"] }, false),
|
||||
{ enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] },
|
||||
"an edited rule keeps its pushes as they are, and each status once");
|
||||
assert.deepEqual(protectionBody({ statusChecks: [] }, false), { enable_status_check: false, status_check_contexts: [] });
|
||||
});
|
||||
|
||||
test("a new rule refuses direct pushes unless asked; an administrator's override only when said", async () => {
|
||||
const { protectionBody } = await import("../protection.ts");
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).enable_push, false);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], push: true }, true).enable_push, true);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).block_admin_merge_override, undefined);
|
||||
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], blockAdminOverride: true }, false).block_admin_merge_override, true);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// A pull request's merge check (novox/hq to-be 45 §9): each new head announced once, and the verdict set
|
||||
// where the pull request shows it — an error never as a success.
|
||||
|
||||
test("each open pull request's new head is announced once, and nothing closed or merged", async () => {
|
||||
const { headsToAnnounce } = await import("../pulls.ts");
|
||||
const pulls = [
|
||||
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", html_url: "" },
|
||||
{ number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" },
|
||||
{ number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" },
|
||||
{ number: 4, title: "d", state: "open", merged: false, html_url: "" },
|
||||
];
|
||||
const announced = { "novox/mesh-catalog#1": "aaa", "novox/mesh-catalog#2": "old" };
|
||||
assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2],
|
||||
"only a head not announced, of a pull request that is open");
|
||||
});
|
||||
|
||||
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
|
||||
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
|
||||
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
|
||||
assert.equal(statusFor({ ...base, verdict: "pass", summary: "every machine composes" }).state, "success");
|
||||
// A warning is a note, never a blocking state: the forge combines `warning` as a failure (issue 293).
|
||||
const wide = statusFor({ ...base, verdict: "warning", summary: "a merge rebuilds 14 module(s)" });
|
||||
assert.equal(wide.state, "success");
|
||||
assert.match(wide.description, /^pass, with a note: a merge rebuilds 14 module/);
|
||||
assert.equal(statusFor({ ...base, verdict: "fail", summary: "x" }).state, "failure");
|
||||
assert.equal(statusFor({ ...base, verdict: "error", summary: "the check could not run" }).state, "error");
|
||||
assert.equal(statusFor({ ...base, verdict: "", summary: "" }).state, "error", "no verdict is no pass");
|
||||
const long = statusFor({ ...base, verdict: "fail", summary: "y".repeat(500) });
|
||||
assert.ok(long.description.length <= 140 && long.context === CHECK_CONTEXT);
|
||||
assert.equal(commentFor({ ...base, verdict: "pass", summary: "fine" }), null, "a pass leaves no comment");
|
||||
const said = commentFor({ ...base, verdict: "fail", summary: "lemurs refused", report: "fails:\n - ```x```" }) ?? "";
|
||||
assert.match(said, /mesh\/merge-gate`: \*\*FAIL\*\*/);
|
||||
assert.match(said, /01234567/);
|
||||
assert.match(said, /builds --log build-1/);
|
||||
assert.ok(!said.slice(said.indexOf("```") + 3, said.lastIndexOf("```")).includes("```"), "the report cannot close its own block");
|
||||
});
|
||||
|
||||
test("each layer is its own status: the gate with the modules it judged, the repository's own check beside it", async () => {
|
||||
const { statusesFor, commentFor, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
|
||||
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1" };
|
||||
const both = statusesFor({ ...base, verdict: "pass", summary: "every machine composes",
|
||||
gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"], dependents: ["node-tools"] },
|
||||
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed: FAIL x" } });
|
||||
assert.deepEqual(both.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "success"], [REPO_CHECK_CONTEXT, "failure"]]);
|
||||
assert.match(both[0].description, /gitea, keycloak \+1 dependent/);
|
||||
assert.match(commentFor({ ...base, verdict: "pass", summary: "", gate: { verdict: "pass", summary: "" },
|
||||
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed" } }) ?? "", /mesh\/repo-check`: \*\*FAIL/);
|
||||
|
||||
// Nothing of the graph touched, no script: a pass and a warning, and no comment on every push.
|
||||
const quiet = { ...base, verdict: "pass", summary: "the change touches no module of the mesh's graph",
|
||||
gate: { verdict: "pass", summary: "the change touches no module of the mesh's graph" },
|
||||
"repo-check": { verdict: "warning", summary: "the repository declares no merge-check.sh" } };
|
||||
assert.deepEqual(statusesFor(quiet, { defined: false, required: false }).map((s) => [s.state, s.description]),
|
||||
[["success", "pass: the change touches no module of the mesh's graph"], ["success", "no repository check defined"]]);
|
||||
assert.equal(commentFor(quiet, { defined: false, required: false }), null);
|
||||
|
||||
// A repository outside the mesh, touching nothing: the gate alone, a pass.
|
||||
assert.equal(statusesFor({ ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" } }).length, 1);
|
||||
|
||||
// A controller from before the layers: its verdict is the gate's.
|
||||
assert.deepEqual(statusesFor({ ...base, verdict: "warning", summary: "wide" }).map((s) => [s.context, s.state]),
|
||||
[[CHECK_CONTEXT, "success"]]);
|
||||
});
|
||||
|
||||
// **A required status is success or it blocks** (novox/hq issue 293): branch protection requires
|
||||
// mesh/merge-gate (and mesh/repo-check on the core repositories) with no administrator override, and the
|
||||
// forge combines `warning` as a failure. No verdict ever sets `warning` on either; a note is a success that
|
||||
// says it; only what a person must decide is a failure, with why.
|
||||
test("no verdict sets warning on a required check; only a person's decision fails", async () => {
|
||||
const { statusesFor, commentFor, UNDEFINED_REQUIRED, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
|
||||
const base = { owner: "novox", repo: "photos", number: 3, commit: "0123456789abcdef", id: "build-2" };
|
||||
const noScript = { verdict: "warning", summary: "the repository declares no merge-check.sh" };
|
||||
const quiet = { ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" }, "repo-check": noScript };
|
||||
for (const verdict of ["pass", "warning", "fail", "error", ""]) {
|
||||
for (const facts of [{}, { defined: true }, { defined: false }, { defined: false, required: true }, { defined: false, required: false }]) {
|
||||
const c = { ...base, verdict, summary: "s", gate: { verdict, summary: "s" }, "repo-check": { verdict, summary: "s" } };
|
||||
for (const s of statusesFor(c, facts)) assert.notEqual(s.state, "warning", `${verdict} ${JSON.stringify(facts)} → ${s.context}`);
|
||||
}
|
||||
}
|
||||
// A repository without a merge-check.sh where repo-check is not required: success, said.
|
||||
assert.deepEqual(statusesFor(quiet, { defined: false, required: false })[1],
|
||||
{ state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" });
|
||||
// Where it is required — or the protection could not be read — a person decides: a failure, with why.
|
||||
for (const facts of [{ defined: false, required: true }, { defined: false }]) {
|
||||
const s = statusesFor(quiet, facts)[1];
|
||||
assert.equal(s.state, "failure");
|
||||
assert.ok(s.description.length <= 140 && UNDEFINED_REQUIRED.startsWith(s.description.replace(/…$/, "")));
|
||||
assert.match(s.description, /^fail: mesh\/repo-check is required/);
|
||||
assert.match(commentFor(quiet, facts) ?? "", /mesh\/repo-check`: \*\*FAIL\*\* — mesh\/repo-check is required/);
|
||||
}
|
||||
// A script that defines its check and said a warning itself: a note, a success.
|
||||
const noted = statusesFor({ ...quiet, "repo-check": { verdict: "warning", summary: "2 tests skipped" } }, { defined: true })[1];
|
||||
assert.deepEqual([noted.state, noted.description], ["success", "pass, with a note: 2 tests skipped"]);
|
||||
// The gate's notes — a wide rebuild, a problem already so on the base — are successes that say so.
|
||||
const already = statusesFor({ ...base, verdict: "warning", summary: "s",
|
||||
gate: { verdict: "warning", summary: "the module check's problems were all so on main already" } })[0];
|
||||
assert.deepEqual([already.state, already.description],
|
||||
["success", "pass, with a note: the module check's problems were all so on main already"]);
|
||||
assert.match(commentFor({ ...base, verdict: "warning", summary: "wide", gate: { verdict: "warning", summary: "wide" } }) ?? "",
|
||||
/PASS, WITH A NOTE/);
|
||||
});
|
||||
|
||||
test("a commit status is set on the commit, under the merge check's context", async () => {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
let seen: { path: string; body: any } | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => {
|
||||
seen = { path: `${req.method} ${req.url}`, body: JSON.parse(raw) };
|
||||
res.statusCode = 201;
|
||||
res.end("{}");
|
||||
});
|
||||
});
|
||||
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");
|
||||
await client.setCommitStatus("novox", "mesh-catalog", "abc123", { state: "failure", context: "mesh/merge-gate", description: "x" });
|
||||
server.close();
|
||||
assert.equal(seen!.path, "POST /api/v1/repos/novox/mesh-catalog/statuses/abc123");
|
||||
assert.equal(seen!.body.state, "failure");
|
||||
assert.equal(seen!.body.context, "mesh/merge-gate");
|
||||
});
|
||||
|
||||
test("a change plan is the gate's result: said on the status and, when it builds something, as a comment", async () => {
|
||||
const { statusFor, commentFor } = await import("../pulls.ts");
|
||||
const plan = {
|
||||
repository: "novox/mesh-catalog", base: "main", head: "0123456789abcdef", moved: ["gitea"],
|
||||
tiers: [["gitea"]], machines: [{ machine: "anchor", receives: ["gitea"] }], steps: [],
|
||||
summary: "builds gitea → anchor; no bus step",
|
||||
};
|
||||
const c = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "b", verdict: "pass",
|
||||
summary: "every machine composes", gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea"] }, plan };
|
||||
assert.equal(statusFor(c).description, "pass: builds gitea → anchor; no bus step; every machine composes");
|
||||
const said = commentFor(c) ?? "";
|
||||
assert.match(said, /Change plan\*\* — builds gitea → anchor/);
|
||||
assert.match(said, /- anchor: receives gitea/);
|
||||
// A plan that builds nothing, passing: the statuses say it, no comment.
|
||||
const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } };
|
||||
assert.equal(commentFor(nothing), null);
|
||||
});
|
||||
@@ -410,6 +410,8 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_get_file",
|
||||
"gitea_list_branches",
|
||||
"gitea_delete_branch",
|
||||
"gitea_branch_protection_get", "gitea_branch_protection_set",
|
||||
"gitea_note_append", "gitea_delivery_view", "gitea_commit_status",
|
||||
"gitea_list_labels", "gitea_create_label",
|
||||
"gitea_api",
|
||||
],
|
||||
|
||||
+119
-28
@@ -1,16 +1,31 @@
|
||||
// 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";
|
||||
import { GiteaClient } from "../client.js";
|
||||
import { GiteaClient, type BranchProtection } from "../client.js";
|
||||
import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
|
||||
|
||||
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
|
||||
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
|
||||
|
||||
/** A protection rule as a person reads it: what it guards, not every field the forge keeps. */
|
||||
export function summarised(p: BranchProtection) {
|
||||
return {
|
||||
rule: p.rule_name ?? p.branch_name,
|
||||
push: p.enable_push ?? false,
|
||||
required_statuses: p.enable_status_check ? (p.status_check_contexts ?? []) : [],
|
||||
required_approvals: p.required_approvals ?? 0,
|
||||
admin_may_override: !(p.block_admin_merge_override ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
/** Coerce a comma-separated label string into names; empty/absent yields none. */
|
||||
function parseLabels(raw: unknown): string[] {
|
||||
@@ -228,29 +243,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 };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -379,6 +376,100 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Branch protection (novox/hq ADR 0237) ----
|
||||
{
|
||||
name: "gitea_branch_protection_get",
|
||||
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch (rule name); every rule when not given" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo);
|
||||
if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) };
|
||||
const rule = await gitea.branchProtection(owner, repo, String(args.branch));
|
||||
return { branch: String(args.branch), rule: rule ? summarised(rule) : null };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_branch_protection_set",
|
||||
description: "Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
branch: { type: "string", description: "the branch to protect, e.g. main" },
|
||||
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" },
|
||||
push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" },
|
||||
block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
|
||||
if (!branch) throw new Error("name the branch to protect");
|
||||
const before = await gitea.branchProtection(owner, repo, branch);
|
||||
const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, {
|
||||
statusChecks: String(args.status_checks ?? "").split(","),
|
||||
push: args.push === undefined ? undefined : Boolean(args.push),
|
||||
blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override),
|
||||
});
|
||||
return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ----
|
||||
{
|
||||
name: "gitea_note_append",
|
||||
description: "Append one line to a commit's git note under refs/notes/<ref> (mesh-plan for a delivery), written in the forge's own repository as the forge's own account; a line the note already holds is not added again. `git log --notes=mesh-plan` shows it.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the commit" },
|
||||
ref: { type: "string", description: "the notes ref's name (default mesh-plan)" },
|
||||
line: { type: "string", description: "the line, one line" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const said = await appendNote(run, forgeContainer, String(args.owner), String(args.repo), String(args.sha),
|
||||
String(args.ref ?? "mesh-plan") || "mesh-plan", String(args.line ?? ""));
|
||||
return { commit: String(args.sha), ...said };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_delivery_view",
|
||||
description: "Keep a delivery's view on its pull request: one comment, marked as the delivery's, created the first time and edited in place after — the page every status of the delivery links to.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
number: { type: "number", description: "the pull request's number" },
|
||||
body: { type: "string", description: "the view, markdown" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo), number = Number(args.number);
|
||||
const body = viewBody(String(args.body ?? ""));
|
||||
const existing = viewComment(await gitea.listComments(owner, repo, number));
|
||||
if (existing) return { edited: await gitea.editComment(owner, repo, existing.id, body) };
|
||||
return { created: await gitea.addComment(owner, repo, number, body) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_status",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the commit" },
|
||||
context: { type: "string", description: "the status's name, mesh/…" },
|
||||
state: { type: "string", description: "pending | success | error | failure | warning" },
|
||||
description: { type: "string", description: "one line" },
|
||||
target_url: { type: "string", description: "the page it links to (the pull request)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""),
|
||||
args.target_url ? String(args.target_url) : undefined);
|
||||
await gitea.setCommitStatus(String(args.owner), String(args.repo), String(args.sha), status);
|
||||
return { set: status };
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Labels ----
|
||||
{
|
||||
name: "gitea_list_labels",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "token.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||
"include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -81,6 +91,11 @@
|
||||
"type": "container",
|
||||
"name": "grafana",
|
||||
"image": "grafana/grafana@sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0",
|
||||
"health": {
|
||||
"kind": "http",
|
||||
"endpoint": "web",
|
||||
"path": "/"
|
||||
},
|
||||
"ports": [
|
||||
"3000"
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user