Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
784a5a6514 | ||
|
|
0c31499fb0 | ||
|
|
f118344246 | ||
|
|
822df220ab | ||
|
|
9eb1265bc8 | ||
|
|
41cfc70b53 | ||
|
|
acedc5d9d9 | ||
|
|
521a8dd1e2 | ||
|
|
e145e2236c | ||
|
|
4d7e37e319 | ||
|
|
026421fd6e | ||
|
|
af89bb11ff | ||
|
|
7c18cdbd39 | ||
|
|
4fb16b2e6b | ||
|
|
c5af8635c8 | ||
|
|
87366c5f36 | ||
|
|
f8ca36aacf | ||
|
|
812355bf31 | ||
|
|
016ddb2b3a | ||
|
|
ea17bf46d2 | ||
|
|
4258f01614 | ||
|
|
4d9b4fdfa6 | ||
|
|
eff11b1d4d | ||
|
|
4ead13d4d4 | ||
|
|
3b77dde666 | ||
|
|
5ea4961980 | ||
|
|
2b8a668d06 | ||
|
|
719fb1e025 | ||
|
|
ac5630bee2 | ||
|
|
1c995fa9fc | ||
|
|
d70cb18ea0 | ||
|
|
1d71787896 | ||
|
|
eb62289f89 | ||
|
|
f5969a2f9f | ||
|
|
7f3d259cf5 | ||
|
|
721149eda1 | ||
|
|
8e27bc1e36 | ||
|
|
f1212620e4 | ||
|
|
b1b18ae390 | ||
|
|
3f0a174392 | ||
|
|
c0edebefb9 | ||
|
|
b9605a6e3b | ||
|
|
dca84d3bb4 | ||
|
|
9f9ce92d0f | ||
|
|
6e5b2557ba | ||
|
|
f97b7dd544 | ||
|
|
5f5798ec8a | ||
|
|
42550dbe43 | ||
|
|
51713dd631 | ||
|
|
adb02da136 |
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
// 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 "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// 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
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "baserow",
|
"label": "baserow",
|
||||||
"port": 80
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 80,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 6767,
|
"port": 6767,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "subs",
|
"label": "subs",
|
||||||
"port": 6767
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8787,
|
"port": 8787,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "books",
|
"label": "books",
|
||||||
"port": 8787
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "de-spiegel",
|
"label": "de-spiegel",
|
||||||
"port": 35621
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 35621,
|
"port": 35621,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "registry",
|
||||||
"port": 5000,
|
"port": 5000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -22,11 +22,20 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "dns-udp",
|
||||||
"port": 53,
|
"port": 53,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "every name for this machine and what it runs \u2014 the mesh's own answered here, the rest forwarded",
|
"why": "every name for this machine and what it runs \u2014 the mesh's own answered here, the rest forwarded",
|
||||||
"fixed": true
|
"fixed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dns-tcp",
|
||||||
|
"port": 53,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "the same names over tcp, which a resolver answers on as well and is asked for whenever an answer will not fit in a datagram. Declared because the daemon serves it: a declaration that covers one of the two protocols its own service listens on leaves the other closed while everything reports success",
|
||||||
|
"fixed": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
|
|||||||
@@ -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\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "jail-sshd",
|
"id": "jail-sshd",
|
||||||
@@ -42,6 +42,14 @@
|
|||||||
"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",
|
||||||
|
|||||||
+35
-2
@@ -9,6 +9,8 @@ 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;
|
||||||
@@ -34,6 +36,9 @@ 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;
|
||||||
@@ -116,9 +121,23 @@ 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 repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
|
const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`);
|
||||||
return (repos ?? []).map(GiteaClient.mapRepo);
|
return (found?.data ?? []).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: {
|
||||||
@@ -210,6 +229,17 @@ 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,
|
||||||
@@ -232,6 +262,7 @@ 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),
|
||||||
@@ -259,6 +290,8 @@ 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,
|
||||||
|
|||||||
+80
-2
@@ -31,7 +31,7 @@ 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.listRepos(1, 50);
|
const repos = await client.listAllRepos();
|
||||||
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) {
|
||||||
@@ -49,6 +49,83 @@ 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
|
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
||||||
@@ -70,5 +147,6 @@ if (gitea) {
|
|||||||
run();
|
run();
|
||||||
};
|
};
|
||||||
tick(() => pollRepos(client), 60_000);
|
tick(() => pollRepos(client), 60_000);
|
||||||
console.log("[gitea] watching for new repositories");
|
tick(() => pollMerged(client), 30_000);
|
||||||
|
console.log("[gitea] watching for new repositories and merged pull requests");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"route": {
|
"route": {
|
||||||
"web": {
|
"web": {
|
||||||
"label": "git",
|
"label": "git",
|
||||||
"port": 3000
|
"endpoint": "web"
|
||||||
},
|
},
|
||||||
"internal-api-refused": {
|
"internal-api-refused": {
|
||||||
"label": "git",
|
"label": "git",
|
||||||
@@ -44,12 +44,14 @@
|
|||||||
],
|
],
|
||||||
"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": 22,
|
"port": 22,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -231,6 +231,11 @@ 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.
|
||||||
|
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", {
|
await emit("pull.merged", {
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
@@ -238,8 +243,12 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
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 };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "grafana",
|
"label": "grafana",
|
||||||
"port": 3000
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "hello",
|
"label": "hello",
|
||||||
"port": 8080
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8123,
|
"port": 8123,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "home-assistant",
|
"label": "home-assistant",
|
||||||
"port": 8123
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "stream",
|
||||||
"port": 8000,
|
"port": 8000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 8086,
|
"port": 8086,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
"route": {
|
"route": {
|
||||||
"site": {
|
"site": {
|
||||||
"label": "invoicing",
|
"label": "invoicing",
|
||||||
"port": 80
|
"endpoint": "web"
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"label": "invoicing-api",
|
"label": "invoicing-api",
|
||||||
"port": 9000
|
"endpoint": "api"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -36,12 +36,14 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 9117,
|
"port": 9117,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "indexers",
|
"label": "indexers",
|
||||||
"port": 9117
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "keycloak",
|
"label": "keycloak",
|
||||||
"port": 8080
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// 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)`);
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
// 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: ".*" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a consumer's user exists with exactly this password and full permissions on its own
|
|
||||||
* vhost. Read-only: the stored hash is salted SHA-256, the scheme `rabbitHash` writes, so the
|
|
||||||
* password is checked by hashing it with the stored salt rather than by logging in. `false` when
|
|
||||||
* the user or its permission is gone or the password differs; an unreachable API rejects
|
|
||||||
* (novox/hq issue 120).
|
|
||||||
*/
|
|
||||||
async holdsConsumer(login: string, password: string): Promise<boolean> {
|
|
||||||
const v = encodeURIComponent(login);
|
|
||||||
const u = encodeURIComponent(login);
|
|
||||||
const user = await this.getOrNull<{ password_hash?: string; hashing_algorithm?: string }>(`/users/${u}`);
|
|
||||||
if (!user?.password_hash) return false;
|
|
||||||
if (user.hashing_algorithm && !/sha256/i.test(user.hashing_algorithm)) {
|
|
||||||
throw new Error(`lavinmq user ${login} is hashed with ${user.hashing_algorithm}, which this check cannot verify`);
|
|
||||||
}
|
|
||||||
const stored = Buffer.from(user.password_hash, "base64");
|
|
||||||
if (stored.length < 5 || rabbitHash(password, stored.subarray(0, 4)) !== user.password_hash) return false;
|
|
||||||
const perm = await this.getOrNull<{ configure?: string; write?: string; read?: string }>(`/permissions/${v}/${u}`);
|
|
||||||
return perm?.configure === ".*" && perm?.write === ".*" && perm?.read === ".*";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A GET that answers null for a 404 and rejects on anything else that is not 2xx. */
|
|
||||||
private async getOrNull<T>(path: string): Promise<T | null> {
|
|
||||||
const resp = await fetch(`${this.conn.base}/api${path}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (resp.status === 404) return null;
|
|
||||||
if (!resp.ok) throw new Error(`lavinmq management API GET ${path} -> ${resp.status}: ${await resp.text()}`);
|
|
||||||
return (await resp.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// 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>("amqp.provisioned", async (e) => {
|
|
||||||
console.log(`[lavinmq] broker provisioned for ${e.body.consumer ?? "?"} (user ${e.body.user}, vhost ${e.body.vhost})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await on<AmqpEvent>("amqp.deprovisioned", async (e) => {
|
|
||||||
console.log(`[lavinmq] broker deprovisioned (user ${e.body.user})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[lavinmq] auditing broker lifecycle events");
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
{
|
|
||||||
"module": "lavinmq",
|
|
||||||
"version": "1",
|
|
||||||
"provides": [
|
|
||||||
{
|
|
||||||
"name": "amqp",
|
|
||||||
"scope": "mesh"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"capabilities": [
|
|
||||||
"container-runtime"
|
|
||||||
],
|
|
||||||
"emits": [
|
|
||||||
"amqp.provisioned",
|
|
||||||
"amqp.deprovisioned"
|
|
||||||
],
|
|
||||||
"consumes": [
|
|
||||||
"lavinmq.amqp.provisioned",
|
|
||||||
"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": "broker-data",
|
|
||||||
"type": "directory",
|
|
||||||
"path": "/var/lib/mesh-broker",
|
|
||||||
"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": [
|
|
||||||
"/var/lib/mesh-broker:/var/lib/lavinmq",
|
|
||||||
"/var/lib/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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"accesses": [
|
|
||||||
{
|
|
||||||
"path": "/var/lib/mesh-broker-tls",
|
|
||||||
"mode": "read"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"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.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"typescript": "^5.6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// 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("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("amqp.deprovisioned", { user: p.as, vhost: 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 lavinmq.holdsConsumer(p.as, p.password);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// 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 [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8283,
|
"port": 8283,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8686,
|
"port": 8686,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "lidarr",
|
"label": "lidarr",
|
||||||
"port": 8686
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -16,27 +16,27 @@
|
|||||||
"route": {
|
"route": {
|
||||||
"web": {
|
"web": {
|
||||||
"label": "mail",
|
"label": "mail",
|
||||||
"port": 7443,
|
"endpoint": "web-tls",
|
||||||
"scheme": "https",
|
"scheme": "https",
|
||||||
"insecure": true
|
"insecure": true
|
||||||
},
|
},
|
||||||
"acme": {
|
"acme": {
|
||||||
"label": "mail",
|
"label": "mail",
|
||||||
"path": "/.well-known/acme-challenge",
|
"path": "/.well-known/acme-challenge",
|
||||||
"port": 7080,
|
"endpoint": "web",
|
||||||
"priority": 100
|
"priority": 100
|
||||||
},
|
},
|
||||||
"autoconfig": {
|
"autoconfig": {
|
||||||
"label": "autoconfig",
|
"label": "autoconfig",
|
||||||
"port": 4243
|
"endpoint": "autoconfig"
|
||||||
},
|
},
|
||||||
"autodiscover": {
|
"autodiscover": {
|
||||||
"label": "autodiscover",
|
"label": "autodiscover",
|
||||||
"port": 4243
|
"endpoint": "autoconfig"
|
||||||
},
|
},
|
||||||
"automx": {
|
"automx": {
|
||||||
"label": "automx",
|
"label": "automx",
|
||||||
"port": 4243
|
"endpoint": "autoconfig"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "smtp",
|
||||||
"port": 25,
|
"port": 25,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "pop3",
|
||||||
"port": 110,
|
"port": 110,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -74,6 +76,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "imap",
|
||||||
"port": 143,
|
"port": 143,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -81,6 +84,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "smtps",
|
||||||
"port": 465,
|
"port": 465,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -88,6 +92,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "submission",
|
||||||
"port": 587,
|
"port": 587,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -95,6 +100,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "imaps",
|
||||||
"port": 993,
|
"port": 993,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -102,6 +108,7 @@
|
|||||||
"fixed": true
|
"fixed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "pop3s",
|
||||||
"port": 995,
|
"port": 995,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -109,18 +116,21 @@
|
|||||||
"fixed": true
|
"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 front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "web-tls",
|
||||||
"port": 7443,
|
"port": 7443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"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"
|
"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,
|
"port": 4243,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 59125,
|
"port": 59125,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -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 \
|
RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/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,3 +48,8 @@ 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
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { Graph, type Made } from "./store.js";
|
|||||||
|
|
||||||
const graph = Graph.fromEnv();
|
const graph = Graph.fromEnv();
|
||||||
|
|
||||||
// Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which
|
// The schema is not brought up here. The mesh prepares this module's state before it starts this
|
||||||
// is the same arrangement model-usage uses: a schema step that had to reach the provider over the
|
// version, and does not start it if that failed (novox/hq ADR 0135) — see prepare/index.ts. Doing it
|
||||||
// overlay would block the very apply that brings the overlay up.
|
// at start made a schema that could not be reached a crash loop instead of a stop, with the graph
|
||||||
await graph.migrate();
|
// keeping a gap and nothing saying so. The reason it used to be here — that a step blocking the apply
|
||||||
|
// 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 {
|
||||||
@@ -47,7 +49,15 @@ interface Built {
|
|||||||
replay?: boolean;
|
replay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
await on("mesh-build-machine.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
|
||||||
@@ -89,7 +99,11 @@ await on("mesh-build-machine.built", async (event) => {
|
|||||||
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).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -29,13 +29,16 @@
|
|||||||
"broker": "/var/lib/mesh/mesh-catalog/broker"
|
"broker": "/var/lib/mesh/mesh-catalog/broker"
|
||||||
},
|
},
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"mesh-build-machine.built"
|
"mesh-build-machine.built",
|
||||||
|
"mesh-controller.built-before"
|
||||||
],
|
],
|
||||||
"emits": [
|
"emits": [
|
||||||
"registered",
|
"registered",
|
||||||
"upgraded",
|
"upgraded",
|
||||||
"rebuild-needed"
|
"rebuild-needed",
|
||||||
|
"catching-up"
|
||||||
],
|
],
|
||||||
|
"prepares": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// 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();
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"pg.d.ts",
|
"pg.d.ts",
|
||||||
"store.ts",
|
"store.ts",
|
||||||
"index.ts",
|
"index.ts",
|
||||||
"tools/index.ts"
|
"tools/index.ts",
|
||||||
|
"prepare/index.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@
|
|||||||
"route": {
|
"route": {
|
||||||
"api": {
|
"api": {
|
||||||
"label": "files-api",
|
"label": "files-api",
|
||||||
"port": 9000
|
"endpoint": "s3"
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"label": "files",
|
"label": "files",
|
||||||
"port": 9001
|
"endpoint": "console"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -31,12 +31,14 @@
|
|||||||
],
|
],
|
||||||
"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,
|
"port": 9001,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "database",
|
||||||
"port": 27017,
|
"port": 27017,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -34,12 +34,14 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "mqtt",
|
||||||
"port": 1883,
|
"port": 1883,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "modules on any machine that were granted a topic namespace"
|
"why": "modules on any machine that were granted a topic namespace"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "mqtt-websockets",
|
||||||
"port": 8081,
|
"port": 8081,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "database",
|
||||||
"port": 4848,
|
"port": 4848,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "n8n",
|
"label": "n8n",
|
||||||
"port": 5682
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 5682,
|
"port": 5682,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
#
|
#
|
||||||
# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image:
|
# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image:
|
||||||
# the module's code is the server, which upstream already built. There is no BUILD_BASE here on
|
# the module's code is the server, which upstream already built. There is no BUILD_BASE here on
|
||||||
# purpose — nothing is compiled.
|
# purpose — nothing is compiled. The upstream image is declared in the manifest under build.on and
|
||||||
FROM nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927
|
# arrives as NATS_BASE, like every other base the mesh copies into its own store before a build
|
||||||
|
# (novox/hq ADR 0097); the digest above is the index one for the reason given.
|
||||||
|
ARG NATS_BASE
|
||||||
|
FROM ${NATS_BASE}
|
||||||
|
|
||||||
COPY entrypoint.sh /usr/local/bin/mesh-nats-entrypoint
|
COPY entrypoint.sh /usr/local/bin/mesh-nats-entrypoint
|
||||||
RUN chmod 0755 /usr/local/bin/mesh-nats-entrypoint
|
RUN chmod 0755 /usr/local/bin/mesh-nats-entrypoint
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"consumes": [],
|
"consumes": [],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "bus",
|
||||||
"port": 4222,
|
"port": 4222,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
"id": "server-conf",
|
"id": "server-conf",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/nats-module/conf/nats.conf",
|
"path": "/var/lib/nats-module/conf/nats.conf",
|
||||||
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n ca_file: \"/tls/ca.crt\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\n# The mesh's own broker certificate \u2014 the one every machine already pins by fingerprint and the\n# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no\n# machine's pin changes when it moves, and no second certificate exists to be wrong about.\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
||||||
"mode": "0644"
|
"mode": "0644"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,18 +62,24 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh-broker-nats:/data",
|
"/var/lib/mesh-broker-nats:/data",
|
||||||
"/var/lib/nats-module/conf:/etc/nats:ro",
|
"/var/lib/nats-module/conf:/etc/nats:ro",
|
||||||
"/var/lib/mesh-broker-nats-tls:/tls:ro"
|
"/var/lib/mesh-broker-tls:/tls:ro"
|
||||||
],
|
],
|
||||||
"artifact": "server"
|
"artifact": "server"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"accesses": [
|
"accesses": [
|
||||||
{
|
{
|
||||||
"path": "/var/lib/mesh-broker-nats-tls",
|
"path": "/var/lib/mesh-broker-tls",
|
||||||
"mode": "read"
|
"mode": "read"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
|
"on": [
|
||||||
|
{
|
||||||
|
"arg": "NATS_BASE",
|
||||||
|
"image": "nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927"
|
||||||
|
}
|
||||||
|
],
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Dial everything the mesh claims is reachable, and say what was found (novox/hq ADR 0145).
|
||||||
|
//
|
||||||
|
// Runs on a cadence, from this machine, in this module's own container — the same position every other
|
||||||
|
// module on the machine calls from. That is the whole point: a check run by the host or by the control
|
||||||
|
// plane reaches these addresses by a path no ordinary caller uses, and would have passed throughout the
|
||||||
|
// outage that produced this module (novox/hq 04-ISSUES/145).
|
||||||
|
//
|
||||||
|
// It reports and does nothing else. A checker that repaired things would be a second control plane.
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import { dial, tally, targetsFor, type Counts, type Result, type Roster } from "../reach.js";
|
||||||
|
|
||||||
|
/** Where the mesh renders this machine's view of the others, and where the counts are kept between runs. */
|
||||||
|
const rosterFile = process.env.MESH_NETWORK_CHECKER_ROSTER ?? "/run/config/roster.json";
|
||||||
|
const stateDir = process.env.MESH_NETWORK_CHECKER_STATE ?? "/run/state";
|
||||||
|
const probePort = Number(process.env.MESH_NETWORK_CHECKER_PORT ?? "9876");
|
||||||
|
const publicPort = process.env.MESH_NETWORK_CHECKER_PUBLIC_PORT
|
||||||
|
? Number(process.env.MESH_NETWORK_CHECKER_PUBLIC_PORT)
|
||||||
|
: undefined;
|
||||||
|
const timeoutMs = Number(process.env.MESH_NETWORK_CHECKER_TIMEOUT_MS ?? "4000");
|
||||||
|
const threshold = Number(process.env.MESH_NETWORK_CHECKER_THRESHOLD ?? "2");
|
||||||
|
|
||||||
|
/** read is a JSON file or a stated failure — never a silent default, which is how a checker comes to
|
||||||
|
* report that everything is fine because it read nothing. */
|
||||||
|
function read<T>(path: string, whenMissing: T | null): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||||
|
} catch (err) {
|
||||||
|
if (whenMissing !== null) return whenMissing;
|
||||||
|
console.error(`network-checker: cannot read ${path}: ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAtomically(path: string, body: string): void {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const temp = `${path}.writing`;
|
||||||
|
writeFileSync(temp, body);
|
||||||
|
renameSync(temp, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const roster = read<Roster>(rosterFile, null);
|
||||||
|
if (!roster.machines?.length) {
|
||||||
|
console.error("network-checker: the roster names no machines; nothing to check");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = targetsFor(roster, probePort, publicPort);
|
||||||
|
// In parallel, because a machine that is away should not delay the rest: a run that takes
|
||||||
|
// machines × timeout would outlast its own cadence on a mesh of any size.
|
||||||
|
const results: Result[] = await Promise.all(targets.map((t) => dial(t, timeoutMs)));
|
||||||
|
|
||||||
|
const countsFile = join(stateDir, "consecutive.json");
|
||||||
|
const { counts, broken } = tally(results, read<Counts>(countsFile, {}), threshold);
|
||||||
|
writeAtomically(countsFile, JSON.stringify(counts, null, 1));
|
||||||
|
|
||||||
|
// Written whole, every run: a reader asking "what does this machine reach" gets an answer about now
|
||||||
|
// rather than the last time something changed.
|
||||||
|
writeAtomically(join(stateDir, "reach.json"), JSON.stringify({
|
||||||
|
node: roster.node,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
checked: results.length,
|
||||||
|
broken: broken.length,
|
||||||
|
results,
|
||||||
|
}, null, 1));
|
||||||
|
|
||||||
|
for (const b of broken) {
|
||||||
|
console.error(
|
||||||
|
`network-checker: ${roster.node} cannot reach ${b.machine} (${b.claim}) at ${b.at}:${b.port} — ` +
|
||||||
|
`${b.failed} failed${b.detail ? `: ${b.detail}` : ""}, ${b.consecutive} run(s) running`);
|
||||||
|
}
|
||||||
|
if (broken.length === 0) {
|
||||||
|
console.log(`network-checker: ${roster.node} reaches all ${results.length} checked path(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broken path is not this process failing. It did its job; exiting non-zero would make the mesh
|
||||||
|
// read the checker as the fault, and a scheduled step that fails is retried rather than believed.
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"module": "network-checker",
|
||||||
|
"version": "1",
|
||||||
|
"slug": "netcheck",
|
||||||
|
"listens": [
|
||||||
|
{
|
||||||
|
"name": "probe",
|
||||||
|
"port": 9876,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"from": "mesh",
|
||||||
|
"why": "what the other machines' checkers dial. Deliberately this module's own endpoint and nothing else's: it is admitted by exactly the rule that governs every internally-exposed service, so it fails when that rule is wrong. A probe on a port that is never closed — ssh, say — would have passed throughout the outage this module exists to catch (novox/hq ADR 0145)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"facts": {
|
||||||
|
"roster": {
|
||||||
|
"path": "/var/lib/network-checker/roster.json",
|
||||||
|
"template": "{\n \"generated\": \"by the mesh — do not edit; replaced whenever a machine joins or leaves\",\n \"node\": \"{{.Node}}\",\n \"machines\": [{{range $i, $m := .Machines}}{{if $i}},{{end}}\n { \"name\": \"{{$m.Name}}\", \"fqdn\": \"{{$m.FQDN}}\", \"address\": \"{{$m.Address}}\" }{{end}}\n ]\n}\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "typescript",
|
||||||
|
"entrypoints": ["probe/index.js", "check/index.js"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "/var/lib/network-checker",
|
||||||
|
"mode": "0700"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "probe",
|
||||||
|
"type": "container",
|
||||||
|
"name": "mesh-network-checker-probe",
|
||||||
|
"args": ["run", "/app/modules/network-checker/dist/probe/index.js"],
|
||||||
|
"ports": ["9876"],
|
||||||
|
"state": "running",
|
||||||
|
"env": { "MESH_NETWORK_CHECKER_PORT": "9876" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check",
|
||||||
|
"type": "container",
|
||||||
|
"name": "mesh-network-checker-check",
|
||||||
|
"network": "host",
|
||||||
|
"schedule": "*/5 * * * *",
|
||||||
|
"args": ["run", "/app/modules/network-checker/dist/check/index.js"],
|
||||||
|
"volumes": ["/var/lib/network-checker:/run/state"],
|
||||||
|
"env": {
|
||||||
|
"MESH_NETWORK_CHECKER_ROSTER": "/run/state/roster.json",
|
||||||
|
"MESH_NETWORK_CHECKER_STATE": "/run/state",
|
||||||
|
"MESH_NETWORK_CHECKER_PORT": "${port:9876}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/module-network-checker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "network-checker — dials what the mesh claims is reachable, from where the callers are, and says what it found.",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// The endpoint the other machines' checkers dial (novox/hq ADR 0145).
|
||||||
|
//
|
||||||
|
// **This module's own endpoint is the instrument.** It is declared reachable over the private network
|
||||||
|
// like any other service, so it is admitted by exactly the rule that governs every internally-exposed
|
||||||
|
// service and it fails when that rule is wrong. A probe on a port that is never closed — ssh, say —
|
||||||
|
// would have passed throughout the outage this module exists to catch.
|
||||||
|
//
|
||||||
|
// It accepts a connection and closes it. Answering anything would make this a protocol, and then the
|
||||||
|
// question would be whether the protocol worked rather than whether the path did.
|
||||||
|
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
|
||||||
|
const port = Number(process.env.MESH_NETWORK_CHECKER_PORT ?? "9876");
|
||||||
|
|
||||||
|
const server = createServer((socket) => {
|
||||||
|
// Written before closing so a person dialling it by hand sees something, and so a half-open
|
||||||
|
// connection is not mistaken for a working path by a client that only checks the handshake.
|
||||||
|
socket.end("mesh network-checker\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (err: Error) => {
|
||||||
|
// Said and fatal: a probe that cannot listen must not look like a probe that nothing dialled.
|
||||||
|
console.error(`network-checker: cannot serve the probe on ${port}: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => console.log(`network-checker: probe listening on ${port}`));
|
||||||
|
|
||||||
|
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
||||||
|
process.on(signal, () => server.close(() => process.exit(0)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// What the mesh claims is reachable, and how to find out (novox/hq ADR 0145).
|
||||||
|
//
|
||||||
|
// The mesh asserts three things are callable (ADR 0144): what runs on the same machine, another
|
||||||
|
// machine's service exposed to the private network, and another machine's service exposed publicly.
|
||||||
|
// This decides what to dial for each and reads the answers. It opens connections and nothing more —
|
||||||
|
// the module that owns a service is the one that knows whether it is working.
|
||||||
|
//
|
||||||
|
// **The target is this module's own endpoint, and that is deliberate.** The obvious thing to dial is a
|
||||||
|
// service every machine has, and the services every machine has are the ones never closed — ssh above
|
||||||
|
// all. Dialling one of those would have passed throughout the outage this exists to catch, because what
|
||||||
|
// broke was a service exposed to the private network and ssh is admitted unconditionally. A probe on a
|
||||||
|
// port that cannot fail measures nothing.
|
||||||
|
|
||||||
|
import { connect } from "node:net";
|
||||||
|
import { lookup } from "node:dns";
|
||||||
|
|
||||||
|
/** One machine as the mesh's roster describes it. */
|
||||||
|
export interface Machine {
|
||||||
|
name: string;
|
||||||
|
fqdn: string;
|
||||||
|
address: string;
|
||||||
|
/** The name this machine is reached by from outside, where it has one. Absent for most machines, and
|
||||||
|
* a machine with no public face has no public claim to check. */
|
||||||
|
public?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The roster the mesh renders for this module: who this machine is, and who the others are. */
|
||||||
|
export interface Roster {
|
||||||
|
node: string;
|
||||||
|
machines: Machine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which of the mesh's three claims a check is about, so a failure says which one broke. */
|
||||||
|
export type Claim = "this machine" | "the private network" | "the public network";
|
||||||
|
|
||||||
|
/** One thing to dial. */
|
||||||
|
export interface Target {
|
||||||
|
claim: Claim;
|
||||||
|
machine: string;
|
||||||
|
/** What to dial — a name where the point is that names resolve, an address where it is not. */
|
||||||
|
at: string;
|
||||||
|
port: number;
|
||||||
|
/** Whether `at` is a name that must resolve first, so a resolution failure is reported as one. */
|
||||||
|
byName: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one dial found. */
|
||||||
|
export interface Result extends Target {
|
||||||
|
ok: boolean;
|
||||||
|
/** Which step failed, so a reader is sent to the right place: the resolver, or the filter. */
|
||||||
|
failed?: "resolution" | "connection";
|
||||||
|
detail?: string;
|
||||||
|
ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* targetsFor is everything this machine should be able to reach, from the roster it was given.
|
||||||
|
*
|
||||||
|
* Its own machine first, because that is the case that distinguishes a caller on the machine from a
|
||||||
|
* caller in one of its containers — the one that broke. Then every other machine over the private
|
||||||
|
* network. The public claim is only checked where a public address is known for a machine, because a
|
||||||
|
* machine with no public face has nothing to fail.
|
||||||
|
*/
|
||||||
|
export function targetsFor(roster: Roster, probePort: number, publicPort?: number): Target[] {
|
||||||
|
const out: Target[] = [];
|
||||||
|
for (const m of roster.machines) {
|
||||||
|
const own = m.name === roster.node;
|
||||||
|
out.push({
|
||||||
|
claim: own ? "this machine" : "the private network",
|
||||||
|
machine: m.name,
|
||||||
|
at: m.address,
|
||||||
|
port: probePort,
|
||||||
|
byName: false,
|
||||||
|
});
|
||||||
|
// And by name, because a name that does not resolve and a port that does not answer are different
|
||||||
|
// faults with different owners.
|
||||||
|
out.push({
|
||||||
|
claim: own ? "this machine" : "the private network",
|
||||||
|
machine: m.name,
|
||||||
|
at: m.fqdn,
|
||||||
|
port: probePort,
|
||||||
|
byName: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (publicPort !== undefined) {
|
||||||
|
for (const m of roster.machines) {
|
||||||
|
if (!m.public) continue;
|
||||||
|
out.push({
|
||||||
|
claim: "the public network",
|
||||||
|
machine: m.name,
|
||||||
|
at: m.public,
|
||||||
|
port: publicPort,
|
||||||
|
byName: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dial opens a connection and closes it. Whether the port accepts is the whole of what is asked. */
|
||||||
|
export function dial(target: Target, timeoutMs: number): Promise<Result> {
|
||||||
|
const began = Date.now();
|
||||||
|
const done = (ok: boolean, failed?: Result["failed"], detail?: string): Result => ({
|
||||||
|
...target, ok, failed, detail, ms: Date.now() - began,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<Result>((resolve) => {
|
||||||
|
const open = () => {
|
||||||
|
const socket = connect({ host: target.at, port: target.port });
|
||||||
|
const finish = (r: Result) => { socket.destroy(); resolve(r); };
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
socket.once("connect", () => finish(done(true)));
|
||||||
|
socket.once("timeout", () => finish(done(false, "connection", "timed out")));
|
||||||
|
socket.once("error", (err: Error) => finish(done(false, "connection", err.message)));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!target.byName) { open(); return; }
|
||||||
|
// Resolved first and reported separately: a checker that says "unreachable" for a name the
|
||||||
|
// resolver never answered sends a reader to the filter, which is not where the fault is.
|
||||||
|
lookup(target.at, (err) => {
|
||||||
|
if (err) { resolve(done(false, "resolution", err.message)); return; }
|
||||||
|
open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path's running count of consecutive failures, keyed so it survives between runs. */
|
||||||
|
export type Counts = Record<string, number>;
|
||||||
|
|
||||||
|
/** keyOf names one path, stably, so a count follows it across runs. */
|
||||||
|
export function keyOf(t: Target): string {
|
||||||
|
return `${t.claim}|${t.machine}|${t.at}|${t.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* tally folds this run's results into the counts carried from the last one.
|
||||||
|
*
|
||||||
|
* **One failure is not a fault.** A machine rebooting is ordinary, and a checker that cries at the
|
||||||
|
* first missed dial trains a reader to ignore it — which is worse than not checking (ADR 0145). A path
|
||||||
|
* is broken once it has failed on consecutive runs, and the count travels with the result so a reader
|
||||||
|
* can tell "briefly away" from "never worked".
|
||||||
|
*/
|
||||||
|
export function tally(results: Result[], before: Counts, threshold: number): {
|
||||||
|
counts: Counts; broken: Array<Result & { consecutive: number }>;
|
||||||
|
} {
|
||||||
|
const counts: Counts = {};
|
||||||
|
const broken: Array<Result & { consecutive: number }> = [];
|
||||||
|
for (const r of results) {
|
||||||
|
const key = keyOf(r);
|
||||||
|
const n = r.ok ? 0 : (before[key] ?? 0) + 1;
|
||||||
|
if (n > 0) counts[key] = n;
|
||||||
|
if (n >= threshold) broken.push({ ...r, consecutive: n });
|
||||||
|
}
|
||||||
|
return { counts, broken };
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { keyOf, tally, targetsFor, type Result, type Roster } from "../reach.js";
|
||||||
|
|
||||||
|
const roster: Roster = {
|
||||||
|
node: "here",
|
||||||
|
machines: [
|
||||||
|
{ name: "here", fqdn: "here.internal", address: "10.0.0.1" },
|
||||||
|
{ name: "there", fqdn: "there.internal", address: "10.0.0.2", public: "there.example.test" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("its own machine is checked, which is the case that distinguishes a caller on it from one in a container", () => {
|
||||||
|
const own = targetsFor(roster, 9876).filter((t) => t.claim === "this machine");
|
||||||
|
assert.equal(own.length, 2, "its own machine by address and by name");
|
||||||
|
assert.ok(own.some((t) => t.at === "10.0.0.1" && !t.byName));
|
||||||
|
assert.ok(own.some((t) => t.at === "here.internal" && t.byName));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every other machine is checked over the private network", () => {
|
||||||
|
const other = targetsFor(roster, 9876).filter((t) => t.claim === "the private network");
|
||||||
|
assert.deepEqual(other.map((t) => t.machine), ["there", "there"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the public claim is only checked where a machine has a public name", () => {
|
||||||
|
const pub = targetsFor(roster, 9876, 443).filter((t) => t.claim === "the public network");
|
||||||
|
assert.equal(pub.length, 1, "only the machine with a public name");
|
||||||
|
assert.equal(pub[0]!.at, "there.example.test");
|
||||||
|
assert.equal(pub[0]!.port, 443);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no public claim is made when no public port was given", () => {
|
||||||
|
assert.equal(targetsFor(roster, 9876).filter((t) => t.claim === "the public network").length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = (at: string): Result => ({
|
||||||
|
claim: "this machine", machine: "here", at, port: 9876, byName: false,
|
||||||
|
ok: false, failed: "connection", ms: 1,
|
||||||
|
});
|
||||||
|
const passed = (at: string): Result => ({
|
||||||
|
claim: "this machine", machine: "here", at, port: 9876, byName: false, ok: true, ms: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one failure is not a fault — a machine rebooting is ordinary", () => {
|
||||||
|
const { counts, broken } = tally([failed("10.0.0.1")], {}, 2);
|
||||||
|
assert.equal(broken.length, 0, "one missed dial says nothing");
|
||||||
|
assert.equal(counts[keyOf(failed("10.0.0.1"))], 1, "and is remembered");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a path that keeps failing is broken, and the count travels with it", () => {
|
||||||
|
const first = tally([failed("10.0.0.1")], {}, 2);
|
||||||
|
const second = tally([failed("10.0.0.1")], first.counts, 2);
|
||||||
|
assert.equal(second.broken.length, 1);
|
||||||
|
assert.equal(second.broken[0]!.consecutive, 2, "so a reader can tell briefly away from never worked");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a path that recovers stops being counted", () => {
|
||||||
|
const first = tally([failed("10.0.0.1")], {}, 2);
|
||||||
|
const second = tally([passed("10.0.0.1")], first.counts, 2);
|
||||||
|
assert.equal(second.broken.length, 0);
|
||||||
|
assert.deepEqual(second.counts, {}, "nothing carried forward for a path that works");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a count follows one path and not another", () => {
|
||||||
|
const a = failed("10.0.0.1");
|
||||||
|
const b = failed("10.0.0.2");
|
||||||
|
const first = tally([a, b], {}, 2);
|
||||||
|
const second = tally([a], first.counts, 2);
|
||||||
|
assert.equal(second.broken.length, 1, "only the path dialled this run is judged");
|
||||||
|
assert.equal(second.broken[0]!.at, "10.0.0.1");
|
||||||
|
});
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["client.ts", "index.ts"]
|
"include": ["reach.ts", "probe/index.ts", "check/index.ts", "test/*.ts"]
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "drive",
|
"label": "drive",
|
||||||
"port": 80
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 80,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 1880,
|
"port": 1880,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "nodered",
|
"label": "nodered",
|
||||||
"port": 1880
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "@",
|
"label": "@",
|
||||||
"port": 4000
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 4000,
|
"port": 4000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 6789,
|
"port": 6789,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 11434,
|
"port": 11434,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "machine",
|
"from": "machine",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 3579,
|
"port": 3579,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "ombi",
|
"label": "ombi",
|
||||||
"port": 3579
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "office",
|
"label": "office",
|
||||||
"port": 9070
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 9070,
|
"port": 9070,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "eef",
|
"label": "eef",
|
||||||
"port": 4012
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 4012,
|
"port": 4012,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "filip",
|
"label": "filip",
|
||||||
"port": 4013
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 4013,
|
"port": 4013,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "photos",
|
"label": "photos",
|
||||||
"port": 4001
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -32,12 +32,14 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "api",
|
||||||
"port": 9000,
|
"port": 9000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the photos backend API; the client sites on the module network call it"
|
"why": "the photos backend API; the client sites on the module network call it"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 4001,
|
"port": 4001,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "stream",
|
||||||
"port": 32400,
|
"port": 32400,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 9090,
|
"port": 9090,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "web-tls",
|
||||||
"port": 9443,
|
"port": 9443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -103,7 +105,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "portainer",
|
"label": "portainer",
|
||||||
"port": 9090
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "database",
|
||||||
"port": 5432,
|
"port": 5432,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 7878,
|
"port": 7878,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "movies",
|
"label": "movies",
|
||||||
"port": 7878
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "cache",
|
||||||
"port": 6379,
|
"port": 6379,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -27,12 +27,14 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "http",
|
||||||
"port": 80,
|
"port": 80,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
"why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified"
|
"why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "https",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -113,7 +114,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "searxng",
|
"label": "searxng",
|
||||||
"port": 8080
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8989,
|
"port": 8989,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -91,7 +92,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "series",
|
"label": "series",
|
||||||
"port": 8989
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "ssh",
|
||||||
"port": 22,
|
"port": 22,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "anywhere",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"type": "service",
|
"type": "service",
|
||||||
"unit": "sshd.service",
|
"unit": "sshd.service",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
|
"boot": "enabled",
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
"config"
|
"config"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "acme",
|
||||||
"port": 9000,
|
"port": 9000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8181,
|
"port": 8181,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
"contributes": {
|
"contributes": {
|
||||||
"route": {
|
"route": {
|
||||||
"label": "tautulli",
|
"label": "tautulli",
|
||||||
"port": 8181
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"route": {
|
"route": {
|
||||||
"label": "umami",
|
"label": "umami",
|
||||||
"port": 3000
|
"endpoint": "web"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binds": {
|
"binds": {
|
||||||
@@ -49,10 +49,11 @@
|
|||||||
},
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 3000,
|
"port": 3000,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "anywhere",
|
"from": "mesh",
|
||||||
"why": "one port serves two surfaces: the dashboard (the proxy gates it to the mesh) and the public collection endpoint that the browsers of every tracked site POST to \u2014 so the port itself must be reachable from anywhere"
|
"why": "one port serves two surfaces \u2014 the dashboard and the collection endpoint that the browsers of every tracked site POST to. Both are reached through the proxy, by name, so the port is how the proxy reaches this module and nothing else (novox/hq ADR 0045). It said \"anywhere\" and gave the reason that the collection endpoint must be public, which is true of the name and not of the port: opened, the machine-side port served the dashboard over plain HTTP to the internet, bypassing every rule the proxy applies by path"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
|
|||||||
@@ -6,54 +6,63 @@
|
|||||||
],
|
],
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
|
"name": "web",
|
||||||
"port": 8443,
|
"port": 8443,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
|
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "inform",
|
||||||
"port": 8080,
|
"port": 8080,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "device inform \u2014 how APs and switches check in and are adopted"
|
"why": "device inform \u2014 how APs and switches check in and are adopted"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "stun",
|
||||||
"port": 3478,
|
"port": 3478,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "STUN, so managed devices can find the controller through NAT"
|
"why": "STUN, so managed devices can find the controller through NAT"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "discovery",
|
||||||
"port": 10001,
|
"port": 10001,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
|
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "discovery-l2",
|
||||||
"port": 1902,
|
"port": 1902,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
|
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "portal-tls",
|
||||||
"port": 8843,
|
"port": 8843,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the guest captive portal over https"
|
"why": "the guest captive portal over https"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "portal",
|
||||||
"port": 8880,
|
"port": 8880,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "the guest captive portal over http"
|
"why": "the guest captive portal over http"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "speedtest",
|
||||||
"port": 6789,
|
"port": 6789,
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
"why": "mobile-app speed-test throughput measurement"
|
"why": "mobile-app speed-test throughput measurement"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "syslog",
|
||||||
"port": 5514,
|
"port": 5514,
|
||||||
"protocol": "udp",
|
"protocol": "udp",
|
||||||
"from": "mesh",
|
"from": "mesh",
|
||||||
|
|||||||
Reference in New Issue
Block a user