Compare commits

..
Author SHA1 Message Date
mesh-admin 740359ffd9 Merge pull request 'Remove portainer: deprecated, and unassigned everywhere' (#233) from jschoubben/remove-portainer into main 2026-10-02 21:26:03 +00:00
jschoubben a309deb479 Remove portainer: deprecated, and unassigned everywhere
It held the docker socket behind a public name. Nothing depends on it;
it is off both machines that ran it, with its data.
2026-10-02 23:25:45 +02:00
mesh-admin 0fd722e829 Merge pull request 'gitea: the jail also bans what gitea's sshd refuses' (#232) from jschoubben/gitea-ssh-jail into main 2026-10-02 21:23:44 +00:00
jschoubben 2e6cc71f7a gitea: the jail also bans what gitea's sshd refuses
The jail read gitea's container journal, which carries its sshd's lines,
but matched only the web login. 167 ssh attempts an hour from the
internet went unbanned. Two patterns, one per attempt: an unknown user,
and a user sshd refuses; tested against a day of the real log, 946
matches and none on an accepted login.
2026-10-02 23:23:35 +02:00
mesh-admin 810c7fbac3 Merge pull request 'A ban list never holds a neighbour (hq ADR 0186)' (#227) from fix/a-ban-list-never-holds-a-neighbour into main 2026-10-02 16:43:26 +00:00
jschoubben 304044da40 A ban list never holds a neighbour (hq ADR 0186)
The home server banned the house's own router within an hour of the first public jail: the router
reflects local traffic, so every client in the building arrives as the gateway's address. Every
private range joins the mesh's own in the never-ban list.
2026-10-02 18:42:16 +02:00
mesh-admin 419d92e810 Merge pull request 'The proxy's jail reads a refused name as well as a refused certificate (hq ADR 0179)' (#226) from fix/the-proxys-jail-reads-both-refusals into main 2026-10-02 15:23:48 +00:00
jschoubben 23112b111c The proxy's jail reads both refusals, each pattern naming the host once
fail2ban expands <HOST> to a named group, so two in one pattern is a duplicate group name and
the daemon refuses to start at all -- every jail on the machine, not just this one. Two patterns,
one <HOST> each: the certificate refused for an unserved name, and the request refused for one.
Caught live on the control node (hq ADR 0179).
2026-10-02 17:23:42 +02:00
jschoubben b547308e05 The proxy's jail reads a refused name as well as a refused certificate
The pattern ended at the line's end, which only the certificate refusal does; a request for
an unserved name carries trailing text and never matched. Caught against the live lines
before the jail counted anything (hq ADR 0179).
2026-10-02 17:20:54 +02:00
mesh-admin 3c3c5c6e03 Merge pull request 'fail2ban holds the intrusion seat's verbs and composes the jails; mail, forge and proxy declare theirs (hq ADR 0179, to-be 31)' (#225) from feat/the-intrusion-seat-serves-its-verbs into main 2026-10-02 15:19:20 +00:00
jschoubben 1601d5a335 fail2ban holds the intrusion seat's verbs and composes the jails; mail, forge and proxy declare theirs (hq ADR 0179, to-be 31)
The module gains a runtime carrying only the fail2ban client with the daemon's socket shared in,
serving status/banned/ban/unban and its own fail2ban_settings. It declares jailing, so the
controller's composition lands in jail.d/mesh.conf and filter.d; mailu, route-proxy and gitea log to
the journal and declare a jail reading it by container name. The base is strict: three in a day for
a day, twice banned in two weeks for four; the mesh's range stays never banned.
2026-10-02 17:02:49 +02:00
mesh-admin 96b3d60a4a Merge pull request 'nftables declares the ufw front end absent once its filter is loaded (hq ADR 0175)' (#223) from feat/the-found-front-end-is-uninstalled into main 2026-10-02 14:38:00 +00:00
jschoubben 3dfbad6f03 nftables declares the ufw front end absent once its filter is loaded (hq ADR 0175) 2026-10-02 16:27:34 +02:00
mesh-admin d5c5415756 Merge pull request 'lab: the image carries python3, file, iproute2 and sudo' (#222) from jschoubben/lab-image-tools into main 2026-10-02 13:24:00 +00:00
jschoubben 6dfd2401c9 lab: the image carries what the builds and the lab call: python3, file, iproute2, sudo 2026-10-02 15:23:52 +02:00
mesh-admin 31923f70e7 Merge pull request 'lab: a run resolves the @novox scope from the forge's package registry' (#221) from jschoubben/lab-npm-scope into main 2026-10-02 13:13:29 +00:00
jschoubben 8cd4f199f1 lab: a run resolves the @novox scope from the forge's package registry 2026-10-02 15:13:22 +02:00
mesh-admin 1b9b298827 Merge pull request 'lab: compile from the module's root, so the runtime finds its tools' (#220) from jschoubben/lab-tools-path into main 2026-10-02 13:08:14 +00:00
jschoubben bce7b3a551 lab: compile from the module's root, so the runtime finds its tools
Both sources sit in tools/, so tsc took tools/ as the root and wrote
dist/index.js, while the runtime loads dist/tools/index.js: the module
started and served nothing.
2026-10-02 15:08:01 +02:00
mesh-admin 17d3d3e63a Merge pull request 'lab: declares the virtualisation capability (hq ADR 0172)' (#218) from jschoubben/the-lab-is-a-module-2 into main 2026-10-02 12:53:43 +00:00
mesh-admin 5c961c446f Merge pull request 'Cite hq ADR 0170, not 0169: the firewall seat's record was renumbered' (#219) from fix/adr-0170-cited into main 2026-10-02 12:53:10 +00:00
jschoubben b77582f6a6 Cite hq ADR 0170, not 0169: the firewall seat's record was renumbered after a collision on hq main 2026-10-02 14:52:26 +02:00
jschoubben b3865d240f lab: declares the virtualisation capability, which grants its daemon's socket 2026-10-02 14:48:10 +02:00
mesh-admin a81b94d4ab Merge pull request 'lab: the lab as a module, running beds when the mesh asks (hq ADR 0172)' (#217) from jschoubben/the-lab-is-a-module into main 2026-10-02 12:17:14 +00:00
jschoubben 67d1a400e8 lab: the lab as a module, running beds when the mesh asks
Five tools on the machine the lab runs on: check, run beds against
branches on the forge, a run's status, its log, and stop. A run checks
out every repository the lab builds, side by side, and runs the suite;
one at a time, answered at once with an id (novox/hq ADR 0172).
2026-10-02 14:15:42 +02:00
mesh-admin 1c201d59c9 Merge pull request 'nftables holds the node-packet-filter seat: rules, reload and remove, from a runtime with NET_ADMIN (hq ADR 0169)' (#216) from feat/the-firewall-seat-serves-its-verbs into main 2026-10-02 11:33:47 +00:00
25 changed files with 868 additions and 407 deletions
+23
View File
@@ -0,0 +1,23 @@
# fail2ban's runtime: the tool runtime, carrying the intrusion prevention's verbs and the client they
# speak through.
#
# Built from this module's own directory and nothing else (novox/hq ADR 0069). Two bases, named in
# module.json's `build.on`: the image this is compiled in and the image it runs in.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/fail2ban
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# The daemon runs on the machine, declared by this module; what runs here is only its client, which
# speaks to the daemon over the socket the machine shares into this container (novox/hq ADR 0179).
# The package brings the client and the daemon together; the daemon is never started here.
RUN apt-get update \
&& apt-get install -y --no-install-recommends fail2ban \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/fail2ban/dist /app/modules/fail2ban/dist
ENV MESH_TOOL_MODULES=/app/modules/fail2ban/dist/tools/index.js
+188 -35
View File
@@ -1,51 +1,204 @@
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails and the daemon are declared // fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from
// resources — the mesh writes /etc/fail2ban/jail.d/* and keeps fail2ban.service running (see // the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept
// module.json). This code exists only to read and steer the *live* state the daemon owns at // running by one. This code exists only to read and steer the *live* state the daemon owns: who is
// runtime: which IPs are banned right now, and the manual ban/unban an operator reaches for. That // banned now and until when, and the ban or release an operator asks for — the node-intrusion-
// state (the running bans, /var/lib/fail2ban's sqlite) is fail2ban's, not the mesh's — the mesh // prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
// reconciles the config, never the ban list. // mesh composes the jails and never writes the ban list.
//
// Spoken through fail2ban-client over the daemon's socket, which the machine shares into this
// runtime; so the client here is the one from the runtime's own package and the daemon is the
// machine's, and the two meet at /var/run/fail2ban/fail2ban.sock.
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { isIP } from "node:net";
import { promisify } from "node:util"; import { promisify } from "node:util";
const run = promisify(execFile); 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>;
export const execRunner: Runner = async (cmd, args) => {
try {
const { stdout } = await execFileP(cmd, args, { 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();
if (e.code === "ENOENT") throw new Error(`${cmd} is not in this runtime`);
if (/Failed to access socket path|Is fail2ban running/i.test(said)) {
throw new Error("fail2ban is not running on this machine, or its socket is not shared with this runtime");
}
// 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 { export class Fail2banClient {
private readonly run: Runner;
constructor(run: Runner = execRunner) {
this.run = run;
}
static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient { static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient {
return new Fail2banClient(); return new Fail2banClient();
} }
/** Overview of every jail, or the detailed status of one — currently-banned IPs and totals. */ private client(...args: string[]): Promise<string> {
async status(jail?: 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) { if (jail) {
const { stdout } = await run("sudo", ["fail2ban-client", "status", jail]); name(jail);
return stdout; out = await this.client("set", jail, "unbanip", ip);
} else {
out = await this.client("unban", ip);
} }
const { stdout: overview } = await run("sudo", ["fail2ban-client", "status"]); return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" };
const match = overview.match(/Jail list:\s*(.+)/);
if (!match) return overview;
const jails = match[1].split(",").map((j) => j.trim()).filter(Boolean);
const parts: string[] = [overview.trimEnd(), ""];
for (const j of jails) {
const { stdout } = await run("sudo", ["fail2ban-client", "status", j]);
parts.push(`=== ${j} ===`, stdout.trimEnd(), "");
}
return parts.join("\n");
} }
/** Manually ban an IP in a jail. Mutates live state, not a mesh-managed file. */ /** One jail's effective settings — the module's own tool, beside the seat's verbs. */
async ban(jail: string, ip: string): Promise<string> { async settings(jail: string): Promise<JailSettings> {
const { stdout } = await run("sudo", ["fail2ban-client", "set", jail, "banip", ip]); name(jail);
return stdout; 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"),
/** Unban an IP from one jail, or from every jail when no jail is given. */ get("journalmatch"),
async unban(ip: string, jail?: string): Promise<string> { ]);
const args = jail return {
? ["fail2ban-client", "set", jail, "unbanip", ip] jail,
: ["fail2ban-client", "unban", ip]; bantime: bantime.trim(),
const { stdout } = await run("sudo", args); findtime: findtime.trim(),
return stdout; 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`);
}
+76 -6
View File
@@ -7,9 +7,25 @@
"claims": [ "claims": [
{ {
"name": "node-intrusion-prevention", "name": "node-intrusion-prevention",
"scope": "node" "scope": "node",
"serves": [
"status",
"banned",
"ban",
"unban"
]
} }
], ],
"tools": [
"fail2ban_settings"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"jailing": {
"into": "/etc/fail2ban/jail.d/mesh.conf",
"filter-into": "/etc/fail2ban/filter.d"
},
"resources": [ "resources": [
{ {
"id": "package", "id": "package",
@@ -28,19 +44,37 @@
"path": "/etc/fail2ban/action.d", "path": "/etc/fail2ban/action.d",
"mode": "0755" "mode": "0755"
}, },
{
"id": "filter-d",
"type": "directory",
"path": "/etc/fail2ban/filter.d",
"mode": "0755"
},
{
"id": "run-dir",
"type": "directory",
"path": "/var/run/fail2ban",
"mode": "0755"
},
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "jail-local", "id": "jail-local",
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.local", "path": "/etc/fail2ban/jail.local",
"mode": "0644", "mode": "0644",
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\n# **A ban list never holds a neighbour.** The mesh's own range is named rather than written\n# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own\n# network, not the internet. On a machine behind a router that reflects local traffic, every\n# client in the house arrives as the gateway's address — so one mistyped local request banned\n# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
}, },
{ {
"id": "jail-sshd", "id": "jail-sshd",
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.d/sshd.conf", "path": "/etc/fail2ban/jail.d/sshd.conf",
"mode": "0644", "mode": "0644",
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n" "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d\n"
}, },
{ {
"id": "log", "id": "log",
@@ -55,7 +89,7 @@
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.d/recidive.conf", "path": "/etc/fail2ban/jail.d/recidive.conf",
"mode": "0644", "mode": "0644",
"content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\nbantime = 1w\nfindtime = 1d\n" "content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\n# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).\nbantime = 4w\nfindtime = 2w\nmaxretry = 2\n"
}, },
{ {
"id": "action-dualchain", "id": "action-dualchain",
@@ -81,8 +115,44 @@
"jail-local", "jail-local",
"jail-sshd", "jail-sshd",
"jail-recidive", "jail-recidive",
"action-dualchain" "action-dualchain",
"composed-jails"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-fail2ban",
"artifact": "runtime",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/run/fail2ban:/var/run/fail2ban"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker"
}
} }
] ],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
} }
+6 -2
View File
@@ -1,14 +1,18 @@
{ {
"name": "@novox/module-fail2ban", "name": "@novox/module-fail2ban",
"version": "0.1.0", "version": "0.1.0",
"description": "fail2ban — intrusion prevention: the mesh declares the jails and keeps the daemon running; its ban/unban/status tools live here.", "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", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"typescript": "^5.6.0" "typescript": "^5.6.0"
},
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
} }
} }
+106
View File
@@ -0,0 +1,106 @@
// 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, 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",
});
});
+45 -38
View File
@@ -1,55 +1,62 @@
// fail2ban's tools — reading and steering the live ban state. The jails themselves are declared // The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned,
// resources (module.json); these three touch what the running daemon holds: what is banned now, // the jails' state, ban one, let one go — and the module's own reading of a jail's settings
// and the manual ban/unban an operator reaches for. The daemon's state is fail2ban's own, so this // (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a
// is the only way to see or change it — the mesh reconciles the config, not the bans. // 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 { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { Fail2banClient } from "../client.js"; import { Fail2banClient } from "../client.js";
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] { export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] {
return [ return [
{ {
name: "fail2ban_status", name: "status",
description: description:
"fail2ban status on this node — the jails and their live bans. Omit `jail` for every jail, or name one for its detail.", "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: { input: { jail: { type: "string", description: "one jail (optional)" } },
type: "object", run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined),
properties: {
jail: {
type: "string",
description: "A specific jail (e.g. sshd, recidive); omit for the overview of all jails.",
},
},
},
run: async (args) => ({ status: await fail2ban.status(args.jail as string | undefined) }),
}, },
{ {
name: "fail2ban_ban", name: "banned",
description: "Manually ban an IP address in a jail — a live change to the running daemon, not a mesh-managed file.", 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: { input: { jail: { type: "string", description: "one jail (optional)" } },
type: "object", run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined),
properties: {
jail: { type: "string", description: "Jail name (e.g. sshd, recidive)." },
ip: { type: "string", description: "IP address to ban." },
},
required: ["jail", "ip"],
},
run: async (args) => ({ result: await fail2ban.ban(args.jail as string, args.ip as string) }),
}, },
{ {
name: "fail2ban_unban", name: "ban",
description: "Unban an IP address from one jail, or from every jail when `jail` is omitted.", 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: { input: {
type: "object", ip: { type: "string", description: "the address" },
properties: { jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" },
ip: { type: "string", description: "IP address to unban." },
jail: { type: "string", description: "A specific jail; omit to unban from all jails." },
},
required: ["ip"],
}, },
run: async (args) => ({ result: await fail2ban.unban(args.ip as string, args.jail as string | undefined) }), 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),
}, },
]; ];
} }
registerModuleTools("fail2ban", () => getFail2banTools(Fail2banClient.fromEnv())); 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.fromEnv();
// 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));
+10 -2
View File
@@ -145,7 +145,8 @@
"volumes": [ "volumes": [
"${dir:data}:/data" "${dir:data}:/data"
], ],
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it" "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it",
"logging": "journald"
}, },
{ {
"id": "admin-bootstrap", "id": "admin-bootstrap",
@@ -237,5 +238,12 @@
"from": "Dockerfile" "from": "Dockerfile"
} }
] ]
} },
"jails": [
{
"name": "gitea",
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
} }
+31
View File
@@ -0,0 +1,31 @@
# lab's runtime: the tool runtime, carrying this module's code, and the toolchain the lab's suite
# builds the mesh with (novox/hq ADR 0172). It reaches the machine's virtualisation and container
# runtime through their sockets, so what it raises is what a hand run on this machine raises.
#
# Every download is pinned by its checksum: an image that builds the mesh is the last place to take
# whatever an upstream serves today.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/lab
COPY . .
RUN node /app/node_modules/typescript/bin/tsc tools/index.ts tools/runs.ts --rootDir . \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
RUN apt-get update \
&& apt-get install -y --no-install-recommends git make ca-certificates curl python3 file iproute2 sudo \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /tmp/go.tgz https://go.dev/dl/go1.26.8.linux-amd64.tar.gz \
&& echo "d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b /tmp/go.tgz" | sha256sum -c - \
&& tar -C /usr/local -xzf /tmp/go.tgz && rm /tmp/go.tgz
RUN curl -fsSL -o /usr/local/bin/incus https://github.com/lxc/incus/releases/download/v7.5.1/bin.linux.incus.x86_64 \
&& echo "7bd6223b369f4d693fcde695bd8549a73b5b3d403735329212483702aa22c179 /usr/local/bin/incus" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/incus
RUN curl -fsSL -o /tmp/docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-28.5.2.tgz \
&& echo "ea90cfd12e1eeb12aa1c971741adb8bd4ed88e2a574eaac13f5029a1dbc6300d /tmp/docker.tgz" | sha256sum -c - \
&& tar -C /tmp -xzf /tmp/docker.tgz docker/docker && mv /tmp/docker/docker /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz
ENV PATH=/usr/local/go/bin:$PATH
COPY --from=build /app/modules/lab/dist /app/modules/lab/dist
ENV MESH_TOOL_MODULES=/app/modules/lab/dist/tools/index.js
+82
View File
@@ -0,0 +1,82 @@
{
"module": "lab",
"version": "1",
"capabilities": [
"container-runtime",
"virtualisation"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "work",
"type": "directory",
"path": "/var/lib/mesh-lab-runs",
"mode": "0700"
},
{
"id": "runtime-env",
"type": "file",
"path": "${dir:state}/lab.env",
"mode": "0600",
"content": "MESH_LAB_FORGE=${setting:forge}\n"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-lab",
"network": "host",
"env-file": [
"${dir:state}/lab.env"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:work}:${dir:work}",
"/var/run/docker.sock:/var/run/docker.sock",
"/var/lib/incus/unix.socket:/var/lib/incus/unix.socket"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LAB_WORK": "${dir:work}"
},
"restart-on": [
"runtime-env"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@novox/module-lab",
"version": "0.1.0",
"description": "lab — the lab, as a module: runs beds against the forge's branches when the mesh asks (novox/hq ADR 0172).",
"type": "module",
"private": true,
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
+86
View File
@@ -0,0 +1,86 @@
// lab's tools — the lab, as the mesh asks for it (novox/hq ADR 0172). They run on the machine the
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
import { spawnSync } from "node:child_process";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
const forge = (env.MESH_LAB_FORGE ?? "").replace(/\/+$/, "");
return [
{
name: "lab_check",
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
input: {},
run: async () => {
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const dir = `${work}/check`;
spawnSync("rm", ["-rf", dir]);
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() };
},
},
{
name: "lab_run",
description:
"Run the lab's beds against branches on the forge: fresh checkouts of every repository the lab builds, " +
"side by side, then the suite on the named test files. Answers at once with the run's id; lab_status " +
"and lab_log follow it. One run at a time.",
input: {
tests: { type: "string", description: "the bed test files, comma-separated, relative to mesh-lab (e.g. test/integration/mesh.test.ts)" },
refs: {
type: "string",
description: `a JSON object of repository to branch, for any of ${REPOSITORIES.join(", ")}; the rest run main`,
},
},
run: async (args) => {
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
let refs: Record<string, string> = {};
if (args.refs) {
try {
refs = JSON.parse(String(args.refs)) as Record<string, string>;
} catch {
return { started: false, reason: "refs is not a JSON object of repository to branch" };
}
}
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
return { started: true, run: start(work, forge, tests, refs) };
},
},
{
name: "lab_status",
description: "A run's state, the commits it tested and how it ended — or every run, newest first, when no id is given.",
input: { id: { type: "string", description: "the run's id (optional)" } },
run: async (args) => {
if (args.id) return readStatus(work, String(args.id)) ?? { found: false, id: String(args.id) };
return { runs: listRuns(work).slice(0, 10) };
},
},
{
name: "lab_log",
description: "The last lines of a run's log.",
input: {
id: { type: "string", description: "the run's id" },
lines: { type: "number", description: "how many lines from the end (default 200)" },
},
run: async (args) => ({ id: String(args.id), log: tail(work, String(args.id), Number(args.lines ?? 200)) }),
},
{
name: "lab_stop",
description: "Stop a run and everything it started.",
input: { id: { type: "string", description: "the run's id" } },
run: async (args) => stop(work, String(args.id)) ?? { found: false, id: String(args.id) },
},
];
}
registerModuleTools("lab", (env) => getLabTools(env));
+175
View File
@@ -0,0 +1,175 @@
// A lab run: fresh checkouts of the named branches, side by side, then the lab's suite on the named
// beds (novox/hq ADR 0172).
//
// **A run is a detached script with its own process group**, so it outlives the tool call that started
// it and `stop` ends everything it started. It writes what it is doing to a status file beside its log,
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
// for it, and says it was lost rather than pretending it is still going.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** The repositories a lab run checks out, side by side, as the lab expects its siblings. */
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-sdk"];
export interface RunStatus {
id: string;
state: "checking-out" | "building" | "running" | "passed" | "failed" | "stopped" | "lost";
started: string;
ended?: string;
tests: string[];
refs: Record<string, string>;
commits?: Record<string, string>;
exit?: number;
pid?: number;
}
export function runDir(work: string, id: string): string {
return join(work, id);
}
function statusPath(work: string, id: string): string {
return join(runDir(work, id), "status.json");
}
export function readStatus(work: string, id: string): RunStatus | undefined {
try {
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
// A run whose process is gone while its status still says it is going was lost — the runtime or
// the machine restarted under it. Said, rather than left reading as running for ever.
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
s.state = "lost";
}
return s;
} catch {
return undefined;
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function listRuns(work: string): RunStatus[] {
if (!existsSync(work)) return [];
return readdirSync(work)
.filter((d) => d.startsWith("run-"))
.map((id) => readStatus(work, id))
.filter((s): s is RunStatus => !!s)
.sort((a, b) => b.started.localeCompare(a.started));
}
/** The run still going, if any: one at a time, because two would contend for the same machine. */
export function running(work: string): RunStatus | undefined {
return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state));
}
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
/**
* The script one run executes. Every step writes its state first, so a run that dies says where.
*
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
* at this run's own tree, so what is built and claimed is exactly what was checked out.
*/
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
`const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=${JSON.stringify(state)};require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)}`;
const clones = REPOSITORIES.map((repo) => {
const ref = refs[repo] ?? "main";
return [
`git clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
].join("\n");
}).join("\n");
const bin = join(dir, "bin");
return `set -euo pipefail
cd ${shellQuote(dir)}
${setState("checking-out")}
${clones}
node -e ${shellQuote(
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
)}
${setState("building")}
# The @novox scope resolves from the mesh's own package registry on the forge, as the build machine
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done
(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present)
(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present)
mkdir -p ${shellQuote(bin)}
for p in postgres-provisioner objectstore-provisioner route-proxy; do
(cd ${shellQuote(dir)}/mesh-controller && CGO_ENABLED=0 go build -o ${shellQuote(bin)}/$p ./examples/$p)
done
export MESH_LAB_HOST_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-host"))}
export MESH_LAB_BUNDLE=${shellQuote(join(dir, "mesh-host", "examples", "foundation-first-node-nats.lock"))}
export MESH_LAB_MODULES=${shellQuote(join(dir, "mesh-controller", "examples", "modules"))}
export MESH_LAB_BUILDER=${shellQuote(join(dir, "mesh-controller", "build", "mesh-builder"))}
export MESH_LAB_BOOTSTRAP_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-bootstrap"))}
export MESH_LAB_CATALOG=${shellQuote(join(dir, "mesh-catalog", "modules"))}
export MESH_LAB_PROVISIONER=${shellQuote(join(bin, "postgres-provisioner"))}
export MESH_LAB_OBJECTSTORE_PROVISIONER=${shellQuote(join(bin, "objectstore-provisioner"))}
export MESH_LAB_ROUTE_PROXY=${shellQuote(join(bin, "route-proxy"))}
${setState("running")}
cd ${shellQuote(join(dir, "mesh-lab"))}
node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join(" ")}
`;
}
/** start begins a run and returns at once with its status. */
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>): RunStatus {
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = runDir(work, id);
mkdirSync(dir, { recursive: true });
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { mode: 0o700 });
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
const wrapper = `bash ${shellQuote(join(dir, "run.sh"))} > ${shellQuote(join(dir, "run.log"))} 2>&1; code=$?
node -e ${shellQuote(
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)} "$code"
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
child.unref();
status.pid = child.pid;
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
return status;
}
/** stop ends a run and everything it started, by its process group. */
export function stop(work: string, id: string): RunStatus | undefined {
const s = readStatus(work, id);
if (!s || !s.pid) return s;
if (["passed", "failed", "stopped", "lost"].includes(s.state)) return s;
s.state = "stopped";
writeFileSync(statusPath(work, id), JSON.stringify(s, null, 2));
try {
process.kill(-s.pid, "SIGTERM");
} catch {
// Already gone between the read and the kill.
}
return s;
}
/** tail is the last lines of a run's log. */
export function tail(work: string, id: string, lines: number): string {
try {
const all = readFileSync(join(runDir(work, id), "run.log"), "utf8").split("\n");
return all.slice(-Math.max(1, lines)).join("\n");
} catch {
return "";
}
}
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "tools/index.ts"] "include": ["tools/index.ts", "tools/runs.ts"]
} }
+10 -2
View File
@@ -462,7 +462,8 @@
], ],
"dns": [ "dns": [
"192.168.203.254" "192.168.203.254"
] ],
"logging": "journald"
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
@@ -561,5 +562,12 @@
}, },
"grants": { "grants": {
"smtp": "${dir:grants}" "smtp": "${dir:grants}"
} },
"jails": [
{
"name": "mailu-front",
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
} }
+1 -1
View File
@@ -14,7 +14,7 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
# The filter's own tools: nft for the machine's ruleset and the mesh's table, iptables for the # The filter's own tools: nft for the machine's ruleset and the mesh's table, iptables for the
# legacy filter and the tables iptables-nft manages — a predecessor's rules live there (ADR 0168). # legacy filter and the tables iptables-nft manages — a predecessor's rules live there (ADR 0168).
# The container runs on the machine's network with NET_ADMIN (ADR 0169), so these act on the # The container runs on the machine's network with NET_ADMIN (ADR 0170), so these act on the
# machine's packet filter, not on a namespace of their own. # machine's packet filter, not on a namespace of their own.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends nftables iptables \ && apt-get install -y --no-install-recommends nftables iptables \
+1 -1
View File
@@ -2,7 +2,7 @@
// rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module // rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module
// loads it through its own unit. This code reads the filter back as the machine enforces it, reloads // loads it through its own unit. This code reads the filter back as the machine enforces it, reloads
// the mesh's own table, and removes one thing the mesh did not write when the operator names it // the mesh's own table, and removes one thing the mesh did not write when the operator names it
// (ADR 0168, ADR 0169) — the seat's three verbs, over the machine's own tools. // (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools.
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
+6
View File
@@ -59,6 +59,12 @@
"filtering" "filtering"
] ]
}, },
{
"id": "front-end",
"type": "package",
"package": "ufw",
"absent": true
},
{ {
"id": "runtime", "id": "runtime",
"type": "container", "type": "container",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@novox/module-nftables", "name": "@novox/module-nftables",
"version": "0.1.0", "version": "0.1.0",
"description": "nftables — loads the mesh's packet filter and holds the node-packet-filter seat: its verbs rules, reload and remove (novox/hq ADR 0045, ADR 0169).", "description": "nftables — loads the mesh's packet filter and holds the node-packet-filter seat: its verbs rules, reload and remove (novox/hq ADR 0045, ADR 0170).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
// The packet filter's tools: the node-packet-filter seat's three verbs — what the machine enforces, // The packet filter's tools: the node-packet-filter seat's three verbs — what the machine enforces,
// reload the mesh's own, remove one thing the mesh did not write — and the module's own reading of // reload the mesh's own, remove one thing the mesh did not write — and the module's own reading of
// the mesh's table (novox/hq ADR 0045, ADR 0168, ADR 0169). // the mesh's table (novox/hq ADR 0045, ADR 0168, ADR 0170).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { FirewallClient } from "../client.js"; import { FirewallClient } from "../client.js";
-30
View File
@@ -1,30 +0,0 @@
# portainer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/portainer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/portainer/dist /app/modules/portainer/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/portainer/dist/tools/index.js
-107
View File
@@ -1,107 +0,0 @@
// The Portainer API client — portainer's own code, living in the module (novox/hq ADR 0039).
// portainer is tools-only: its "events" would really be the underlying containers' lifecycle,
// which the host owns and emits — so this module reads Portainer's own resources (endpoints,
// stacks, containers) and exposes them, and stops there.
import { readFileSync } from "node:fs";
export interface PortainerEndpoint {
id: number;
name: string;
type: number;
url: string;
status: number;
}
export interface PortainerStack {
id: number;
name: string;
type: number;
endpointId: number;
status: number;
}
export interface PortainerContainer {
id: string;
names: string[];
image: string;
state: string;
status: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class PortainerClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly token: string,
) {
this.baseUrl = url.replace(/\/+$/, "");
}
/**
* Build from the module's resolved environment. The URL is MESH_PORTAINER_URL (or the local
* dashboard port) and the API token is MESH_PORTAINER_TOKEN — an access token minted in
* Portainer, sent as X-API-Key. Throws when no token is configured, so a misconfigured module
* exposes nothing rather than calling Portainer unauthenticated.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient {
const cfg = meshConfig(env.MESH_PORTAINER_CONFIG_FILE);
const url = cfg.url ?? env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`;
const token = cfg.token ?? env.MESH_PORTAINER_TOKEN;
if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN");
return new PortainerClient(url, token);
}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, { headers: { "X-API-Key": this.token } });
if (!res.ok) throw new Error(`Portainer ${path}: ${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
/** The environments (endpoints) Portainer manages — each a Docker host or cluster it talks to. */
async listEndpoints(): Promise<PortainerEndpoint[]> {
const raw = await this.get<any[]>("/api/endpoints");
return (raw ?? []).map((e) => ({
id: e.Id,
name: e.Name,
type: e.Type,
url: e.URL,
status: e.Status,
}));
}
/** The stacks (compose/swarm deployments) Portainer knows about. */
async listStacks(): Promise<PortainerStack[]> {
const raw = await this.get<any[]>("/api/stacks");
return (raw ?? []).map((s) => ({
id: s.Id,
name: s.Name,
type: s.Type,
endpointId: s.EndpointId,
status: s.Status,
}));
}
/**
* The containers on one endpoint, read through Portainer's Docker API proxy. Includes stopped
* containers, so the caller sees the whole picture rather than only what is running.
*/
async listContainers(endpointId: number): Promise<PortainerContainer[]> {
const raw = await this.get<any[]>(`/api/endpoints/${endpointId}/docker/containers/json?all=1`);
return (raw ?? []).map((c) => ({
id: c.Id,
names: c.Names ?? [],
image: c.Image,
state: c.State,
status: c.Status,
}));
}
}
-114
View File
@@ -1,114 +0,0 @@
{
"module": "portainer",
"version": "1",
"slug": "portain",
"capabilities": [
"container-runtime"
],
"listens": [
{
"name": "web",
"port": 9000,
"protocol": "tcp",
"from": "mesh",
"why": "the dashboard over http; its public name is a route grant and the proxy reaches it here"
},
{
"name": "web-tls",
"port": 9443,
"protocol": "tcp",
"from": "mesh",
"why": "the same dashboard over its own tls; the runtime sidecar talks to it here"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "data",
"type": "directory",
"mode": "0700"
},
{
"id": "server",
"type": "container",
"name": "portainer",
"image": "portainer/portainer-ce@sha256:4d616db18cfeb5dd41a69c0958bc825c84483ea9cde1106eb82a5d26f3bd8b0e",
"ports": [
"9000",
"9443"
],
"volumes": [
"${dir:data}:/data",
"/var/run/docker.sock:/var/run/docker.sock"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-portainer",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_PORTAINER_URL": "https://127.0.0.1:9443",
"MESH_PORTAINER_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
},
"requires": [
"route"
],
"contributes": {
"route": {
"label": "portainer",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:mesh-state}/route.json"
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-portainer",
"version": "0.1.0",
"description": "portainer — container management UI. Its API client and tools live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-50
View File
@@ -1,50 +0,0 @@
// portainer's tools — its own code (novox/hq ADR 0039), importing portainer's own client. They
// return structured data; the mesh serves them through the sdk's tool harness. portainer is
// tools-only (no events entrypoint): a container starting or stopping is the host's signal to emit,
// not Portainer's to re-announce.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { PortainerClient } from "../client.js";
export function getPortainerTools(portainer: PortainerClient): ToolDefinition[] {
return [
{
name: "portainer_endpoints",
description: "List the environments (endpoints) Portainer manages — each a Docker host or cluster.",
input: {},
run: async () => {
const endpoints = await portainer.listEndpoints();
return { count: endpoints.length, endpoints };
},
},
{
name: "portainer_stacks",
description: "List the stacks (compose/swarm deployments) Portainer knows about.",
input: {},
run: async () => {
const stacks = await portainer.listStacks();
return { count: stacks.length, stacks };
},
},
{
name: "portainer_containers",
description: "List the containers on one Portainer endpoint, including stopped ones.",
input: { endpoint: { type: "number", description: "the endpoint id (see portainer_endpoints)" } },
run: async (args) => {
const endpointId = Number(args.endpoint);
const containers = await portainer.listContainers(endpointId);
return { endpointId, count: containers.length, containers };
},
},
];
}
// The tools exist only when a token is configured; without one, portainer contributes none rather
// than failing the whole runtime.
registerModuleTools("portainer", (env) => {
try {
return getPortainerTools(PortainerClient.fromEnv(env));
} catch {
return [];
}
});
+10 -2
View File
@@ -163,7 +163,8 @@
"acme-env", "acme-env",
"internal-trust", "internal-trust",
"internal-acme-env" "internal-acme-env"
] ],
"logging": "journald"
} }
], ],
"build": { "build": {
@@ -194,5 +195,12 @@
"image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" "image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"
} }
] ]
} },
"jails": [
{
"name": "route-proxy",
"failregex": "^.*TLS handshake error from <HOST>:\\d+: (?:no public route for|acme/autocert: missing server name)\n ^.*refused: no route for .*, asked from <HOST>:\\d+$",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=route-proxy\nport = http,https\nmaxretry = 10\nfindtime = 1d\nbantime = 1d"
}
]
} }