Compare commits

..
Author SHA1 Message Date
jschoubben 4991c09c1f Convert the resolver from the module it replaces: forward, answer at 127.0.0.1, point the runtime at it
Read against hal/modules/dnsmasq-app (hal dnsmasq-app conversion, hq 08-connectivity).
On the machines it runs, the predecessor's dnsmasq answers every name: the mesh's own
itself, the rest forwarded to 1.1.1.1 and 8.8.8.8, its module's defaults; resolv.conf
names it alone at 127.0.0.1, and the container runtime's dns is the machine's tunnel
address, so the host and every container resolve the world through it. The nox module
forwarded nothing, listened on 127.0.0.55 — a convention of its own beside the one every
machine already followed — and read a machines file that the module's `facts` already
asks the mesh for, so taking it would have left an adopted machine with a resolv.conf
pointing at an address nothing answered on, and no upstream for anything else.

Now the resolver keeps `no-resolv` (the documented loop — finding its own address in
resolv.conf and becoming its own upstream — stays impossible) and forwards to the same
two explicit upstreams; listens on mesh0 and 127.0.0.1, which systemd-resolved does not
hold; requires `mesh-addressing`, since its data is the mesh's addresses; and writes the
runtime's `dns` into daemon.json beside whatever the machine had (ADR 0102), at this
machine's own address — `${machine:address}`, new in the controller. The runtime is not
restarted for it: it reads the key at start, not on reload, and a restart stops every
container; on the machine this replaces the value is already there.

resolv-conf names the resolver alone, as the predecessor's file did; its placeholder second
line was a fallback nothing ever reached. resolved-split-dns follows the address. The
mesh's suffix as a local domain comes with the machines file, so a mesh name the resolver
does not know is refused here rather than asked upstream. mDNS is not carried: no module
does it and the design says mesh names are not multicast names.
2026-09-23 23:53:51 +02:00
180 changed files with 1942 additions and 4685 deletions
+56
View File
@@ -0,0 +1,56 @@
{
"module": "amqp-email-forwarder",
"version": "1",
"slug": "emailfwd",
"capabilities": [
"container-runtime"
],
"requires": [
"amqp"
],
"contributes": {},
"binds": {
"amqp": "/var/lib/amqp-email-forwarder/amqp.json"
},
"secrets": {
"amqp": "/var/lib/amqp-email-forwarder/amqp.secret"
},
"own-secrets": {
"smtp-user": "/var/lib/amqp-email-forwarder/smtp-user.secret",
"smtp-password": "/var/lib/amqp-email-forwarder/smtp-password.secret"
},
"resources": [
{
"id": "state",
"type": "directory",
"path": "/var/lib/amqp-email-forwarder",
"mode": "0700"
},
{
"id": "app-env",
"type": "file",
"path": "/var/lib/amqp-email-forwarder/app.env",
"mode": "0600",
"content": "AMQP_HOST=${bound:amqp:at}\nAMQP_PORT=${bound:amqp:port}\nAMQP_USER=${bound:amqp:as}\nAMQP_VHOST=EMAILDELIVERY_T\nAMQP_EXCHANGE=News.TransactionalEmailing.Command\nAMQP_QUEUE=email-forwarder\nAMQP_URL=amqp://${bound:amqp:as}:${secret:amqp}@${bound:amqp:at}:${bound:amqp:port}/EMAILDELIVERY_T\nSMTP_HOST=mail.novox.be\nSMTP_PORT=587\nSMTP_USER=${secret:smtp-user}\nSMTP_PASSWORD=${secret:smtp-password}\n"
},
{
"id": "net",
"type": "network",
"name": "amqp-email-forwarder"
},
{
"id": "app",
"type": "container",
"name": "amqp-email-forwarder",
"image": "registry-api.novox.be/novox/amqp-email-forwarder@sha256:f76d34646d9d3b2098c72688a63f6ae656f1888ffcb2f90a9c8dd2a44ad7f8af",
"network": "amqp-email-forwarder",
"env-file": [
"/var/lib/amqp-email-forwarder/app.env"
],
"restart-on": [
"app-env"
],
"secrets-in-environment": "the application's own code reads AMQP_URL, SMTP_USER and SMTP_PASSWORD from the environment (amqp-email-forwarder app.js); converting is that repository's change"
}
]
}
+31
View File
@@ -0,0 +1,31 @@
# amqp-ping'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 not
# copied out of neighbouring checkouts — they are in the base image, which is published like any
# other artifact. That 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: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules, so resolving `@novox/mesh-sdk` walks up to the base's own
# node_modules — the module is compiled against exactly the sdk it will run against.
WORKDIR /app/modules/amqp-ping
COPY . .
# The compiler is invoked by its real path, not through node_modules/.bin. Those are symlinks to
# a launcher that requires its library relatively, and the base image resolves them when copying —
# leaving a launcher whose relative require no longer points at anything.
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/amqp-ping/dist /app/modules/amqp-ping/dist
# Declared rather than derived from which files happen to exist: the module knows what it serves.
ENV MESH_TOOL_MODULES=/app/modules/amqp-ping/dist/index.js
+191
View File
@@ -0,0 +1,191 @@
// amqp-ping's AMQP client — the demo consumer's own code (novox/hq ADR 0039). It speaks AMQP 0-9-1
// directly over a raw TCP socket (node:net), the way redis's client speaks RESP: the module carries
// NO npm dependency beyond @novox/mesh-sdk — no amqplib, no CLI in the image. It does exactly one
// thing, the round-trip that proves the grant works: connect, authenticate with PLAIN to the vhost
// the mesh named, declare a queue, publish one message and get it back.
//
// This is the consumer half of the `amqp` interface. It connects as the login the mesh derived
// (`${bound:amqp:as}`) with the password the mesh minted (`${secret:amqp}`) to a vhost of that SAME
// name — the provider named the vhost after the login, so the consumer must too. Nothing here is
// hardcoded: user AND vhost are both the bound login, and a wrong vhost is refused by the broker.
import { createConnection, type Socket } from "node:net";
import { readFileSync } from "node:fs";
const FRAME_END = 0xce;
const PROTOCOL_HEADER = Buffer.from([0x41, 0x4d, 0x51, 0x50, 0x00, 0x00, 0x09, 0x01]); // "AMQP" 0-9-1
export interface AmqpConn {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
readonly vhost: string;
}
/** Build the connection facts from the environment the mesh's env-file set (see the module manifest). */
export function connFromEnv(env: NodeJS.ProcessEnv = process.env): AmqpConn {
const host = env.MESH_AMQP_HOST ?? "";
const port = Number(env.MESH_AMQP_PORT ?? "5672") || 5672;
const user = env.MESH_AMQP_USER ?? "";
const vhost = env.MESH_AMQP_VHOST ?? user; // the provider names the vhost after the login
const password = env.MESH_AMQP_PASSWORD ?? readMaybe(env.MESH_AMQP_PASSWORD_FILE);
if (!host || !user || !password) {
throw new Error(`amqp-ping: connection is not fully set yet (host=${host} user=${user} password=${password ? "set" : "unset"})`);
}
return { host, port, user, password, vhost };
}
// --- wire helpers ---------------------------------------------------------------------------------
function shortstr(s: string): Buffer {
const b = Buffer.from(s, "utf8");
const o = Buffer.alloc(1 + b.length);
o.writeUInt8(b.length, 0);
b.copy(o, 1);
return o;
}
function longstr(s: Buffer | string): Buffer {
const b = Buffer.isBuffer(s) ? s : Buffer.from(s, "utf8");
const o = Buffer.alloc(4 + b.length);
o.writeUInt32BE(b.length, 0);
b.copy(o, 4);
return o;
}
function u16(n: number): Buffer {
const o = Buffer.alloc(2);
o.writeUInt16BE(n, 0);
return o;
}
function u32(n: number): Buffer {
const o = Buffer.alloc(4);
o.writeUInt32BE(n, 0);
return o;
}
function frame(type: number, channel: number, payload: Buffer): Buffer {
const o = Buffer.alloc(7 + payload.length + 1);
o.writeUInt8(type, 0);
o.writeUInt16BE(channel, 1);
o.writeUInt32BE(payload.length, 3);
payload.copy(o, 7);
o.writeUInt8(FRAME_END, 7 + payload.length);
return o;
}
function method(channel: number, classId: number, methodId: number, ...parts: Buffer[]): Buffer {
return frame(1, channel, Buffer.concat([u16(classId), u16(methodId), ...parts]));
}
interface MethodWaiter {
classId: number;
methodId: number;
resolve: (args: Buffer) => void;
reject: (e: Error) => void;
}
/**
* Connect, authenticate to the vhost, declare a queue, publish one message and get it back. Returns
* the body that came back — the caller checks it equals what went out. Throws on any protocol error,
* including the broker's `NOT_ALLOWED` refusal of a vhost the login has no permission on (the
* isolation the provider builds, seen from the consumer's side).
*/
export function roundTrip(conn: AmqpConn, queue = "amqp-ping", payload?: string): Promise<string> {
const body = Buffer.from(payload ?? `ping-${Date.now()}`);
return new Promise<string>((resolve, reject) => {
const sock: Socket = createConnection({ host: conn.host, port: conn.port });
let buf = Buffer.alloc(0);
const waiters: MethodWaiter[] = [];
let lastBody: Buffer | null = null;
let done = false;
const fail = (e: Error): void => {
if (done) return;
done = true;
sock.destroy();
reject(e);
};
const expect = (classId: number, methodId: number): Promise<Buffer> =>
new Promise((res, rej) => waiters.push({ classId, methodId, resolve: res, reject: rej }));
sock.on("error", (e) => fail(e));
sock.on("close", () => fail(new Error("amqp connection closed before the round-trip completed")));
sock.on("data", (chunk: Buffer) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
if (buf.length < 7) return;
const type = buf.readUInt8(0);
const size = buf.readUInt32BE(3);
if (buf.length < 7 + size + 1) return;
const framePayload = buf.subarray(7, 7 + size);
buf = buf.subarray(7 + size + 1);
if (type === 1) {
const classId = framePayload.readUInt16BE(0);
const methodId = framePayload.readUInt16BE(2);
const args = framePayload.subarray(4);
const w = waiters.shift();
if (!w) continue;
if (w.classId === classId && w.methodId === methodId) w.resolve(args);
else w.reject(new Error(`expected method ${w.classId}/${w.methodId}, got ${classId}/${methodId}: ${args.toString("utf8")}`));
} else if (type === 3) {
lastBody = framePayload; // a content body frame
}
// type 2 (content header) and type 8 (heartbeat) need no handling for this round-trip.
}
});
sock.on("connect", () => {
void (async () => {
try {
sock.write(PROTOCOL_HEADER);
await expect(10, 10); // Connection.Start
const response = Buffer.concat([
Buffer.from([0]), Buffer.from(conn.user, "utf8"), Buffer.from([0]), Buffer.from(conn.password, "utf8"),
]);
// Connection.Start-Ok: empty client-properties table, PLAIN, the SASL response, locale.
sock.write(method(0, 10, 11, u32(0), shortstr("PLAIN"), longstr(response), shortstr("en_US")));
const tune = await expect(10, 30); // Connection.Tune
const frameMax = tune.readUInt32BE(2) || 131072;
sock.write(method(0, 10, 31, u16(tune.readUInt16BE(0)), u32(frameMax), u16(0))); // Tune-Ok, no heartbeat
sock.write(method(0, 10, 40, shortstr(conn.vhost), shortstr(""), Buffer.from([0]))); // Connection.Open
await expect(10, 41); // Open-Ok — authenticated and into the vhost
sock.write(method(1, 20, 10, shortstr(""))); // Channel.Open
await expect(20, 11);
// Queue.Declare: reserved, queue, bits(auto-delete=1), empty arguments table.
sock.write(method(1, 50, 10, u16(0), shortstr(queue), Buffer.from([0b00001000]), u32(0)));
await expect(50, 11);
// Basic.Publish to the default exchange, routing-key = queue; then content header + body.
sock.write(method(1, 60, 40, u16(0), shortstr(""), shortstr(queue), Buffer.from([0])));
const bodySize = Buffer.alloc(8);
bodySize.writeBigUInt64BE(BigInt(body.length), 0);
sock.write(frame(2, 1, Buffer.concat([u16(60), u16(0), bodySize, u16(0)]))); // content header, no properties
sock.write(frame(3, 1, body)); // content body
await new Promise((r) => setTimeout(r, 200));
lastBody = null;
sock.write(method(1, 60, 70, u16(0), shortstr(queue), Buffer.from([1]))); // Basic.Get, no-ack
await expect(60, 71); // Get-Ok (a Get-Empty would arrive as 60/72 and reject the expect)
await new Promise((r) => setTimeout(r, 200));
const received = lastBody ? (lastBody as Buffer).toString("utf8") : "";
sock.write(method(0, 10, 50, u16(200), shortstr("bye"), u16(0), u16(0))); // Connection.Close
await expect(10, 51).catch(() => undefined);
done = true;
sock.end();
resolve(received);
} catch (e) {
fail(e instanceof Error ? e : new Error(String(e)));
}
})();
});
});
}
function readMaybe(path: string | undefined): string {
if (!path) return "";
try {
return readFileSync(path, "utf8").replace(/\n$/, "");
} catch {
return "";
}
}
+53
View File
@@ -0,0 +1,53 @@
// amqp-ping — a tiny demo consumer of the mesh `amqp` interface, run as a long-lived container by
// `mesh-tools run` (it never returns, so the container stays up). It exists to PROVE the grant end to
// end: the mesh gave it a scoped login and a vhost of that name on the lavinmq provider, and this
// connects with exactly those and round-trips a message.
//
// The connection facts arrive the way every consumer's do — the mesh writes them into an env-file the
// container reads (novox/hq ADR 0048): MESH_AMQP_HOST/PORT from the binding, MESH_AMQP_USER and
// MESH_AMQP_VHOST both from `${bound:amqp:as}` (the provider named the vhost after the login, so the
// consumer uses the login for both — the db-name lesson applied to AMQP), and MESH_AMQP_PASSWORD from
// `${secret:amqp}`.
//
// It retries: on first boot the provider may not have provisioned this consumer yet (the reconcile is
// asynchronous and cross-container), so a refused or unreachable connection is a "not yet", not a
// failure — it waits and tries again until the round-trip succeeds, then holds the connection idle
// and re-pings on a slow cadence so the container is a stable, running proof.
import { connFromEnv, roundTrip } from "./client.js";
async function sleep(ms: number): Promise<void> {
await new Promise((r) => setTimeout(r, ms));
}
async function pingOnce(): Promise<boolean> {
try {
const conn = connFromEnv();
const sent = `ping-${Date.now()}`;
const got = await roundTrip(conn, "amqp-ping", sent);
if (got === sent) {
console.log(`[amqp-ping] round-trip ok as ${conn.user} on vhost ${conn.vhost} (${conn.host}:${conn.port})`);
return true;
}
console.error(`[amqp-ping] round-trip mismatch: sent ${sent}, got ${got}`);
return false;
} catch (err) {
console.error(`[amqp-ping] not ready yet: ${err instanceof Error ? err.message : err}`);
return false;
}
}
// Wait for the first successful round-trip — the proof this consumer's grant works — then stay up.
let first = false;
for (let i = 0; !first; i++) {
first = await pingOnce();
if (!first) await sleep(3000);
}
console.log("[amqp-ping] connected and round-tripped; holding steady");
for (;;) {
await sleep(30000);
await pingOnce();
}
// changed by the one-node test at build 66e54af151df
// changed by the one-node test at build 4fb41636cffd
// changed by the one-node test at build a69f083bf6a6
+89
View File
@@ -0,0 +1,89 @@
{
"module": "amqp-ping",
"slug": "ping",
"version": "1",
"capabilities": [
"container-runtime"
],
"requires": [
"amqp"
],
"contributes": {},
"binds": {
"amqp": "/var/lib/amqp-ping/amqp.json"
},
"secrets": {
"amqp": "/var/lib/amqp-ping/amqp.secret"
},
"own-secrets": {
"broker": "/var/lib/mesh/amqp-ping/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/amqp-ping",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/amqp-ping",
"mode": "0700"
},
{
"id": "amqp-env",
"type": "file",
"path": "/var/lib/amqp-ping/amqp.env",
"mode": "0600",
"content": "MESH_AMQP_HOST=${bound:amqp:at}\nMESH_AMQP_PORT=${bound:amqp:port}\nMESH_AMQP_USER=${bound:amqp:as}\nMESH_AMQP_VHOST=${bound:amqp:as}\n"
},
{
"id": "net",
"type": "network",
"name": "amqp-ping"
},
{
"id": "runtime",
"type": "container",
"name": "amqp-ping",
"network": "amqp-ping",
"volumes": [
"/var/lib/mesh/amqp-ping/broker:/run/secrets/broker:ro",
"/var/lib/amqp-ping/amqp.secret:/run/secrets/amqp:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_AMQP_PASSWORD_FILE": "/run/secrets/amqp"
},
"env-file": [
"/var/lib/amqp-ping/amqp.env"
],
"restart-on": [
"amqp-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"
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-amqp-ping",
"version": "0.1.0",
"description": "amqp-ping — a demo consumer of the mesh amqp interface. Connects with its scoped grant and round-trips one message to prove the broker the mesh gave it (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"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts"]
}
+1 -1
View File
@@ -18,7 +18,7 @@
"broker": "/var/lib/mesh/anthropic-consumer/broker" "broker": "/var/lib/mesh/anthropic-consumer/broker"
}, },
"emits": [ "emits": [
"usage.session" "module.anthropic-consumer.usage.session"
], ],
"resources": [ "resources": [
{ {
+1 -1
View File
@@ -123,7 +123,7 @@ async function emitUsage(body: Record<string, unknown>): Promise<void> {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const child = spawn( const child = spawn(
process.execPath, process.execPath,
[main, "emit", "usage.session", JSON.stringify(body)], [main, "emit", "module.anthropic-consumer.usage.session", JSON.stringify(body)],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
child.on("exit", () => resolve()); child.on("exit", () => resolve());
+1 -1
View File
@@ -18,7 +18,7 @@
"broker": "/var/lib/mesh/anthropic-manager/broker" "broker": "/var/lib/mesh/anthropic-manager/broker"
}, },
"emits": [ "emits": [
"usage.read" "module.anthropic-manager.usage.read"
], ],
"resources": [ "resources": [
{ {
+1 -1
View File
@@ -143,7 +143,7 @@ async function emitUsage(body: Record<string, unknown>): Promise<void> {
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js"; const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
const { spawn } = await import("node:child_process"); const { spawn } = await import("node:child_process");
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const child = spawn(process.execPath, [main, "emit", "usage.read", JSON.stringify(body)], { const child = spawn(process.execPath, [main, "emit", "module.anthropic-manager.usage.read", JSON.stringify(body)], {
stdio: "inherit", stdio: "inherit",
}); });
child.on("exit", () => resolve()); child.on("exit", () => resolve());
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "1", "version": "1",
"slug": "audit", "slug": "audit",
"consumes": [ "consumes": [
"**" "#"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/audit-logger/broker" "broker": "/var/lib/audit-logger/broker"
+3 -3
View File
@@ -15,16 +15,16 @@ test("audit-logger records every event to the trail as one line each", async ()
const path = join(dir, "audit.log"); const path = join(dir, "audit.log");
// The audit-logger's whole behaviour: consume everything, record it. // The audit-logger's whole behaviour: consume everything, record it.
await on("**", async (event) => record(event, path)); await on("#", async (event) => record(event, path));
process.env.MESH_MODULE = "umami"; process.env.MESH_MODULE = "umami";
process.env.MESH_NODE = "anchor"; process.env.MESH_NODE = "anchor";
await emit("site.created", { domain: "my-app" }); await emit("module.umami.site.created", { domain: "my-app" });
await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one 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)); const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 2); assert.equal(lines.length, 2);
assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]); assert.deepEqual(lines.map((l) => l.type), ["module.umami.site.created", "node.anchor.joined"]);
assert.equal(lines[0].source, "umami"); assert.equal(lines[0].source, "umami");
assert.equal(lines[0].node, "anchor"); assert.equal(lines[0].node, "anchor");
assert.equal(lines[0].body.domain, "my-app"); assert.equal(lines[0].body.domain, "my-app");
+1 -2
View File
@@ -14,7 +14,7 @@
}, },
"route": { "route": {
"label": "baserow", "label": "baserow",
"endpoint": "web" "port": 80
} }
}, },
"binds": { "binds": {
@@ -30,7 +30,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1 -1
View File
@@ -24,7 +24,7 @@ async function pollHistory(): Promise<void> {
for (const entry of entries) { for (const entry of entries) {
if (seen.has(entry.id)) continue; if (seen.has(entry.id)) continue;
if (primed) { if (primed) {
await emit("subtitle.downloaded", { await emit("module.bazarr.subtitle.downloaded", {
kind: entry.kind, kind: entry.kind,
title: entry.title, title: entry.title,
language: entry.language, language: entry.language,
+2 -3
View File
@@ -5,7 +5,7 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"subtitle.downloaded" "module.bazarr.subtitle.downloaded"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker", "broker": "/var/lib/mesh/bazarr/broker",
@@ -13,7 +13,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 6767, "port": 6767,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -111,7 +110,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "subs", "label": "subs",
"endpoint": "web" "port": 6767
} }
}, },
"binds": { "binds": {
+1 -4
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/bookshelf WORKDIR /app/modules/bookshelf
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
@@ -22,6 +22,3 @@ COPY --from=build /app/modules/bookshelf/dist /app/modules/bookshelf/dist
# provider's provisioner runs its reconcile loop in the same process, with the broker connected — # provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. # the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/bookshelf/dist/index.js,/app/modules/bookshelf/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/bookshelf/dist/index.js,/app/modules/bookshelf/dist/tools/index.js
# NOT dist/downloads/index.js: that is a step the host runs to completion, named by the `downloads`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
-188
View File
@@ -1,188 +0,0 @@
// The downloads step — run once by the host after the app's server starts, and again whenever a
// binding, a pair credential or the step's settings change (the container's `restart-on`,
// novox/hq ADR 0099). Byte-identical in sonarr, radarr, lidarr and bookshelf; see settings.ts.
//
// **A step, not a loop**: everything it does is a function of files the mesh writes, and the host
// already knows when they change. It connects to no broker.
//
// Exits non-zero when anything could not be put right — a refused credential, an unreachable
// provider, an entry the app would not save — so the node reports the step failed and the host
// runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
// else of the module's (novox/hq ADR 0136).
//
// Reads, in MESH_DOWNLOADS_DIR, `<provision>.json` (the binding), `<provision>.secret` (the pair
// credential) and the settings file; remembers in MESH_DOWNLOADS_MEMORY the jackett hosts it has
// pointed feeds at, so a jackett that moves takes its feeds with it. Never prints a credential.
import { mkdir, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
CLIENTS,
JACKETT,
acceptRemedy,
appConfig,
appReady,
listEntries,
providerTakes,
readIfThere,
readJson,
reconcileClient,
reconcileIndexers,
scrub,
stepSettings,
wanted,
type App,
type Binding,
type Http,
type Entry,
type Outcome,
type Took,
} from "./settings.js";
const module = process.env.MESH_DOWNLOADS_APP ?? "app";
const dir = process.env.MESH_DOWNLOADS_DIR ?? "/run/downloads";
const settingsFile = process.env.MESH_DOWNLOADS_SETTINGS ?? join(dir, "downloads.json");
const memoryFile = process.env.MESH_DOWNLOADS_MEMORY ?? "/var/lib/downloads/memory.json";
const waitSeconds = Number(process.env.MESH_DOWNLOADS_WAIT_SECONDS ?? "180");
const tag = `[${module}-downloads]`;
const http: Http = { fetch: (u, init) => fetch(u, init) };
const secrets: string[] = [];
const say = (line: string) => console.log(scrub(`${tag} ${line}`, secrets));
const fail = (line: string) => console.error(scrub(`${tag} ${line}`, secrets));
const config = appConfig((await readIfThere(process.env.MESH_DOWNLOADS_APP_CONFIG)) ?? "");
if (!config.apiKey) {
fail(`no API key in ${module}'s config.xml yet — ${module} writes it on its first start; the step runs again on the next apply`);
process.exit(1);
}
secrets.push(config.apiKey);
const app: App = {
module,
url: `${(process.env.MESH_DOWNLOADS_APP_URL ?? "http://127.0.0.1").replace(/\/$/, "")}${config.urlBase}`,
apiKey: config.apiKey,
api: process.env.MESH_DOWNLOADS_API ?? "v3",
};
const settings = stepSettings(await readJson(settingsFile));
if (!(await appReady(http, app, waitSeconds * 1000))) {
fail(`${module} did not answer at ${app.url} within ${waitSeconds}s`);
process.exit(1);
}
const outcomes: Outcome[] = [];
// The download clients.
let clients: Entry[];
try {
clients = await listEntries(http, app, "downloadclient");
} catch (err) {
fail(err instanceof Error ? err.message : String(err));
process.exit(1);
}
for (const kind of CLIENTS) {
const credential = await readIfThere(join(dir, `${kind.provision}.secret`));
if (credential) secrets.push(credential.trim());
const w = wanted(kind.provision, (await readJson(join(dir, `${kind.provision}.json`))) as Binding | undefined, credential, true);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) {
outcomes.push({ what: kind.provision, result: "refused", problem: w.problem });
continue;
}
const ep = w.endpoint;
try {
if (!(await providerTakes(http, kind.provision, ep)).took) {
outcomes.push({
what: kind.provision,
result: "refused",
problem: acceptRemedy(app, settings.node, kind.provision, kind.provider, kind.secretName, ep.from),
});
continue;
}
} catch (err) {
outcomes.push({
what: kind.provision,
result: "refused",
problem: `${kind.provider} could not be asked whether it takes the credential at ${ep.host}:${ep.port}: ${err instanceof Error ? err.message : String(err)}`,
});
continue;
}
outcomes.push(...(await reconcileClient(http, app, kind, settings.names[kind.provision], ep, clients)));
}
// The indexers, through jackett.
{
const credential = await readIfThere(join(dir, `${JACKETT}.secret`));
if (credential) secrets.push(credential.trim());
const w = wanted(JACKETT, (await readJson(join(dir, `${JACKETT}.json`))) as Binding | undefined, credential, false);
if ("problem" in w) {
outcomes.push({ what: JACKETT, result: "refused", problem: w.problem });
} else {
const ep = w.endpoint;
let took: Took | undefined;
try {
took = await providerTakes(http, JACKETT, ep);
} catch (err) {
outcomes.push({
what: JACKETT,
result: "refused",
problem: `jackett could not be asked whether it takes the key at ${ep.host}:${ep.port}: ${err instanceof Error ? err.message : String(err)}`,
});
}
if (took && !took.took) {
outcomes.push({ what: JACKETT, result: "refused", problem: acceptRemedy(app, settings.node, JACKETT, "jackett", "API key", ep.from) });
} else if (took) {
const memory = ((await readJson(memoryFile)) ?? {}) as Record<string, { hosts?: string[] } | undefined>;
const remembered = Array.isArray(memory[JACKETT]?.hosts) ? (memory[JACKETT]?.hosts as string[]) : [];
try {
const indexers = await listEntries(http, app, "indexer");
outcomes.push(
...(await reconcileIndexers(http, app, ep, took.configured ?? new Map(), settings.indexers, remembered, indexers)),
);
} catch (err) {
outcomes.push({ what: JACKETT, result: "refused", problem: err instanceof Error ? err.message : String(err) });
}
// Remember where the feeds now point, so they are still recognised as the mesh's if jackett
// moves. Written whole and renamed, so a crash leaves the old memory, never half of one.
const hosts = [...new Set([...remembered, ep.host.toLowerCase()])].sort();
if (hosts.join() !== [...remembered].sort().join()) {
try {
await mkdir(dirname(memoryFile), { recursive: true });
await writeFile(`${memoryFile}.tmp`, JSON.stringify({ ...memory, [JACKETT]: { hosts } }, null, 2) + "\n", { mode: 0o600 });
await rename(`${memoryFile}.tmp`, memoryFile);
} catch (err) {
outcomes.push({
what: JACKETT,
result: "notice",
note: `could not remember ${ep.host} as a jackett host (${err instanceof Error ? err.message : String(err)}); if jackett moves, list it in downloads.jackett-api.adopt-hosts`,
});
}
}
}
}
}
let failed = 0;
for (const o of outcomes) {
switch (o.result) {
case "unchanged":
say(`${o.what}: already as the mesh says${o.untested ? "" : "; connection tested"}`);
break;
case "written":
say(`${o.what}: wrote ${o.fields.join(", ")}${o.untested ? "" : "; connection tested"}`);
break;
case "created":
say(`${o.what}: registered; connection tested`);
break;
case "notice":
say(`${o.what}: ${o.note}`);
break;
case "refused":
failed++;
fail(`${o.what}: ${o.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
-778
View File
@@ -1,778 +0,0 @@
// Where a Servarr app reaches its download clients and its indexer proxy — decided by the mesh,
// written into the app by the app's own API.
//
// **One file, four copies.** sonarr, radarr, lidarr and bookshelf (a Readarr fork) keep their
// download clients and indexers behind the same API — `/api/v3/…` for the first two, `/api/v1/…`
// for the others — so this step is the same code in each. Each module builds from its own
// directory (novox/hq ADR 0069), so each carries a byte-identical copy under `downloads/`;
// `test/downloads.test.ts` checks the copies agree wherever the siblings are checked out beside
// it. Change all four together.
//
// **Why this exists.** The app keeps its connection to nzbget, qBittorrent and jackett in its own
// database, not in a file, so the mesh has nowhere to write `${bound:nzbget-api:at}` for it. The
// module requires `nzbget-api`, `qbittorrent-api` and `jackett-api`; the mesh delivers, for each,
// a binding (where the provider is — `at` — and what it serves: `port`, `scheme`, `url-base`, and
// for a download client the `username`) and a pair credential (nzbget's ControlPassword,
// qBittorrent's WebUI password, jackett's API key — each the provider's one and only, accepted by
// the operator per pair; the mesh cannot mint them). This step reads those files and makes the
// app's entries say the same thing.
//
// **Which entries are the mesh's.** Never a guess from what an entry looks like:
// - a download client is the mesh's when its name is the one the module's settings give for
// that provision (`downloads.<provision>.name`) and its kind is that provider's. On a fresh
// machine the step registers it under that name; on a migrated one the assignment names the
// entry the migration adopts ("NZBGet" on ace).
// - a Torznab indexer is the mesh's when it reads a jackett feed
// (`…/api/v2.0/indexers/<id>/results/torznab`) on a host the mesh put there — the bound `at`,
// one it bound before (remembered by the step), or one the settings adopt
// (`downloads.jackett-api.adopt-hosts`, which is how a migration names the entries that pointed
// at the old container). `downloads.jackett-api.indexers` lists the jackett indexers the app
// should have; one missing is registered.
// Everything else — every entry a person made, an nzbget elsewhere, a seedbox's jackett, a Newznab
// indexer — is left exactly as it is. **Nothing is ever deleted.**
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path, user name and
// the credential. Categories, priorities, "enabled", seed criteria and every other choice made in
// the app are left alone. The stored credential cannot be read back (the app masks it), so the
// step asks the app to test the entry with the mesh's host and port and the credential it already
// holds: passing, the credential is already the provider's one and only; failing, the delivered
// credential is written.
//
// **A credential the provider refuses is never written.** Until the operator accepts the
// provider's secret for this pair, the mesh delivers a value it minted itself, which no provider
// will ever accept (novox/hq ADR 0092). Writing it would replace a working password with a dead
// one. So it is tried against the provider first; refused, nothing of that provision is written
// and the step fails naming the `secret accept` that fixes it.
//
// Pure logic and a small HTTP seam, so it is tested against fake servers (test/downloads.test.ts).
// Never prints a credential, an API key, or a URL carrying one.
import { readFile } from "node:fs/promises";
/** A download-client provision and the shape of its entry in the app. */
export interface ClientKind {
provision: "nzbget-api" | "qbittorrent-api";
/** The app's `implementation` for it. */
implementation: "Nzbget" | "QBittorrent";
/** The provider, as a person calls it. */
provider: string;
/** What the pair credential is, in the provider's words. */
secretName: string;
}
export const CLIENTS: readonly ClientKind[] = [
{ provision: "nzbget-api", implementation: "Nzbget", provider: "nzbget", secretName: "ControlPassword" },
{ provision: "qbittorrent-api", implementation: "QBittorrent", provider: "qBittorrent", secretName: "WebUI password" },
];
export const JACKETT = "jackett-api";
/** What the mesh wrote at `binds.<provision>`: the binding document. */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
/** Where a provider is, as the mesh bound it, and the credential for it. */
export interface Endpoint {
scheme: "http" | "https";
host: string;
port: number;
/** "" at the root, otherwise "/base" — one leading slash, none trailing. */
urlBase: string;
/** The user a download client logs in as; "" for jackett, which takes a key. */
username: string;
credential: string;
/** The provider's node, for the `secret accept` remedy. */
from: string;
}
export type Wanted = { ok: true; endpoint: Endpoint } | { ok: false; problem: string };
/**
* The endpoint the mesh says to use, from a binding and its pair credential.
*
* Refused rather than guessed when the binding cannot be dialled from the app's own container: a
* loopback `at` — what the mesh hands a machine that is not on the private network — is the app's
* container itself.
*/
export function wanted(
provision: string,
binding: Binding | undefined,
credential: string | undefined,
needsUser: boolean,
): Wanted {
if (!binding) {
return { ok: false, problem: `no binding for ${provision} was delivered — the mesh writes it before this step runs` };
}
const host = typeof binding.at === "string" ? binding.at.trim() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!host) return { ok: false, problem: `the ${provision} binding names no host (at)` };
if (isLoopback(host)) {
return {
ok: false,
problem:
`the ${provision} binding says the provider is at ${host}, which from the app's own container is the ` +
`app itself. The mesh hands loopback to a machine that is not on the private network; put it on the ` +
`private network so the provider has an address the app can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${provision} binding serves no usable port (${String(serves.port)})` };
}
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") {
return { ok: false, problem: `the ${provision} binding serves scheme ${scheme}, which the app cannot dial` };
}
const username = typeof serves.username === "string" ? serves.username.trim() : "";
if (needsUser && !username) {
return { ok: false, problem: `the ${provision} binding serves no username for the app to log in as` };
}
const key = (credential ?? "").trim();
if (!key) return { ok: false, problem: `the ${provision} credential is empty or was not delivered` };
return {
ok: true,
endpoint: {
scheme,
host,
port,
urlBase: normBase(serves["url-base"]),
username,
credential: key,
from: typeof binding.from === "string" ? binding.from : "",
},
};
}
/** A URL base as "" or "/x/y": slashes trimmed, one put back in front. */
export function normBase(v: unknown): string {
const s = typeof v === "string" ? v.trim().replace(/^\/+|\/+$/g, "") : "";
return s === "" ? "" : `/${s}`;
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
function hostForUrl(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
/** The provider's base URL, as the step dials it and as the app is given it. */
export function baseUrl(ep: Endpoint): string {
return `${ep.scheme}://${hostForUrl(ep.host)}:${ep.port}${ep.urlBase}`;
}
// ---------------------------------------------------------------------------------------------
// HTTP
/** The HTTP the step needs, so a test can stand fakes in for the app and the providers. */
export interface Http {
fetch(
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string },
): Promise<{ status: number; text(): Promise<string> }>;
}
/** The app, as the step reaches it from the host. */
export interface App {
/** The module, for messages and the remedy: sonarr, radarr, lidarr, bookshelf. */
module: string;
/** Its own URL, base path included. */
url: string;
apiKey: string;
/** v3 (sonarr, radarr) or v1 (lidarr, bookshelf). */
api: string;
}
interface Answer {
status: number;
body: unknown;
}
async function appCall(http: Http, app: App, method: string, path: string, body?: unknown): Promise<Answer> {
const res = await http.fetch(`${app.url.replace(/\/$/, "")}/api/${app.api}${path}`, {
method,
headers: {
"X-Api-Key": app.apiKey,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed: unknown = undefined;
if (text) {
try {
parsed = JSON.parse(text) as unknown;
} catch {
parsed = text;
}
}
return { status: res.status, body: parsed };
}
async function appGet(http: Http, app: App, path: string): Promise<unknown> {
const a = await appCall(http, app, "GET", path);
if (a.status < 200 || a.status >= 300) throw new Error(`${app.module} GET ${path} answered ${a.status}`);
return a.body;
}
/** One of the app's validation failures: the only parts of an answer the step ever prints. */
interface Failure {
property: string;
message: string;
warning: boolean;
}
function failuresOf(body: unknown): Failure[] {
const list = Array.isArray(body) ? body : [];
return list.map((f) => {
const o = (f ?? {}) as Record<string, unknown>;
return {
property: String(o.propertyName ?? ""),
message: String(o.errorMessage ?? ""),
warning: o.isWarning === true,
};
});
}
/**
* One shape rather than a union on `ok`: the Dockerfile compiles without strict, where a boolean
* discriminant does not narrow.
*/
interface Verdict {
ok: boolean;
/** Warnings the app raised on a pass; errors (and any warnings) on a failure. */
failures: Failure[];
status: number;
saved?: Entry;
}
function verdict(a: Answer): Verdict {
if (a.status >= 200 && a.status < 300) return { ok: true, failures: [], status: a.status };
const failures = failuresOf(a.body);
if (a.status === 400 && failures.length > 0 && failures.every((f) => f.warning)) return { ok: true, failures, status: a.status };
return { ok: false, failures, status: a.status };
}
/** The app's own test of an entry, from the app's own container. */
async function appTest(http: Http, app: App, resource: string, entry: Entry): Promise<Verdict> {
return verdict(await appCall(http, app, "POST", `/${resource}/test`, entry));
}
/**
* Save an entry. The app tests an enabled entry before saving it; a test that passes with only
* warnings ("a category is recommended") is saved with `forceSave`, the same as pressing "save
* anyway" in its screen. An error is never forced.
*/
async function appSave(http: Http, app: App, resource: string, entry: Entry, create: boolean): Promise<Verdict> {
const path = create ? `/${resource}` : `/${resource}/${entry.id}`;
const method = create ? "POST" : "PUT";
let a = await appCall(http, app, method, path, entry);
let v = verdict(a);
if (v.ok && (a.status < 200 || a.status >= 300)) {
a = await appCall(http, app, method, `${path}?forceSave=true`, entry);
v = verdict(a);
if (v.ok && (a.status < 200 || a.status >= 300)) {
return { ok: false, failures: failuresOf(a.body), status: a.status };
}
}
if (!v.ok) return v;
return { ...v, saved: (a.body ?? undefined) as Entry | undefined };
}
// ---------------------------------------------------------------------------------------------
// Entries
/** An entry as the app's API gives it: a download client or an indexer. */
export interface Entry {
id?: number;
name?: string;
implementation?: string;
enable?: boolean;
enableRss?: boolean;
enableAutomaticSearch?: boolean;
enableInteractiveSearch?: boolean;
supportsRss?: boolean;
supportsSearch?: boolean;
fields?: { name: string; value?: unknown; [k: string]: unknown }[];
[k: string]: unknown;
}
export function field(e: Entry, name: string): unknown {
return e.fields?.find((f) => f.name === name)?.value;
}
/** The entry with these fields' values replaced — added when the app's shape lacks one. */
export function withFields(e: Entry, values: Record<string, unknown>): Entry {
const fields = (e.fields ?? []).map((f) => (f.name in values ? { ...f, value: values[f.name] } : { ...f }));
for (const [name, value] of Object.entries(values)) {
if (!fields.some((f) => f.name === name)) fields.push({ name, value });
}
return { ...e, fields };
}
function enabled(e: Entry): boolean {
return e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true || e.enableInteractiveSearch === true;
}
/** What one provision, or one entry of it, came to. */
export type Outcome =
| { what: string; result: "unchanged"; untested?: boolean }
| { what: string; result: "written"; fields: string[]; untested?: boolean }
| { what: string; result: "created"; fields: string[] }
| { what: string; result: "notice"; note: string }
| { what: string; result: "refused"; problem: string };
function said(fs: Failure[]): string {
return fs.length === 0 ? "no reason given" : fs.map((f) => (f.property ? `${f.property}: ${f.message}` : f.message)).join("; ");
}
/**
* Bring one of the mesh's entries in line: `connection` is the non-secret fields as the mesh says
* them, `differs` which of them the entry does not already say, `secretField` where the credential
* goes. Never throws: every failure is an outcome with a reason.
*/
export async function reconcileEntry(
http: Http,
app: App,
resource: "downloadclient" | "indexer",
what: string,
current: Entry,
connection: Record<string, unknown>,
differs: string[],
secretField: string,
credential: string,
testable = true,
): Promise<Outcome> {
try {
const base = withFields(current, connection); // the credential as the app holds it — masked
if (!testable) {
// Nothing to test against (jackett no longer has the indexer it names): the connection is
// still the mesh's to state, and the key goes with it when the address changes.
if (differs.length === 0) return { what, result: "unchanged", untested: true };
const s = await appSave(http, app, resource, withFields(base, { [secretField]: credential }), false);
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not save it: ${said(s.failures)}` };
return { what, result: "written", fields: [...differs, secretField], untested: true };
}
const first = await appTest(http, app, resource, base);
if (first.ok) {
// The credential the app holds already works against the provider at the mesh's address —
// and the provider has exactly one, so it is the delivered one.
if (differs.length === 0) return { what, result: "unchanged" };
const s = await appSave(http, app, resource, base, false);
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not save it: ${said(s.failures)}` };
return { what, result: "written", fields: differs };
}
// The app's test failed with what it holds. The delivered credential was already checked
// against the provider, so writing it is safe; the app tests an enabled entry again on save.
const s = await appSave(http, app, resource, withFields(base, { [secretField]: credential }), false);
if (!s.ok) {
return {
what,
result: "refused",
problem: `${app.module} tested it at the mesh's address and would not save it (nothing was written): ${said(s.failures)}`,
};
}
const stored = (await appGet(http, app, `/${resource}/${current.id}`)) as Entry;
const second = await appTest(http, app, resource, stored);
const fields = [...differs, secretField];
if (second.ok) return { what, result: "written", fields };
const why = `written (${fields.join(", ")}), and ${app.module}'s own test still fails: ${said(second.failures)}`;
return enabled(stored) ? { what, result: "refused", problem: why } : { what, result: "notice", note: `${why} — it is disabled, so nothing uses it` };
} catch (err) {
return { what, result: "refused", problem: message(err) };
}
}
/**
* Register a new entry from the app's own template for that kind: its defaults, the name, the
* connection and the credential. A category the provider does not have (nzbget refuses one it was
* not told about) is left empty rather than failing the registration, and said.
*/
export async function createEntry(
http: Http,
app: App,
resource: "downloadclient" | "indexer",
what: string,
implementation: string,
name: string,
values: Record<string, unknown>,
): Promise<Outcome> {
try {
const schema = (await appGet(http, app, `/${resource}/schema`)) as Entry[];
const template = (schema ?? []).find((s) => s.implementation === implementation);
if (!template) return { what, result: "refused", problem: `${app.module} has no ${implementation} to register` };
let entry: Entry = withFields({ ...template, id: undefined, name }, values);
if (resource === "downloadclient") entry.enable = true;
else {
entry.enableRss = template.supportsRss !== false;
entry.enableAutomaticSearch = template.supportsSearch !== false;
entry.enableInteractiveSearch = template.supportsSearch !== false;
}
let s = await appSave(http, app, resource, entry, true);
let note = "";
if (!s.ok && s.failures.length > 0 && s.failures.every((f) => /category/i.test(f.property))) {
const emptied: Record<string, unknown> = {};
for (const f of entry.fields ?? []) if (/category$/i.test(f.name) && !/imported/i.test(f.name)) emptied[f.name] = "";
entry = withFields(entry, emptied);
s = await appSave(http, app, resource, entry, true);
note = " with its category left empty — the provider has none by the app's default name; set one in both";
}
if (!s.ok) return { what, result: "refused", problem: `${app.module} would not register it: ${said(s.failures)}` };
const created = s.saved;
if (created?.id !== undefined) {
const t = await appTest(http, app, resource, (await appGet(http, app, `/${resource}/${created.id}`)) as Entry);
if (!t.ok) {
return { what, result: "refused", problem: `registered as "${name}"${note}, and ${app.module}'s own test fails: ${said(t.failures)}` };
}
}
return note
? { what, result: "notice", note: `registered as "${name}"${note}` }
: { what, result: "created", fields: Object.keys(values) };
} catch (err) {
return { what, result: "refused", problem: message(err) };
}
}
// ---------------------------------------------------------------------------------------------
// Providers: does it take the credential?
/**
* Does the provider take this credential? `took` it does (for jackett, with the ids of the indexers
* it has configured); `refused` it said no; a thrown error when it could not be asked.
*/
export interface Took {
took: boolean;
configured?: Map<string, string>;
}
export async function providerTakes(http: Http, provision: string, ep: Endpoint): Promise<Took> {
const base = baseUrl(ep);
if (provision === "nzbget-api") {
const auth = Buffer.from(`${ep.username}:${ep.credential}`).toString("base64");
const res = await http.fetch(`${base}/jsonrpc/version`, { method: "GET", headers: { Authorization: `Basic ${auth}` } });
await res.text();
if (res.status === 401 || res.status === 403) return { took: false };
if (res.status >= 200 && res.status < 300) return { took: true };
throw new Error(`nzbget answered ${res.status}`);
}
if (provision === "qbittorrent-api") {
const form = `username=${encodeURIComponent(ep.username)}&password=${encodeURIComponent(ep.credential)}`;
const res = await http.fetch(`${base}/api/v2/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const text = (await res.text()).trim();
if (res.status === 401 || /^fails\.?$/i.test(text)) return { took: false };
if (res.status === 403) {
throw new Error(
"qBittorrent answered 403: it has banned this address after failed logins (WebUI\\BanDuration); it lifts by itself",
);
}
if (res.status >= 200 && res.status < 300) return { took: true };
throw new Error(`qBittorrent answered ${res.status}`);
}
if (provision === JACKETT) {
const url = `${base}/api/v2.0/indexers/all/results/torznab/api?t=indexers&configured=true&apikey=${encodeURIComponent(ep.credential)}`;
const res = await http.fetch(url, { method: "GET" });
const text = await res.text();
if (res.status === 401 || res.status === 403) return { took: false };
if (res.status < 200 || res.status >= 300) throw new Error(`jackett answered ${res.status}`);
// jackett answers a wrong key 200 with an error document: code 100, "Invalid API Key".
if (/<error\b[^>]*\bcode="100"/i.test(text)) return { took: false };
if (/<error\b/i.test(text)) throw new Error("jackett answered with an error document");
return { took: true, configured: configuredIndexers(text) };
}
throw new Error(`no check for ${provision}`);
}
/** jackett's configured indexers, id → title, from its `t=indexers` feed. */
export function configuredIndexers(xml: string): Map<string, string> {
const out = new Map<string, string>();
const re = /<indexer\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/indexer>/g;
for (let m = re.exec(xml); m; m = re.exec(xml)) {
const title = /<title>([\s\S]*?)<\/title>/.exec(m[2])?.[1]?.trim() ?? m[1];
out.set(m[1], decodeXml(title));
}
return out;
}
function decodeXml(s: string): string {
return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
}
/** The remedy for a refused credential, in the controller's own words (ADR 0092). */
export function acceptRemedy(app: App, node: string, provision: string, provider: string, secretName: string, from: string): string {
return (
`${provider} refuses the ${provision} credential the mesh delivered, so nothing of ${provision} was written ` +
`into ${app.module}. ${provider} has one ${secretName} and the mesh cannot make it: accept it for this pair — ` +
`\`secret accept ${node || "<this node>"} ${app.module} ${provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${provider}'s ${secretName}>\``
);
}
// ---------------------------------------------------------------------------------------------
// Download clients
/** The non-secret fields of a download client as the mesh says them. */
export function clientConnection(ep: Endpoint): Record<string, unknown> {
return { host: ep.host, port: ep.port, useSsl: ep.scheme === "https", urlBase: ep.urlBase === "" ? null : ep.urlBase, username: ep.username };
}
/** Which of them the entry does not already say. Names only. */
export function clientDiffers(e: Entry, ep: Endpoint): string[] {
const out: string[] = [];
if (String(field(e, "host") ?? "").toLowerCase() !== ep.host.toLowerCase()) out.push("host");
if (Number(field(e, "port") ?? 0) !== ep.port) out.push("port");
if (Boolean(field(e, "useSsl")) !== (ep.scheme === "https")) out.push("useSsl");
if (normBase(field(e, "urlBase")) !== ep.urlBase) out.push("urlBase");
if (String(field(e, "username") ?? "") !== ep.username) out.push("username");
return out;
}
/**
* Bring the app's download client for one provision in line: the entry by that name and kind is
* the mesh's and is updated; none, it is registered. An entry by that name of another kind, or by
* that name in another case, is somebody else's and is refused rather than touched.
*/
export async function reconcileClient(
http: Http,
app: App,
kind: ClientKind,
name: string,
ep: Endpoint,
entries: Entry[],
): Promise<Outcome[]> {
const what = `${kind.provision} ("${name}")`;
const exact = entries.filter((e) => e.name === name);
const alike = entries.filter((e) => e.name !== name && String(e.name ?? "").toLowerCase() === name.toLowerCase());
if (exact.length > 0 && exact[0].implementation !== kind.implementation) {
return [{
what,
result: "refused",
problem: `the download client named "${name}" is a ${exact[0].implementation}, not ${kind.implementation}; it is not the mesh's and was left alone. Name the mesh's entry otherwise in downloads.${kind.provision}.name`,
}];
}
if (exact.length === 0 && alike.length > 0) {
return [{
what,
result: "refused",
problem: `${app.module} already has "${alike[0].name}", which is not the mesh's name ("${name}") and was left alone. To have the mesh manage it, set downloads.${kind.provision}.name to "${alike[0].name}"`,
}];
}
const outcomes: Outcome[] = [];
const others = entries.filter((e) => e.implementation === kind.implementation && e.name !== name);
if (others.length > 0) {
outcomes.push({
what,
result: "notice",
note: `also present and not the mesh's, left alone: ${others.map((o) => `"${o.name}"`).join(", ")}`,
});
}
if (exact.length === 0) {
outcomes.push(
await createEntry(http, app, "downloadclient", what, kind.implementation, name, {
...clientConnection(ep),
password: ep.credential,
}),
);
return outcomes;
}
outcomes.push(
await reconcileEntry(http, app, "downloadclient", what, exact[0], clientConnection(ep), clientDiffers(exact[0], ep), "password", ep.credential),
);
return outcomes;
}
// ---------------------------------------------------------------------------------------------
// Indexers
const FEED = /\/api\/v2\.0\/indexers\/([^/]+)\/results\/torznab\/?$/;
/** A Torznab entry reading a jackett feed: the host it points at and the jackett indexer's id. */
export function jackettFeed(e: Entry): { host: string; id: string } | undefined {
if (e.implementation !== "Torznab") return undefined;
const raw = String(field(e, "baseUrl") ?? "").trim();
if (!raw) return undefined;
let u: URL;
try {
u = new URL(raw);
} catch {
return undefined;
}
const path = `${u.pathname.replace(/\/+$/, "")}/${String(field(e, "apiPath") ?? "").replace(/^\/+/, "")}`;
const m = FEED.exec(path);
if (!m) return undefined;
return { host: u.hostname.replace(/^\[|\]$/g, "").toLowerCase(), id: decodeURIComponent(m[1]) };
}
/** The feed's two fields as the mesh says them. */
export function feedConnection(ep: Endpoint, id: string): Record<string, unknown> {
return { baseUrl: baseUrl(ep), apiPath: `/api/v2.0/indexers/${id}/results/torznab/` };
}
function sameUrl(a: string, b: string): boolean {
try {
const x = new URL(a);
const y = new URL(b);
const port = (u: URL) => u.port || (u.protocol === "https:" ? "443" : "80");
return (
x.protocol === y.protocol &&
x.hostname.toLowerCase() === y.hostname.toLowerCase() &&
port(x) === port(y) &&
x.pathname.replace(/\/+$/, "") === y.pathname.replace(/\/+$/, "")
);
} catch {
return false;
}
}
export function feedDiffers(e: Entry, ep: Endpoint, id: string): string[] {
const want = feedConnection(ep, id);
const out: string[] = [];
if (!sameUrl(String(field(e, "baseUrl") ?? ""), String(want.baseUrl))) out.push("baseUrl");
const path = (v: unknown) => `/${String(v ?? "").replace(/^\/+|\/+$/g, "")}`;
if (path(field(e, "apiPath")) !== path(want.apiPath)) out.push("apiPath");
return out;
}
export interface IndexerSettings {
/** Hosts whose jackett feeds the mesh takes over — a migration's old names. */
adoptHosts: string[];
/** jackett indexer ids the app should have. */
indexers: string[];
}
/**
* Bring the app's jackett feeds in line: every one the mesh manages is pointed at the bound jackett
* with its key, and every listed jackett indexer the app lacks is registered.
*/
export async function reconcileIndexers(
http: Http,
app: App,
ep: Endpoint,
configured: Map<string, string>,
settings: IndexerSettings,
remembered: string[],
entries: Entry[],
): Promise<Outcome[]> {
const ours = new Set([ep.host, ...settings.adoptHosts, ...remembered].map((h) => h.trim().toLowerCase()).filter(Boolean));
const outcomes: Outcome[] = [];
const managed = new Set<string>();
for (const e of entries) {
const feed = jackettFeed(e);
if (!feed || !ours.has(feed.host)) continue;
managed.add(feed.id);
const what = `jackett-api ("${e.name}", jackett indexer ${feed.id})`;
const known = feed.id === "all" || configured.has(feed.id);
const outcome = await reconcileEntry(
http, app, "indexer", what, e, feedConnection(ep, feed.id), feedDiffers(e, ep, feed.id), "apiKey", ep.credential, known,
);
outcomes.push(outcome);
if (!known && outcome.result !== "refused") {
outcomes.push({ what, result: "notice", note: `jackett has no indexer "${feed.id}" configured, so it was not tested; configure it in jackett or remove the entry in ${app.module}` });
}
}
for (const id of settings.indexers) {
if (managed.has(id)) continue;
const what = `jackett-api (jackett indexer ${id})`;
if (id !== "all" && !configured.has(id)) {
outcomes.push({ what, result: "refused", problem: `downloads.jackett-api.indexers lists "${id}", and jackett has no indexer by that id configured` });
continue;
}
const name = `Jackett - ${id === "all" ? "all" : configured.get(id)}`;
outcomes.push(await createEntry(http, app, "indexer", what, "Torznab", name, { ...feedConnection(ep, id), apiKey: ep.credential }));
}
if (outcomes.length === 0) {
outcomes.push({ what: "jackett-api", result: "notice", note: `${app.module} has no jackett feed the mesh manages, and downloads.jackett-api.indexers lists none to register` });
}
return outcomes;
}
// ---------------------------------------------------------------------------------------------
// Files
/** The module's settings for this step, from its merged `downloads.json`. */
export interface StepSettings {
node: string;
names: Record<string, string>;
indexers: IndexerSettings;
}
export function stepSettings(raw: unknown): StepSettings {
const doc = (raw ?? {}) as Record<string, unknown>;
const d = (doc.downloads ?? {}) as Record<string, Record<string, unknown> | undefined>;
const names: Record<string, string> = {};
for (const k of CLIENTS) {
const n = d[k.provision]?.name;
names[k.provision] = typeof n === "string" && n.trim() ? n.trim() : k.provision.replace(/-api$/, "");
}
const j = d[JACKETT] ?? {};
const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim() !== "").map((x) => x.trim()) : []);
return {
node: typeof doc.node === "string" ? doc.node : "",
names,
indexers: { adoptHosts: strings(j["adopt-hosts"]), indexers: strings(j.indexers) },
};
}
/** The app's own key and base path, from its config.xml. */
export function appConfig(xml: string): { apiKey: string; urlBase: string } {
const tag = (t: string) => new RegExp(`<${t}>([^<]*)</${t}>`).exec(xml)?.[1]?.trim() ?? "";
return { apiKey: tag("ApiKey"), urlBase: normBase(tag("UrlBase")) };
}
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A JSON file parsed, or undefined when absent or not JSON. */
export async function readJson(path: string | undefined): Promise<unknown> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
/** Wait for the app to answer, because the step runs right after its container starts. */
export async function appReady(http: Http, app: App, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const a = await appCall(http, app, "GET", "/system/status");
if (a.status === 200) return true;
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, pauseMs));
}
}
export async function listEntries(http: Http, app: App, resource: "downloadclient" | "indexer"): Promise<Entry[]> {
return ((await appGet(http, app, `/${resource}`)) as Entry[]) ?? [];
}
/** Anything printed goes through this: the credentials the step holds never reach a log. */
export function scrub(text: string, secrets: string[]): string {
let out = text;
for (const s of secrets) if (s && s.length >= 4) out = out.split(s).join("<redacted>");
return out;
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+2 -2
View File
@@ -45,12 +45,12 @@ async function pollQueue(bookshelf: BookshelfClient): Promise<void> {
if (primed) { if (primed) {
// Entered the queue since last look — Bookshelf grabbed a release. // Entered the queue since last look — Bookshelf grabbed a release.
for (const [id, item] of now) { for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("book.grabbed", { title: item.title, status: item.status }); if (!inQueue.has(id)) await emit("module.bookshelf.book.grabbed", { title: item.title, status: item.status });
} }
// Left the queue — imported and done, unless it was last seen failing. // Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) { for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("download.completed", { title: item.title }); await emit("module.bookshelf.download.completed", { title: item.title });
} }
} }
} }
+9 -80
View File
@@ -6,8 +6,8 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"book.grabbed", "module.bookshelf.book.grabbed",
"download.completed" "module.bookshelf.download.completed"
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
@@ -15,7 +15,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8787, "port": 8787,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -39,15 +38,10 @@
"path": "/var/lib/mesh/bookshelf", "path": "/var/lib/mesh/bookshelf",
"mode": "0700" "mode": "0700"
}, },
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/bookshelf/config",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -55,7 +49,7 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "bookshelf", "name": "bookshelf",
"image": "ghcr.io/pennydreadful/bookshelf@sha256:67498dd5ece516867d72ee642abd6c1a66b36a135c8f7da0127109564372beb1", "image": "ghcr.io/pennydreadful/bookshelf@sha256:388eecc94362580eae31ee0a454be6af516f8a311f8432a521c202fb475f4359",
"env": { "env": {
"PUID": "1000", "PUID": "1000",
"PGID": "1000", "PGID": "1000",
@@ -65,7 +59,7 @@
"8787" "8787"
], ],
"volumes": [ "volumes": [
"${dir:config}:/config", "/services/bookshelf/config:/config",
"/services/media/books:/books", "/services/media/books:/books",
"/services/media/downloads:/downloads" "/services/media/downloads:/downloads"
] ]
@@ -77,92 +71,27 @@
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/bookshelf/broker:/run/secrets/broker:ro", "/var/lib/mesh/bookshelf/broker:/run/secrets/broker:ro",
"${dir:config}:/var/lib/bookshelf/config:ro" "/services/bookshelf/config:/var/lib/bookshelf/config:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:${port:8787}", "MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config" "MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
}, },
"artifact": "runtime" "artifact": "runtime"
},
{
"id": "downloads-config",
"type": "file",
"path": "${dir:state}/downloads.json",
"mode": "0600",
"content": "{\n \"node\": \"${machine:name}\",\n \"downloads\": {\n \"nzbget-api\": {\n \"name\": \"nzbget\"\n },\n \"qbittorrent-api\": {\n \"name\": \"qbittorrent\"\n },\n \"jackett-api\": {\n \"adopt-hosts\": [],\n \"indexers\": []\n }\n }\n}\n",
"merge": "json"
},
{
"id": "downloads-memory",
"type": "directory",
"mode": "0700"
},
{
"id": "downloads",
"type": "container",
"name": "mesh-bookshelf-downloads",
"network": "host",
"run-once": true,
"volumes": [
"${dir:state}/downloads.json:/run/downloads/downloads.json:ro",
"${dir:state}/nzbget-api.json:/run/downloads/nzbget-api.json:ro",
"${dir:state}/nzbget-api.secret:/run/downloads/nzbget-api.secret:ro",
"${dir:state}/qbittorrent-api.json:/run/downloads/qbittorrent-api.json:ro",
"${dir:state}/qbittorrent-api.secret:/run/downloads/qbittorrent-api.secret:ro",
"${dir:state}/jackett-api.json:/run/downloads/jackett-api.json:ro",
"${dir:state}/jackett-api.secret:/run/downloads/jackett-api.secret:ro",
"${dir:config}:/var/lib/bookshelf/config:ro",
"${dir:downloads-memory}:/var/lib/downloads"
],
"env": {
"MESH_DOWNLOADS_APP": "bookshelf",
"MESH_DOWNLOADS_API": "v1",
"MESH_DOWNLOADS_APP_URL": "http://127.0.0.1:${port:8787}",
"MESH_DOWNLOADS_APP_CONFIG": "/var/lib/bookshelf/config/config.xml",
"MESH_DOWNLOADS_DIR": "/run/downloads",
"MESH_DOWNLOADS_SETTINGS": "/run/downloads/downloads.json",
"MESH_DOWNLOADS_MEMORY": "/var/lib/downloads/memory.json"
},
"args": [
"run",
"/app/modules/bookshelf/dist/downloads/index.js"
],
"restart-on": [
"downloads-config",
"bound-nzbget-api",
"secret-nzbget-api",
"bound-qbittorrent-api",
"secret-qbittorrent-api",
"bound-jackett-api",
"secret-jackett-api"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
"jackett-api",
"nzbget-api",
"qbittorrent-api",
"route" "route"
], ],
"contributes": { "contributes": {
"route": { "route": {
"label": "books", "label": "books",
"endpoint": "web" "port": 8787
} }
}, },
"binds": { "binds": {
"route": "${dir:state}/route.json", "route": "/var/lib/mesh/bookshelf/route.json"
"nzbget-api": "${dir:state}/nzbget-api.json",
"qbittorrent-api": "${dir:state}/qbittorrent-api.json",
"jackett-api": "${dir:state}/jackett-api.json"
},
"secrets": {
"nzbget-api": "${dir:state}/nzbget-api.secret",
"qbittorrent-api": "${dir:state}/qbittorrent-api.secret",
"jackett-api": "${dir:state}/jackett-api.secret"
}, },
"build": { "build": {
"on": [ "on": [
-5
View File
@@ -4,11 +4,6 @@
"description": "bookshelf — ebook/audiobook management (Readarr fork). Its API client, tools and events live here (novox/hq ADR 0039).", "description": "bookshelf — ebook/audiobook management (Readarr fork). Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts downloads/settings.ts downloads/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.0"
}, },
-365
View File
@@ -1,365 +0,0 @@
// What holds the downloads step (downloads/settings.ts): the app's download clients and jackett
// feeds are made to say what the mesh bound — host, port, TLS, base path, user, credential — and
// nothing else they keep is touched; only the mesh's entries are touched, and nothing is ever
// deleted; nothing is written when nothing differs; a missing one is registered; and a credential
// the provider refuses (the mesh's own minted value, before the operator accepts the provider's)
// is never written, with the `secret accept` that fixes it named.
//
// The app and the providers are fakes: the routes the step touches, answering as the real ones do
// (checked against the catalogue's pinned sonarr, radarr, lidarr, bookshelf, nzbget, qBittorrent
// and jackett): the app masks a stored password as "********", tests an enabled entry before
// saving it, refuses a warning unless forceSave, and jackett answers a wrong key 200 with an error.
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
CLIENTS,
acceptRemedy,
jackettFeed,
providerTakes,
reconcileClient,
reconcileIndexers,
stepSettings,
wanted,
type App,
type Binding,
type Endpoint,
type Entry,
type Http,
} from "../downloads/settings.ts";
const NZBGET = CLIENTS.find((c) => c.provision === "nzbget-api")!;
const QBIT = CLIENTS.find((c) => c.provision === "qbittorrent-api")!;
const APP: App = { module: "sonarr", url: "http://127.0.0.1:8989", apiKey: "app-key", api: "v3" };
const MASK = "********";
interface Provider {
host: string;
port: number;
user?: string;
secret: string;
configured?: Record<string, string>;
}
const nzbget: Provider = { host: "ace.internal", port: 20201, user: "luffy", secret: "nzb-real" };
const qbit: Provider = { host: "ace.internal", port: 8112, user: "luffy", secret: "qbt-real" };
const jackett: Provider = { host: "ace.internal", port: 20204, secret: "jackett-real", configured: { rutracker: "RuTracker", torrent9: "Torrent9" } };
function binding(p: Provider, provision: string, extra: Record<string, unknown> = {}): Binding {
return {
provision,
from: "ace",
at: p.host,
as: "mesh_ace_sonarr",
serves: { scheme: "http", port: p.port, "url-base": "", ...(p.user ? { username: p.user } : {}), ...extra },
};
}
function endpoint(p: Provider, provision: string, credential = p.secret): Endpoint {
const w = wanted(provision, binding(p, provision), credential, provision !== "jackett-api");
if (!w.ok) throw new Error(w.problem);
return w.endpoint;
}
const f = (name: string, value: unknown) => ({ name, value });
function client(id: number, name: string, implementation: string, host: string, port: number, password: string, extra: Record<string, unknown> = {}): Entry {
return {
id, name, implementation, enable: true, priority: 1, tags: [],
fields: [f("host", host), f("port", port), f("useSsl", false), f("urlBase", null), f("username", "luffy"), f("password", password), f("tvCategory", "Series"), ...Object.entries(extra).map(([k, v]) => f(k, v))],
};
}
function feed(id: number, name: string, baseUrl: string, jid: string, key: string, on = true): Entry {
return {
id, name, implementation: "Torznab", enableRss: on, enableAutomaticSearch: on, enableInteractiveSearch: on, priority: 25,
fields: [f("baseUrl", baseUrl), f("apiPath", `/api/v2.0/indexers/${jid}/results/torznab/`), f("apiKey", key), f("categories", [5000])],
};
}
interface Call {
method: string;
url: string;
body?: unknown;
}
/** A Servarr app and the three providers behind one fetch. */
function fakes(start: { clients?: Entry[]; indexers?: Entry[] }) {
const calls: Call[] = [];
const store: Record<string, Entry[]> = {
downloadclient: structuredClone(start.clients ?? []),
indexer: structuredClone(start.indexers ?? []),
};
let next = 100;
const secretOf: Record<string, string> = { downloadclient: "password", indexer: "apiKey" };
const masked = (e: Entry): Entry => ({ ...e, fields: e.fields!.map((x) => (x.name === "password" || x.name === "apiKey") && x.value ? { ...x, value: MASK } : { ...x }) });
const val = (e: Entry, n: string) => e.fields?.find((x) => x.name === n)?.value;
/** What the app's own test says: dialled from its container, with its stored secret for a mask. */
const appTest = (kind: string, e: Entry): { propertyName: string; errorMessage: string; isWarning: boolean }[] => {
let secret = val(e, secretOf[kind]);
if (secret === MASK) secret = val(store[kind].find((s) => s.id === e.id) ?? {}, secretOf[kind]);
if (kind === "downloadclient") {
const p = e.implementation === "Nzbget" ? nzbget : qbit;
if (val(e, "host") !== p.host || val(e, "port") !== p.port) return [{ propertyName: "Host", errorMessage: "Unable to connect", isWarning: false }];
if (secret !== p.secret || val(e, "username") !== p.user) return [{ propertyName: "Username", errorMessage: "Authentication Failure", isWarning: false }];
if (val(e, "tvCategory") === "") return [{ propertyName: "TvCategory", errorMessage: "A category is recommended", isWarning: true }];
if (e.implementation === "Nzbget" && val(e, "tvCategory") === "tv") return [{ propertyName: "TvCategory", errorMessage: "Category does not exist", isWarning: false }];
return [];
}
const u = new URL(String(val(e, "baseUrl")));
if (u.hostname !== jackett.host || Number(u.port) !== jackett.port) return [{ propertyName: "BaseUrl", errorMessage: "Unable to connect", isWarning: false }];
if (secret !== jackett.secret) return [{ propertyName: "ApiKey", errorMessage: "Invalid API Key", isWarning: false }];
const id = /indexers\/([^/]+)\//.exec(String(val(e, "apiPath")))?.[1] ?? "";
if (!(id in jackett.configured!)) return [{ propertyName: "", errorMessage: "Unknown indexer", isWarning: false }];
return [];
};
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
const body = init?.body && init.headers?.["Content-Type"] === "application/json" ? (JSON.parse(init.body) as Entry) : init?.body;
calls.push({ method, url, body });
const reply = (status: number, value?: unknown) => ({ status, text: async () => (value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value)) });
const u = new URL(url);
// Providers.
if (u.pathname === "/jsonrpc/version") {
const want = "Basic " + Buffer.from(`${nzbget.user}:${nzbget.secret}`).toString("base64");
return init?.headers?.Authorization === want ? reply(200, { result: "26.0" }) : reply(401);
}
if (u.pathname === "/api/v2/auth/login") {
const form = new URLSearchParams(String(init?.body ?? ""));
return form.get("username") === qbit.user && form.get("password") === qbit.secret ? reply(204) : reply(401, "Unauthorized");
}
if (u.pathname.endsWith("/torznab/api")) {
if (u.searchParams.get("apikey") !== jackett.secret) return reply(200, '<?xml version="1.0"?><error code="100" description="Invalid API Key" />');
const items = Object.entries(jackett.configured!).map(([id, t]) => `<indexer id="${id}" configured="true"><title>${t}</title></indexer>`).join("");
return reply(200, `<?xml version="1.0"?><indexers>${items}</indexers>`);
}
// The app.
if (init?.headers?.["X-Api-Key"] !== APP.apiKey) return reply(401);
const m = /^\/api\/v3\/(downloadclient|indexer)(?:\/(schema|test|\d+))?$/.exec(u.pathname);
if (!m) return reply(404);
const [, kind, sub] = m;
const force = u.searchParams.get("forceSave") === "true";
if (method === "GET" && !sub) return reply(200, store[kind].map(masked));
if (method === "GET" && sub === "schema") {
return reply(200, kind === "downloadclient"
? [client(0, "", "Nzbget", "localhost", 6789, MASK, {}), client(0, "", "QBittorrent", "localhost", 8080, "", {})].map((e) => ({ ...e, fields: e.fields!.map((x) => x.name === "tvCategory" ? { ...x, value: e.implementation === "Nzbget" ? "tv" : "tv-sonarr" } : x.name === "username" ? { ...x, value: null } : x) }))
: [{ ...feed(0, "", "", "x", "", false), supportsRss: true, supportsSearch: true, fields: [f("baseUrl", null), f("apiPath", "/api"), f("apiKey", null), f("categories", [5030, 5040])] }]);
}
if (method === "GET") {
const e = store[kind].find((s) => s.id === Number(sub));
return e ? reply(200, masked(e)) : reply(404);
}
if (method === "POST" && sub === "test") {
const fails = appTest(kind, body as Entry);
return fails.length ? reply(400, fails) : reply(200);
}
// Save: tested when enabled; a mask keeps what is stored.
const e = body as Entry;
const on = e.enable === true || e.enableRss === true || e.enableAutomaticSearch === true;
if (on) {
const fails = appTest(kind, e);
if (fails.some((x) => !x.isWarning) || (fails.length && !force)) return reply(400, fails);
}
if (method === "POST" && !sub) {
if (store[kind].some((s) => String(s.name).toLowerCase() === String(e.name).toLowerCase())) {
return reply(400, [{ propertyName: "Name", errorMessage: "Should be unique", isWarning: false }]);
}
const created = { ...e, id: next++ };
store[kind].push(created);
return reply(201, masked(created));
}
if (method === "PUT") {
const i = store[kind].findIndex((s) => s.id === Number(sub));
if (i < 0) return reply(404);
const was = store[kind][i];
store[kind][i] = { ...e, fields: e.fields!.map((x) => x.value === MASK ? { ...x, value: val(was, x.name) } : x) };
return reply(202, masked(store[kind][i]));
}
return reply(405);
},
};
const saves = () => calls.filter((c) => (c.method === "PUT" || (c.method === "POST" && !c.url.endsWith("/test"))) && c.url.includes("/api/v3/"));
return { http, calls, store, saves };
}
const aceClients = () => [
client(1, "NZBGet", "Nzbget", "nzbget", 6789, "nzb-real"),
client(2, "qBitTorrent", "QBittorrent", "qbittorrent", 8112, "qbt-real"),
];
test("the migration's download client is repointed, its category and everything else kept", async () => {
const f = fakes({ clients: aceClients() });
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(nzbget, "nzbget-api"), f.store.downloadclient.map((e) => ({ ...e })));
assert.deepEqual(out, [{ what: 'nzbget-api ("NZBGet")', result: "written", fields: ["host", "port"] }]);
const saved = f.store.downloadclient.find((e) => e.id === 1)!;
const v = (n: string) => saved.fields!.find((x) => x.name === n)?.value;
assert.equal(v("host"), "ace.internal");
assert.equal(v("port"), 20201);
assert.equal(v("tvCategory"), "Series");
assert.equal(v("password"), "nzb-real", "the password it held works, so it was kept, not rewritten");
assert.equal(saved.priority, 1);
});
test("rerun: already as the mesh says, nothing saved", async () => {
const f = fakes({ clients: [client(1, "NZBGet", "Nzbget", "ace.internal", 20201, "nzb-real")] });
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(nzbget, "nzbget-api"), f.store.downloadclient);
assert.deepEqual(out, [{ what: 'nzbget-api ("NZBGet")', result: "unchanged" }]);
assert.equal(f.saves().length, 0);
});
test("a stale password is replaced by the delivered one, which the provider took first", async () => {
const f = fakes({ clients: [client(2, "qBitTorrent", "QBittorrent", "ace.internal", 8112, "old-pass")] });
const out = await reconcileClient(f.http, APP, QBIT, "qBitTorrent", endpoint(qbit, "qbittorrent-api"), f.store.downloadclient);
assert.deepEqual(out, [{ what: 'qbittorrent-api ("qBitTorrent")', result: "written", fields: ["password"] }]);
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "password")?.value, "qbt-real");
});
test("a minted credential is refused by the provider: nothing written, the accept named", async () => {
const f = fakes({ clients: aceClients() });
const ep = endpoint(nzbget, "nzbget-api", "a-value-the-mesh-minted");
assert.deepEqual(await providerTakes(f.http, "nzbget-api", ep), { took: false });
const remedy = acceptRemedy(APP, "ace", "nzbget-api", "nzbget", "ControlPassword", ep.from);
assert.match(remedy, /`secret accept ace sonarr nzbget-api --provider ace --from <file holding nzbget's ControlPassword>`/);
assert.doesNotMatch(remedy, /minted/);
assert.equal(f.calls.some((c) => c.url.includes("/api/v3/")), false, "the app was not even asked");
const q = endpoint(qbit, "qbittorrent-api", "minted");
assert.deepEqual(await providerTakes(f.http, "qbittorrent-api", q), { took: false });
const j = endpoint(jackett, "jackett-api", "minted");
assert.deepEqual(await providerTakes(f.http, "jackett-api", j), { took: false });
assert.equal(f.calls.find((c) => c.url.includes("apikey="))?.url.includes("jackett-real"), false);
});
test("a fresh app gets the mesh's client registered, under the mesh's name", async () => {
const f = fakes({});
const out = await reconcileClient(f.http, APP, QBIT, "qbittorrent", endpoint(qbit, "qbittorrent-api"), []);
assert.equal(out[0].result, "created");
const e = f.store.downloadclient[0];
assert.equal(e.name, "qbittorrent");
assert.equal(e.enable, true);
assert.equal(e.fields!.find((x) => x.name === "tvCategory")?.value, "tv-sonarr", "the app's own default category");
});
test("nzbget without the app's default category: registered with none, and said", async () => {
const f = fakes({});
const out = await reconcileClient(f.http, APP, NZBGET, "nzbget", endpoint(nzbget, "nzbget-api"), []);
assert.equal(out[0].result, "notice");
assert.match((out[0] as { note: string }).note, /category left empty/);
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "tvCategory")?.value, "");
});
test("somebody else's entries are never touched: another name of the same kind, and a clash of names", async () => {
const mine = client(7, "My seedbox", "QBittorrent", "seedbox.example", 443, "theirs");
const f = fakes({ clients: [mine] });
const out = await reconcileClient(f.http, APP, QBIT, "qbittorrent", endpoint(qbit, "qbittorrent-api"), f.store.downloadclient);
assert.equal(out[0].result, "notice");
assert.deepEqual(f.store.downloadclient.find((e) => e.id === 7), mine);
// Same name in another case: refused, and the setting that adopts it named.
const g = fakes({ clients: aceClients() });
const clash = await reconcileClient(g.http, APP, NZBGET, "nzbget", endpoint(nzbget, "nzbget-api"), g.store.downloadclient);
assert.equal(clash[0].result, "refused");
assert.match((clash[0] as { problem: string }).problem, /downloads\.nzbget-api\.name to "NZBGet"/);
assert.equal(g.saves().length, 0);
});
test("jackett feeds: the migration's are repointed, a person's is left, a listed one is registered", async () => {
const personal = feed(9, "Seedbox jackett", "https://jackett.seedbox.example", "rutracker", "their-key");
const f = fakes({
indexers: [
feed(5, "Torznab - RuTracker", "https://indexers.zurag.be", "rutracker-ru", "jackett-real", false),
feed(6, "Torznab - Torrent9", "https://indexers.zurag.be", "torrent9", "jackett-real", false),
feed(4, "Jackett - RARBG", "http://jackett:9117", "therarbg", "old", false),
personal,
],
});
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(
f.http, APP, endpoint(jackett, "jackett-api"), configured,
{ adoptHosts: ["indexers.zurag.be", "jackett"], indexers: ["rutracker"] }, [], f.store.indexer,
);
const byWhat = Object.fromEntries(out.map((o) => [o.what + ":" + o.result, o]));
// torrent9: jackett has it; repointed, key already right, tested.
assert.ok(byWhat['jackett-api ("Torznab - Torrent9", jackett indexer torrent9):written']);
// rutracker-ru and therarbg: jackett has neither — repointed with the key, untested, and said.
assert.ok(byWhat['jackett-api ("Jackett - RARBG", jackett indexer therarbg):written']);
assert.ok(byWhat['jackett-api ("Jackett - RARBG", jackett indexer therarbg):notice']);
// rutracker is listed and nothing of the mesh's reads it: registered.
assert.ok(byWhat["jackett-api (jackett indexer rutracker):created"]);
assert.deepEqual(f.store.indexer.find((e) => e.id === 9), personal, "a person's jackett is not the mesh's");
const t9 = f.store.indexer.find((e) => e.id === 6)!;
assert.equal(t9.fields!.find((x) => x.name === "baseUrl")?.value, "http://ace.internal:20204");
assert.equal(t9.enableRss, false, "disabled stays disabled");
const created = f.store.indexer.find((e) => e.name === "Jackett - RuTracker")!;
assert.equal(created.enableRss, true);
assert.equal(f.calls.some((c) => c.method === "DELETE"), false, "nothing is ever deleted");
});
test("jackett feeds rerun: nothing saved, and a remembered host keeps a moved jackett's feeds", async () => {
const f = fakes({ indexers: [feed(6, "Torznab - Torrent9", "http://ace.internal:20204", "torrent9", "jackett-real")] });
const configured = new Map(Object.entries(jackett.configured!));
const out = await reconcileIndexers(f.http, APP, endpoint(jackett, "jackett-api"), configured, { adoptHosts: [], indexers: ["torrent9"] }, [], f.store.indexer);
assert.deepEqual(out.map((o) => o.result), ["unchanged"]);
assert.equal(f.saves().length, 0);
// jackett moved to novox: the feed on ace.internal is still the mesh's because it was remembered.
const moved = { ...jackett, host: "novox.internal" };
const g = fakes({ indexers: [feed(6, "Torznab - Torrent9", "http://ace.internal:20204", "torrent9", "jackett-real")] });
const saved = jackett.host;
jackett.host = moved.host;
try {
const again = await reconcileIndexers(g.http, APP, endpoint(moved, "jackett-api"), configured, { adoptHosts: [], indexers: [] }, ["ace.internal"], g.store.indexer);
assert.equal(again[0].result, "written");
assert.equal(g.store.indexer[0].fields!.find((x) => x.name === "baseUrl")?.value, "http://novox.internal:20204");
} finally {
jackett.host = saved;
}
});
test("an enabled entry the app cannot test at the mesh's address is not saved", async () => {
const f = fakes({ clients: [client(1, "NZBGet", "Nzbget", "nzbget", 6789, "nzb-real")] });
const elsewhere = { ...nzbget, port: 1 };
const out = await reconcileClient(f.http, APP, NZBGET, "NZBGet", endpoint(elsewhere, "nzbget-api"), f.store.downloadclient);
assert.equal(out[0].result, "refused");
assert.equal(f.store.downloadclient[0].fields!.find((x) => x.name === "host")?.value, "nzbget", "left as it was");
});
test("bindings: loopback, no user, no credential are refused; the base path is normalised", () => {
assert.equal(wanted("nzbget-api", { ...binding(nzbget, "nzbget-api"), at: "127.0.0.1" }, "x", true).ok, false);
assert.equal(wanted("nzbget-api", binding({ ...nzbget, user: undefined }, "nzbget-api"), "x", true).ok, false);
assert.equal(wanted("nzbget-api", binding(nzbget, "nzbget-api"), " \n", true).ok, false);
const w = wanted("jackett-api", binding(jackett, "jackett-api", { "url-base": "jackett/" }), "k", false);
assert.equal(w.ok && w.endpoint.urlBase, "/jackett");
});
test("a feed is read whole: base path in the base URL or in the API path", () => {
const e = feed(1, "x", "http://ace.internal:9117/jackett", "rutracker", "k");
assert.deepEqual(jackettFeed(e), { host: "ace.internal", id: "rutracker" });
assert.equal(jackettFeed({ ...e, implementation: "Newznab" }), undefined);
assert.equal(jackettFeed({ ...e, fields: [f("baseUrl", "https://api.nzbgeek.info"), f("apiPath", "/api")] }), undefined);
});
test("settings: names default to the provider's, adoption and registration read from the merged file", () => {
assert.deepEqual(stepSettings({ node: "ace" }), { node: "ace", names: { "nzbget-api": "nzbget", "qbittorrent-api": "qbittorrent" }, indexers: { adoptHosts: [], indexers: [] } });
const s = stepSettings({ node: "ace", downloads: { "nzbget-api": { name: "NZBGet" }, "jackett-api": { "adopt-hosts": ["jackett"], indexers: ["torrent9", 3] } } });
assert.equal(s.names["nzbget-api"], "NZBGet");
assert.deepEqual(s.indexers, { adoptHosts: ["jackett"], indexers: ["torrent9"] });
});
// The four copies are one step. Where the siblings are checked out beside this module, they must
// be byte-identical — a fix made in one and not the others is a bug in three apps.
test("the step is the same in sonarr, radarr, lidarr and bookshelf", () => {
const here = dirname(dirname(fileURLToPath(import.meta.url)));
const modules = dirname(here);
for (const file of ["downloads/settings.ts", "downloads/index.ts", "test/downloads.test.ts"]) {
const mine = readFileSync(join(here, file), "utf8");
for (const sibling of ["sonarr", "radarr", "lidarr", "bookshelf"]) {
const theirs = join(modules, sibling, file);
if (existsSync(theirs)) assert.equal(readFileSync(theirs, "utf8"), mine, `${sibling}/${file} differs`);
}
}
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "tools/index.ts", "downloads/settings.ts", "downloads/index.ts"] "include": ["client.ts", "index.ts", "tools/index.ts"]
} }
-25
View File
@@ -1,25 +0,0 @@
ARG GO_BASE
ARG ALPINE_BASE
# builder's own image: the build machine itself, compiled into a container.
#
# **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder,
# internal/catalogue — lives in the mesh-controller repository, the same control plane it is one
# half of. This module ships the packaging, not a second copy of the source, so the build context
# is the mesh-controller repository root (declared under build.artifacts[].context), and this
# Dockerfile compiles ./cmd/mesh-builder from it — the same shape route-proxy already uses for the
# same reason.
FROM ${GO_BASE} AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
# not a second copy of either tool vendored into this image. apk installs both from the base's own
# packages, not fetched on its own at build time.
FROM ${ALPINE_BASE}
RUN apk add --no-cache docker-cli git
COPY --from=build /mesh-builder /usr/local/bin/mesh-builder
ENTRYPOINT ["/usr/local/bin/mesh-builder"]
+28 -38
View File
@@ -6,22 +6,19 @@
], ],
"claims": [ "claims": [
{ {
"name": "mesh-build-machine", "name": "the-build-machine",
"scope": "mesh" "scope": "node"
} }
], ],
"requires": [ "requires": [
"artifact-store", "artifact-store"
"npm-package-registry" ],
"emits": [
"module.builder.built"
], ],
"binds": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json"
},
"secrets": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret"
},
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/builder/broker" "broker": "/var/lib/mesh/builder/broker",
"npm-password": "/var/lib/mesh/builder/npm-password"
}, },
"resources": [ "resources": [
{ {
@@ -41,13 +38,27 @@
"type": "file", "type": "file",
"path": "/var/lib/mesh/builder/builder.env", "path": "/var/lib/mesh/builder/builder.env",
"mode": "0600", "mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=/var/lib/builder/workspace\n" "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/npm-password\nMESH_WORKSPACE=/var/lib/builder/workspace\n"
},
{
"id": "package-binding",
"type": "file",
"path": "/var/lib/mesh/builder/package-registry.json",
"mode": "0600",
"merge": "json",
"protected": [
"provision",
"from",
"at",
"as"
],
"content": "{\"provision\": \"package-registry\", \"from\": \"gitea\", \"at\": \"127.0.0.1\", \"as\": \"mesh-builder\", \"serves\": {\"scheme\": \"http\", \"port\": 3000, \"npm-path\": \"/api/packages/novox/npm/\"}}\n"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "mesh-builder", "name": "mesh-builder",
"artifact": "server", "image": "mesh-builder@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"env-file": [ "env-file": [
"/var/lib/mesh/builder/builder.env" "/var/lib/mesh/builder/builder.env"
], ],
@@ -57,32 +68,11 @@
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"restart-on": [ "restart-on": [
"builder-env" "builder-env",
"package-binding",
"needs-npm-password"
], ],
"network": "host" "network": "host"
} }
], ]
"build": {
"artifacts": [
{
"name": "server",
"kind": "image",
"from": "Dockerfile",
"context": {
"repository": "https://git.novox.be/novox/mesh-controller.git",
"ref": "main"
}
}
],
"on": [
{
"arg": "GO_BASE",
"image": "golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
},
{
"arg": "ALPINE_BASE",
"image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"
}
]
}
} }
-56
View File
@@ -1,56 +0,0 @@
{
"module": "ca-trust",
"version": "1",
"slug": "catrust",
"capabilities": [
"service-manager"
],
"requires": [
"internal-acme-ca"
],
"seats": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"claims": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "anchor",
"type": "file",
"path": "${dir:state}/anchor",
"mode": "0755",
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
},
{
"id": "unit",
"type": "file",
"path": "/etc/systemd/system/mesh-ca-trust.service",
"mode": "0644",
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
},
{
"id": "trust",
"type": "service",
"unit": "mesh-ca-trust.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"anchor",
"unit"
]
}
]
}
+2 -2
View File
@@ -22,8 +22,8 @@
"broker": "/var/lib/mesh/cloudflare-dns/broker" "broker": "/var/lib/mesh/cloudflare-dns/broker"
}, },
"emits": [ "emits": [
"record.created", "module.cloudflare-dns.record.created",
"record.removed" "module.cloudflare-dns.record.removed"
], ],
"resources": [ "resources": [
{ {
+2 -2
View File
@@ -20,7 +20,7 @@ runProvisioner("public-dns", {
async create(p: Provision): Promise<void> { async create(p: Provision): Promise<void> {
const fqdn = cloudflare.nameFor(p.as); const fqdn = cloudflare.nameFor(p.as);
await cloudflare.upsert(fqdn); await cloudflare.upsert(fqdn);
await announce("record.created", { await announce("module.cloudflare-dns.record.created", {
name: fqdn, name: fqdn,
target: cloudflare.ingress, target: cloudflare.ingress,
consumer: p.consumer ?? "", consumer: p.consumer ?? "",
@@ -30,7 +30,7 @@ runProvisioner("public-dns", {
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
const fqdn = cloudflare.nameFor(p.as); const fqdn = cloudflare.nameFor(p.as);
await cloudflare.remove(fqdn); await cloudflare.remove(fqdn);
await announce("record.removed", { name: fqdn, consumer: p.as }); await announce("module.cloudflare-dns.record.removed", { name: fqdn, consumer: p.as });
}, },
}); });
+2 -3
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "de-spiegel", "label": "de-spiegel",
"endpoint": "web" "port": 35621
} }
}, },
"binds": { "binds": {
@@ -23,7 +23,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 35621, "port": 35621,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -59,7 +58,7 @@
"/var/lib/de-spiegel/server.env" "/var/lib/de-spiegel/server.env"
], ],
"ports": [ "ports": [
"35621" "35621:35621"
], ],
"secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change" "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change"
} }
-43
View File
@@ -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.
-30
View File
@@ -1,30 +0,0 @@
{
"module": "dhcpcd",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"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"
}
]
}
+1 -1
View File
@@ -33,7 +33,7 @@ async function pollCatalog(): Promise<void> {
for (const tag of tags) { for (const tag of tags) {
const id = `${repo}:${tag}`; const id = `${repo}:${tag}`;
if (!seen.has(id)) { if (!seen.has(id)) {
if (primed) await emit("image.pushed", { repo, tag }); if (primed) await emit("module.registry.image.pushed", { repo, tag });
seen.add(id); seen.add(id);
} }
} }
+3 -10
View File
@@ -10,14 +10,14 @@
"claims": [ "claims": [
{ {
"name": "the-artifact-store", "name": "the-artifact-store",
"scope": "mesh" "scope": "node"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"image.pushed" "module.registry.image.pushed"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/registry/broker" "broker": "/var/lib/mesh/registry/broker"
@@ -29,7 +29,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "registry",
"port": 5000, "port": 5000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -43,12 +42,6 @@
"path": "/var/lib/mesh/registry", "path": "/var/lib/mesh/registry",
"mode": "0700" "mode": "0700"
}, },
{
"id": "registry-data",
"type": "directory",
"path": "/var/lib/mesh-registry",
"mode": "0700"
},
{ {
"id": "store", "id": "store",
"type": "container", "type": "container",
@@ -58,7 +51,7 @@
"5000:5000" "5000:5000"
], ],
"volumes": [ "volumes": [
"/var/lib/mesh-registry:/var/lib/registry" "mesh-registry-data:/var/lib/registry"
] ]
} }
] ]
+2 -2
View File
@@ -20,10 +20,10 @@ async function poll(): Promise<void> {
const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address])); const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address]));
if (primed) { if (primed) {
for (const [name, address] of now) { for (const [name, address] of now) {
if (!known.has(name)) await emit("name.added", { name, address }); if (!known.has(name)) await emit("module.dnsmasq.name.added", { name, address });
} }
for (const [name] of known) { for (const [name] of known) {
if (!now.has(name)) await emit("name.removed", { name }); if (!now.has(name)) await emit("module.dnsmasq.name.removed", { name });
} }
} }
known.clear(); known.clear();
File diff suppressed because one or more lines are too long
+2 -10
View File
@@ -6,7 +6,7 @@
], ],
"claims": [ "claims": [
{ {
"name": "node-intrusion-prevention", "name": "the-intrusion-prevention",
"scope": "node" "scope": "node"
} }
], ],
@@ -33,7 +33,7 @@
"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\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
}, },
{ {
"id": "jail-sshd", "id": "jail-sshd",
@@ -42,14 +42,6 @@
"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 = 5\n"
}, },
{
"id": "log",
"type": "file",
"path": "/var/log/fail2ban.log",
"mode": "0640",
"create-once": true,
"content": ""
},
{ {
"id": "jail-recidive", "id": "jail-recidive",
"type": "file", "type": "file",
+1 -1
View File
@@ -23,7 +23,7 @@ COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are # The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image # symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled. # was assembled.
RUN node /app/node_modules/typescript/bin/tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
+45 -137
View File
@@ -4,13 +4,10 @@
// does. // does.
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
/** A repository, trimmed to what the mesh cares about. */ /** A repository, trimmed to what the mesh cares about. */
export interface GiteaRepo { export interface GiteaRepo {
full_name: string; full_name: string;
/** The URL a build clones — what a module records as its source. */
clone_url?: string;
name: string; name: string;
owner: string; owner: string;
private: boolean; private: boolean;
@@ -36,9 +33,6 @@ export interface GiteaPull {
title: string; title: string;
state: string; state: string;
merged: boolean; merged: boolean;
/** The commit the merge produced — what a build of the base branch is made from. */
merge_commit_sha?: string;
merged_at?: string;
user?: string; user?: string;
head?: string; head?: string;
base?: string; base?: string;
@@ -59,60 +53,44 @@ function meshConfig(file?: string): Record<string, string> {
export class GiteaClient { export class GiteaClient {
readonly baseUrl: string; readonly baseUrl: string;
private readonly tokens: TokenSource; private cachedUsername: string | null = null;
/** A token given as a string is one somebody configured; a source decides for itself (token.ts). */ constructor(
constructor(url: string, token: string | TokenSource) { url: string,
private readonly token: string,
) {
this.baseUrl = url.replace(/\/+$/, ""); this.baseUrl = url.replace(/\/+$/, "");
this.tokens = typeof token === "string" ? new ConfiguredToken(token) : token;
} }
/** /**
* Build from the module's resolved environment. The URL comes from MESH_GITEA_URL (the mesh's own * Build from the module's resolved environment. URL and token come from MESH_GITEA_URL /
* name), falling back to the bare GITEA_URL and to the forge's loopback port. The token, in order: * MESH_GITEA_TOKEN (the mesh's own names), falling back to the bare GITEA_* names and, for the
* one configured in settings or the environment (MESH_GITEA_TOKEN / GITEA_TOKEN), which wins; else * URL, to the forge's loopback port. A token is required — without one there is no authenticated
* one the module mints for itself with the admin account the vault delivered and keeps in its own * call to make, so this throws rather than hand back a client that fails on first use.
* state (token.ts; hq issue 100). Throws only when neither is possible, naming what is missing,
* rather than hand back a client that fails on first use.
*/ */
static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): GiteaClient {
const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE); const cfg = meshConfig(env.MESH_GITEA_CONFIG_FILE);
const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`; const url = cfg.url ?? env.MESH_GITEA_URL ?? env.GITEA_URL ?? `http://127.0.0.1:${env.GITEA_PORT ?? "3000"}`;
const configured = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN; const token = cfg.token ?? env.MESH_GITEA_TOKEN ?? env.GITEA_TOKEN;
if (configured) return new GiteaClient(url, new ConfiguredToken(configured)); if (!token) throw new Error("no Gitea token — set MESH_GITEA_TOKEN");
return new GiteaClient(url, MintedToken.fromEnv(url, env)); return new GiteaClient(url, token);
} }
/**
* One authenticated call. A 401 is the forge saying the token is not one it knows — the case
* after the forge's data was restored, or after somebody revoked it — so the source is asked to
* renew once and the call is repeated with the new token. A configured token has nothing to renew
* with, and its source says so.
*/
private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> { private async request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
let token = await this.tokens.current(); const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
let res = await this.send(path, options, token); ...options,
if (res.status === 401) { headers: {
token = await this.tokens.renew(token); "Content-Type": "application/json",
res = await this.send(path, options, token); Authorization: `token ${this.token}`,
} ...(options.headers as Record<string, string> | undefined),
},
});
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
if (res.status === 204) return null as T; if (res.status === 204) return null as T;
const text = await res.text(); const text = await res.text();
return (text ? JSON.parse(text) : null) as T; return (text ? JSON.parse(text) : null) as T;
} }
private send(path: string, options: RequestInit, token: string): Promise<Response> {
return fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `token ${token}`,
...(options.headers as Record<string, string> | undefined),
},
});
}
/** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method.
* Path is relative to /api/v1. */ * Path is relative to /api/v1. */
async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> { async api<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
@@ -121,23 +99,9 @@ export class GiteaClient {
// ---- Repositories ---- // ---- Repositories ----
/** Every repository this token can see, one page. `/user/repos` is only what the token's own
* user owns — for the mesh's administrator that is nothing, which is how the forge watched an
* empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> { async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`); const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
return (found?.data ?? []).map(GiteaClient.mapRepo); return (repos ?? []).map(GiteaClient.mapRepo);
}
/** Every repository, all pages. */
async listAllRepos(): Promise<GiteaRepo[]> {
const all: GiteaRepo[] = [];
for (let page = 1; page < 100; page++) {
const batch = await this.listRepos(page, 50);
all.push(...batch);
if (batch.length < 50) break;
}
return all;
} }
async createRepo(data: { async createRepo(data: {
@@ -229,17 +193,6 @@ export class GiteaClient {
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`)); return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
} }
/** The files a merged pull request changed, as paths from the repository's root.
*
* `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 };
}
async createPullRequest( async createPullRequest(
owner: string, owner: string,
repo: string, repo: string,
@@ -262,7 +215,6 @@ export class GiteaClient {
private static mapRepo(r: any): GiteaRepo { private static mapRepo(r: any): GiteaRepo {
return { return {
full_name: r.full_name, full_name: r.full_name,
clone_url: r.clone_url ?? undefined,
name: r.name, name: r.name,
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown", owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
private: Boolean(r.private), private: Boolean(r.private),
@@ -290,8 +242,6 @@ export class GiteaClient {
title: p.title, title: p.title,
state: p.state, state: p.state,
merged: Boolean(p.merged), merged: Boolean(p.merged),
merge_commit_sha: p.merge_commit_sha ?? undefined,
merged_at: p.merged_at ?? undefined,
user: p.user?.login, user: p.user?.login,
head: p.head?.ref, head: p.head?.ref,
base: p.base?.ref, base: p.base?.ref,
@@ -385,34 +335,24 @@ export class GiteaAdmin {
GiteaAdmin.fail("/orgs", res); GiteaAdmin.fail("/orgs", res);
} }
/** Ensure the org's package team exists with exactly these units, and return its id. Found or /** Ensure the org's package team exists, granting read+write on packages, and return its id. The
* created, the units are applied either way — a team is configuration the reconcile loop owns, * team is found by name if it is already there, created otherwise; a lost create race is resolved
* the same as a user's password, so a unit this code gains reaches a team that already exists * by re-listing. */
* rather than only the next mesh raised from scratch. A lost create race is resolved by
* re-listing. */
async ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number> { async ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number> {
// The units a consumer needs, and no more. `units_map` is exhaustive — a unit not named is a
// unit the team does not have — so code read must be said here: without it gitea answers a
// member's clone of a private repository with "not found", which is how the builder's first
// credentialed clone failed against a team that named only packages.
const units = {
permission: "read",
units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" },
includes_all_repositories: true,
can_create_org_repo: false,
};
const found = await this.findTeam(org, team); const found = await this.findTeam(org, team);
if (found !== null) { if (found !== null) return found;
const patch = await this.request(`/teams/${found}`, {
method: "PATCH",
body: JSON.stringify({ name: team, ...units }),
});
if (patch.status === 200) return found;
GiteaAdmin.fail(`/teams/${found}`, patch);
}
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, { const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, {
method: "POST", method: "POST",
body: JSON.stringify({ name: team, ...units }), body: JSON.stringify({
name: team,
permission: "read",
// Package access is a per-unit grant; the team needs write on the packages unit and nothing
// else. includes_all_repositories keeps the team's repo view whole without widening its
// repo permission beyond read.
units_map: { "repo.packages": packageWrite ? "write" : "read" },
includes_all_repositories: true,
can_create_org_repo: false,
}),
}); });
if (res.status === 201) return Number(res.body?.id); if (res.status === 201) return Number(res.body?.id);
if (res.status === 422 || res.status === 409) { if (res.status === 422 || res.status === 409) {
@@ -423,18 +363,14 @@ export class GiteaAdmin {
} }
private async findTeam(org: string, team: string): Promise<number | null> { private async findTeam(org: string, team: string): Promise<number | null> {
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`);
if (res.status !== 200) return null; if (res.status !== 200) return null;
const match = (res.body as any[] | null)?.find((t) => t?.name === team); const match = (res.body as any[] | null)?.find((t) => t?.name === team);
return match ? Number(match.id) : null; return match ? Number(match.id) : null;
} }
/** Ensure a user exists with exactly this password. Created if absent; if already there, its /** Ensure a user exists with exactly this password. Created if absent; if already there, its
* password is patched — so the mesh minting a new secret takes on the next reconcile. * password is patched — so the mesh minting a new secret takes on the next reconcile. */
*
* The edit path is taken only when the user actually exists. A 422 from the create is also what
* a plain validation failure returns, and reading it as "already there" made the follow-up edit
* 404 — burying the create's own message, which is the one that says what is actually wrong. */
async ensureUser(username: string, password: string, email: string): Promise<void> { async ensureUser(username: string, password: string, email: string): Promise<void> {
const res = await this.request("/admin/users", { const res = await this.request("/admin/users", {
method: "POST", method: "POST",
@@ -442,18 +378,13 @@ export class GiteaAdmin {
}); });
if (res.status === 201) return; if (res.status === 201) return;
if (res.status === 422 || res.status === 409) { if (res.status === 422 || res.status === 409) {
const seen = await this.request(`/users/${encodeURIComponent(username)}`); const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
if (seen.status === 200) { method: "PATCH",
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, { // login_name is required by the admin edit endpoint; for a local user it is the username.
method: "PATCH", body: JSON.stringify({ login_name: username, password, must_change_password: false }),
// login_name is required by the admin edit endpoint; for a local user it is the username. });
// active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong if (patch.status === 200) return;
// password, so the provisioner's check reports it lost; applying again must undo both. GiteaAdmin.fail(`/admin/users/${username}`, patch);
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
});
if (patch.status === 200) return;
GiteaAdmin.fail(`/admin/users/${username}`, patch);
}
} }
GiteaAdmin.fail("/admin/users", res); GiteaAdmin.fail("/admin/users", res);
} }
@@ -468,29 +399,6 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res); GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
} }
/**
* Whether a consumer's user logs in with exactly this password and is still a member of the
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
* API, and membership through the admin API. `false` for a refused login or a missing member; any
* other answer rejects (novox/hq issue 120).
*/
async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean> {
const me = await fetch(`${this.baseUrl}/api/v1/user`, {
headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") },
});
if (me.status === 401 || me.status === 403) return false;
if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`);
const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
if (teams.status === 404) return false;
if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams);
const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team);
if (!found) return false;
const member = await this.request(`/teams/${found.id}/members/${encodeURIComponent(username)}`);
if (member.status === 200 || member.status === 204) return true;
if (member.status === 404) return false;
GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member);
}
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not /** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
* an error, so a re-run of remove is safe. */ * an error, so a re-run of remove is safe. */
async deleteUser(username: string): Promise<void> { async deleteUser(username: string): Promise<void> {
+5 -98
View File
@@ -16,9 +16,7 @@
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient } from "./client.js"; import { GiteaClient } from "./client.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // Without a token there is nothing to watch; log and stay quiet rather than crash the runtime.
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
// or reuses the token, so the runtime's start also shows what it did about it.
let gitea: GiteaClient | null = null; let gitea: GiteaClient | null = null;
try { try {
gitea = GiteaClient.fromEnv(); gitea = GiteaClient.fromEnv();
@@ -31,11 +29,11 @@ try {
const seen = new Set<string>(); const seen = new Set<string>();
let primed = false; let primed = false;
async function pollRepos(client: GiteaClient): Promise<void> { async function pollRepos(client: GiteaClient): Promise<void> {
const repos = await client.listAllRepos(); const repos = await client.listRepos(1, 50);
for (const repo of repos) { for (const repo of repos) {
if (!seen.has(repo.full_name)) { if (!seen.has(repo.full_name)) {
if (primed) { if (primed) {
await emit("repo.created", { await emit("module.gitea.repo.created", {
full_name: repo.full_name, full_name: repo.full_name,
owner: repo.owner, owner: repo.owner,
name: repo.name, name: repo.name,
@@ -49,104 +47,13 @@ async function pollRepos(client: GiteaClient): Promise<void> {
primed = true; primed = true;
} }
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
// What has been announced is kept beside the module's state, so a restart does not announce the
// whole history again — and the first tick on a machine with no record announces nothing, because
// everything it sees then predates the watching.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
const announced = new Set<string>();
let primedMerges = false;
// since is the moment the watching began: a merge made before it is history, whatever page of the
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
// rebuilt everything built from that repository, once per old merge (2026-09-28).
let since = "";
if (mergedRecord && existsSync(mergedRecord)) {
try {
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
const list = Array.isArray(kept) ? kept : kept.announced;
for (const sha of list) announced.add(sha);
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
primedMerges = true;
} catch {
// An unreadable record is treated as no record: prime again rather than re-announce history.
}
}
function keepAnnounced(): void {
if (!mergedRecord) return;
mkdirSync(join(mergedRecord, ".."), { recursive: true });
const tmp = mergedRecord + ".tmp";
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
renameSync(tmp, mergedRecord);
}
async function pollMerged(client: GiteaClient): Promise<void> {
const repos = await client.listAllRepos();
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) {
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.
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
if (primedMerges && fresh) {
// What it changed, asked for only now: a module is rebuilt because a file inside its own
// 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);
await emit("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
head: pull.head,
base: pull.base,
merge_commit_sha: pull.merge_commit_sha,
merged_at: pull.merged_at,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
});
// 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.
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
}
announced.add(pull.merge_commit_sha);
changed = true;
}
}
if (!primedMerges) since = new Date().toISOString();
if (!primedMerges || changed) keepAnnounced();
primedMerges = true;
}
if (gitea) { if (gitea) {
const client = gitea; const client = gitea;
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
// 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 => { const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => const run = (): void => void fn().catch((err) => console.error(`[gitea] ${err}`));
void fn()
.then(() => {
if (failing !== null) console.log("[gitea] watching again");
failing = null;
})
.catch((err) => {
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); setInterval(run, everyMs);
run(); run();
}; };
tick(() => pollRepos(client), 60_000); tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), 30_000); console.log("[gitea] watching for new repositories");
console.log("[gitea] watching for new repositories and merged pull requests");
} }
+30 -64
View File
@@ -11,80 +11,56 @@
"name": "gitea" "name": "gitea"
}, },
"route": { "route": {
"web": { "label": "git",
"label": "git", "port": 3000
"endpoint": "web"
},
"internal-api-refused": {
"label": "git",
"path": "/api/internal",
"deny": true,
"priority": 100000
}
} }
}, },
"binds": { "binds": {
"postgres-database": "${dir:state}/database.json", "postgres-database": "/var/lib/gitea/database.json",
"route": "${dir:state}/route.json" "route": "/var/lib/gitea/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "${dir:state}/database.secret", "postgres-database": "/var/lib/gitea/database.secret",
"secret": { "secret": {
"internal-token": "${dir:state}/internal-token.secret", "internal-token": "/var/lib/gitea/internal-token.secret",
"admin": "${dir:state}/admin.secret" "admin": "/var/lib/gitea/admin.secret"
} }
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"repo.created", "module.gitea.repo.created",
"issue.opened", "module.gitea.issue.opened",
"pull.merged" "module.gitea.pull.merged"
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 3000, "port": 3000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the forge, over http" "why": "the forge, over http"
}, },
{ {
"name": "ssh", "port": 2222,
"port": 22,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take" "why": "git over ssh. Not 22: the machine's own daemon holds that, and a module does not take it"
} }
], ],
"serves": { "serves": {
"npm-package-registry": { "package-registry": {
"scheme": "http", "scheme": "http",
"port": 3000, "port": 3000,
"npm-path": "/api/packages/novox/npm/" "npm-path": "/api/packages/novox/npm/"
},
"git": {
"scheme": "http",
"port": 3000
} }
}, },
"receives": { "receives": {
"npm-package-registry": "${dir:grants}/npm.json" "package-registry": "/var/lib/gitea/grants/mesh.json"
}, },
"grants": { "grants": {
"npm-package-registry": "${dir:grants}" "package-registry": "/var/lib/gitea/grants"
}, },
"claims": [
{
"name": "npm-package-registry",
"scope": "mesh"
},
{
"name": "git",
"scope": "mesh"
}
],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/gitea/broker" "broker": "/var/lib/mesh/gitea/broker"
}, },
@@ -95,33 +71,29 @@
"path": "/var/lib/mesh/gitea", "path": "/var/lib/mesh/gitea",
"mode": "0700" "mode": "0700"
}, },
{
"id": "runtime-state",
"type": "directory",
"path": "/var/lib/mesh/gitea/state",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"mode": "0700", "path": "/var/lib/gitea",
"place": "." "mode": "0700"
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/gitea/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "${dir:state}/server.env", "path": "/var/lib/gitea/server.env",
"mode": "0600", "mode": "0600",
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n" "content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n"
}, },
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/services/gitea/gitea",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -136,14 +108,14 @@
"USER_GID": "1000" "USER_GID": "1000"
}, },
"env-file": [ "env-file": [
"${dir:state}/server.env" "/var/lib/gitea/server.env"
], ],
"ports": [ "ports": [
"3000", "3000",
"222:22" "2222:22"
], ],
"volumes": [ "volumes": [
"${dir:data}:/data" "/services/gitea/gitea:/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"
}, },
@@ -159,11 +131,11 @@
"MESH_GITEA_ADMIN_USER": "mesh-admin" "MESH_GITEA_ADMIN_USER": "mesh-admin"
}, },
"env-file": [ "env-file": [
"${dir:state}/server.env" "/var/lib/gitea/server.env"
], ],
"volumes": [ "volumes": [
"${dir:data}:/data", "/services/gitea/gitea:/data",
"${dir:state}/admin.secret:/run/secrets/admin:ro" "/var/lib/gitea/admin.secret:/run/secrets/admin:ro"
], ],
"args": [ "args": [
"/bin/sh", "/bin/sh",
@@ -188,9 +160,8 @@
"volumes": [ "volumes": [
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro", "/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro", "/var/lib/mesh/gitea/config.json:/run/config/config.json:ro",
"${dir:grants}:${dir:grants}:ro", "/var/lib/gitea/grants:/var/lib/gitea/grants:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro", "/var/lib/gitea/admin.secret:/run/secrets/admin:ro"
"/var/lib/mesh/gitea/state:/run/state"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
@@ -198,8 +169,7 @@
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json", "MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin", "MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin", "MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
"MESH_GITEA_STATE_DIR": "/run/state", "MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json"
"MESH_RECEIVES": "${dir:grants}/npm.json"
}, },
"artifact": "runtime", "artifact": "runtime",
"restart-on": [ "restart-on": [
@@ -209,11 +179,7 @@
], ],
"provides": [ "provides": [
{ {
"name": "npm-package-registry", "name": "package-registry",
"scope": "mesh"
},
{
"name": "git",
"scope": "mesh" "scope": "mesh"
} }
], ],
+1 -5
View File
@@ -4,12 +4,8 @@
"description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0039).", "description": "gitea — git hosting. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+7 -27
View File
@@ -1,15 +1,9 @@
// gitea's provisioner — the adapter that makes gitea a provider of the mesh // gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry`
// `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the // interface. The reconcile loop, the contributions file, and reading the mesh's minted password are
// mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea // the sdk harness's; this writes only the per-service half: how gitea creates and removes a
// creates and removes a consumer's npm credential (novox/hq ADR 0048/0076). // consumer's npm credential (novox/hq ADR 0048/0076).
// //
// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat // The `package-registry` interface: a consumer authenticates to the npm registry at
// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another
// `receives` path and another registration below — not widening this one. `git`, which gitea also
// provides, mints nothing and so registers nothing here: the mesh's own repositories are public,
// and a clone credential is not yet decided (ADR 0111).
//
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can // `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org // read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
// `novox`; a consumer is a gitea *user* placed on that org's package team. // `novox`; a consumer is a gitea *user* placed on that org's package team.
@@ -32,11 +26,7 @@ const PACKAGE_TEAM = "packages";
const gitea = GiteaAdmin.fromEnv(); const gitea = GiteaAdmin.fromEnv();
// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written runProvisioner("package-registry", {
// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that
// path in code would drift from it. One variable carries one path, so a second registration in this
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
runProvisioner("npm-package-registry", {
async create(p: Provision): Promise<void> { async create(p: Provision): Promise<void> {
// The org and its package team are the same for every consumer; ensuring them per-create is // The org and its package team are the same for every consumer; ensuring them per-create is
// idempotent and needs no separate bootstrap step. // idempotent and needs no separate bootstrap step.
@@ -44,21 +34,11 @@ runProvisioner("npm-package-registry", {
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true); const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
// The user carries the consumer's login and the mesh's minted password, set every run so a // The user carries the consumer's login and the mesh's minted password, set every run so a
// rotation takes. Membership of the package team is what grants read+write on packages. // rotation takes. Membership of the package team is what grants read+write on packages.
// await gitea.ensureUser(p.as, p.password, `${p.as}@localhost`);
// The address is gitea's own convention for one that is not real: its email validation
// requires a dotted domain, so `@localhost` was refused at create — the fault that had this
// grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives
// hidden addresses.
await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
await gitea.addUserToTeam(teamId, p.as); await gitea.addUserToTeam(teamId, p.as);
}, },
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
await gitea.deleteUser(p.as); await gitea.deleteUser(p.as);
}, },
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
},
}); });
-313
View File
@@ -1,313 +0,0 @@
// What holds the module to its own token (token.ts; hq issue 100, the forge's tools): minted with
// the delivered admin account on the first call and kept at 0600, reused on the next start, minted
// afresh when the forge rejects it or the kept file is gone, and a refused admin account reported in
// plain words rather than crash-looped. A configured token still wins. And the tools register once
// there is a way to a token at all — before one exists.
//
// The forge is a fake: the four routes the module touches, with the same status codes gitea gives.
// Run against the compiled module (npm test builds first), the way the runtime loads it.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectTools } from "@novox/mesh-sdk/tools";
import { AdminRefused, MintedToken, TOKEN_SCOPES } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import "../dist/tools/index.js";
const ADMIN = "mesh-admin";
const PASSWORD = "the-vault-minted-this";
// ---- A fake forge: what the module sends, and what gitea would answer. ----
interface Forge {
url: string;
mints: number;
lastScopes: string[] | null;
tokens: Map<string, string>;
admins: Map<string, string>;
close(): Promise<void>;
}
function fakeForge(): Promise<Forge> {
const forge = {
mints: 0,
lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // name -> value
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
admins: new Map([[ADMIN, PASSWORD]]),
};
// write:X implies read:X — gitea's own rule (models/auth/access_token_scope.go).
const covers = (scopes: string[], required: string): boolean =>
scopes.includes(required) || scopes.includes(`write:${required.split(":")[1]}`);
const json = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === null ? "" : JSON.stringify(body));
};
const body = (req: IncomingMessage): Promise<any> =>
new Promise((resolve) => {
let text = "";
req.on("data", (c) => (text += c));
req.on("end", () => resolve(text ? JSON.parse(text) : null));
});
const basic = (req: IncomingMessage): string | null => {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Basic ")) return null;
const [user, pass] = Buffer.from(h.slice(6), "base64").toString().split(":");
return forge.admins.get(user) === pass ? user : null;
};
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
if (tokens) {
const user = basic(req);
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
if (req.method === "POST") {
const { name, scopes } = await body(req);
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
forge.mints++;
forge.lastScopes = scopes;
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
forge.tokens.set(name, sha1);
forge.scopesOf.set(sha1, scopes);
return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
}
if (req.method === "DELETE" && tokens[2]) {
const name = decodeURIComponent(tokens[2]);
if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" });
forge.tokens.delete(name);
return json(res, 204, null);
}
return json(res, 405, { message: "method not allowed" });
}
if (url.pathname === "/api/v1/user/repos") {
const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : "";
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" });
// gitea 1.27.3: GET /user/repos sits under the `user` scope category, not `repository` —
// confirmed against the live forge. A token without read:user (or write:user) is refused here.
const scopes = forge.scopesOf.get(value) ?? [];
if (!covers(scopes, "read:user")) {
return json(res, 403, {
message: `token does not have at least one of required scope(s), required=[read:user]`,
});
}
return json(res, 200, [
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
]);
}
return json(res, 404, { message: "no such route in the fake" });
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as { port: number };
resolve({
url: `http://127.0.0.1:${port}`,
get mints() { return forge.mints; },
get lastScopes() { return forge.lastScopes; },
tokens: forge.tokens,
admins: forge.admins,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
// ---- What the runtime's environment gives the module. ----
async function delivered(forge: Forge): Promise<{ env: NodeJS.ProcessEnv; file: string; logs: string[] }> {
const dir = await mkdtemp(join(tmpdir(), "gitea-"));
const passwordFile = join(dir, "admin.secret");
await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 });
const state = join(dir, "state");
return {
env: {
MESH_GITEA_URL: forge.url,
MESH_GITEA_ADMIN_USER: ADMIN,
MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile,
MESH_GITEA_STATE_DIR: state,
},
file: join(state, "token"),
logs: [],
};
}
/** A client as a fresh process would build it: a new source over the kept file, its log captured. */
function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient {
const source = new MintedToken({
url: env.MESH_GITEA_URL!,
admin: env.MESH_GITEA_ADMIN_USER!,
passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!,
file: join(env.MESH_GITEA_STATE_DIR!, "token"),
log: (l) => logs.push(l),
});
return new GiteaClient(env.MESH_GITEA_URL!, source);
}
const forge = await fakeForge();
after(() => forge.close());
test("first start: mints with the admin account, keeps the token at 0600, asks for two scopes only", async () => {
const { env, file, logs } = await delivered(forge);
const repos = await minted(env, logs).listRepos();
assert.equal(repos[0]?.full_name, "novox/hq");
assert.equal(forge.mints, 1);
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n");
assert.equal((await stat(file)).mode & 0o777, 0o600);
// Said that it minted, and where it keeps it — never what it is.
assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n"));
assert.ok(logs.every((l) => !l.includes(token) && !l.includes(PASSWORD)), logs.join("\n"));
});
test("second start: reuses the kept token, mints nothing", async () => {
const { env, logs } = await delivered(forge);
await minted(env, logs).listRepos();
const before = forge.mints;
const again: string[] = [];
await minted(env, again).listRepos();
assert.equal(forge.mints, before);
assert.ok(again.some((l) => l.startsWith("reusing the token kept at")), again.join("\n"));
assert.ok(again.every((l) => !l.includes(forge.tokens.get("mesh-tools")!)), again.join("\n"));
});
test("the forge rejects the kept token (its data was restored): minted afresh, once, and the call goes through", async () => {
const { env, file, logs } = await delivered(forge);
const client = minted(env, logs);
await client.listRepos();
const before = forge.mints;
forge.tokens.clear(); // the forge no longer knows any token — a restore from the predecessor
const repos = await client.listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
});
test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => {
const { env, file, logs } = await delivered(forge);
await minted(env, logs).listRepos();
const before = forge.mints;
await rm(file);
const repos = await minted(env, logs).listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.equal([...forge.tokens.keys()].filter((n) => n === "mesh-tools").length, 1);
assert.ok(logs.some((l) => l.includes('already holds a token named "mesh-tools"')), logs.join("\n"));
});
test("concurrent first calls share one mint", async () => {
const { env, logs } = await delivered(forge);
const client = minted(env, logs);
const before = forge.mints;
await Promise.all([client.listRepos(), client.listRepos(), client.listRepos()]);
assert.equal(forge.mints, before + 1);
});
test("the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing", async () => {
const { env, file, logs } = await delivered(forge);
forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there
try {
const client = minted(env, logs);
const before = forge.mints;
await assert.rejects(client.listRepos(), (err: unknown) => {
assert.ok(err instanceof AdminRefused, String(err));
assert.match(err.message, /refused the admin account "mesh-admin" \(401\)/);
assert.match(err.message, /admin-bootstrap step creates it/);
assert.match(err.message, /came from a predecessor/);
assert.ok(!err.message.includes(PASSWORD));
return true;
});
await assert.rejects(client.listRepos(), AdminRefused);
assert.equal(forge.mints, before);
await assert.rejects(stat(file), /ENOENT/);
// The account appears (the operator created it): the very next call mints and works.
forge.admins.set(ADMIN, PASSWORD);
assert.equal((await client.listRepos()).length, 1);
assert.equal(forge.mints, before + 1);
} finally {
forge.admins.set(ADMIN, PASSWORD);
}
});
test("one process shares one source per kept file — the watcher and the tools never renew against each other", async () => {
const { env } = await delivered(forge);
assert.equal(MintedToken.fromEnv(env.MESH_GITEA_URL!, env), MintedToken.fromEnv(env.MESH_GITEA_URL!, env));
});
test("a second process finds the token the first renewed, and reuses it instead of minting over it", async () => {
const { env, logs } = await delivered(forge);
const first = minted(env, logs);
const second = minted(env, logs);
await first.listRepos();
await second.listRepos(); // both hold the same kept token
const before = forge.mints;
forge.tokens.clear();
await first.listRepos(); // renews: one mint
await second.listRepos(); // rejected too — but the kept file already carries the renewed one
assert.equal(forge.mints, before + 1);
assert.ok(logs.some((l) => l.includes("is newer — reusing it")), logs.join("\n"));
});
test("a configured token wins, and is reported rather than minted over when the forge rejects it", async () => {
const { env } = await delivered(forge);
const before = forge.mints;
const client = GiteaClient.fromEnv({ ...env, MESH_GITEA_TOKEN: "one-somebody-pasted-in" });
await assert.rejects(client.listRepos(), /rejected the configured Gitea token \(401\)/);
assert.equal(forge.mints, before);
});
test("nothing to mint with and no token: the client says what is missing", async () => {
assert.throws(
() => GiteaClient.fromEnv({ MESH_GITEA_URL: forge.url, MESH_GITEA_ADMIN_USER: ADMIN }),
/set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR/,
);
});
test("the tools register once there is a way to a token, and the first call mints it", async () => {
const { env } = await delivered(forge);
const before = forge.mints;
const withAdmin = collectTools(env).find((c) => c.module === "gitea")!;
const withNothing = collectTools({}).find((c) => c.module === "gitea")!;
assert.equal(withNothing.tools.length, 0);
assert.deepEqual(
withAdmin.tools.map((t) => t.name),
[
"gitea_list_repos", "gitea_create_repo", "gitea_delete_repo",
"gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment",
"gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request",
"gitea_list_labels", "gitea_create_label",
"gitea_api",
],
);
assert.equal(forge.mints, before, "registering must not mint — the forge may not be up yet");
const result = (await withAdmin.tools.find((t) => t.name === "gitea_list_repos")!.run({})) as { repos: unknown[] };
assert.equal(result.repos.length, 1);
assert.equal(forge.mints, before + 1);
});
-253
View File
@@ -1,253 +0,0 @@
// The token the forge's tools and watcher authenticate with — and where it comes from.
//
// Nobody configures it. The forge is raised by the mesh, so there is no operator holding a token to
// paste in, and pasting one into settings would put a secret in the inventory in plaintext. What
// the mesh does deliver is the admin account: a login the manifest names and a password the vault
// minted and the host unsealed into a file (novox/hq ADR 0086). That account is enough to mint a
// token, so the module mints its own (hq issue 100, the forge's tools):
//
// - at first use, when none is kept: POST /users/{admin}/tokens over basic auth, with the two
// scopes the tools and the watcher need, and no more;
// - kept in the module's own state, a 0600 file, and read back on the next start — the forge
// hands a token's value out exactly once, so a token not kept is a token lost;
// - re-minted when the forge rejects it (401) or the kept file is gone. The one case that is not
// a fault: the forge's data was restored from a predecessor and the token the file names never
// existed there.
//
// An explicitly configured token still wins, and is never minted over: if it is rejected, that is
// reported, not repaired — somebody chose it.
//
// The token is never logged. Lines say that one was minted, reused or renewed, and where it is
// kept; never what it is.
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
/** The name the token carries in the forge's own list — one per mesh runtime, found by name. */
export const TOKEN_NAME = "mesh-tools";
/**
* The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens):
* write:repository — create/delete repositories, pull requests (list/get/open/merge);
* write:issue — issues, comments, labels;
* read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call.
* It sits under the `user` category despite listing repositories, not `repository`
* — confirmed against the running forge (1.27.3), which answered
* `required=[read:user]` to a token carrying only the other two.
* Nothing under /admin, /orgs or write:user — the escape-hatch tool reaches only what these three cover.
*/
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"];
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
export interface TokenSource {
/** The token to authenticate with now; minted, read or configured. */
current(): Promise<string>;
/** The forge answered 401 to `rejected`. A fresh token, or a plain error when there is nothing to renew with. */
renew(rejected: string): Promise<string>;
}
/** A token somebody set — in settings or the environment. Never minted over. */
export class ConfiguredToken implements TokenSource {
constructor(private readonly token: string) {}
async current(): Promise<string> {
return this.token;
}
async renew(): Promise<string> {
throw new Error(
"the forge rejected the configured Gitea token (401). It was set explicitly (settings or MESH_GITEA_TOKEN), " +
"so the module does not mint over it — fix it, or unset it and the module mints its own",
);
}
}
/** The forge would not take the admin account: it is missing, or its password is not the one the mesh holds. */
export class AdminRefused extends Error {
constructor(admin: string, status: number) {
super(
`the forge refused the admin account "${admin}" (${status}) — it does not exist there, or its password is not ` +
`the one the vault delivered. The admin-bootstrap step creates it on a forge the mesh raised; a forge whose data ` +
`came from a predecessor does not have it. Create "${admin}" on the forge with the delivered password and the ` +
`token is minted on the next call — the tools stay registered and the watcher keeps trying`,
);
this.name = "AdminRefused";
}
}
export interface MintedTokenOptions {
/** The forge, e.g. http://127.0.0.1:3000. */
readonly url: string;
/** The admin login the manifest names. */
readonly admin: string;
/** The file the host unsealed the admin password into (ADR 0086). Read at mint time, so a rotation takes. */
readonly passwordFile: string;
/** Where the token is kept: a 0600 file in the module's own state. */
readonly file: string;
readonly name?: string;
readonly scopes?: readonly string[];
readonly log?: (line: string) => void;
readonly fetch?: typeof fetch;
}
/** The token the module mints for itself, kept in its state and renewed when the forge rejects it. */
export class MintedToken implements TokenSource {
private held: string | null = null;
private readFile = false;
private inflight: Promise<string> | null = null;
private readonly name: string;
private readonly scopes: readonly string[];
private readonly log: (line: string) => void;
private readonly fetchImpl: typeof fetch;
constructor(private readonly opts: MintedTokenOptions) {
this.name = opts.name ?? TOKEN_NAME;
this.scopes = opts.scopes ?? TOKEN_SCOPES;
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
this.fetchImpl = opts.fetch ?? fetch;
}
/**
* Build from the runtime's environment: the forge's URL, the admin login and password file the
* manifest hands the runtime, and the module's state directory (MESH_GITEA_STATE_DIR, a directory
* the runtime mounts writable). Throws, naming what is missing, rather than hand back a source
* that cannot mint.
*/
static fromEnv(url: string, env: NodeJS.ProcessEnv = process.env): MintedToken {
const opts = MintedToken.optionsFromEnv(url, env);
// One source per kept file in a process. The watcher and the tools entrypoint both build a
// client in the same runtime; two sources over one file would each renew on a 401 and drop the
// other's token by name, forever. Shared, a renewal is one renewal.
const shared = MintedToken.shared.get(opts.file);
if (shared) return shared;
const source = new MintedToken(opts);
MintedToken.shared.set(opts.file, source);
return source;
}
private static readonly shared = new Map<string, MintedToken>();
private static optionsFromEnv(url: string, env: NodeJS.ProcessEnv): MintedTokenOptions {
const admin = env.MESH_GITEA_ADMIN_USER;
const passwordFile = env.MESH_GITEA_ADMIN_PASSWORD_FILE;
const stateDir = env.MESH_GITEA_STATE_DIR;
const missing = [
admin ? null : "MESH_GITEA_ADMIN_USER",
passwordFile ? null : "MESH_GITEA_ADMIN_PASSWORD_FILE",
stateDir ? null : "MESH_GITEA_STATE_DIR",
].filter((v): v is string => v !== null);
if (missing.length) {
throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`);
}
return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") };
}
async current(): Promise<string> {
if (this.held !== null) return this.held;
if (!this.readFile) {
this.readFile = true;
const kept = this.read();
if (kept !== null) {
this.held = kept;
this.log(`reusing the token kept at ${this.opts.file}`);
return kept;
}
}
return this.mint("no token kept — minting one");
}
async renew(rejected: string): Promise<string> {
// Another caller already renewed while this one was in flight with the old token.
if (this.held !== null && this.held !== rejected) return this.held;
// Or another process did, and kept it: use what is kept before minting over it.
const kept = this.read();
if (kept !== null && kept !== rejected) {
this.held = kept;
this.log(`the forge rejected the token held; the kept one at ${this.opts.file} is newer — reusing it`);
return kept;
}
this.held = null;
return this.mint("the forge rejected the kept token — minting a fresh one");
}
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */
private mint(why: string): Promise<string> {
if (this.inflight === null) {
this.log(why);
this.inflight = this.doMint().finally(() => {
this.inflight = null;
});
}
return this.inflight;
}
private read(): string | null {
try {
const token = readFileSync(this.opts.file, "utf8").replace(/\n$/, "");
return token.length ? token : null;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw new Error(`cannot read the kept Gitea token at ${this.opts.file}: ${(err as Error).message}`);
}
}
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. */
private keep(token: string): void {
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
const tmp = `${this.opts.file}.tmp`;
writeFileSync(tmp, token + "\n", { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, this.opts.file);
}
private async doMint(): Promise<string> {
let password: string;
try {
password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, "");
} catch (err) {
throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`);
}
const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64");
const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`;
const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => {
const res = await this.fetchImpl(tokens + path, {
method,
headers: { "Content-Type": "application/json", Authorization: authorization },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await res.text();
let parsed: any = null;
if (text) {
try { parsed = JSON.parse(text); } catch { parsed = text; }
}
return { status: res.status, body: parsed };
};
let res = await call("POST", "", { name: this.name, scopes: this.scopes });
if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status);
if (res.status === 400 || res.status === 422) {
// The forge still holds a token by this name whose value we no longer have — the kept file
// went while the forge's data stayed. It is ours to replace: drop it by name and mint again.
this.log(`the forge already holds a token named "${this.name}" — replacing it`);
const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`);
if (dropped.status !== 204 && dropped.status !== 404) {
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${dropped.status} ${detail(dropped.body)}`);
}
res = await call("POST", "", { name: this.name, scopes: this.scopes });
}
if (res.status !== 201 && res.status !== 200) {
throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`);
}
const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null;
if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`);
this.keep(token);
this.held = token;
this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
return token;
}
}
function detail(body: unknown): string {
return typeof body === "string" ? body : JSON.stringify(body);
}
+5 -17
View File
@@ -124,7 +124,7 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
labels: labelIds, labels: labelIds,
}); });
// The mesh just opened an issue — announce it the moment it exists. // The mesh just opened an issue — announce it the moment it exists.
await emit("issue.opened", { await emit("module.gitea.issue.opened", {
owner, owner,
repo, repo,
number: issue.number, number: issue.number,
@@ -231,24 +231,15 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
// Read the PR first, so the merged event carries a title and branches, not just a number. // 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); const pull = await gitea.getPullRequest(owner, repo, number);
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
// Read it again: the merge commit only exists now, and it is what a build is made from. await emit("module.gitea.pull.merged", {
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, owner,
repo, repo,
number, number,
title: pull.title, title: pull.title,
head: pull.head, head: pull.head,
base: pull.base, base: pull.base,
merge_commit_sha: merged.merge_commit_sha,
merged_at: merged.merged_at,
method, method,
html_url: pull.html_url, 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 };
}, },
@@ -304,15 +295,12 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
]; ];
} }
// The tools exist when the client has a way to a token: one configured, or the admin account to mint // The tools exist only when a token can be found; without one, gitea contributes none rather than
// one with (token.ts). The mint itself happens on the first call, not here — a contributor is // failing the whole runtime.
// synchronous, and a forge not yet answering must not keep the runtime from serving. Without either
// way, gitea contributes none rather than failing the whole runtime, and says why.
registerModuleTools("gitea", (env) => { registerModuleTools("gitea", (env) => {
try { try {
return getGiteaTools(GiteaClient.fromEnv(env)); return getGiteaTools(GiteaClient.fromEnv(env));
} catch (err) { } catch {
console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`);
return []; return [];
} }
}); });
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "token.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
} }
+1 -1
View File
@@ -40,7 +40,7 @@ async function pollAlerts(client: GrafanaClient): Promise<void> {
for (const key of now) { for (const key of now) {
if (!firing.has(key)) { if (!firing.has(key)) {
const a = byKey.get(key)!; const a = byKey.get(key)!;
await emit("alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt }); await emit("module.grafana.alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt });
} }
} }
} }
+2 -3
View File
@@ -2,7 +2,7 @@
"module": "grafana", "module": "grafana",
"version": "1", "version": "1",
"emits": [ "emits": [
"alert.firing" "module.grafana.alert.firing"
], ],
"own-secrets": { "own-secrets": {
"admin": "/var/lib/grafana-module/admin.secret", "admin": "/var/lib/grafana-module/admin.secret",
@@ -13,7 +13,6 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 3000, "port": 3000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -97,7 +96,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "grafana", "label": "grafana",
"endpoint": "web" "port": 3000
} }
}, },
"binds": { "binds": {
+2 -3
View File
@@ -11,7 +11,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "hello", "label": "hello",
"endpoint": "web" "port": 8080
} }
}, },
"binds": { "binds": {
@@ -19,7 +19,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -51,7 +50,7 @@
"name": "hello-web", "name": "hello-web",
"network": "hello-web", "network": "hello-web",
"ports": [ "ports": [
"8080" "8080:8080"
], ],
"volumes": [ "volumes": [
"/var/lib/hello-web/index.html:/www/index.html:ro" "/var/lib/hello-web/index.html:/www/index.html:ro"
+1 -1
View File
@@ -44,7 +44,7 @@ async function pollStates(): Promise<void> {
for (const s of states) { for (const s of states) {
const prev = lastState.get(s.entity_id); const prev = lastState.get(s.entity_id);
if (primed && prev !== undefined && prev !== s.state) { if (primed && prev !== undefined && prev !== s.state) {
await emit("state.changed", { await emit("module.home-assistant.state.changed", {
entity: s.entity_id, entity: s.entity_id,
name: nameOf(s), name: nameOf(s),
from: prev, from: prev,
+2 -3
View File
@@ -6,7 +6,7 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"state.changed" "module.home-assistant.state.changed"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/home-assistant/broker", "broker": "/var/lib/mesh/home-assistant/broker",
@@ -14,7 +14,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8123, "port": 8123,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -86,7 +85,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "home-assistant", "label": "home-assistant",
"endpoint": "web" "port": 8123
} }
}, },
"binds": { "binds": {
+2 -2
View File
@@ -23,7 +23,7 @@ async function pollMounts(): Promise<void> {
if (primed) { if (primed) {
for (const [mount, m] of now) { for (const [mount, m] of now) {
if (!live.has(mount)) { if (!live.has(mount)) {
await emit("stream.started", { await emit("module.icecast.stream.started", {
mount, mount,
name: m.name, name: m.name,
description: m.description, description: m.description,
@@ -33,7 +33,7 @@ async function pollMounts(): Promise<void> {
} }
for (const [mount, m] of live) { for (const [mount, m] of live) {
if (!now.has(mount)) { if (!now.has(mount)) {
await emit("stream.stopped", { mount, name: m.name }); await emit("module.icecast.stream.stopped", { mount, name: m.name });
} }
} }
} }
+2 -3
View File
@@ -5,15 +5,14 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"stream.started", "module.icecast.stream.started",
"stream.stopped" "module.icecast.stream.stopped"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/icecast/broker" "broker": "/var/lib/mesh/icecast/broker"
}, },
"listens": [ "listens": [
{ {
"name": "stream",
"port": 8000, "port": 8000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
-1
View File
@@ -9,7 +9,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "api",
"port": 8086, "port": 8086,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+6 -11
View File
@@ -14,15 +14,12 @@
"mongodb-database": { "mongodb-database": {
"name": "invoicing" "name": "invoicing"
}, },
"s3-bucket": {
"bucket": "invoicing"
},
"route": { "route": {
"site": { "label": "invoicing",
"label": "invoicing", "port": 80
"endpoint": "web"
},
"api": {
"label": "invoicing-api",
"endpoint": "api"
}
} }
}, },
"binds": { "binds": {
@@ -36,14 +33,12 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 80, "port": 80,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the invoicing web frontend; a public name is a route grant later" "why": "the invoicing web frontend; a public name is a route grant later"
}, },
{ {
"name": "api",
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -68,7 +63,7 @@
"type": "file", "type": "file",
"path": "/var/lib/invoicing/api.env", "path": "/var/lib/invoicing/api.env",
"mode": "0600", "mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/invoicing?authSource=admin\nMINIO_BUCKET=invoicing\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
}, },
{ {
"id": "net", "id": "net",
+1 -2
View File
@@ -6,7 +6,6 @@
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 9117, "port": 9117,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -83,7 +82,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "indexers", "label": "indexers",
"endpoint": "web" "port": 9117
} }
}, },
"binds": { "binds": {
+6 -6
View File
@@ -28,17 +28,17 @@ async function announce(type: string, body: Record<string, unknown>): Promise<vo
export const events = { export const events = {
userCreated: (realm: string, username: string, email?: string) => userCreated: (realm: string, username: string, email?: string) =>
announce("user.created", { realm, username, ...(email ? { email } : {}) }), announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }),
userDeleted: (realm: string, userId: string) => userDeleted: (realm: string, userId: string) =>
announce("user.deleted", { realm, userId }), announce("module.keycloak.user.deleted", { realm, userId }),
passwordReset: (realm: string, userId: string) => passwordReset: (realm: string, userId: string) =>
announce("password.reset", { realm, userId }), announce("module.keycloak.password.reset", { realm, userId }),
clientCreated: (realm: string, clientId: string, name?: string) => clientCreated: (realm: string, clientId: string, name?: string) =>
announce("client.created", { realm, clientId, ...(name ? { name } : {}) }), announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }),
groupCreated: (realm: string, name: string) => groupCreated: (realm: string, name: string) =>
announce("group.created", { realm, name }), announce("module.keycloak.group.created", { realm, name }),
roleCreated: (realm: string, name: string) => roleCreated: (realm: string, name: string) =>
announce("role.created", { realm, name }), announce("module.keycloak.role.created", { realm, name }),
}; };
console.log("[keycloak] event surface ready — identity, client, group and role changes are announced"); console.log("[keycloak] event surface ready — identity, client, group and role changes are announced");
+9 -12
View File
@@ -11,7 +11,7 @@
}, },
"route": { "route": {
"label": "keycloak", "label": "keycloak",
"endpoint": "web" "port": 8080
} }
}, },
"binds": { "binds": {
@@ -25,16 +25,15 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"user.created", "module.keycloak.user.created",
"user.deleted", "module.keycloak.user.deleted",
"password.reset", "module.keycloak.password.reset",
"client.created", "module.keycloak.client.created",
"group.created", "module.keycloak.group.created",
"role.created" "module.keycloak.role.created"
], ],
"listens": [ "listens": [
{ {
"name": "web",
"port": 8080, "port": 8080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -89,9 +88,7 @@
"env": { "env": {
"KC_DB": "postgres", "KC_DB": "postgres",
"KC_HTTP_ENABLED": "true", "KC_HTTP_ENABLED": "true",
"KC_HEALTH_ENABLED": "true", "KC_HEALTH_ENABLED": "true"
"KC_HOSTNAME": "https://keycloak.novox.be",
"KC_PROXY_HEADERS": "xforwarded"
}, },
"env-file": [ "env-file": [
"/var/lib/keycloak/admin.env", "/var/lib/keycloak/admin.env",
@@ -121,7 +118,7 @@
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}", "MESH_KEYCLOAK_URL": "http://127.0.0.1:8080",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json" "MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
}, },
"restart-on": [ "restart-on": [
+42
View File
@@ -0,0 +1,42 @@
# lavinmq's runtime: the tool runtime, carrying this module's compiled bootstrap, provisioner,
# tools and event consumer.
#
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this 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: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
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.
WORKDIR /app/modules/lavinmq
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
#
# Four entrypoints and a client, because this module is four things: a run-once bootstrap that
# writes the broker's configuration before it first starts, a provisioner that grants consumers
# their own vhost and user, a set of tools, and an event consumer.
RUN node /app/node_modules/typescript/bin/tsc \
client.ts index.ts bootstrap/index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/lavinmq/dist /app/modules/lavinmq/dist
# What the runtime loads from this module in serve mode: its event consumer, its tools, and its
# provisioner — all three in one process, so the provisioner's reconcile loop runs with the broker
# connected (novox/hq issues 060/061; the provisioner used to be run as a separate container `args`
# command, which meant it served no tools and, once, ran nowhere at all). The run-once bootstrap is
# NOT listed here — it is named in its own container's `args`, because it runs to completion before
# the broker starts rather than serving. One image, because they are one module and share a client.
ENV MESH_TOOL_MODULES=/app/modules/lavinmq/dist/index.js,/app/modules/lavinmq/dist/tools/index.js,/app/modules/lavinmq/dist/provisioner/index.js
+45
View File
@@ -0,0 +1,45 @@
// lavinmq's run-once bootstrap — lavinmq's own code (novox/hq ADR 0039), run once before the broker
// first starts (ADR 0052). lavinmq's default admin is set at first boot from a config file's
// `default_password_hash`, and that value is a HASH of the mesh-minted admin password, not the
// password itself — a form the mesh's plain-secret delivery cannot produce and no `${secret:...}`
// placeholder can compute. So this step computes it: it reads the admin password the mesh minted and
// the host unsealed, hashes it the way lavinmq expects (see client.rabbitHash), and writes the config
// file the broker container reads with `--config`. The host runs it to completion and only then
// starts the broker the manifest places after it — so the broker's first boot finds a config with an
// admin it can authenticate, and the provisioner (which reaches the management API as that admin)
// can do its work.
//
// It runs in the module's own runtime image, under the module's own account, as `mesh-tools run`
// imports it — no broker connection, because writing a config file is an offline operation and there
// is no broker to reach yet.
//
// lavinmq consults `default_user`/`default_password_hash` only on a first boot with an empty data
// dir; a later boot uses the persisted user database and ignores them. So this seeds the admin once,
// and a rotation of the admin secret does not re-key an already-initialised broker — the same
// first-boot-only shape the RabbitMQ-compatible default user has always had.
import { writeFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { rabbitHash } from "../client.js";
const adminUser = process.env.MESH_PROVISION_ADMIN_USER ?? process.env.MESH_LAVINMQ_ADMIN_USER ?? "mesh-admin";
const passwordFile = process.env.MESH_PROVISION_PASSWORD_FILE ?? process.env.MESH_LAVINMQ_ADMIN_PASSWORD_FILE ?? "/run/secrets/default";
const configOut = process.env.MESH_LAVINMQ_CONFIG_OUT ?? "/var/lib/lavinmq-module/lavinmq.ini";
const dataDir = process.env.MESH_LAVINMQ_DATA_DIR ?? "/var/lib/lavinmq";
const password = readFileSync(passwordFile, "utf8").replace(/\n$/, "");
if (!password) {
throw new Error(`[lavinmq:bootstrap] admin password file ${passwordFile} is empty — cannot seed the admin`);
}
// The broker reads only what it needs to authenticate its admin on first boot; bind/ports come from
// the container's entrypoint (`-b 0.0.0.0`), so this file names the admin and nothing else about the
// network.
const ini =
"[main]\n" +
`data_dir = ${dataDir}\n` +
`default_user = ${adminUser}\n` +
`default_password_hash = ${rabbitHash(password)}\n`;
writeFileSync(configOut, ini, { mode: 0o600 });
console.log(`[lavinmq:bootstrap] wrote ${configOut} with admin '${adminUser}' (password hashed for lavinmq)`);
+150
View File
@@ -0,0 +1,150 @@
// lavinmq's admin client — lavinmq's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside lavinmq does.
//
// It drives lavinmq through its HTTP management API (the RabbitMQ-compatible surface lavinmq serves
// on 15672), not a hand-rolled AMQP admin stack: the module may take NO npm dependency beyond
// @novox/mesh-sdk, and the management API is exactly the admin surface — create/remove a vhost, a
// user, and its permissions — reached with `fetch` (global on node 22) and HTTP Basic auth. One
// boundary, `api()`, and every method is built on it. This is the module's one impure seam, the way
// postgres's is `psql` and redis's is a RESP socket.
//
// **The login and password are the mesh's, not the provisioner's (novox/hq ADR 0048).** The mesh
// derives the login and hands it to both ends so they agree, and mints the password and delivers a
// copy to each. lavinmq creates exactly that user with exactly that password on a vhost of the same
// name — a name or password the provisioner invented is one the consumer could never present.
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
export interface LavinmqConn {
/** Base URL of the management API, e.g. http://lavinmq:15672 (no trailing /api). */
readonly base: string;
readonly adminUser: string;
readonly adminPassword: string;
}
export class LavinmqClient {
constructor(private readonly conn: LavinmqConn) {}
/**
* Build from the module's resolved environment. Reads MESH_LAVINMQ_* first (the documented
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner
* container so the module runs unchanged there. Throws if it cannot find a management endpoint and
* an admin password — the right failure, because without them nothing it does can work.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): LavinmqClient {
const base = (env.MESH_LAVINMQ_MANAGEMENT ?? env.MESH_PROVISION_LAVINMQ ?? "").replace(/\/+$/, "");
const adminUser = env.MESH_LAVINMQ_ADMIN_USER ?? env.MESH_PROVISION_ADMIN_USER ?? "mesh-admin";
const adminPassword = env.MESH_LAVINMQ_ADMIN_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
if (!base || !adminPassword) {
throw new Error("lavinmq management endpoint or admin password is not set — lavinmq's own code cannot reach the server");
}
return new LavinmqClient({ base, adminUser, adminPassword });
}
/** One request against the management API. A non-2xx reply rejects, carrying the body for the log. */
async api(method: string, path: string, body?: unknown): Promise<unknown> {
const headers: Record<string, string> = {
Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"),
};
if (body !== undefined) headers["Content-Type"] = "application/json";
const resp = await fetch(`${this.conn.base}/api${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
throw new Error(`lavinmq management API ${method} ${path} -> ${resp.status}: ${await resp.text()}`);
}
const text = await resp.text();
return text ? JSON.parse(text) : null;
}
/** True once the management API answers — the server has finished starting. */
async ready(): Promise<boolean> {
try {
await this.api("GET", "/overview");
return true;
} catch {
return false;
}
}
/** Block until the management API answers, or throw once the budget is spent. */
async waitReady(retries = 30, delayMs = 1000): Promise<void> {
for (let i = 0; i < retries; i++) {
if (await this.ready()) return;
await new Promise((r) => setTimeout(r, delayMs));
}
throw new Error("lavinmq management API did not become ready");
}
/**
* Create (or reset to a known state) one consumer's broker: a vhost and a user both named for the
* consumer's login, with the login owning full permissions on exactly that vhost. Idempotent — a
* PUT of a vhost or user that exists is a no-op or a password reset, so a reconcile can call it
* again without harm. The consumer connects as `<login>` to vhost `<login>` and can reach nothing
* else (novox/hq ADR 0048).
*/
async createConsumer(login: string, password: string): Promise<void> {
const v = encodeURIComponent(login);
const u = encodeURIComponent(login);
await this.api("PUT", `/vhosts/${v}`);
await this.api("PUT", `/users/${u}`, { password, tags: "" });
await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" });
}
/** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */
async removeConsumer(login: string): Promise<void> {
const v = encodeURIComponent(login);
const u = encodeURIComponent(login);
try {
await this.api("DELETE", `/vhosts/${v}`);
} catch (err) {
console.error(`[lavinmq] delete vhost ${login} failed (continuing): ${err}`);
}
try {
await this.api("DELETE", `/users/${u}`);
} catch (err) {
console.error(`[lavinmq] delete user ${login} failed (continuing): ${err}`);
}
}
/** The vhosts, for the amqp_list_vhosts tool. */
async listVhosts(): Promise<{ name: string; messages: number }[]> {
const vhosts = (await this.api("GET", "/vhosts")) as { name: string; messages?: number }[];
return vhosts.map((v) => ({ name: v.name, messages: v.messages ?? 0 }));
}
/** The queues on one vhost (default the root vhost), for the amqp_list_queues tool. */
async listQueues(vhost = "/"): Promise<{ name: string; messages: number; consumers: number }[]> {
const queues = (await this.api("GET", `/queues/${encodeURIComponent(vhost)}`)) as
{ name: string; messages?: number; consumers?: number }[];
return queues.map((q) => ({ name: q.name, messages: q.messages ?? 0, consumers: q.consumers ?? 0 }));
}
}
/**
* The RabbitMQ-compatible SHA-256 password hash lavinmq's `default_password_hash` expects:
* base64( salt[4] || sha256( salt || utf8(password) ) ). The salt is any four bytes — random here,
* because a fixed salt buys nothing and a fresh one is free. Verified against `lavinmqctl
* hash_password`: a hash produced here is accepted by the server unchanged.
*/
export function rabbitHash(password: string, salt: Buffer = randomBytes(4)): string {
const digest = createHash("sha256").update(Buffer.concat([salt, Buffer.from(password, "utf8")])).digest();
return Buffer.concat([salt, digest]).toString("base64");
}
/** Generate a URL-safe password. */
export function generatePassword(): string {
return randomBytes(24).toString("base64url");
}
function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim();
} catch {
return undefined;
}
}
+25
View File
@@ -0,0 +1,25 @@
// lavinmq's events entrypoint, loaded by the per-node tool host (the provisioner container runs
// ./provisioner separately). The broker lifecycle events are EMITTED from the provisioner, where the
// lifecycle actually happens (novox/hq ADR 0041/0042):
// module.lavinmq.amqp.provisioned — a consumer's vhost + user was created
// module.lavinmq.amqp.deprovisioned — that vhost + user was removed
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
// broker and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events";
interface AmqpEvent {
consumer?: string;
user: string;
vhost?: string;
}
await on<AmqpEvent>("module.lavinmq.amqp.provisioned", async (e) => {
console.log(`[lavinmq] broker provisioned for ${e.body.consumer ?? "?"} (user ${e.body.user}, vhost ${e.body.vhost})`);
});
await on<AmqpEvent>("module.lavinmq.amqp.deprovisioned", async (e) => {
console.log(`[lavinmq] broker deprovisioned (user ${e.body.user})`);
});
console.log("[lavinmq] auditing broker lifecycle events");
+139
View File
@@ -0,0 +1,139 @@
{
"module": "lavinmq",
"version": "1",
"provides": [
{
"name": "amqp",
"scope": "mesh"
}
],
"claims": [
{
"name": "mesh-broker",
"scope": "mesh"
}
],
"capabilities": [
"container-runtime"
],
"emits": [
"module.lavinmq.amqp.provisioned",
"module.lavinmq.amqp.deprovisioned"
],
"consumes": [
"module.lavinmq.amqp.provisioned",
"module.lavinmq.amqp.deprovisioned"
],
"serves": {
"amqp": {
"port": 5672
}
},
"receives": {
"amqp": "/var/lib/lavinmq-module/grants/mesh.json"
},
"grants": {
"amqp": "/var/lib/lavinmq-module/grants"
},
"own-secrets": {
"admin": "/var/lib/lavinmq-module/admin.secret",
"broker": "/var/lib/mesh/lavinmq/broker"
},
"listens": [
{
"port": 5671,
"protocol": "tcp",
"from": "mesh",
"why": "the mesh bus \u2014 amqps, every module's events and the control plane, reached over the overlay"
},
{
"port": 5672,
"protocol": "tcp",
"from": "mesh",
"why": "modules on any machine that were granted a queue"
}
],
"guards": [
15672
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/lavinmq",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/lavinmq-module",
"mode": "0700"
},
{
"id": "grants-dir",
"type": "directory",
"path": "/var/lib/lavinmq-module/grants",
"mode": "0700"
},
{
"id": "server",
"type": "container",
"name": "mesh-broker",
"image": "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b",
"ports": [
"5671:5671",
"5672:5672",
"127.0.0.1:15672:15672"
],
"volumes": [
"mesh-broker-data:/var/lib/lavinmq",
"mesh-broker-tls:/tls:ro"
],
"args": [
"--amqps-port=5671",
"--cert=/tls/tls.crt",
"--key=/tls/tls.key"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-lavinmq",
"artifact": "runtime",
"network": "host",
"volumes": [
"/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro",
"/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro",
"/var/lib/lavinmq-module/admin.secret:/run/secrets/admin:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/lavinmq-module/grants/mesh.json",
"MESH_PROVISION_LAVINMQ": "http://127.0.0.1:15672",
"MESH_PROVISION_ADMIN_USER": "guest",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
}
}
],
"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"
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-lavinmq",
"version": "0.1.0",
"description": "lavinmq — provides the mesh amqp interface (a per-consumer AMQP message broker). Its management client, provisioner, run-once bootstrap, tools and events 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"
}
}
+51
View File
@@ -0,0 +1,51 @@
// lavinmq's provisioner — the adapter that makes lavinmq a provider of the mesh `amqp` interface.
// The reconcile loop, the contributions file, and reading the mesh's minted password are the sdk
// harness's; this writes only the per-service half: how lavinmq creates and removes a consumer's own
// broker (novox/hq ADR 0039/0040/0048).
//
// The `amqp` interface: a consumer connects as `as` with the password the mesh minted, to a vhost
// named for that same login — its own message broker, isolated from every other consumer's by the
// vhost boundary. It is a broker of its own, not a shared account on the mesh's control-plane broker.
//
// **The login and password are the mesh's, not the provisioner's (novox/hq ADR 0048).** The mesh
// derives the login and hands it to both ends so they agree, and mints the password and delivers a
// copy to each. lavinmq creates exactly that user with exactly that password — a name or password the
// provisioner invented is one the consumer could never present.
//
// Vhost-per-login is the isolation model, the exact analog of postgres's database-per-login: the
// consumer owns one vhost, named for its login, and a user with full rights on that vhost and no
// rights anywhere else. lavinmq enforces it — a user with no permission on `/` is refused the moment
// it opens that vhost (`NOT_ALLOWED`), so a login is a broker the consumer alone can reach.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { LavinmqClient } from "../client.js";
const lavinmq = LavinmqClient.fromEnv();
/** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */
async function announce(type: string, body: Record<string, string>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:amqp] emit ${type} failed: ${err}`);
}
}
runProvisioner("amqp", {
async create(p: Provision): Promise<void> {
// The vhost and the user share the consumer's login, so one cannot reach another's broker.
await lavinmq.waitReady();
await lavinmq.createConsumer(p.as, p.password);
await announce("module.lavinmq.amqp.provisioned", {
consumer: p.consumer ?? "",
user: p.as,
vhost: p.as,
});
},
async remove(p: { as: string }): Promise<void> {
await lavinmq.removeConsumer(p.as);
await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as });
},
});
+32
View File
@@ -0,0 +1,32 @@
// lavinmq's tools — lavinmq's own code (novox/hq ADR 0039), importing lavinmq's own management
// client. They return structured data; the mesh serves them through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { LavinmqClient } from "../client.js";
export function getLavinmqTools(lavinmq: LavinmqClient): ToolDefinition[] {
return [
{
name: "amqp_list_vhosts",
description: "List the lavinmq virtual hosts — one per consumer that was granted a broker.",
input: {},
run: async () => ({ vhosts: await lavinmq.listVhosts() }),
},
{
name: "amqp_list_queues",
description: "List the queues on a vhost with message and consumer counts. Omit vhost for the root '/'.",
input: { vhost: { type: "string", description: "the vhost to list, e.g. a consumer's login; defaults to '/'" } },
run: async (args) => ({ queues: await lavinmq.listQueues(args.vhost ? String(args.vhost) : undefined) }),
},
];
}
// The tools exist only when the server can be reached from the environment; without it, lavinmq
// contributes none rather than failing the whole tool runtime.
registerModuleTools("lavinmq", (env) => {
try {
return getLavinmqTools(LavinmqClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
}
-1
View File
@@ -24,7 +24,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8283, "port": 8283,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+2 -2
View File
@@ -40,12 +40,12 @@ async function pollQueue(lidarr: LidarrClient): Promise<void> {
if (primed) { if (primed) {
// Entered the queue since last look — Lidarr grabbed a release. // Entered the queue since last look — Lidarr grabbed a release.
for (const [id, item] of now) { for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("album.grabbed", { title: item.title, status: item.status }); if (!inQueue.has(id)) await emit("module.lidarr.album.grabbed", { title: item.title, status: item.status });
} }
// Left the queue — imported and done, unless it was last seen failing. // Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) { for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) { if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("download.completed", { title: item.title }); await emit("module.lidarr.download.completed", { title: item.title });
} }
} }
} }
+3 -4
View File
@@ -5,8 +5,8 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"album.grabbed", "module.lidarr.album.grabbed",
"download.completed" "module.lidarr.download.completed"
], ],
"consumes": [], "consumes": [],
"own-secrets": { "own-secrets": {
@@ -14,7 +14,6 @@
}, },
"listens": [ "listens": [
{ {
"name": "web",
"port": 8686, "port": 8686,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
@@ -87,7 +86,7 @@
"contributes": { "contributes": {
"route": { "route": {
"label": "lidarr", "label": "lidarr",
"endpoint": "web" "port": 8686
} }
}, },
"binds": { "binds": {
+2 -2
View File
@@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build
# resolved away. # resolved away.
WORKDIR /app/modules/mailu WORKDIR /app/modules/mailu
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
@@ -27,4 +27,4 @@ COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist
# the convention novox/hq issues 060/061 settled. A container that instead ran only its # 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 # provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all. # ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js
-44
View File
@@ -1,44 +0,0 @@
# automx2 — the autoconfig/autodiscover responder, carried by the mailu module as its own
# artifact: it is a config-baked sidecar of this mail server, not a standalone application
# (novox/hq ADR 0015 draws that line at applications).
#
# The base is named rather than pinned (novox/hq issue 044): declared in module.json's
# `build.on`. The build context is the module's own directory; every ADD says so.
ARG PYTHON_BASE
FROM ${PYTHON_BASE}
RUN apk add --no-cache bash sqlite
WORKDIR /automx2
ADD automx/files/setupvenv.sh /automx2/setupvenv.sh
ADD automx/files/start /automx2/start
ADD automx/files/setup /automx2/setup
ADD automx/files/setup-db /automx2/setup-db
ADD automx/files/add-domains /automx2/add-domains
RUN chmod u+x setupvenv.sh start add-domains setup setup-db
RUN ./setupvenv.sh \
&& . .venv/bin/activate \
&& pip install automx2==2021.6
# The launcher `start` expects. In the predecessor's image this wrapper appeared during a build
# step that never made it into the files this module carries — the image worked and the recipe
# could not reproduce it. Written here explicitly, verbatim from the proven image, so the build
# is the whole truth about the image again.
RUN mkdir -p .venv/scripts && printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'. .venv/bin/activate' \
"export FLASK_ENV='production'" \
"export FLASK_APP='automx2.server:app'" \
'flask "$@"' > .venv/scripts/flask.sh && chmod +x .venv/scripts/flask.sh
ENV AUTOMX2_CONF=/etc/automx2.conf
ADD automx/files/automx2.conf /etc/automx2.conf
# VOLUME deliberately absent: the anonymous /data volume is exactly what lost db.sqlite on
# every recreate (measured on novox 2026-08-10). The manifest binds a real directory instead.
ENTRYPOINT ["/bin/sh"]
CMD ["./start"]
EXPOSE 4243
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
set -e
echo "${MAIL_DOMAINS}"
# Split domains into array
IFS=', ' read -r -a array <<< "${AMX_MAIL_DOMAINS}"
# User configurable section -- START
PROVIDER_ID=001
SQL_CMD="";
# Iterate domains resulting from split on second arg
for element in "${array[@]}"
do
# Set vars
DOMAIN=$element
PROVIDER_NAME=$DOMAIN
PROVIDER_SHORTNAME=$DOMAIN
# Optional LDAP server
#LDAP_SERVER="ldap.${DOMAIN}"
# User configurable section -- END
s1_id=$((PROVIDER_ID + 1))
s2_id=$((PROVIDER_ID + 2))
s3_id=$((PROVIDER_ID + 3))
dom_id=$((PROVIDER_ID + 4))
s3_id='NULL'
SQL_CMD=$(cat <<EOT
$SQL_CMD
INSERT INTO provider(id, name, short_name) VALUES(${PROVIDER_ID}, '${PROVIDER_NAME}', '${PROVIDER_SHORTNAME}');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s1_id}, ${AMX_IMAP_PORT}, 'imap', '${AMX_IMAP_SERVER}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s2_id}, ${AMX_SMTP_PORT}, 'smtp', '${AMX_SMTP_ADDRESS}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO domain(id, name, provider_id, ldapserver_id) VALUES(${dom_id}, '${DOMAIN}', ${PROVIDER_ID}, ${s3_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s1_id}, ${dom_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s2_id}, ${dom_id});
EOT
)
PROVIDER_ID=$((PROVIDER_ID+10))
done
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
-21
View File
@@ -1,21 +0,0 @@
[automx2]
# A typical production setup would use loglevel = WARNING
loglevel = WARNING
# Echo SQL commands into log? Used for debugging.
db_echo = false
# In-memory SQLite database
# db_uri = sqlite:///:memory:
# SQLite database in a UNIX-like file system
db_uri = sqlite:////data/db.sqlite
# MySQL database on a remote server. This example does not use an encrypted
# connection and is therefore *not* recommended for production use.
#db_uri = mysql://username:password@server.example.com/db
# Number of proxy servers between automx2 and the client (default: 0).
# If your logs only show 127.0.0.1 or ::1 as the source IP for incoming
# connections, proxy_count probably needs to be changed.
proxy_count = 1
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -e
if [ ! -e /data/db.sqlite ]; then
# DB SETUP
echo "SETTING UP DB"
./setup-db
echo "ADDING DOMAINS"
# Add the domains
./add-domains
fi
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env bash
set -e
# LDAP-Server
LDAP=$(cat <<EOT
CREATE TABLE ldapserver(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
port INT NOT NULL,
use_ssl INT NOT NULL,
search_base TEXT NOT NULL,
search_filter TEXT NOT NULL,
attr_uid TEXT NOT NULL,
attr_cn TEXT NOT NULL,
bind_password TEXT NOT NULL,
bind_user TEXT NOT NULL
);
EOT
)
# Provider
PROVIDER=$(cat <<EOT
CREATE TABLE provider(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
short_name TEXT NOT NULL
);
EOT
)
# Server
SERVER=$(cat <<EOT
CREATE TABLE server(
id INT PRIMARY KEY NOT NULL,
prio INT NOT NULL DEFAULT 10,
name TEXT NOT NULL,
port INT NOT NULL,
type TEXT NOT NULL,
socket_type TEXT NOT NULL,
user_name TEXT NOT NULL,
authentication TEXT NOT NULL
);
EOT
)
# Domain
DOMAIN=$(cat <<EOT
CREATE TABLE domain(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
provider_id INT NOT NULL,
ldapserver_id INT NULL,
FOREIGN KEY(ldapserver_id) REFERENCES ldapserver(id),
FOREIGN KEY(provider_id) REFERENCES provider(id)
);
CREATE UNIQUE INDEX domain_name ON domain(name);
EOT
)
# Server-Domain
SERVER_DOMAIN=$(cat <<EOT
CREATE TABLE server_domain(
server_id INT NOT NULL,
domain_id INT NOT NULL,
FOREIGN KEY(server_id) REFERENCES server(id),
FOREIGN KEY(domain_id) REFERENCES domain(id)
);
EOT
)
## TODO Foreign keys
SQL_CMD=$(cat <<EOT
$LDAP
$PROVIDER
$SERVER
$DOMAIN
$SERVER_DOMAIN
EOT
)
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# vim:ts=4:sw=4:noet
#
# Creates a Python 3 virtual environment. The target directory can be passed
# as a parameter. The default path is '.venv' in the current directory.
dir="${1:-.venv}"
echo "Setup dir $dir"
set -e
if [ -d "${dir}" ]; then
echo >&2 "Directory '${dir}' already exists, exiting."
exit 1
fi
python3 -m venv "${dir}"
source "${dir}/bin/activate"
set +e
pip install -U pip setuptools wheel || true
#set -e
## vim:tabstop=4:noexpandtab
##
## Creates a Python 3 virtual environment. The target directory can be passed
## as a parameter. The default path is 'venv' in the current directory.
#
#dir="${1:-venv}"
#
#set -e
#if [ -d "${dir}" ]; then
# echo "Directory '${dir}' already exists, exiting." >&2
# exit 1
#fi
#python3 -m venv "${dir}"
#. "${dir}/bin/activate"
#
#set +e
#pip install -U pip setuptools || true
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -e
# Setup
./setup
# Start
./.venv/scripts/flask.sh run --host=0.0.0.0 --port=4243
-29
View File
@@ -130,39 +130,10 @@ export class MailuClient {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
} }
/**
* Set the mesh's password on a mailbox the mesh provisions, and enable it. A disabled mailbox is
* what the provisioner's check reports as lost, so applying again must enable it, or the two would
* disagree for ever. Separate from changePassword, which an operator's tool uses and which must
* not re-enable a mailbox someone disabled.
*/
async applyProvisioned(email: string, password: string): Promise<void> {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true });
}
async deleteUser(email: string): Promise<void> { async deleteUser(email: string): Promise<void> {
await this.api("DELETE", `/user/${encodeURIComponent(email)}`); await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
} }
/**
* Whether a mailbox exists and is enabled. Read-only, through the admin API.
*
* **The password is not checked.** Mailu authenticates in its admin service, behind the front;
* the imap server's own password database accepts any password from Mailu's subnet, so asking it
* (`doveadm auth test`) proves nothing, or refuses everyone. A lost or disabled mailbox is caught;
* a password changed by hand is not (novox/hq issue 120).
*/
async holdsUser(email: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, {
headers: { Authorization: this.apiKey, Accept: "application/json" },
});
if (res.status === 404) return false;
if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`);
const user = (await res.json()) as { enabled?: boolean };
return user.enabled !== false;
}
async listAliases(): Promise<MailuAlias[]> { async listAliases(): Promise<MailuAlias[]> {
const aliases = await this.api<any[]>("GET", "/alias"); const aliases = await this.api<any[]>("GET", "/alias");
return (aliases ?? []).map((a) => ({ return (aliases ?? []).map((a) => ({
+2 -2
View File
@@ -36,8 +36,8 @@ function watcher(created: string, deleted: string): (keys: string[]) => Promise<
}; };
} }
const watchUsers = watcher("user.created", "user.deleted"); const watchUsers = watcher("module.mailu.user.created", "module.mailu.user.deleted");
const watchAliases = watcher("alias.created", "alias.deleted"); const watchAliases = watcher("module.mailu.alias.created", "module.mailu.alias.deleted");
async function pollUsers(): Promise<void> { async function pollUsers(): Promise<void> {
await watchUsers((await mailu.listUsers()).map((u) => u.email)); await watchUsers((await mailu.listUsers()).map((u) => u.email));
+94 -233
View File
@@ -14,53 +14,30 @@
"name": "mailu" "name": "mailu"
}, },
"route": { "route": {
"web": { "label": "mail",
"label": "mail", "port": 7080
"endpoint": "web-tls",
"scheme": "https",
"insecure": true
},
"acme": {
"label": "mail",
"path": "/.well-known/acme-challenge",
"endpoint": "web",
"priority": 100
},
"autoconfig": {
"label": "autoconfig",
"endpoint": "autoconfig"
},
"autodiscover": {
"label": "autodiscover",
"endpoint": "autoconfig"
},
"automx": {
"label": "automx",
"endpoint": "autoconfig"
}
} }
}, },
"binds": { "binds": {
"postgres-database": "${dir:state}/database.json", "postgres-database": "/var/lib/mailu/database.json",
"route": "${dir:state}/route.json" "route": "/var/lib/mailu/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "${dir:state}/database.secret", "postgres-database": "/var/lib/mailu/database.secret",
"secret": { "secret": {
"secret-key": "${dir:state}/secret-key.secret", "secret-key": "/var/lib/mailu/secret-key.secret",
"admin": "${dir:state}/admin.secret", "admin": "/var/lib/mailu/admin.secret",
"api-token": "${dir:state}/api-token.secret" "api-token": "/var/lib/mailu/api-token.secret"
} }
}, },
"emits": [ "emits": [
"user.created", "module.mailu.user.created",
"user.deleted", "module.mailu.user.deleted",
"alias.created", "module.mailu.alias.created",
"alias.deleted" "module.mailu.alias.deleted"
], ],
"listens": [ "listens": [
{ {
"name": "smtp",
"port": 25, "port": 25,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -68,23 +45,6 @@
"fixed": true "fixed": true
}, },
{ {
"name": "pop3",
"port": 110,
"protocol": "tcp",
"from": "anywhere",
"why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change",
"fixed": true
},
{
"name": "imap",
"port": 143,
"protocol": "tcp",
"from": "anywhere",
"why": "IMAP with STARTTLS, kept at parity",
"fixed": true
},
{
"name": "smtps",
"port": 465, "port": 465,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -92,15 +52,13 @@
"fixed": true "fixed": true
}, },
{ {
"name": "submission",
"port": 587, "port": 587,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
"why": "submission; also what the smtp provision serves consumers", "why": "submission",
"fixed": true "fixed": true
}, },
{ {
"name": "imaps",
"port": 993, "port": 993,
"protocol": "tcp", "protocol": "tcp",
"from": "anywhere", "from": "anywhere",
@@ -108,33 +66,10 @@
"fixed": true "fixed": true
}, },
{ {
"name": "pop3s",
"port": 995,
"protocol": "tcp",
"from": "anywhere",
"why": "POP3 over TLS, kept at parity",
"fixed": true
},
{
"name": "web",
"port": 7080, "port": 7080,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy" "why": "the web interface (admin, webmail, admin API), behind the route proxy"
},
{
"name": "web-tls",
"port": 7443,
"protocol": "tcp",
"from": "mesh",
"why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here"
},
{
"name": "autoconfig",
"port": 4243,
"protocol": "tcp",
"from": "mesh",
"why": "automx: mail client autoconfiguration; autoconfig/autodiscover/automx.novox.be are route grants reaching it here"
} }
], ],
"own-secrets": { "own-secrets": {
@@ -150,125 +85,125 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"mode": "0700", "path": "/var/lib/mailu",
"place": "."
},
{
"id": "grants",
"type": "directory",
"mode": "0700"
},
{
"id": "data-automx",
"type": "directory",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "config-env", "id": "config-env",
"type": "file", "type": "file",
"path": "${dir:state}/mailu.env", "path": "/var/lib/mailu/mailu.env",
"mode": "0644", "mode": "0644",
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n" "content": "DOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\n"
}, },
{ {
"id": "secret-env", "id": "secret-env",
"type": "file", "type": "file",
"path": "${dir:state}/secret.env", "path": "/var/lib/mailu/secret.env",
"mode": "0600", "mode": "0600",
"content": "SECRET_KEY=${secret:secret-key}\n" "content": "SECRET_KEY=${secret:secret-key}\n"
}, },
{ {
"id": "database-env", "id": "database-env",
"type": "file", "type": "file",
"path": "${dir:state}/database.env", "path": "/var/lib/mailu/database.env",
"mode": "0600", "mode": "0600",
"content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n" "content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n"
}, },
{ {
"id": "admin-env", "id": "admin-env",
"type": "file", "type": "file",
"path": "${dir:state}/admin.env", "path": "/var/lib/mailu/admin.env",
"mode": "0600", "mode": "0600",
"content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n" "content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n"
}, },
{ {
"id": "data-certs", "id": "data-certs",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/certs",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-data", "id": "data-data",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/data",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-dkim", "id": "data-dkim",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/dkim",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-mail", "id": "data-mail",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/mail",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-mailqueue", "id": "data-mailqueue",
"type": "directory", "type": "directory",
"mode": "0755" "path": "/services/mailu/data/mailqueue",
"mode": "0700"
}, },
{ {
"id": "data-filter", "id": "data-filter",
"type": "directory", "type": "directory",
"mode": "0700" "path": "/services/mailu/data/filter",
},
{
"id": "data-clamav",
"type": "directory",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-redis", "id": "data-redis",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/redis",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-webmail", "id": "data-webmail",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/webmail",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-dav", "id": "data-dav",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/dav",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-fetchmail", "id": "data-fetchmail",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/data/fetchmail",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-nginx", "id": "data-overrides-nginx",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/nginx",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-dovecot", "id": "data-overrides-dovecot",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/dovecot",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-postfix", "id": "data-overrides-postfix",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/postfix",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-rspamd", "id": "data-overrides-rspamd",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/rspamd",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "data-overrides-roundcube", "id": "data-overrides-roundcube",
"type": "directory", "type": "directory",
"path": "/services/mailu/data/overrides/roundcube",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -280,191 +215,164 @@
"id": "resolver", "id": "resolver",
"type": "container", "type": "container",
"name": "mailu-resolver", "name": "mailu-resolver",
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a", "image": "ghcr.io/mailu/unbound@sha256:142aaad82ad1b0d5b59a5f1303778dba61a3e0a540f5d969c48862bcc99f6f5d",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env", "/var/lib/mailu/mailu.env",
"${dir:state}/secret.env" "/var/lib/mailu/secret.env"
], ],
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
"ip": "192.168.203.254"
}, },
{ {
"id": "redis", "id": "redis",
"type": "container", "type": "container",
"name": "mailu-redis", "name": "mailu-redis",
"image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d", "image": "redis@sha256:1db42ccef14898aa29bae778452d567534b59c107129cbc1163fb552de184d3c",
"network": "mailu", "network": "mailu",
"volumes": [ "volumes": [
"${dir:data-redis}:/data" "/services/mailu/data/redis:/data"
] ]
}, },
{ {
"id": "admin", "id": "admin",
"type": "container", "type": "container",
"name": "mailu-admin", "name": "mailu-admin",
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a", "image": "ghcr.io/mailu/admin@sha256:dcac20e9cbdad560faef9653b1b5ac0d9266f4098dc00f0e7f0d35f4e70ed8f1",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env", "/var/lib/mailu/mailu.env",
"${dir:state}/secret.env", "/var/lib/mailu/secret.env",
"${dir:state}/database.env", "/var/lib/mailu/database.env",
"${dir:state}/admin.env" "/var/lib/mailu/admin.env"
], ],
"volumes": [ "volumes": [
"${dir:data-data}:/data", "/services/mailu/data/data:/data",
"${dir:data-dkim}:/dkim" "/services/mailu/data/dkim:/dkim"
], ],
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
"dns": [
"192.168.203.254"
]
}, },
{ {
"id": "imap", "id": "imap",
"type": "container", "type": "container",
"name": "mailu-imap", "name": "mailu-imap",
"image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993", "image": "ghcr.io/mailu/dovecot@sha256:46d18ba51032be8ebd6841aa49c1ef8762c729038c5fd86a081b5b884d478af9",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env" "/var/lib/mailu/mailu.env"
], ],
"volumes": [ "volumes": [
"${dir:data-mail}:/mail", "/services/mailu/data/mail:/mail",
"${dir:data-overrides-dovecot}:/overrides:ro" "/services/mailu/data/overrides/dovecot:/overrides:ro"
],
"dns": [
"192.168.203.254"
] ]
}, },
{ {
"id": "smtp", "id": "smtp",
"type": "container", "type": "container",
"name": "mailu-smtp", "name": "mailu-smtp",
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e", "image": "ghcr.io/mailu/postfix@sha256:bbf882880f68849511710b35237a933f3fe80c4b28bf48ff20205dbd1f1433d7",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env" "/var/lib/mailu/mailu.env"
], ],
"volumes": [ "volumes": [
"${dir:data-mailqueue}:/queue", "/services/mailu/data/mailqueue:/queue",
"${dir:data-overrides-postfix}:/overrides:ro" "/services/mailu/data/overrides/postfix:/overrides:ro"
],
"dns": [
"192.168.203.254"
] ]
}, },
{ {
"id": "antispam", "id": "antispam",
"type": "container", "type": "container",
"name": "mailu-antispam", "name": "mailu-antispam",
"image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d", "image": "ghcr.io/mailu/rspamd@sha256:e87ab93dd252cc69499caa5317dd10d445fd4291a7ecf6bca09793c7d475a0c8",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env" "/var/lib/mailu/mailu.env"
], ],
"volumes": [ "volumes": [
"${dir:data-filter}:/var/lib/rspamd", "/services/mailu/data/filter:/var/lib/rspamd",
"${dir:data-overrides-rspamd}:/etc/rspamd/override.d:ro" "/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro"
],
"dns": [
"192.168.203.254"
] ]
}, },
{ {
"id": "antivirus", "id": "antivirus",
"type": "container", "type": "container",
"name": "mailu-antivirus", "name": "mailu-antivirus",
"image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a", "image": "ghcr.io/mailu/clamav@sha256:01d30483e4a8a20a54566addb1f9b00ebb51e8a103f9226602379c412cf5fb62",
"network": "mailu", "network": "mailu",
"volumes": [ "env-file": [
"${dir:data-clamav}:/var/lib/clamav" "/var/lib/mailu/mailu.env",
"/var/lib/mailu/secret.env"
], ],
"dns": [ "volumes": [
"192.168.203.254" "/services/mailu/data/filter:/data"
] ],
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
}, },
{ {
"id": "webmail", "id": "webmail",
"type": "container", "type": "container",
"name": "mailu-webmail", "name": "mailu-webmail",
"image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6", "image": "ghcr.io/mailu/roundcube@sha256:19ccc9c21b2420dabb893ffa707ef90785c785e53dcb6bb9f98da01598412c43",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env", "/var/lib/mailu/mailu.env",
"${dir:state}/secret.env" "/var/lib/mailu/secret.env"
], ],
"volumes": [ "volumes": [
"${dir:data-webmail}:/data", "/services/mailu/data/webmail:/data",
"${dir:data-overrides-roundcube}:/overrides:ro" "/services/mailu/data/overrides/roundcube:/overrides:ro"
], ],
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
"dns": [
"192.168.203.254"
]
}, },
{ {
"id": "webdav", "id": "webdav",
"type": "container", "type": "container",
"name": "mailu-webdav", "name": "mailu-webdav",
"image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de", "image": "ghcr.io/mailu/radicale@sha256:e13cbad3791c0a6841b5d387e57e49a117808dcef87b8c9969f671ae9c3b67c0",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env", "/var/lib/mailu/mailu.env",
"${dir:state}/secret.env" "/var/lib/mailu/secret.env"
], ],
"volumes": [ "volumes": [
"${dir:data-dav}:/data" "/services/mailu/data/dav:/data"
], ],
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
"dns": [
"192.168.203.254"
]
}, },
{ {
"id": "fetchmail", "id": "fetchmail",
"type": "container", "type": "container",
"name": "mailu-fetchmail", "name": "mailu-fetchmail",
"image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d", "image": "ghcr.io/mailu/fetchmail@sha256:7dcd1392882925d612ab2d0230d437f0c660989d572283c48b0d0f2d491adce7",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env", "/var/lib/mailu/mailu.env",
"${dir:state}/secret.env" "/var/lib/mailu/secret.env"
], ],
"volumes": [ "volumes": [
"${dir:data-fetchmail}:/data" "/services/mailu/data/data/fetchmail:/data"
], ],
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified", "secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
"dns": [
"192.168.203.254"
]
}, },
{ {
"id": "front", "id": "front",
"type": "container", "type": "container",
"name": "mailu-front", "name": "mailu-front",
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d", "image": "ghcr.io/mailu/nginx@sha256:09f28ab6d36367fcacc7994f7021f132ac845bdc05f04bf80906102d11aaa057",
"network": "mailu", "network": "mailu",
"env-file": [ "env-file": [
"${dir:state}/mailu.env" "/var/lib/mailu/mailu.env"
], ],
"ports": [ "ports": [
"25", "25",
"110",
"143",
"465", "465",
"587", "587",
"993", "993",
"995", "7080:80"
"7080:80",
"7443:443"
], ],
"volumes": [ "volumes": [
"${dir:data-certs}:/certs", "/services/mailu/data/certs:/certs",
"${dir:data-overrides-nginx}:/overrides:ro" "/services/mailu/data/overrides/nginx:/overrides:ro"
],
"dns": [
"192.168.203.254"
] ]
}, },
{ {
@@ -482,40 +390,21 @@
"network": "mailu", "network": "mailu",
"volumes": [ "volumes": [
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro", "/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
"${dir:state}/api-token.secret:/run/secrets/api-token:ro", "/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro",
"${dir:grants}:${dir:grants}:ro",
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro", "/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_MAILU_URL": "http://mailu-admin:8080/api/v1", "MESH_MAILU_URL": "http://mailu-admin/api/v1",
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token", "MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap", "MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json", "MESH_MAILU_CONFIG_FILE": "/run/config/config.json"
"MESH_MAILU_DOMAIN": "novox.be",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}, },
"restart-on": [ "restart-on": [
"runtime-config" "runtime-config"
], ],
"artifact": "runtime" "artifact": "runtime"
},
{
"id": "automx",
"type": "container",
"name": "mailu-automx",
"artifact": "automx",
"network": "mailu",
"env-file": [
"${dir:state}/mailu.env"
],
"ports": [
"4243"
],
"volumes": [
"${dir:data-automx}:/data"
]
} }
], ],
"build": { "build": {
@@ -529,10 +418,6 @@
"arg": "RUNTIME_BASE", "arg": "RUNTIME_BASE",
"module": "mesh-tools", "module": "mesh-tools",
"artifact": "runtime" "artifact": "runtime"
},
{
"arg": "PYTHON_BASE",
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
} }
], ],
"artifacts": [ "artifacts": [
@@ -540,31 +425,7 @@
"name": "runtime", "name": "runtime",
"kind": "image", "kind": "image",
"from": "Dockerfile" "from": "Dockerfile"
},
{
"name": "automx",
"kind": "image",
"from": "automx/Dockerfile"
} }
] ]
},
"provides": [
{
"name": "smtp",
"scope": "mesh"
}
],
"serves": {
"smtp": {
"port": 587,
"domain": "novox.be",
"name": "mail.novox.be"
}
},
"receives": {
"smtp": "${dir:grants}/mesh.json"
},
"grants": {
"smtp": "${dir:grants}"
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
-70
View File
@@ -1,70 +0,0 @@
// mailu's provisioner — the adapter that makes mailu a provider of the mesh `smtp` interface.
// The reconcile loop, the contributions file, and reading the mesh's minted password are the sdk
// harness's; this writes only the per-service half: how mailu creates and removes a consumer's
// sending account (novox/hq ADR 0048/0076, gitea's package-registry provisioner is the sibling).
//
// The `smtp` interface: a consumer authenticates to submission (port 587, STARTTLS) as a real
// mailbox this provisioner creates. The address is `<account>@<domain>`: the local part is the
// consumer's `account` contribution — the name it wants to send as — falling back to the mesh's
// own login for a consumer that named none; the domain is the mail server's, which is this
// module's fact, not the consumer's.
//
// **The password is the mesh's, not the provisioner's (ADR 0048).** The mesh mints it and hands
// it to both ends; mailu sets exactly that password every run — so a rotation takes — and seals
// nothing: the consumer already has its copy through the mesh's own channel.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { MailuClient } from "../client.js";
const mailu = MailuClient.fromEnv();
// The mail server's own domain. From the environment the manifest composes, because the client's
// config file carries the admin API's coordinates, not the mail domain.
function domain(): string {
const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim();
if (named === "") {
throw new Error("MESH_MAILU_DOMAIN is not set, so a consumer's address cannot be composed");
}
return named;
}
// The address one consumer sends as. The local part is refused rather than sanitised when it is
// not a plain mailbox name — a rewritten name is an address nobody asked for.
function addressOf(p: { as: string; values?: Readonly<Record<string, unknown>> }): string {
const contributed = typeof p.values?.["account"] === "string" ? (p.values["account"] as string).trim() : "";
const local = contributed !== "" ? contributed : p.as;
if (!/^[a-z0-9][a-z0-9._-]*$/.test(local)) {
throw new Error(`${JSON.stringify(local)} is not a usable mailbox name`);
}
return `${local}@${domain()}`;
}
runProvisioner("smtp", {
async create(p: Provision): Promise<void> {
const email = addressOf(p);
// Create if absent, and set exactly the minted password either way so a rotation takes.
// Mailu's create refuses a duplicate address, which is the signal to fall through to the
// password set — the same found-then-apply shape gitea's ensureUser settled on.
try {
await mailu.createUser(email, p.password);
} catch {
await mailu.applyProvisioned(email, p.password);
}
},
async remove(p: { as: string }): Promise<void> {
// The withdrawal only knows the mesh login, never the contributed local part — so accounts
// that contributed one are removed when the address matching the login is absent? No: the
// harness hands remove only `as`, and an address composed from a contribution cannot be
// recomputed from it. The account is therefore removed by its login-shaped address when one
// exists, and left otherwise — a mailbox holding mail is the one thing a background loop
// must not guess about (this module's own events file says the same). Withdrawal of a
// named-account consumer is an operator action until the harness carries values here.
await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return mailu.holdsUser(addressOf(p));
},
});
-1
View File
@@ -6,7 +6,6 @@
], ],
"listens": [ "listens": [
{ {
"name": "api",
"port": 59125, "port": 59125,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
+1 -6
View File
@@ -22,7 +22,7 @@ COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are # The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image # symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled. # was assembled.
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \ RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **A module may need something the base image does not carry.** The base holds what every module # **A module may need something the base image does not carry.** The base holds what every module
@@ -48,8 +48,3 @@ COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
# to listen for what the builder announces. Serve binds the broker first, then imports these, so # to listen for what the builder announces. Serve binds the broker first, then imports these, so
# `on()` has something to subscribe to. # `on()` has something to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
# here, beside the entrypoints above, because the module knows which of its files prepares its state
# and nothing else could: the mesh asks one word and this says what answers it.
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
+11 -25
View File
@@ -1,6 +1,6 @@
// mesh-catalog's entrypoint — the module graph's consumer (novox/hq ADR 0070, ADR 0072). // mesh-catalog's entrypoint — the module graph's consumer (novox/hq ADR 0070, ADR 0072).
// //
// The build-machine role announces what it built; this places it in the graph and announces what that means. // The builder announces what it built; this places it in the graph and announces what that means.
// The control plane hooks the *meaning* — a module was upgraded — rather than the build output, so // The control plane hooks the *meaning* — a module was upgraded — rather than the build output, so
// it never has to interpret an artifact or ask this module anything. // it never has to interpret an artifact or ask this module anything.
// //
@@ -14,12 +14,10 @@ import { Graph, type Made } from "./store.js";
const graph = Graph.fromEnv(); const graph = Graph.fromEnv();
// The schema is not brought up here. The mesh prepares this module's state before it starts this // Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which
// version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it // is the same arrangement model-usage uses: a schema step that had to reach the provider over the
// at start made a schema that could not be reached a crash loop instead of a stop, with the graph // overlay would block the very apply that brings the overlay up.
// keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply await graph.migrate();
// would block the very apply that brings the overlay up — stopped being true when a step's failure
// became this module's business and not the machine's (ADR 0136).
/** What the builder says when it has built something. */ /** What the builder says when it has built something. */
interface Built { interface Built {
@@ -49,15 +47,7 @@ interface Built {
replay?: boolean; replay?: boolean;
} }
/** await on("module.builder.built", async (event) => {
* What a build means for the graph, wherever it came from.
*
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
* and the control plane says what it already held when this module asks what it missed
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
* months ago — see `replay` above.
*/
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
const body = event.body as Built; const body = event.body as Built;
if (!body.module || !body.commit) { if (!body.module || !body.commit) {
// Said rather than dropped: a build that announced itself without saying what it built is a // Said rather than dropped: a build that announced itself without saying what it built is a
@@ -79,7 +69,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
// it was missing, and the mesh is told nothing happened, because nothing did. // it was missing, and the mesh is told nothing happened, because nothing did.
if (body.replay) return; if (body.replay) return;
await emit("registered", { await emit("module.mesh-catalog.registered", {
module: body.module, commit: body.commit, upgraded, module: body.module, commit: body.commit, upgraded,
}); });
@@ -87,23 +77,19 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
// through modules that did not change, forever (ADR 0072). // through modules that did not change, forever (ADR 0072).
if (!upgraded) return; if (!upgraded) return;
await emit("upgraded", { await emit("module.mesh-catalog.upgraded", {
module: body.module, commit: body.commit, previous, module: body.module, commit: body.commit, previous,
}); });
// What can be built now — stale, and waiting on nothing that is itself stale. // What can be built now — stale, and waiting on nothing that is itself stale.
for (const next of await graph.buildable()) { for (const next of await graph.buildable()) {
await emit("rebuild-needed", { await emit("module.mesh-catalog.rebuild-needed", {
module: next.module, module: next.module,
builtAt: next.commit, builtAt: next.commit,
because: next.because, because: next.because,
}); });
} }
}; });
// As it happens, and what the mesh already held when this module asked what it missed.
await on("mesh-build-machine.built", placeTheBuild);
await on("mesh-controller.built-before", placeTheBuild);
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050). // **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
// //
@@ -115,4 +101,4 @@ await on("mesh-controller.built-before", placeTheBuild);
// Asked on every start, not only the first. A catalogue cannot tell whether it has a gap, and the // Asked on every start, not only the first. A catalogue cannot tell whether it has a gap, and the
// answer is idempotent: registering a build already held changes nothing and announces nothing. // answer is idempotent: registering a build already held changes nothing and announces nothing.
// Asked AFTER subscribing, so a build arriving during the replay is not lost between the two. // Asked AFTER subscribing, so a build arriving during the replay is not lost between the two.
await emit("catching-up", {}); await emit("module.mesh-catalog.catching-up", {});
+5 -8
View File
@@ -7,7 +7,7 @@
], ],
"claims": [ "claims": [
{ {
"name": "mesh-catalog", "name": "the-catalogue",
"scope": "mesh" "scope": "mesh"
} }
], ],
@@ -29,16 +29,13 @@
"broker": "/var/lib/mesh/mesh-catalog/broker" "broker": "/var/lib/mesh/mesh-catalog/broker"
}, },
"consumes": [ "consumes": [
"mesh-build-machine.built", "module.builder.built"
"mesh-controller.built-before"
], ],
"emits": [ "emits": [
"registered", "module.mesh-catalog.registered",
"upgraded", "module.mesh-catalog.upgraded",
"rebuild-needed", "module.mesh-catalog.rebuild-needed"
"catching-up"
], ],
"prepares": true,
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
-17
View File
@@ -1,17 +0,0 @@
// The catalogue's state, brought to the shape this version needs (novox/hq ADR 0135).
//
// **The mesh runs this before the version that needs it, and does not start that version if it
// fails** — and the refusal reaches this module and nothing else on the machine
// (novox/hq ADR 0136). That is the whole difference from where this used to happen: at start, inside
// the runtime, a schema that could not be brought up was a crash loop, the graph kept a gap, and
// nothing anywhere said so.
//
// Nothing here connects to the broker. Preparation runs before the version that would use it, so
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
// process exiting non-zero is how the host knows not to start the runtime.
import { Graph } from "../store.js";
const graph = Graph.fromEnv();
await graph.migrate();
console.log("[mesh-catalog] the module graph's schema is what this version needs");
await graph.close();
+1 -2
View File
@@ -12,7 +12,6 @@
"pg.d.ts", "pg.d.ts",
"store.ts", "store.ts",
"index.ts", "index.ts",
"tools/index.ts", "tools/index.ts"
"prepare/index.ts"
] ]
} }
+3 -3
View File
@@ -16,15 +16,15 @@ interface SecretEvent {
rotations?: number; rotations?: number;
} }
await on<SecretEvent>("secret.provisioned", async (e) => { await on<SecretEvent>("module.mesh-vault.secret.provisioned", async (e) => {
console.log(`[mesh-vault] secret provisioned for ${e.body.as} on ${e.body.consumer} (${e.body.fingerprint})`); console.log(`[mesh-vault] secret provisioned for ${e.body.as} on ${e.body.consumer} (${e.body.fingerprint})`);
}); });
await on<SecretEvent>("secret.rotated", async (e) => { await on<SecretEvent>("module.mesh-vault.secret.rotated", async (e) => {
console.log(`[mesh-vault] secret rotated for ${e.body.as} — rotation ${e.body.rotations} (${e.body.fingerprint})`); console.log(`[mesh-vault] secret rotated for ${e.body.as} — rotation ${e.body.rotations} (${e.body.fingerprint})`);
}); });
await on<SecretEvent>("secret.deprovisioned", async (e) => { await on<SecretEvent>("module.mesh-vault.secret.deprovisioned", async (e) => {
console.log(`[mesh-vault] secret withdrawn from ${e.body.as}`); console.log(`[mesh-vault] secret withdrawn from ${e.body.as}`);
}); });
+6 -6
View File
@@ -11,14 +11,14 @@
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"secret.provisioned", "module.mesh-vault.secret.provisioned",
"secret.rotated", "module.mesh-vault.secret.rotated",
"secret.deprovisioned" "module.mesh-vault.secret.deprovisioned"
], ],
"consumes": [ "consumes": [
"mesh-vault.secret.provisioned", "module.mesh-vault.secret.provisioned",
"mesh-vault.secret.rotated", "module.mesh-vault.secret.rotated",
"mesh-vault.secret.deprovisioned" "module.mesh-vault.secret.deprovisioned"
], ],
"receives": { "receives": {
"secret": "/var/lib/mesh-vault/grants/mesh.json" "secret": "/var/lib/mesh-vault/grants/mesh.json"
+1 -1
View File
@@ -43,6 +43,6 @@ runProvisioner("secret", {
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string }): Promise<void> {
if (!ledger.withdraw(p.as)) return; if (!ledger.withdraw(p.as)) return;
console.log(`[mesh-vault] withdrawn: ${p.as}`); console.log(`[mesh-vault] withdrawn: ${p.as}`);
await announce("secret.deprovisioned", { as: p.as }); await announce("module.mesh-vault.secret.deprovisioned", { as: p.as });
}, },
}); });
+4 -17
View File
@@ -125,18 +125,6 @@ export class MinioClient {
throw new Error(`minio bucketExists ${bucket}: ${status}`); throw new Error(`minio bucketExists ${bucket}: ${status}`);
} }
/**
* Whether a consumer's access key, with exactly this secret, reaches its bucket: a HEAD of the
* bucket signed as the consumer, the way it signs. Read-only. `false` when the key is unknown, the
* secret wrong, access denied or the bucket gone; any other answer rejects (novox/hq issue 120).
*/
async canReachAs(bucket: string, accessKey: string, secretKey: string): Promise<boolean> {
const { status } = await this.request("HEAD", `/${bucket}`, {}, { accessKey, secretKey });
if (status === 200) return true;
if (status === 403 || status === 404) return false;
throw new Error(`minio HEAD ${bucket} as ${accessKey}: ${status}`);
}
async createBucket(bucket: string): Promise<void> { async createBucket(bucket: string): Promise<void> {
const { status, text } = await this.request("PUT", `/${bucket}`); const { status, text } = await this.request("PUT", `/${bucket}`);
// 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail. // 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail.
@@ -263,7 +251,6 @@ export class MinioClient {
method: string, method: string,
path: string, path: string,
query: Record<string, string> = {}, query: Record<string, string> = {},
as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword },
): Promise<{ status: number; headers: Headers; text: string }> { ): Promise<{ status: number; headers: Headers; text: string }> {
const { amzDate, dateStamp } = this.stamp(); const { amzDate, dateStamp } = this.stamp();
const host = new URL(this.baseUrl).host; const host = new URL(this.baseUrl).host;
@@ -275,8 +262,8 @@ export class MinioClient {
const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n"); const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
const scope = `${dateStamp}/${this.region}/s3/aws4_request`; const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex"); const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex");
const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`; const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`;
const res = await fetch(url, { const res = await fetch(url, {
@@ -288,8 +275,8 @@ export class MinioClient {
return { status: res.status, headers: res.headers, text }; return { status: res.status, headers: res.headers, text };
} }
private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer { private signingKey(dateStamp: string): Buffer {
const kDate = hmac(`AWS4${secretKey}`, dateStamp); const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp);
const kRegion = hmac(kDate, this.region); const kRegion = hmac(kDate, this.region);
const kService = hmac(kRegion, "s3"); const kService = hmac(kRegion, "s3");
return hmac(kService, "aws4_request"); return hmac(kService, "aws4_request");
+14 -66
View File
@@ -7,48 +7,25 @@
"scope": "mesh" "scope": "mesh"
} }
], ],
"requires": [
"route"
],
"contributes": {
"route": {
"api": {
"label": "files-api",
"endpoint": "s3"
},
"console": {
"label": "files",
"endpoint": "console"
}
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"emits": [ "emits": [
"bucket.created", "module.minio.bucket.created",
"bucket.removed" "module.minio.bucket.removed"
], ],
"listens": [ "listens": [
{ {
"name": "s3",
"port": 9000, "port": 9000,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the S3 endpoint" "why": "the S3 endpoint"
},
{
"name": "console",
"port": 9001,
"protocol": "tcp",
"from": "mesh",
"why": "the admin console"
} }
], ],
"serves": { "serves": {
"s3-bucket": { "s3-bucket": {
"scheme": "http", "scheme": "http",
"region": "eu-west", "region": "us-east-1",
"port": 9000 "port": 9000
} }
}, },
@@ -91,20 +68,20 @@
{ {
"id": "data", "id": "data",
"type": "directory", "type": "directory",
"path": "/var/lib/minio-store", "path": "/services/minio/data/data1-1",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "net", "id": "net",
"type": "network", "type": "network",
"name": "minio-net" "name": "minio"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "minio", "name": "minio",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", "image": "quay.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e",
"network": "minio-net", "network": "minio",
"args": [ "args": [
"server", "server",
"/data", "/data",
@@ -115,24 +92,22 @@
"/var/lib/minio/root.env" "/var/lib/minio/root.env"
], ],
"ports": [ "ports": [
"9000", "9000"
"9001"
], ],
"volumes": [ "volumes": [
"/var/lib/minio-store:/data", "/services/minio/data/data1-1:/data",
"/var/lib/minio/root.secret:/run/secrets/root:ro" "/var/lib/minio/root.secret:/run/secrets/root:ro"
], ],
"env": { "env": {
"MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root"
"MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be",
"MINIO_REGION": "eu-west"
} }
}, },
{ {
"id": "runtime", "id": "runtime",
"type": "container", "type": "container",
"name": "mesh-minio", "name": "mesh-minio",
"network": "minio-net", "image": "mesh-runtime-minio@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"network": "minio",
"volumes": [ "volumes": [
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro", "/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
"/var/lib/minio/grants:/var/lib/minio/grants:ro", "/var/lib/minio/grants:/var/lib/minio/grants:ro",
@@ -142,36 +117,9 @@
"MESH_MINIO_ENDPOINT": "http://minio:9000", "MESH_MINIO_ENDPOINT": "http://minio:9000",
"MESH_MINIO_ROOT_USER": "meshroot", "MESH_MINIO_ROOT_USER": "meshroot",
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
"MESH_MINIO_REGION": "eu-west",
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/minio/grants/mesh.json" "MESH_RECEIVES": "/var/lib/minio/grants/mesh.json"
}, }
"artifact": "runtime"
} }
], ]
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "MC_CLI",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+2 -8
View File
@@ -30,7 +30,7 @@ runProvisioner("s3-bucket", {
try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ } try { await minio.removeAccessKey(accessKeyId); } catch { /* none yet — first provision */ }
await minio.createAccessKey(bucket, accessKeyId, p.password); await minio.createAccessKey(bucket, accessKeyId, p.password);
await announce("bucket.created", { await announce("module.minio.bucket.created", {
bucket, bucket,
consumer: p.consumer ?? "", consumer: p.consumer ?? "",
accessKey: accessKeyId, accessKey: accessKeyId,
@@ -51,13 +51,7 @@ runProvisioner("s3-bucket", {
console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`); console.error(`[minio] bucket ${bucket} not removed (likely non-empty), access revoked: ${err}`);
} }
await announce("bucket.removed", { bucket, accessKey: p.as }); await announce("module.minio.bucket.removed", { bucket, accessKey: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return minio.canReachAs(bucketFor(p.as), p.as, p.password);
}, },
}); });
+1 -1
View File
@@ -22,7 +22,7 @@ const store = UsageStore.fromEnv();
// to reach the provider over the overlay would block the very apply that brings the overlay up. // to reach the provider over the overlay would block the very apply that brings the overlay up.
await store.migrate(); await store.migrate();
await on("*.usage.*", async (event) => { await on("module.*.usage.*", async (event) => {
const body = event.body as { rows?: UsageRow[]; raw?: unknown }; const body = event.body as { rows?: UsageRow[]; raw?: unknown };
for (const row of body.rows ?? []) { for (const row of body.rows ?? []) {
try { try {
+1 -1
View File
@@ -20,7 +20,7 @@
"postgres-database": "/var/lib/model-usage/database.secret" "postgres-database": "/var/lib/model-usage/database.secret"
}, },
"consumes": [ "consumes": [
"*.usage.*" "module.*.usage.*"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/model-usage/broker" "broker": "/var/lib/mesh/model-usage/broker"
-28
View File
@@ -109,34 +109,6 @@ print(EJSON.stringify({ ok: 1 }));
await this.evalJs<{ ok: number }>(js); await this.evalJs<{ ok: number }>(js);
} }
/**
* Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner`
* there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// Connected without credentials, then authenticated inside the eval from the environment, so
// the consumer's password is neither on argv nor in the message of a failed command.
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`;
const js =
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" +
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" +
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))";
let stdout: string;
try {
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], {
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password },
timeout: 30_000,
}));
} catch (err) {
const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`;
if (/Authentication failed|AuthenticationFailed/i.test(text)) return false;
throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`);
}
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
return roles.some((r) => r.role === "dbOwner" && r.db === database);
}
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the /** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
* user is removed first so a re-grant of the same login starts clean. */ * user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> { async dropDatabaseAndUser(database: string, user: string): Promise<void> {

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