Compare commits

..
Author SHA1 Message Date
jschoubben bf818b9fa6 route-proxy: tell the proxy the private network's range
The proxy serves internal names only to requests from the private network
and needs its range to know which those are (novox/hq issue 191).
2026-10-02 01:10:13 +02:00
131 changed files with 3062 additions and 3402 deletions
+22
View File
@@ -0,0 +1,22 @@
# anthropic-consumer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/anthropic-consumer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts usage/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/anthropic-consumer/dist /app/modules/anthropic-consumer/dist
# No serve-time entrypoints: every container of this module names its command (`run` on a
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
+62 -26
View File
@@ -14,10 +14,19 @@
"secrets": { "secrets": {
"model-access": "${dir:state}/access-token" "model-access": "${dir:state}/access-token"
}, },
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"emits": [ "emits": [
"usage.session" "usage.session"
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -37,39 +46,66 @@
}, },
{ {
"id": "apply", "id": "apply",
"type": "process", "type": "container",
"name": "anthropic-consumer-apply", "name": "mesh-anthropic-consumer-apply",
"artifact": "code", "network": "host",
"run": [
"node",
"apply/index.js"
],
"schedule": "*/5 * * * *", "schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-consumer/dist/apply/index.js"
],
"volumes": [
"${dir:state}:/run/state"
],
"env": { "env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token", "MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token",
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json", "MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
"MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json", "MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json",
"MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json" "MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json"
} },
"artifact": "runtime"
},
{
"id": "usage",
"type": "container",
"name": "mesh-anthropic-consumer-usage",
"network": "host",
"schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-consumer/dist/usage/index.js"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json",
"MESH_TOOLS_MAIN": "/app/dist/main.js"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"apply/index.js",
"usage/index.js"
],
"loads": [
"usage/index.js"
],
"env": {
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
}
} }
] ]
} }
+18 -19
View File
@@ -3,15 +3,12 @@
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account // per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
// attribution is done — just the totals (port map "don't-map" #3). // attribution is done — just the totals (port map "don't-map" #3).
// //
// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through // Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools
// the runtime as this module; the totals are also written to a file so the reading is observable // `emit` primitive; the totals are also written to a file so the reading is observable without one.
// without one.
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { emit } from "@novox/mesh-sdk/events";
import { readSessionFile, type SessionUsage } from "../transcript.js"; import { readSessionFile, type SessionUsage } from "../transcript.js";
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local /** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
@@ -119,20 +116,22 @@ function atomicWrite(path: string, content: string): void {
renameSync(tmp, path); renameSync(tmp, path);
} }
/** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */ /** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */
async function emitUsage(body: Record<string, unknown>): Promise<void> { async function emitUsage(body: Record<string, unknown>): Promise<void> {
try { const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
await emit("usage.session", body); const { spawn } = await import("node:child_process");
} catch (err) { await new Promise<void>((resolve) => {
console.error(`[anthropic-consumer] could not emit usage: ${err}`); const child = spawn(
} process.execPath,
[main, "emit", "usage.session", JSON.stringify(body)],
{ stdio: "inherit" },
);
child.on("exit", () => resolve());
child.on("error", (err) => {
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
resolve();
});
});
} }
// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the await main();
// runtime's handshake is answered while a long first reading is still under way.
const EVERY_MS = 5 * 60 * 1000;
const tick = (): void => {
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
};
tick();
setInterval(tick, EVERY_MS);
+33
View File
@@ -0,0 +1,33 @@
# audit-logger's runtime: the shared runtime image, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The toolkit 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 laid out beside it.
# 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 toolkit it will run against.
WORKDIR /app/modules/audit-logger
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.
RUN node /app/node_modules/typescript/bin/tsc audit.ts index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/audit-logger/dist /app/modules/audit-logger/dist
# **Served, not run.** This subscribes on import, and the serve mode binds the broker before it
# imports anything — `run` exists for a step that works offline and exits, and would leave this
# with nothing to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/audit-logger/dist/index.js
+33 -12
View File
@@ -5,21 +5,27 @@
"consumes": [ "consumes": [
"**" "**"
], ],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js"
],
"loads": [
"index.js"
],
"env": {
"AUDIT_LOG": "${dir:trail}/audit.log"
}
} }
] ]
}, },
@@ -34,6 +40,21 @@
"id": "trail", "id": "trail",
"type": "directory", "type": "directory",
"mode": "0700" "mode": "0700"
},
{
"id": "run",
"type": "container",
"name": "mesh-audit-logger",
"network": "host",
"volumes": [
"${dir:state}/broker:/run/secrets/broker:ro",
"${dir:trail}:/trail"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"AUDIT_LOG": "/trail/audit.log"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
+2 -4
View File
@@ -15,7 +15,7 @@ test("audit-logger records every event to the trail as one line each", async ()
const path = join(dir, "audit.log"); const path = join(dir, "audit.log");
// The audit-logger's whole behaviour: consume everything, record it. // The audit-logger's whole behaviour: consume everything, record it.
await on("#", async (event) => record(event, path)); // the pattern index.ts subscribes await on("**", async (event) => record(event, path));
process.env.MESH_MODULE = "umami"; process.env.MESH_MODULE = "umami";
process.env.MESH_NODE = "anchor"; process.env.MESH_NODE = "anchor";
@@ -24,9 +24,7 @@ test("audit-logger records every event to the trail as one line each", async ()
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l)); const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 2); assert.equal(lines.length, 2);
// A module names its events locally (design 29); the module is the `source`, which together with assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]);
// the type says whose event it was. This broker does no namespacing, so the type is as emitted.
assert.deepEqual(lines.map((l) => l.type), ["site.created", "node.anchor.joined"]);
assert.equal(lines[0].source, "umami"); assert.equal(lines[0].source, "umami");
assert.equal(lines[0].node, "anchor"); assert.equal(lines[0].node, "anchor");
assert.equal(lines[0].body.domain, "my-app"); assert.equal(lines[0].body.domain, "my-app");
+24
View File
@@ -0,0 +1,24 @@
# baserow's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/baserow
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/baserow/dist /app/modules/baserow/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/baserow/dist/tools/index.js
+36 -14
View File
@@ -25,7 +25,8 @@
"postgres-database": "${dir:state}/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": { "own-secrets": {
"admin": "${dir:state}/admin.secret" "admin": "${dir:state}/admin.secret",
"broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -91,24 +92,45 @@
"mode": "0600", "mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n", "content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-baserow",
"network": "baserow",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BASEROW_URL": "http://baserow:80",
"MESH_BASEROW_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "tools", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
-15
View File
@@ -1,15 +0,0 @@
# build-agent
The mesh's build machine as a role every machine can hold (novox/hq ADR 0190). It holds the node seat
`node-build-agent`: every holder pulls one build at a time from the role's one work queue when it is
idle, so a tier of many images is built by as many machines as hold the seat and are online, and a
machine that is off builds nothing and blocks nothing. The controller asks the role, never a machine;
the outcome names the machine that built it.
What a holding machine needs is what the builder always needed, said here once: a container runtime
(the socket is mounted), the artifact store and the package registry as provisions, a workspace, and
the bus credential. The code is `cmd/mesh-builder` in the mesh-controller repository, compiled from
that repository's main (`build.artifacts[].context`); this module ships the packaging.
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
same thing; retire that once this is assigned where it was.
@@ -1,6 +1,6 @@
ARG GO_BASE ARG GO_BASE
ARG ALPINE_BASE ARG ALPINE_BASE
# build-agent's image: the build machine itself, compiled into a container (novox/hq ADR 0190). # builder's own image: the build machine itself, compiled into a container.
# #
# **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder, # **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder,
# internal/catalogue — lives in the mesh-controller repository, the same control plane it is one # internal/catalogue — lives in the mesh-controller repository, the same control plane it is one
@@ -1,14 +1,13 @@
{ {
"module": "build-agent", "module": "builder",
"version": "1", "version": "1",
"slug": "agent",
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"claims": [ "claims": [
{ {
"name": "node-build-agent", "name": "mesh-build-machine",
"scope": "node" "scope": "mesh"
} }
], ],
"requires": [ "requires": [
@@ -37,19 +36,19 @@
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "agent-env", "id": "builder-env",
"type": "file", "type": "file",
"path": "${dir:mesh-state}/build-agent.env", "path": "${dir:mesh-state}/builder.env",
"mode": "0600", "mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n" "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "mesh-build-agent", "name": "mesh-builder",
"artifact": "server", "artifact": "server",
"env-file": [ "env-file": [
"${dir:mesh-state}/build-agent.env" "${dir:mesh-state}/builder.env"
], ],
"volumes": [ "volumes": [
"${dir:mesh-state}:/run/mesh:ro", "${dir:mesh-state}:/run/mesh:ro",
@@ -57,7 +56,7 @@
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"restart-on": [ "restart-on": [
"agent-env" "builder-env"
], ],
"network": "host" "network": "host"
} }
+24
View File
@@ -0,0 +1,24 @@
# cloudflare-dns's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/cloudflare-dns
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/cloudflare-dns/dist /app/modules/cloudflare-dns/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/cloudflare-dns/dist/tools/index.js,/app/modules/cloudflare-dns/dist/provisioner/index.js
+42 -17
View File
@@ -18,13 +18,20 @@
"public-dns": "${dir:grants}/mesh.json" "public-dns": "${dir:grants}/mesh.json"
}, },
"own-secrets": { "own-secrets": {
"token": "${dir:state}/token" "token": "${dir:state}/token",
"broker": "${dir:mesh-state}/broker"
}, },
"emits": [ "emits": [
"record.created", "record.created",
"record.removed" "record.removed"
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -43,30 +50,48 @@
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-cloudflare-dns",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:grants}:/grants",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json",
"MESH_RECEIVES": "/var/lib/cloudflare-dns/grants/mesh.json"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "${dir:state}/token",
"MESH_CLOUDFLARE_CONFIG_FILE": "${dir:state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
+24
View File
@@ -0,0 +1,24 @@
# confluence's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/confluence
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/confluence/dist /app/modules/confluence/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/confluence/dist/tools/index.js
+40 -14
View File
@@ -3,9 +3,16 @@
"version": "1", "version": "1",
"slug": "confl", "slug": "confl",
"own-secrets": { "own-secrets": {
"token": "${dir:state}/token" "token": "${dir:state}/token",
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -19,27 +26,46 @@
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-confluence",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token",
"MESH_CONFLUENCE_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "tools", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token",
"MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json"
}
} }
] ]
} }
File diff suppressed because one or more lines are too long
+36 -221
View File
@@ -1,236 +1,51 @@
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from // fail2ban's own code, in the module (novox/hq ADR 0039). The jails and the daemon are declared
// the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept // resources — the mesh writes /etc/fail2ban/jail.d/* and keeps fail2ban.service running (see
// running by one. This code exists only to read and steer the *live* state the daemon owns: who is // module.json). This code exists only to read and steer the *live* state the daemon owns at
// banned now and until when, and the ban or release an operator asks for — the node-intrusion- // runtime: which IPs are banned right now, and the manual ban/unban an operator reaches for. That
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the // state (the running bans, /var/lib/fail2ban's sqlite) is fail2ban's, not the mesh's — the mesh
// mesh composes the jails and never writes the ban list. // reconciles the config, never the ban list.
//
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
// package this module declares on the machine, and the socket is root's: root is the module's
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { accessSync, constants } from "node:fs";
import { isIP } from "node:net";
import { delimiter, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
const execFileP = promisify(execFile); const run = promisify(execFile);
/** A command runner, so the verbs can be tested without a daemon. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
/** The command as it is run: as given when this process is root, else through sudo without a
* prompt. The daemon's socket answers only to root. */
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
/** Whether a tool is on this machine: an executable of that name on the path, or where the
* system keeps its administration. */
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
return dirs.some((dir) => {
try {
accessSync(join(dir, tool), constants.X_OK);
return true;
} catch {
return false;
}
});
}
export const execRunner: Runner = async (cmd, args) => {
if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`);
const [program, argv] = escalated(cmd, args);
try {
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
return stdout;
} catch (err) {
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
// on its own stderr line, and the rest is the client's own answer.
if (program === "sudo") {
if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
}
if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) {
throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account");
}
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`));
}
};
/** One jail as the daemon reports it. */
export interface JailStatus {
jail: string;
/** What the jail is reading: files or journal matches, as fail2ban names them. */
watching: string[];
/** Addresses with failures counted against them right now, and all failures since the jail started. */
failing: { now: number; total: number };
/** Addresses held right now, and all bans since the jail started. */
banned: { now: number; total: number; addresses: string[] };
}
/** One ban as the daemon holds it. */
export interface Ban {
ip: string;
jail: string;
/** When the ban was placed, in the machine's local time as fail2ban prints it. */
since: string;
/** When the ban ends; "never" for a permanent ban. */
until: string;
}
export interface JailSettings {
jail: string;
bantime: string;
findtime: string;
maxretry: number;
ignoreip: string[];
actions: string[];
/** The log files the jail reads, when it reads files. */
logpath: string[];
/** The journal match the jail reads, when it reads the journal. */
journalmatch: string;
}
export class Fail2banClient { export class Fail2banClient {
private readonly run: Runner; static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient {
constructor(run: Runner = execRunner) {
this.run = run;
}
/** The daemon as this machine has it, through its own client. */
static onThisMachine(): Fail2banClient {
return new Fail2banClient(); return new Fail2banClient();
} }
private client(...args: string[]): Promise<string> { /** Overview of every jail, or the detailed status of one — currently-banned IPs and totals. */
return this.run("fail2ban-client", args); async status(jail?: string): Promise<string> {
}
/** The jails the daemon runs, by name. */
async jails(): Promise<string[]> {
const out = await this.client("status");
const m = out.match(/Jail list:\s*(.*)/);
if (!m) return [];
return m[1].split(",").map((j) => j.trim()).filter(Boolean);
}
/** Every jail with what it watches and holds, or one jail's detail. */
async status(jail?: string): Promise<{ jails: JailStatus[] }> {
const names = jail ? [jail] : await this.jails();
const jails: JailStatus[] = [];
for (const name of names) {
jails.push(parseJailStatus(name, await this.client("status", name)));
}
return { jails };
}
/** Every address banned now, with the jail holding it and when the ban ends. */
async banned(jail?: string): Promise<{ banned: Ban[] }> {
const names = jail ? [jail] : await this.jails();
const banned: Ban[] = [];
for (const name of names) {
banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time")));
}
banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip));
return { banned };
}
/** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */
async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> {
address(ip);
name(jail);
const out = await this.client("set", jail, "banip", ip);
const added = Number.parseInt(out.trim(), 10) || 0;
const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null;
return { banned: held, added };
}
/** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */
async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> {
address(ip);
let out: string;
if (jail) { if (jail) {
name(jail); const { stdout } = await run("sudo", ["fail2ban-client", "status", jail]);
out = await this.client("set", jail, "unbanip", ip); return stdout;
} else {
out = await this.client("unban", ip);
} }
return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" }; const { stdout: overview } = await run("sudo", ["fail2ban-client", "status"]);
const match = overview.match(/Jail list:\s*(.+)/);
if (!match) return overview;
const jails = match[1].split(",").map((j) => j.trim()).filter(Boolean);
const parts: string[] = [overview.trimEnd(), ""];
for (const j of jails) {
const { stdout } = await run("sudo", ["fail2ban-client", "status", j]);
parts.push(`=== ${j} ===`, stdout.trimEnd(), "");
}
return parts.join("\n");
} }
/** One jail's effective settings — the module's own tool, beside the seat's verbs. */ /** Manually ban an IP in a jail. Mutates live state, not a mesh-managed file. */
async settings(jail: string): Promise<JailSettings> { async ban(jail: string, ip: string): Promise<string> {
name(jail); const { stdout } = await run("sudo", ["fail2ban-client", "set", jail, "banip", ip]);
const get = (key: string) => this.client("get", jail, key); return stdout;
const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([ }
get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"),
get("journalmatch"), /** Unban an IP from one jail, or from every jail when no jail is given. */
]); async unban(ip: string, jail?: string): Promise<string> {
return { const args = jail
jail, ? ["fail2ban-client", "set", jail, "unbanip", ip]
bantime: bantime.trim(), : ["fail2ban-client", "unban", ip];
findtime: findtime.trim(), const { stdout } = await run("sudo", args);
maxretry: Number.parseInt(maxretry.trim(), 10), return stdout;
ignoreip: listed(ignoreip),
actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean),
logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath),
journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "),
};
} }
} }
/** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */
function listed(out: string): string[] {
return out
.split("\n")
.map((l) => l.replace(/^[\s|`-]+/, "").trim())
.filter((l, i) => i > 0 && l.length > 0);
}
export function parseJailStatus(jail: string, out: string): JailStatus {
const field = (label: string) => {
const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)"));
return m ? m[1].trim() : "";
};
const num = (label: string) => Number.parseInt(field(label), 10) || 0;
const watching = [field("File list"), field("Journal matches")].filter(Boolean);
return {
jail,
watching,
failing: { now: num("Currently failed"), total: num("Total failed") },
banned: {
now: num("Currently banned"),
total: num("Total banned"),
addresses: field("Banned IP list").split(/\s+/).filter(Boolean),
},
};
}
/** `get <jail> banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */
export function parseBans(jail: string, out: string): Ban[] {
const bans: Ban[] = [];
for (const line of out.split("\n")) {
const m = line.match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)/);
if (!m) continue;
bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] });
}
return bans;
}
function address(ip: string): void {
if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`);
}
function name(jail: string): void {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`);
}
+6 -44
View File
@@ -7,22 +7,9 @@
"claims": [ "claims": [
{ {
"name": "node-intrusion-prevention", "name": "node-intrusion-prevention",
"scope": "node", "scope": "node"
"serves": [
"status",
"banned",
"ban",
"unban"
]
} }
], ],
"tools": [
"fail2ban_settings"
],
"jailing": {
"into": "/etc/fail2ban/jail.d/mesh.conf",
"filter-into": "/etc/fail2ban/filter.d"
},
"resources": [ "resources": [
{ {
"id": "package", "id": "package",
@@ -41,31 +28,19 @@
"path": "/etc/fail2ban/action.d", "path": "/etc/fail2ban/action.d",
"mode": "0755" "mode": "0755"
}, },
{
"id": "filter-d",
"type": "directory",
"path": "/etc/fail2ban/filter.d",
"mode": "0755"
},
{
"id": "run-dir",
"type": "directory",
"path": "/var/run/fail2ban",
"mode": "0755"
},
{ {
"id": "jail-local", "id": "jail-local",
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.local", "path": "/etc/fail2ban/jail.local",
"mode": "0644", "mode": "0644",
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\n# **A ban list never holds a neighbour.** The mesh's own range is named rather than written\n# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own\n# network, not the internet. On a machine behind a router that reflects local traffic, every\n# client in the house arrives as the gateway's address — so one mistyped local request banned\n# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" "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",
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.d/sshd.conf", "path": "/etc/fail2ban/jail.d/sshd.conf",
"mode": "0644", "mode": "0644",
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d\n" "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n"
}, },
{ {
"id": "log", "id": "log",
@@ -80,7 +55,7 @@
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.d/recidive.conf", "path": "/etc/fail2ban/jail.d/recidive.conf",
"mode": "0644", "mode": "0644",
"content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\n# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).\nbantime = 4w\nfindtime = 2w\nmaxretry = 2\n" "content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\nbantime = 1w\nfindtime = 1d\n"
}, },
{ {
"id": "action-dualchain", "id": "action-dualchain",
@@ -106,21 +81,8 @@
"jail-local", "jail-local",
"jail-sshd", "jail-sshd",
"jail-recidive", "jail-recidive",
"action-dualchain", "action-dualchain"
"composed-jails"
] ]
} }
], ]
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
]
}
]
}
} }
+2 -6
View File
@@ -1,18 +1,14 @@
{ {
"name": "@novox/module-fail2ban", "name": "@novox/module-fail2ban",
"version": "0.1.0", "version": "0.1.0",
"description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).", "description": "fail2ban — intrusion prevention: the mesh declares the jails and keeps the daemon running; its ban/unban/status tools live here.",
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"typescript": "^5.6.0" "typescript": "^5.6.0"
},
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
} }
} }
-114
View File
@@ -1,114 +0,0 @@
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
// on the control node on 2026-10-02 (novox/hq ADR 0179).
import { test } from "node:test";
import assert from "node:assert/strict";
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts";
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
const RECIDIVE =
"Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n";
const SSHD =
"Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
" |- Total banned:\t150\n `- Banned IP list:\t\n";
const WITH_TIME =
"195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n";
function fake(answers: Record<string, string>, calls: string[][] = []): Runner {
return async (cmd, args) => {
calls.push([cmd, ...args]);
const key = args.join(" ");
if (key in answers) return answers[key];
throw new Error(`unexpected ${cmd} ${key}`);
};
}
test("a jail's status is read into numbers, what it watches and who it holds", () => {
const s = parseJailStatus("recidive", RECIDIVE);
assert.deepEqual(s, {
jail: "recidive",
watching: ["/var/log/fail2ban.log"],
failing: { now: 36, total: 149 },
banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] },
});
const j = parseJailStatus("sshd", SSHD);
assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]);
assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] });
});
test("status covers every jail the daemon lists, or the one named", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls));
const all = await f.status();
assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]);
const one = await f.status("sshd");
assert.equal(one.jails.length, 1);
assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]);
});
test("bans are read with when they were placed and when they end, a permanent one as never", () => {
const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n");
assert.equal(bans.length, 3);
assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" });
assert.equal(bans[2].until, "never");
assert.deepEqual(parseBans("sshd", "\n"), []);
});
test("banned gathers every jail's bans, soonest to end first", async () => {
const f = new Fail2banClient(fake({
status: STATUS,
"get recidive banip --with-time": WITH_TIME,
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
}));
const { banned } = await f.banned();
assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]);
});
test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({
"set recidive banip 198.51.100.7": "1\n",
"get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
}, calls));
const r = await f.ban("198.51.100.7", "recidive");
assert.equal(r.added, 1);
assert.equal(r.banned?.until, "2026-10-09 17:00:00");
assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]);
await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/);
await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/);
assert.equal(calls.length, 2);
});
test("unban releases from one jail or from every jail", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls));
assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" });
assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" });
assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]);
});
test("a jail's settings are read from the daemon's listings", async () => {
const f = new Fail2banClient(fake({
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
"get sshd logpath": "No file is currently monitored\n",
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
}));
assert.deepEqual(await f.settings("sshd"), {
jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3,
ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"],
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
});
});
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
assert.equal(installed("sh"), true);
assert.equal(installed("no-such-client-of-the-mesh"), false);
});
+43 -50
View File
@@ -1,62 +1,55 @@
// The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned, // fail2ban's tools — reading and steering the live ban state. The jails themselves are declared
// the jails' state, ban one, let one go — and the module's own reading of a jail's settings // resources (module.json); these three touch what the running daemon holds: what is banned now,
// (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a // and the manual ban/unban an operator reaches for. The daemon's state is fail2ban's own, so this
// machine runs and written as declared resources; these touch only what the running daemon holds. // is the only way to see or change it — the mesh reconciles the config, not the bans.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { Fail2banClient } from "../client.js"; import { Fail2banClient } from "../client.js";
export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] {
return [
{
name: "status",
description:
"Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
input: { jail: { type: "string", description: "one jail (optional)" } },
run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined),
},
{
name: "banned",
description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
input: { jail: { type: "string", description: "one jail (optional)" } },
run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined),
},
{
name: "ban",
description:
"Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
input: {
ip: { type: "string", description: "the address" },
jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" },
},
run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")),
},
{
name: "unban",
description: "Let one address go, from one jail or from every jail when none is named.",
input: {
ip: { type: "string", description: "the address" },
jail: { type: "string", description: "one jail (optional)" },
},
run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined),
},
];
}
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] { export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
return [ return [
{ {
name: "fail2ban_settings", name: "fail2ban_status",
description: description:
"One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.", "fail2ban status on this node — the jails and their live bans. Omit `jail` for every jail, or name one for its detail.",
input: { jail: { type: "string", description: "the jail" } }, input: {
run: async (args) => fail2ban.settings(String(args.jail ?? "")), type: "object",
properties: {
jail: {
type: "string",
description: "A specific jail (e.g. sshd, recidive); omit for the overview of all jails.",
},
},
},
run: async (args) => ({ status: await fail2ban.status(args.jail as string | undefined) }),
},
{
name: "fail2ban_ban",
description: "Manually ban an IP address in a jail — a live change to the running daemon, not a mesh-managed file.",
input: {
type: "object",
properties: {
jail: { type: "string", description: "Jail name (e.g. sshd, recidive)." },
ip: { type: "string", description: "IP address to ban." },
},
required: ["jail", "ip"],
},
run: async (args) => ({ result: await fail2ban.ban(args.jail as string, args.ip as string) }),
},
{
name: "fail2ban_unban",
description: "Unban an IP address from one jail, or from every jail when `jail` is omitted.",
input: {
type: "object",
properties: {
ip: { type: "string", description: "IP address to unban." },
jail: { type: "string", description: "A specific jail; omit to unban from all jails." },
},
required: ["ip"],
},
run: async (args) => ({ result: await fail2ban.unban(args.ip as string, args.jail as string | undefined) }),
}, },
]; ];
} }
const fail2ban = Fail2banClient.onThisMachine(); registerModuleTools("fail2ban", () => getFail2banTools(Fail2banClient.fromEnv()));
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
// module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
registerModuleTools("fail2ban", () => getFail2banTools(fail2ban));
+37
View File
@@ -0,0 +1,37 @@
# gitea's runtime: the tool runtime, carrying this module's compiled 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/gitea
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.
RUN node /app/node_modules/typescript/bin/tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# **No apt packages.** gitea's provisioner talks to the forge over HTTP (the gitea REST API), not
# through a CLI the way postgres drives psql — so the runtime base holds everything this needs.
COPY --from=build /app/modules/gitea/dist /app/modules/gitea/dist
# What a tool host should load from this module: its event consumer and its tools, which are
# separate entrypoints because they are loaded by different things. The provisioner is the third,
# and is not listed here — the declaration names it in the container's `args`, because it is what
# this module's own container runs. One image, because they are one module and share a client.
ENV MESH_TOOL_MODULES=/app/modules/gitea/dist/index.js,/app/modules/gitea/dist/tools/index.js,/app/modules/gitea/dist/provisioner/index.js
+46 -31
View File
@@ -85,6 +85,9 @@
"scope": "mesh" "scope": "mesh"
} }
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -142,8 +145,7 @@
"volumes": [ "volumes": [
"${dir:data}:/data" "${dir:data}:/data"
], ],
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it", "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it"
"logging": "journald"
}, },
{ {
"id": "admin-bootstrap", "id": "admin-bootstrap",
@@ -177,6 +179,32 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-gitea",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:runtime-state}:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
"MESH_GITEA_STATE_DIR": "/run/state",
"MESH_RECEIVES": "${dir:grants}/npm.json"
},
"artifact": "runtime",
"restart-on": [
"runtime-config"
]
} }
], ],
"provides": [ "provides": [
@@ -190,37 +218,24 @@
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
"MESH_RECEIVES": "${dir:grants}/npm.json"
}
} }
] ]
}, }
"jails": [
{
"name": "gitea",
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
} }
+24
View File
@@ -0,0 +1,24 @@
# gitlab's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/gitlab
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/gitlab/dist /app/modules/gitlab/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/gitlab/dist/tools/index.js
+40 -14
View File
@@ -2,9 +2,16 @@
"module": "gitlab", "module": "gitlab",
"version": "1", "version": "1",
"own-secrets": { "own-secrets": {
"token": "${dir:state}/token" "token": "${dir:state}/token",
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -18,27 +25,46 @@
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-gitlab",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_GITLAB_TOKEN_FILE": "/run/secrets/token",
"MESH_GITLAB_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "tools", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_GITLAB_TOKEN_FILE": "${dir:state}/token",
"MESH_GITLAB_CONFIG_FILE": "${dir:state}/config.json"
}
} }
] ]
} }
+24
View File
@@ -0,0 +1,24 @@
# grafana's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/grafana
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/grafana/dist /app/modules/grafana/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/grafana/dist/index.js,/app/modules/grafana/dist/tools/index.js
+36 -16
View File
@@ -5,7 +5,8 @@
"alert.firing" "alert.firing"
], ],
"own-secrets": { "own-secrets": {
"admin": "${dir:mesh-state}/admin" "admin": "${dir:mesh-state}/admin",
"broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -111,6 +112,25 @@
"mode": "0600", "mode": "0600",
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n", "content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-grafana",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -142,23 +162,23 @@
"influxdb-api": "${dir:mesh-state}/influxdb-api" "influxdb-api": "${dir:mesh-state}/influxdb-api"
}, },
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GRAFANA_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
+27
View File
@@ -0,0 +1,27 @@
# home-assistant's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/home-assistant
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/home-assistant/dist /app/modules/home-assistant/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/home-assistant/dist/index.js,/app/modules/home-assistant/dist/tools/index.js
# NOT dist/provisions/index.js: that is a step the host runs to completion, named by the
# `provisions` container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run
# inside the serving sidecar too, and exit it.
+61 -33
View File
@@ -9,6 +9,7 @@
"state.changed" "state.changed"
], ],
"own-secrets": { "own-secrets": {
"broker": "${dir:mesh-state}/broker",
"token": "${dir:mesh-state}/token" "token": "${dir:mesh-state}/token"
}, },
"listens": [ "listens": [
@@ -79,27 +80,55 @@
"merge": "json" "merge": "json"
}, },
{ {
"id": "provisions-env", "id": "runtime",
"type": "file", "type": "container",
"path": "${dir:state}/provisions.env", "name": "mesh-home-assistant",
"mode": "0600", "network": "host",
"content": "MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123}\nMESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n" "volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}, },
{ {
"id": "provisions", "id": "provisions",
"type": "process", "type": "container",
"name": "home-assistant-provisions", "name": "mesh-home-assistant-provisions",
"artifact": "code", "network": "host",
"run": [
"node",
"provisions/index.js"
],
"run-once": true, "run-once": true,
"env-file": [ "volumes": [
"${dir:state}/provisions.env" "${dir:mesh-state}/token:/run/secrets/token:ro",
"${dir:written}:/var/lib/home-assistant-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/sonarr-api.json:/run/provisions/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/provisions/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/provisions/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/provisions/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/provisions/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/provisions/lidarr-api.secret:ro"
],
"env": {
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/home-assistant-provisions"
},
"args": [
"run",
"/app/modules/home-assistant/dist/provisions/index.js"
], ],
"restart-on": [ "restart-on": [
"provisions-env",
"bound-mqtt-topic", "bound-mqtt-topic",
"secret-mqtt-topic", "secret-mqtt-topic",
"bound-sonarr-api", "bound-sonarr-api",
@@ -108,7 +137,8 @@
"secret-radarr-api", "secret-radarr-api",
"bound-lidarr-api", "bound-lidarr-api",
"secret-lidarr-api" "secret-lidarr-api"
] ],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -143,25 +173,23 @@
"lidarr-api": "${dir:state}/lidarr-api.secret" "lidarr-api": "${dir:state}/lidarr-api.secret"
}, },
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"provisions/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "${dir:mesh-state}/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
+24
View File
@@ -0,0 +1,24 @@
# icecast's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/icecast
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/icecast/dist /app/modules/icecast/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/icecast/dist/index.js,/app/modules/icecast/dist/tools/index.js
+37 -15
View File
@@ -28,6 +28,9 @@
"stream.started", "stream.started",
"stream.stopped" "stream.stopped"
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"listens": [ "listens": [
{ {
"name": "stream", "name": "stream",
@@ -92,26 +95,45 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-icecast",
"network": "icecast",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_ICECAST_URL": "http://icecast:8000",
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_ICECAST_URL": "http://127.0.0.1:${port:8000}",
"MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
+24
View File
@@ -0,0 +1,24 @@
# influxdb's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/influxdb
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
+39 -17
View File
@@ -11,6 +11,7 @@
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"broker": "${dir:mesh-state}/broker",
"admin": "${dir:state}/admin.secret", "admin": "${dir:state}/admin.secret",
"admin-token": "${dir:state}/admin-token.secret" "admin-token": "${dir:state}/admin-token.secret"
}, },
@@ -99,6 +100,29 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-influxdb",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
"${dir:grants}:${dir:grants}:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -111,25 +135,23 @@
} }
}, },
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_INFLUXDB_TOKEN_FILE": "${dir:state}/admin-token.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
+24
View File
@@ -0,0 +1,24 @@
# jira's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/jira
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/jira/dist /app/modules/jira/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/jira/dist/tools/index.js
+40 -14
View File
@@ -2,9 +2,16 @@
"module": "jira", "module": "jira",
"version": "1", "version": "1",
"own-secrets": { "own-secrets": {
"token": "${dir:state}/token" "token": "${dir:state}/token",
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -18,27 +25,46 @@
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-jira",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_JIRA_TOKEN_FILE": "/run/secrets/token",
"MESH_JIRA_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "tools", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_JIRA_TOKEN_FILE": "${dir:state}/token",
"MESH_JIRA_CONFIG_FILE": "${dir:state}/config.json"
}
} }
] ]
} }
+30
View File
@@ -0,0 +1,30 @@
# keycloak's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/keycloak
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/keycloak/dist /app/modules/keycloak/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js,/app/modules/keycloak/dist/provisioner/index.js
+40 -20
View File
@@ -62,7 +62,8 @@
"oidc-client": "${dir:grants}" "oidc-client": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"admin": "${dir:state}/admin.secret" "admin": "${dir:state}/admin.secret",
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
@@ -143,30 +144,49 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-keycloak",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:grants}:${dir:grants}:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
-93
View File
@@ -1,93 +0,0 @@
{
"module": "lab",
"version": "1",
"capabilities": [
"container-runtime",
"virtualisation"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "work",
"type": "directory",
"path": "/var/lib/mesh-lab-runs",
"mode": "0700"
},
{
"id": "runtime-env",
"type": "file",
"path": "${dir:state}/lab.env",
"mode": "0600",
"content": "MESH_LAB_FORGE=${setting:forge}\n"
},
{
"id": "git",
"type": "package",
"package": "git"
},
{
"id": "make",
"type": "package",
"package": "make"
},
{
"id": "python",
"type": "package",
"package": "python"
},
{
"id": "file",
"type": "package",
"package": "file"
},
{
"id": "iproute2",
"type": "package",
"package": "iproute2"
},
{
"id": "sudo",
"type": "package",
"package": "sudo"
},
{
"id": "npm",
"type": "package",
"package": "npm"
},
{
"id": "go",
"type": "package",
"package": "go"
},
{
"id": "incus",
"type": "package",
"package": "incus"
}
],
"build": {
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_LAB_WORK": "${dir:work}",
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
}
}
]
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"name": "@novox/module-lab",
"version": "0.1.0",
"description": "lab — the lab, as a module: runs beds against the forge's branches when the mesh asks (novox/hq ADR 0172).",
"type": "module",
"private": true,
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
-108
View File
@@ -1,108 +0,0 @@
// lab's tools — the lab, as the mesh asks for it (novox/hq ADR 0172). They run on the machine the
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
// The forge is an operator's setting, which reaches a file and never a bundle's words (novox/hq
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
return [
{
name: "lab_check",
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
input: {},
run: async () => {
const forge = forgeOf();
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const dir = `${work}/check`;
spawnSync("rm", ["-rf", dir]);
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() };
},
},
{
name: "lab_run",
description:
"Run the lab's beds against branches on the forge: fresh checkouts of every repository the lab builds, " +
"side by side, then the suite on the named test files. Answers at once with the run's id; lab_status " +
"and lab_log follow it. One run at a time.",
input: {
tests: { type: "string", description: "the bed test files, comma-separated, relative to mesh-lab (e.g. test/integration/mesh.test.ts)" },
refs: {
type: "string",
description: `a JSON object of repository to branch, for any of ${REPOSITORIES.join(", ")}; the rest run main`,
},
},
run: async (args) => {
const forge = forgeOf();
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
let refs: Record<string, string> = {};
if (args.refs) {
try {
refs = JSON.parse(String(args.refs)) as Record<string, string>;
} catch {
return { started: false, reason: "refs is not a JSON object of repository to branch" };
}
}
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
return { started: true, run: start(work, forge, tests, refs) };
},
},
{
name: "lab_status",
description: "A run's state, the commits it tested and how it ended — or every run, newest first, when no id is given.",
input: { id: { type: "string", description: "the run's id (optional)" } },
run: async (args) => {
if (args.id) return readStatus(work, String(args.id)) ?? { found: false, id: String(args.id) };
return { runs: listRuns(work).slice(0, 10) };
},
},
{
name: "lab_log",
description: "The last lines of a run's log.",
input: {
id: { type: "string", description: "the run's id" },
lines: { type: "number", description: "how many lines from the end (default 200)" },
},
run: async (args) => ({ id: String(args.id), log: tail(work, String(args.id), Number(args.lines ?? 200)) }),
},
{
name: "lab_stop",
description: "Stop a run and everything it started.",
input: { id: { type: "string", description: "the run's id" } },
run: async (args) => stop(work, String(args.id)) ?? { found: false, id: String(args.id) },
},
];
}
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
export function wordIn(file: string | undefined, word: string): string {
if (!file) return "";
let text: string;
try {
text = readFileSync(file, "utf8");
} catch {
return "";
}
for (const line of text.split("\n")) {
const at = line.indexOf("=");
if (at > 0 && line.slice(0, at).trim() === word) return line.slice(at + 1).trim();
}
return "";
}
registerModuleTools("lab", (env) => getLabTools(env));
-175
View File
@@ -1,175 +0,0 @@
// A lab run: fresh checkouts of the named branches, side by side, then the lab's suite on the named
// beds (novox/hq ADR 0172).
//
// **A run is a detached script with its own process group**, so it outlives the tool call that started
// it and `stop` ends everything it started. It writes what it is doing to a status file beside its log,
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
// for it, and says it was lost rather than pretending it is still going.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** The repositories a lab run checks out, side by side, as the lab expects its siblings. */
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-sdk"];
export interface RunStatus {
id: string;
state: "checking-out" | "building" | "running" | "passed" | "failed" | "stopped" | "lost";
started: string;
ended?: string;
tests: string[];
refs: Record<string, string>;
commits?: Record<string, string>;
exit?: number;
pid?: number;
}
export function runDir(work: string, id: string): string {
return join(work, id);
}
function statusPath(work: string, id: string): string {
return join(runDir(work, id), "status.json");
}
export function readStatus(work: string, id: string): RunStatus | undefined {
try {
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
// A run whose process is gone while its status still says it is going was lost — the runtime or
// the machine restarted under it. Said, rather than left reading as running for ever.
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
s.state = "lost";
}
return s;
} catch {
return undefined;
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function listRuns(work: string): RunStatus[] {
if (!existsSync(work)) return [];
return readdirSync(work)
.filter((d) => d.startsWith("run-"))
.map((id) => readStatus(work, id))
.filter((s): s is RunStatus => !!s)
.sort((a, b) => b.started.localeCompare(a.started));
}
/** The run still going, if any: one at a time, because two would contend for the same machine. */
export function running(work: string): RunStatus | undefined {
return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state));
}
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
/**
* The script one run executes. Every step writes its state first, so a run that dies says where.
*
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
* at this run's own tree, so what is built and claimed is exactly what was checked out.
*/
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
`const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=${JSON.stringify(state)};require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)}`;
const clones = REPOSITORIES.map((repo) => {
const ref = refs[repo] ?? "main";
return [
`git clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
].join("\n");
}).join("\n");
const bin = join(dir, "bin");
return `set -euo pipefail
cd ${shellQuote(dir)}
${setState("checking-out")}
${clones}
node -e ${shellQuote(
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
)}
${setState("building")}
# The @novox scope resolves from the mesh's own package registry on the forge, as the build machine
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done
(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present)
(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present)
mkdir -p ${shellQuote(bin)}
for p in postgres-provisioner objectstore-provisioner route-proxy; do
(cd ${shellQuote(dir)}/mesh-controller && CGO_ENABLED=0 go build -o ${shellQuote(bin)}/$p ./examples/$p)
done
export MESH_LAB_HOST_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-host"))}
export MESH_LAB_BUNDLE=${shellQuote(join(dir, "mesh-host", "examples", "foundation-first-node-nats.lock"))}
export MESH_LAB_MODULES=${shellQuote(join(dir, "mesh-controller", "examples", "modules"))}
export MESH_LAB_BUILDER=${shellQuote(join(dir, "mesh-controller", "build", "mesh-builder"))}
export MESH_LAB_BOOTSTRAP_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-bootstrap"))}
export MESH_LAB_CATALOG=${shellQuote(join(dir, "mesh-catalog", "modules"))}
export MESH_LAB_PROVISIONER=${shellQuote(join(bin, "postgres-provisioner"))}
export MESH_LAB_OBJECTSTORE_PROVISIONER=${shellQuote(join(bin, "objectstore-provisioner"))}
export MESH_LAB_ROUTE_PROXY=${shellQuote(join(bin, "route-proxy"))}
${setState("running")}
cd ${shellQuote(join(dir, "mesh-lab"))}
node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join(" ")}
`;
}
/** start begins a run and returns at once with its status. */
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>): RunStatus {
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = runDir(work, id);
mkdirSync(dir, { recursive: true });
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { mode: 0o700 });
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
const wrapper = `bash ${shellQuote(join(dir, "run.sh"))} > ${shellQuote(join(dir, "run.log"))} 2>&1; code=$?
node -e ${shellQuote(
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)} "$code"
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
child.unref();
status.pid = child.pid;
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
return status;
}
/** stop ends a run and everything it started, by its process group. */
export function stop(work: string, id: string): RunStatus | undefined {
const s = readStatus(work, id);
if (!s || !s.pid) return s;
if (["passed", "failed", "stopped", "lost"].includes(s.state)) return s;
s.state = "stopped";
writeFileSync(statusPath(work, id), JSON.stringify(s, null, 2));
try {
process.kill(-s.pid, "SIGTERM");
} catch {
// Already gone between the read and the kill.
}
return s;
}
/** tail is the last lines of a run's log. */
export function tail(work: string, id: string, lines: number): string {
try {
const all = readFileSync(join(runDir(work, id), "run.log"), "utf8").split("\n");
return all.slice(-Math.max(1, lines)).join("\n");
} catch {
return "";
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["tools/index.ts", "tools/runs.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
# letta's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/letta
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/letta/dist /app/modules/letta/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/letta/dist/tools/index.js
+36 -14
View File
@@ -26,7 +26,8 @@
}, },
"own-secrets": { "own-secrets": {
"server-password": "${dir:state}/server-password.secret", "server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret" "openai-api-key": "${dir:state}/openai-api-key.secret",
"broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -83,24 +84,45 @@
"mode": "0600", "mode": "0600",
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n", "content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-letta",
"network": "letta",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LETTA_URL": "http://letta:8283",
"MESH_LETTA_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "tools", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_LETTA_URL": "http://127.0.0.1:${port:8283}",
"MESH_LETTA_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
+30
View File
@@ -0,0 +1,30 @@
# mailu's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/mailu
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js
+43 -40
View File
@@ -135,15 +135,11 @@
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here" "why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here"
},
{
"name": "admin-api",
"port": 8080,
"protocol": "tcp",
"from": "machine",
"why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network"
} }
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -309,9 +305,6 @@
"name": "mailu-admin", "name": "mailu-admin",
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a", "image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
"network": "mailu", "network": "mailu",
"ports": [
"8080"
],
"env-file": [ "env-file": [
"${dir:state}/mailu.env", "${dir:state}/mailu.env",
"${dir:state}/secret.env", "${dir:state}/secret.env",
@@ -469,8 +462,7 @@
], ],
"dns": [ "dns": [
"192.168.203.254" "192.168.203.254"
], ]
"logging": "journald"
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
@@ -480,6 +472,31 @@
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
}, },
{
"id": "runtime",
"type": "container",
"name": "mesh-mailu",
"network": "mailu",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}/api-token.secret:/run/secrets/api-token:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/var/run/docker.sock:/var/run/docker.sock"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_MAILU_URL": "http://mailu-admin:8080/api/v1",
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{ {
"id": "automx", "id": "automx",
"type": "container", "type": "container",
@@ -499,6 +516,16 @@
], ],
"build": { "build": {
"on": [ "on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{ {
"arg": "PYTHON_BASE", "arg": "PYTHON_BASE",
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
@@ -506,26 +533,9 @@
], ],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
}, },
{ {
"name": "automx", "name": "automx",
@@ -551,12 +561,5 @@
}, },
"grants": { "grants": {
"smtp": "${dir:grants}" "smtp": "${dir:grants}"
}, }
"jails": [
{
"name": "mailu-front",
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
} }
+55
View File
@@ -0,0 +1,55 @@
# mesh-catalog's runtime: the tool runtime, carrying the catalogue's compiled graph, its consumer
# of what the builder announces, and its tools.
#
# **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 with 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/mesh-catalog
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.
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
# **A module may need something the base image does not carry.** The base holds what every module
# needs — the sdk, the broker client — and a postgres driver is not that: the one other module that
# reaches a database shells out to psql instead. So the catalogue brings its own.
#
# Installed into an empty directory rather than into the module's, because the module's package.json
# also names `@novox/mesh-sdk`, which is not on any registry — it is in the base image. Asking npm to
# resolve this module's dependencies would therefore fail on the one it already has.
RUN mkdir -p /deps && cd /deps && \
npm install --omit=dev --no-audit --no-fund --no-package-lock pg@8
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist
# Beside the compiled code, so `pg` resolves from it while `@novox/mesh-sdk` keeps walking up to the
# base image's own node_modules — the module gets its extra dependency without shadowing the sdk it
# was compiled against.
COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
# Both entrypoints, loaded in serve mode.
#
# **A consumer cannot be started with `run`.** That mode imports an entrypoint without binding a
# broker — it is for a step that does its work offline and exits — and the catalogue's whole job is
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
# `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
# 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
+36 -24
View File
@@ -25,6 +25,9 @@
"secrets": { "secrets": {
"postgres-database": "${dir:state}/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"consumes": [ "consumes": [
"mesh-build-machine.built", "mesh-build-machine.built",
"mesh-controller.built-before" "mesh-controller.built-before"
@@ -35,7 +38,14 @@
"rebuild-needed", "rebuild-needed",
"catching-up" "catching-up"
], ],
"prepares": true,
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -50,41 +60,43 @@
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
}, },
{ {
"id": "prepare", "id": "runtime",
"type": "process", "type": "container",
"name": "mesh-catalog-prepare", "name": "mesh-catalog",
"artifact": "code", "network": "host",
"run": [ "volumes": [
"node", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"prepare/index.js" "${dir:state}:/run/state",
"${dir:state}/database.url:/run/secrets/database-url:ro"
], ],
"run-once": true,
"env": { "env": {
"DATABASE_URL_FILE": "${dir:state}/database.url" "MESH_BROKER_FILE": "/run/secrets/broker",
"DATABASE_URL_FILE": "/run/secrets/database-url"
}, },
"artifact": "runtime",
"restart-on": [ "restart-on": [
"database-url" "database-url"
] ]
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"prepare/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
}
} }
] ]
} }
+3 -3
View File
@@ -1,9 +1,9 @@
// Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg` // Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg`
// package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses — // package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses —
// the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts, // the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts,
// listed in tsconfig `include`, default-imported). The real `pg` is the package.json dependency the // listed in tsconfig `include`, default-imported). The real `pg` is installed into the module's
// builder installs and inlines into the module's bundle (novox/hq ADR 0198 §4), so this types the // runtime image (package.json `dependencies`; novox/hq ADR 0052), so this types the code without
// code without deciding what runs. // deciding what runs.
declare module "pg" { declare module "pg" {
/** One checked-out connection. Needed because registering a module-version and its edges is one /** One checked-out connection. Needed because registering a module-version and its edges is one
* act: a half-written registration is a graph that lies about what something was built against. */ * act: a half-written registration is a graph that lies about what something was built against. */
+2 -3
View File
@@ -7,9 +7,8 @@
// nothing anywhere said so. // nothing anywhere said so.
// //
// Nothing here connects to the broker. Preparation runs before the version that would use it, 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 host runs this file as a run-once process, with the module's // there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this
// words and no bus (novox/hq ADR 0198 §3), before the node's runtime is started with the version // process exiting non-zero is how the host knows not to start the runtime.
// that needs it, and this process exiting non-zero is how the host knows the step did not complete.
import { Graph } from "../store.js"; import { Graph } from "../store.js";
const graph = Graph.fromEnv(); const graph = Graph.fromEnv();
+13
View File
@@ -0,0 +1,13 @@
# The console (novox/hq ADR 0152, design 34): the mesh's tools for whoever is on a machine, served
# over MCP on that machine's loopback.
#
# **Nothing is compiled here.** The console is the tool runtime's own client — `mesh serve` — which
# the runtime image already carries beside the runtime it runs modules with. This recipe changes the
# program the image starts and nothing else, so the console is exactly the client a person can run by
# hand, started by the mesh instead, on the credential the mesh sealed to the machine.
#
# One base, named rather than pinned: the mesh answers with the copy it holds (novox/hq issue 044).
ARG RUNTIME_BASE
FROM ${RUNTIME_BASE}
ENTRYPOINT ["node", "dist/mesh.js"]
+38
View File
@@ -0,0 +1,38 @@
# mesh-console
The mesh's tools, on the machine a person sits at, served by a module the mesh assigned there
(novox/hq [ADR 0152](https://git.novox.be/novox/hq), design 34).
Assign it to a machine and an agent on that machine has the mesh's tools at
`http://127.0.0.1:<port>/mcp` — MCP over HTTP, `initialize`, `tools/list`, `tools/call`. A person at
a terminal reaches the same endpoint with `mesh tools --console http://127.0.0.1:<port>` and
`mesh call <module>.<tool> --console …`, with no credential of their own: the console holds it.
## What it is
The tool runtime's own client, `mesh serve`, started by the mesh on the credential it sealed to the
machine for `<node>.mesh-console`. The manifest says three things nothing else in the catalogue says
together:
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
- a listener `from: machine` — loopback only, and the filter opens nothing for it;
- no `emits`, no `consumes`, no `tools` — nothing on the bus can address it.
**Loopback is the authority boundary.** Whoever can connect is on the machine, and whoever is on the
machine is the account that owns the mesh there (ADR 0034, ADR 0144). There is no token and no login,
and `mesh serve` refuses to bind anything but a loopback address.
## What it lists
What the running modules answer: every tool runtime serves a `tools` verb for its module, and the
console asks the catalogue which modules the mesh holds and each module what it serves. A module that
did not answer — not assigned, not up, or built before the runtime answered `tools` — is named in the
list's `_meta.notAnswering` and can still be called by `<module>.<tool>`.
The mesh's own verbs (`status`, `push`, `assign`) are the `mesh-controller` seat's tools under
ADR 0132 and are not served on the bus yet; they appear here when they are.
## Port
The manifest declares port 4270 and the mesh assigns the machine port as it does for any listener;
the console binds `127.0.0.1:${port:4270}`. `node show <machine>` says which port a machine was given.
+64
View File
@@ -0,0 +1,64 @@
{
"module": "mesh-console",
"version": "1",
"slug": "console",
"capabilities": [
"container-runtime"
],
"invokes": [
"*"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"listens": [
{
"name": "mcp",
"port": 4270,
"protocol": "tcp",
"from": "machine",
"why": "the mesh's tools for whoever is on this machine, over MCP on loopback; the machine's login is the authority (novox/hq ADR 0152)"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "server",
"type": "container",
"name": "mesh-console",
"network": "host",
"args": [
"serve"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CONSOLE_LISTEN": "127.0.0.1:${port:4270}"
},
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
+22
View File
@@ -0,0 +1,22 @@
# mesh-vault's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
# consumer. The same shape as postgres's, minus the client the database needs: mesh-vault reaches no
# server, because what it provides is a value the mesh already delivered to its node.
#
# **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 (novox/hq ADR 0069). Two bases, named rather than
# pinned — the image this is COMPILED in and the image it RUNS in — answered by the mesh from
# `build.on` in module.json (novox/hq issue 044).
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/vault
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/vault/dist /app/modules/vault/dist
# The entrypoints a tool host loads from this module: its event consumer, its tools and its
# provisioner — one image, one process, one broker account (novox/hq ADR 0052).
ENV MESH_TOOL_MODULES=/app/modules/vault/dist/index.js,/app/modules/vault/dist/tools/index.js,/app/modules/vault/dist/provisioner/index.js
+43 -18
View File
@@ -27,7 +27,16 @@
"secret": "${dir:grants}" "secret": "${dir:grants}"
}, },
"keeps": "/var/lib/mesh-vault/root", "keeps": "/var/lib/mesh-vault/root",
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -48,29 +57,45 @@
"id": "root", "id": "root",
"type": "directory", "type": "directory",
"mode": "0700" "mode": "0700"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-vault",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:ledger}:${dir:ledger}",
"${dir:root}:${dir:root}:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
}
} }
] ]
}, },
+40
View File
@@ -0,0 +1,40 @@
# minio's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
ARG MC_CLI
# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder
# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM.
FROM ${MC_CLI} AS mccli
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/minio
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/minio/dist /app/modules/minio/dist
# The provisioner shells out to mc to actually create buckets and service accounts on the running
# minio server — mc itself was never in this runtime image, only in minio's own. Silently retried
# "spawn mc ENOENT" forever: a requirement was granted at the control-plane level without ever
# materializing the credential on minio. /usr/bin/mc there is a symlink to the real binary, mcli —
# both copied so the symlink resolves.
COPY --from=mccli /usr/bin/mcli /usr/bin/mcli
COPY --from=mccli /usr/bin/mc /usr/bin/mc
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/minio/dist/tools/index.js,/app/modules/minio/dist/provisioner/index.js
+45 -24
View File
@@ -59,9 +59,16 @@
"s3-bucket": "${dir:grants}" "s3-bucket": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"root": "${dir:state}/root.secret" "root": "${dir:state}/root.secret",
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -120,34 +127,48 @@
} }
}, },
{ {
"id": "client", "id": "runtime",
"type": "package", "type": "container",
"package": "minio-client" "name": "mesh-minio",
"network": "minio-net",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/root.secret:/run/secrets/root:ro"
],
"env": {
"MESH_MINIO_ENDPOINT": "http://minio:9000",
"MESH_MINIO_ROOT_USER": "meshroot",
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
"MESH_MINIO_REGION": "eu-west",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "MC_CLI",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_MINIO_ENDPOINT": "http://127.0.0.1:${port:9000}",
"MESH_MINIO_ROOT_USER": "meshroot",
"MESH_MINIO_ROOT_PASSWORD_FILE": "${dir:state}/root.secret",
"MESH_MINIO_REGION": "eu-west",
"MESH_MINIO_MC_BIN": "mcli",
"MESH_MINIO_MC_CONFIG": "${dir:state}/mc",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
+37
View File
@@ -0,0 +1,37 @@
# mongodb's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/mongodb
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# mongodb's client shells out to `mongosh`, installed from MongoDB's own apt repo so its shared
# libraries come with it — copying the bare binary out of the mongo image leaves it unable to load.
RUN apt-get update && apt-get install -y --no-install-recommends gnupg curl ca-certificates \
&& curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/mongodb.gpg] https://repo.mongodb.org/apt/debian bookworm/mongodb-org/7.0 main" > /etc/apt/sources.list.d/mongodb.list \
&& apt-get update && apt-get install -y --no-install-recommends mongodb-mongosh \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/mongodb/dist /app/modules/mongodb/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/mongodb/dist/index.js,/app/modules/mongodb/dist/tools/index.js,/app/modules/mongodb/dist/provisioner/index.js
+79 -70
View File
@@ -1,16 +1,19 @@
// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this // mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside mongodb does. // module's tools and its provisioner import it, and nothing outside mongodb does.
// //
// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to // Commands run through `mongosh`, not a wire-protocol driver: the module may take NO npm dependency
// `mongosh`, which the module's container installed from MongoDB's apt repository; the module's code // beyond @novox/mesh-sdk, and hand-rolling the MongoDB wire protocol + SCRAM auth is more surface
// now runs in the node's runtime, on machines whose system carries no mongosh, so it speaks to the // than this should carry — so it shells out to the shell the mongodb image ships, the same way
// server through the official `mongodb` driver its package.json names — installed and inlined into // postgres drives itself through `psql`, minio through `mc` and mailu through doveadm. One boundary,
// the bundle by the builder. One connection per call, as one mongosh invocation was: the module is // `evalJs()`, and every method is built on it: a snippet of JavaScript is evaluated server-side and
// called rarely, and a pool held open across calls would hold a credential the mesh may rotate. // its result comes back as EJSON on stdout.
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb"; import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
export interface DatabaseInfo { export interface DatabaseInfo {
readonly name: string; readonly name: string;
@@ -56,26 +59,32 @@ export class MongoClient {
return this.conn.port; return this.conn.port;
} }
/** The admin connection URI, credentials percent-encoded. */ /** The admin connection URI mongosh authenticates with, credentials percent-encoded. */
private uri(): string { private uri(): string {
const u = encodeURIComponent(this.conn.user); const u = encodeURIComponent(this.conn.user);
const p = encodeURIComponent(this.conn.password); const p = encodeURIComponent(this.conn.password);
const a = encodeURIComponent(this.conn.authSource); const a = encodeURIComponent(this.conn.authSource);
return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}&directConnection=true`; return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}`;
} }
/** /**
* The one execution boundary: connect as the administrator, do `work`, and close — a failure to * Evaluate a JavaScript snippet server-side through `mongosh` and parse the JSON it prints (see
* connect or to authenticate rejects here rather than returning a partial success. * header). The snippet MUST `print()` exactly one JSON document as its only stdout — every method
* below ends in `print(EJSON.stringify(...))`. `--quiet` suppresses the shell banner so stdout is
* the JSON alone; a non-zero exit (auth failure, bad command) rejects here rather than returning
* a partial success.
*/ */
private async admin<T>(work: (client: Driver) => Promise<T>): Promise<T> { async evalJs<T>(js: string): Promise<T> {
const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 }); const { stdout } = await run(
try { "mongosh",
await client.connect(); [this.uri(), "--quiet", "--eval", js],
return await work(client); { maxBuffer: 16 << 20 },
} finally { );
await client.close(); const text = stdout.trim();
if (text.length === 0) {
throw new Error("mongosh returned no output — the eval printed nothing");
} }
return JSON.parse(text) as T;
} }
/** /**
@@ -85,16 +94,19 @@ export class MongoClient {
* password and roles, so a rotated credential converges. * password and roles, so a rotated credential converges.
*/ */
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> { async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
await this.admin(async (client) => { const js = `
const target = client.db(database); const target = db.getSiblingDB(${lit(database)});
const roles = [{ role: "dbOwner", db: database }]; let existing = null;
const found = await target.command({ usersInfo: user }); try { existing = target.getUser(${lit(user)}); } catch (e) { existing = null; }
if (Array.isArray(found.users) && found.users.length > 0) { const roles = [{ role: "dbOwner", db: ${lit(database)} }];
await target.command({ updateUser: user, pwd: password, roles }); if (existing) {
} else { target.updateUser(${lit(user)}, { pwd: ${lit(password)}, roles: roles });
await target.command({ createUser: user, pwd: password, roles }); } else {
} target.createUser({ user: ${lit(user)}, pwd: ${lit(password)}, roles: roles });
}); }
print(EJSON.stringify({ ok: 1 }));
`;
await this.evalJs<{ ok: number }>(js);
} }
/** /**
@@ -103,43 +115,45 @@ export class MongoClient {
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/ */
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> { async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// Credentials as options, never in a URI, so the consumer's password is in no message a failed // Connected without credentials, then authenticated inside the eval from the environment, so
// connection prints. // the consumer's password is neither on argv nor in the message of a failed command.
const client = new Driver(`mongodb://${this.conn.host}:${this.conn.port}/?directConnection=true`, { const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`;
auth: { username: user, password }, const js =
authSource: database, "const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" +
serverSelectionTimeoutMS: 10_000, "t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" +
}); "print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))";
let stdout: string;
try { try {
await client.connect(); ({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], {
const status = await client.db(database).command({ connectionStatus: 1 }); env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password },
const roles = (status.authInfo?.authenticatedUserRoles ?? []) as { role: string; db: string }[]; timeout: 30_000,
return roles.some((r) => r.role === "dbOwner" && r.db === database); }));
} catch (err) { } catch (err) {
if (isAuthFailure(err)) return false; const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`;
throw new Error(`mongodb could not check ${user}: ${String((err as Error).message).split("\n")[0]}`); if (/Authentication failed|AuthenticationFailed/i.test(text)) return false;
} finally { throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`);
await client.close();
} }
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
return roles.some((r) => r.role === "dbOwner" && r.db === database);
} }
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the /** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
* user is removed first so a re-grant of the same login starts clean. */ * user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> { async dropDatabaseAndUser(database: string, user: string): Promise<void> {
await this.admin(async (client) => { const js = `
const target = client.db(database); const target = db.getSiblingDB(${lit(database)});
try { try { target.dropUser(${lit(user)}); } catch (e) {}
await target.command({ dropUser: user }); target.dropDatabase();
} catch (err) { print(EJSON.stringify({ ok: 1 }));
if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound `;
} await this.evalJs<{ ok: number }>(js);
await target.dropDatabase();
});
} }
/** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */ /** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */
async listDatabases(): Promise<DatabaseInfo[]> { async listDatabases(): Promise<DatabaseInfo[]> {
const res = await this.admin((client) => client.db("admin").admin().listDatabases()); const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>(
`print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`,
);
return (res.databases ?? []) return (res.databases ?? [])
.map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) })) .map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) }))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
@@ -148,8 +162,6 @@ export class MongoClient {
/** /**
* Run a read-only `find` against a collection in a named database, for the mongodb_query tool. * Run a read-only `find` against a collection in a named database, for the mongodb_query tool.
* `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result. * `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result.
* Documents come back as relaxed Extended JSON — an ObjectId as `{"$oid": …}` — exactly as the
* shell's `EJSON.stringify` rendered them before.
*/ */
async find( async find(
database: string, database: string,
@@ -158,29 +170,26 @@ export class MongoClient {
limit: number, limit: number,
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const capped = Math.max(1, Math.min(limit, 1000)); const capped = Math.max(1, Math.min(limit, 1000));
const docs = await this.admin((client) => const js =
client `print(EJSON.stringify(` +
.db(database) `db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` +
.collection(collection) `.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` +
.find(BSON.EJSON.deserialize(filter as Document, { relaxed: true }) as Document) `));`;
.limit(capped) return this.evalJs<Record<string, unknown>[]>(js);
.toArray(),
);
return BSON.EJSON.serialize(docs, { relaxed: true }) as Record<string, unknown>[];
} }
} }
/** An authentication failure, as the server or the driver reports it. */
function isAuthFailure(err: unknown): boolean {
if (err instanceof MongoServerError && err.code === 18) return true; // 18: AuthenticationFailed
return /Authentication failed|AuthenticationFailed/i.test(String((err as Error)?.message ?? ""));
}
/** Generate a URL-safe password. */ /** Generate a URL-safe password. */
export function generatePassword(): string { export function generatePassword(): string {
return randomBytes(24).toString("base64url"); return randomBytes(24).toString("base64url");
} }
/** Embed a value as a JavaScript literal inside a mongosh snippet — JSON.stringify escapes quotes,
* backslashes and control characters, so a string cannot break out of the snippet. */
function lit(val: unknown): string {
return JSON.stringify(val);
}
function readSecretFile(path: string | undefined): string | undefined { function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined; if (!path) return undefined;
try { try {
+3 -3
View File
@@ -1,9 +1,9 @@
// mongodb's events entrypoint, launched by the node's runtime beside its tools and provisioner // mongodb's events entrypoint, loaded by the per-node tool host (the provisioner container runs
// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where // ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042): // the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mongodb.database.provisioned — a consumer's database + owning user was created // module.mongodb.database.provisioned — a consumer's database + owning user was created
// module.mongodb.database.deprovisioned — that database was removed // module.mongodb.database.deprovisioned — that database was removed
// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a // Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
// database and who lost one — observability the provider itself is best placed to log. // database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
+43 -28
View File
@@ -39,9 +39,17 @@
"mongodb-database": "${dir:grants}" "mongodb-database": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"root": "${dir:state}/root.secret" "root": "${dir:state}/root.secret",
"broker": "${dir:mesh-state}/broker"
}, },
"secrets-owner": "999:999",
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -63,14 +71,6 @@
"type": "network", "type": "network",
"name": "mongodb" "name": "mongodb"
}, },
{
"id": "server-root",
"type": "file",
"path": "${dir:state}/server-root.secret",
"mode": "0400",
"owner": "999:999",
"content": "${secret:root}"
},
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
@@ -86,31 +86,46 @@
], ],
"volumes": [ "volumes": [
"${dir:data}:/data/db", "${dir:data}:/data/db",
"${dir:state}/server-root.secret:/run/secrets/root:ro" "${dir:state}/root.secret:/run/secrets/root:ro"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mongodb",
"network": "mongodb",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/root.secret:/run/secrets/root:ro"
],
"env": {
"MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_PROVISION_MONGODB": "mongodb://root@127.0.0.1:${port:27017}/admin?authSource=admin",
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
+1 -2
View File
@@ -5,8 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1", "@novox/mesh-sdk": "^0.1.1"
"mongodb": "^6.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+2 -1
View File
@@ -11,7 +11,8 @@
// same-named database under exactly that login — a name the consumer cannot learn is a database it // same-named database under exactly that login — a name the consumer cannot learn is a database it
// cannot reach. // cannot reach.
// //
// The commands run through MongoClient, the official driver inside this bundle (see client.ts). // The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see
// client.ts).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
+2 -2
View File
@@ -1,6 +1,6 @@
// mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They // mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They
// return structured data; the mesh serves them through the sdk's tool harness. Both call the server // return structured data; the mesh serves them through the sdk's tool harness. Both call through
// through MongoClient, the driver inside this bundle (see client.ts). // MongoClient.evalJs(), the module's one execution boundary (see client.ts).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MongoClient } from "../client.js"; import { MongoClient } from "../client.js";
+28
View File
@@ -0,0 +1,28 @@
# mosquitto's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/mosquitto
COPY . .
RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# mosquitto's client and bootstrap drive `mosquitto_ctrl`; the apt package carries it with its
# shared libraries — the musl binary from the eclipse image would not load on this glibc base.
RUN apt-get update && apt-get install -y --no-install-recommends mosquitto \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/mosquitto/dist /app/modules/mosquitto/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/mosquitto/dist/index.js,/app/modules/mosquitto/dist/tools/index.js,/app/modules/mosquitto/dist/provisioner/index.js
+4 -36
View File
@@ -18,7 +18,6 @@ import { randomBytes } from "node:crypto";
import { connect as tcpConnect } from "node:net"; import { connect as tcpConnect } from "node:net";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { basename, dirname } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js"; import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
@@ -31,14 +30,6 @@ export interface MqttConn {
/** The Dynamic Security admin client the runtime authenticates as. */ /** The Dynamic Security admin client the runtime authenticates as. */
readonly adminUser: string; readonly adminUser: string;
readonly adminPassword: string; readonly adminPassword: string;
/**
* The broker's own container, when `mosquitto_ctrl` is run inside it rather than from this
* machine's packages. The broker's image carries the tool at the broker's version, and inside it
* the broker listens on 127.0.0.1:1883 whatever port the machine publishes.
*/
readonly container?: string;
/** The broker's image, to seed the security file before the broker has ever started. */
readonly image?: string;
} }
export class MosquittoClient { export class MosquittoClient {
@@ -62,11 +53,7 @@ export class MosquittoClient {
"mosquitto host or admin password is not set — mosquitto's own code cannot reach the broker", "mosquitto host or admin password is not set — mosquitto's own code cannot reach the broker",
); );
} }
return new MosquittoClient({ return new MosquittoClient({ host, port, adminUser, adminPassword: adminPassword ?? "" });
host, port, adminUser, adminPassword: adminPassword ?? "",
container: env.MESH_MQTT_CTRL_CONTAINER || undefined,
image: env.MESH_MQTT_CTRL_IMAGE || undefined,
});
} }
get host(): string { get host(): string {
@@ -97,18 +84,16 @@ export class MosquittoClient {
* to this single-purpose runtime container; see the module README. * to this single-purpose runtime container; see the module README.
*/ */
async ctl(...args: string[]): Promise<string> { async ctl(...args: string[]): Promise<string> {
const inside = this.conn.container !== undefined;
const base = [ const base = [
"-h", inside ? "127.0.0.1" : this.conn.host, "-h", this.conn.host,
"-p", inside ? "1883" : String(this.conn.port), "-p", String(this.conn.port),
"-u", this.conn.adminUser, "-u", this.conn.adminUser,
"-P", this.conn.adminPassword, "-P", this.conn.adminPassword,
]; ];
let stdout: string; let stdout: string;
let stderr: string; let stderr: string;
try { try {
const [command, argv] = this.ctrl([...base, "dynsec", ...args]); ({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
({ stdout, stderr } = await run(command, argv, {
maxBuffer: 16 << 20, maxBuffer: 16 << 20,
timeout: 30_000, timeout: 30_000,
})); }));
@@ -255,27 +240,10 @@ export class MosquittoClient {
async initBootstrapFile(configFile: string): Promise<void> { async initBootstrapFile(configFile: string): Promise<void> {
// `dynsec init <file> <admin-username> [admin-password]` is an offline file operation — it does // `dynsec init <file> <admin-username> [admin-password]` is an offline file operation — it does
// not connect to the broker. The password is a positional argument (omitting it prompts). // not connect to the broker. The password is a positional argument (omitting it prompts).
if (this.conn.image) {
// Before the broker has ever started there is no container to enter: a throwaway one from the
// broker's own image writes the file into the directory the broker will mount.
await run("docker", [
"run", "--rm", "--entrypoint", "mosquitto_ctrl",
"-v", `${dirname(configFile)}:/mosquitto/data`,
this.conn.image,
"dynsec", "init", `/mosquitto/data/${basename(configFile)}`, this.conn.adminUser, this.conn.adminPassword,
], { maxBuffer: 16 << 20 });
return;
}
await run("mosquitto_ctrl", ["dynsec", "init", configFile, this.conn.adminUser, this.conn.adminPassword], { await run("mosquitto_ctrl", ["dynsec", "init", configFile, this.conn.adminUser, this.conn.adminPassword], {
maxBuffer: 16 << 20, maxBuffer: 16 << 20,
}); });
} }
/** How `mosquitto_ctrl` is run here: inside the broker's container when one is named. */
ctrl(argv: string[]): [string, string[]] {
if (this.conn.container) return ["docker", ["exec", this.conn.container, "mosquitto_ctrl", ...argv]];
return ["mosquitto_ctrl", argv];
}
} }
/** Generate a URL-safe password with no argv- or MQTT-hostile characters. */ /** Generate a URL-safe password with no argv- or MQTT-hostile characters. */
+51 -40
View File
@@ -32,7 +32,8 @@
"mqtt-topic": "${dir:grants}" "mqtt-topic": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"admin": "${dir:mesh-state}/admin" "admin": "${dir:mesh-state}/admin",
"broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -87,29 +88,26 @@
"type": "network", "type": "network",
"name": "mosquitto" "name": "mosquitto"
}, },
{
"id": "bootstrap-env",
"type": "file",
"path": "${dir:state}/bootstrap.env",
"mode": "0600",
"content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\nMESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408\n"
},
{ {
"id": "bootstrap", "id": "bootstrap",
"type": "process", "type": "container",
"name": "mosquitto-bootstrap", "name": "mosquitto-bootstrap",
"artifact": "code",
"run": [
"node",
"bootstrap/index.js"
],
"run-once": true, "run-once": true,
"env-file": [ "volumes": [
"${dir:state}/bootstrap.env" "${dir:data}:/mosquitto/data",
"${dir:mesh-state}/admin:/run/secrets/admin:ro"
], ],
"restart-on": [ "env": {
"bootstrap-env" "MESH_PROVISION_MQTT": "mosquitto:1883",
] "MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin",
"MESH_DYNSEC_FILE": "/mosquitto/data/dynamic-security.json"
},
"args": [
"run",
"/app/modules/mosquitto/dist/bootstrap/index.js"
],
"artifact": "runtime"
}, },
{ {
"id": "server", "id": "server",
@@ -125,32 +123,45 @@
"${dir:data}:/mosquitto/data", "${dir:data}:/mosquitto/data",
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" "${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mosquitto",
"network": "mosquitto",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:mesh-state}/admin:/run/secrets/admin:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_PROVISION_MQTT": "mosquitto:1883",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js",
"bootstrap/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_PROVISION_MQTT": "127.0.0.1:${port:1883}",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin",
"MESH_MQTT_CTRL_CONTAINER": "mosquitto"
}
} }
] ]
} }
-19
View File
@@ -1,19 +0,0 @@
// Run after `npm run build`.
// mosquitto_ctrl runs inside the broker's own container when the manifest names it, so a machine
// needs no mosquitto package (whose index may be too stale to install from) and the tool always
// matches the broker's version.
import assert from "node:assert/strict";
import { test } from "node:test";
import { MosquittoClient } from "../dist/client.js"; // compiled: client.ts uses parameter properties, which type stripping cannot run
const env = { MESH_PROVISION_MQTT: "127.0.0.1:21883", MESH_MQTT_PASSWORD: "pw" };
test("named, the broker's container runs mosquitto_ctrl", () => {
const c = MosquittoClient.fromEnv({ ...env, MESH_MQTT_CTRL_CONTAINER: "mosquitto" });
assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["docker", ["exec", "mosquitto", "mosquitto_ctrl", "dynsec", "listClients"]]);
});
test("unnamed, this machine's mosquitto_ctrl runs", () => {
const c = MosquittoClient.fromEnv(env);
assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["mosquitto_ctrl", ["dynsec", "listClients"]]);
});
+43
View File
@@ -0,0 +1,43 @@
# mssql's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/mssql
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **sqlcmd, which this module's client drives, has to be here** — it never was, so every tool failed
# with `spawn sqlcmd ENOENT`. go-sqlcmd is one static binary; fetched at a pinned release and checked
# against its digest, so a build that receives anything else stops here.
FROM ${BUILD_BASE} AS sqlcmd
ARG SQLCMD_VERSION=v1.10.0
ARG SQLCMD_SHA256=92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates bzip2 \
&& curl -fsSL -o /tmp/sqlcmd.tar.bz2 \
"https://github.com/microsoft/go-sqlcmd/releases/download/${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2" \
&& echo "${SQLCMD_SHA256} /tmp/sqlcmd.tar.bz2" | sha256sum -c - \
&& tar -xjf /tmp/sqlcmd.tar.bz2 -C /usr/local/bin sqlcmd
FROM ${RUNTIME_BASE}
COPY --from=sqlcmd /usr/local/bin/sqlcmd /usr/local/bin/sqlcmd
COPY --from=build /app/modules/mssql/dist /app/modules/mssql/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/mssql/dist/index.js,/app/modules/mssql/dist/tools/index.js,/app/modules/mssql/dist/provisioner/index.js
+98 -111
View File
@@ -1,74 +1,22 @@
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this // mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside mssql does. // module's tools and its provisioner import it, and nothing outside mssql does.
// //
// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to // SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm
// `sqlcmd`, a binary the module's container fetched; the module's code now runs in the node's // dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query
// runtime, on machines whose system carries no SQL Server client, so it speaks TDS through the // protocol is more surface than this should carry — so it shells out to the client the mssql
// `mssql` driver its package.json names — installed and inlined into the bundle by the builder. One // tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu
// boundary, `session()`, and every method is built on it: a connection as one login to one database, // through doveadm. One boundary, `run()`, and every method is built on it.
// opened for one call and closed after, as one sqlcmd invocation was.
// //
// Structured rows still come back as JSON rendered by SQL Server itself (`FOR JSON`), so a tool's // Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and
// answer is shaped exactly as it was: SQL Server owns the quoting and typing. // this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's
// column-aligned text, since SQL Server owns the quoting and typing.
import { isIP } from "node:net";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import sql from "mssql"; import { execFile } from "node:child_process";
import { promisify } from "node:util";
/** Where a session connects, and as whom. */ const run = promisify(execFile);
export interface Target {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
readonly database: string;
}
/** One login's connection to one database: run a batch, answer the rows of its last result set. */
export interface Session {
/** `params` are bound as NVARCHAR parameters (`@name`), never written into the text. */
run(text: string, params?: Record<string, string>): Promise<Record<string, unknown>[]>;
close(): Promise<void>;
}
/** How a session is opened — the driver, or a test's fake. */
export type Connect = (to: Target) => Promise<Session>;
/**
* The driver's session: TLS, trusting the self-signed certificate the mssql image ships with (what
* sqlcmd's `-C` did), one connection, closed with the session.
*/
export const connectWithDriver: Connect = async (to) => {
const pool = new sql.ConnectionPool({
server: to.host,
port: to.port,
user: to.user,
password: to.password,
database: to.database,
// TLS names a host, never an address: Node refuses an IP as the server name (DEP0123, an error
// since Node 25), and the module reaches its server on loopback. The certificate is trusted
// either way, so the name only has to be one TLS accepts.
options: { encrypt: true, trustServerCertificate: true, ...(isIP(to.host) ? { serverName: "localhost" } : {}) },
pool: { min: 0, max: 1 },
connectionTimeout: 15_000,
requestTimeout: 60_000,
});
await pool.connect();
return {
async run(text, params = {}) {
const request = pool.request();
const names = Object.keys(params);
for (const name of names) request.input(name, sql.NVarChar, params[name]);
// A batch when nothing is bound — CREATE DATABASE must stand alone in its batch, which a
// parameterised query (sp_executesql) is not.
const result = names.length > 0 ? await request.query(text) : await request.batch(text);
const sets = (result.recordsets ?? []) as Record<string, unknown>[][];
return sets.length > 0 ? sets[sets.length - 1] : [];
},
close: () => pool.close(),
};
};
export interface QueryResult { export interface QueryResult {
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */ /** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
@@ -97,17 +45,20 @@ export interface MssqlConn {
*/ */
export const READER = "mesh_mssql_reader"; export const READER = "mesh_mssql_reader";
/** Who a session logs in as. */ /** Who a sqlcmd invocation logs in as, and whether the text is a caller's rather than the module's. */
interface Invocation { interface Invocation {
readonly user: string; readonly user: string;
readonly password: string; readonly password: string;
/**
* A caller's text: sqlcmd substitutes no `$(NAME)` in it, which would read this process's
* environment — the administrator's password among it. (Its own commands are kept out by the
* caller's text never beginning a line; see readOnlyQuery.)
*/
readonly caller: boolean;
} }
export class MssqlClient { export class MssqlClient {
constructor( constructor(private readonly conn: MssqlConn) {}
private readonly conn: MssqlConn,
private readonly connect: Connect = connectWithDriver,
) {}
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */ /** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
private readerReady?: Promise<void>; private readerReady?: Promise<void>;
@@ -139,41 +90,63 @@ export class MssqlClient {
return this.conn.port; return this.conn.port;
} }
/** Execute a batch that returns no rows (DDL and the like). A failed statement rejects. */ /**
async exec(text: string, database = "master"): Promise<void> { * Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is
await this.session(text, database); * passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a
* failed statement an error here rather than a success with a warning, and `-C` trusts the
* server's self-signed certificate the mssql image ships with.
*/
async exec(sql: string, database = "master"): Promise<void> {
await this.sqlcmd(sql, database);
} }
/** /**
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is * Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document it answers * wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd
* (split across rows for a large result, and reassembled here) is parsed. An empty result yields * prints (split across output lines for a large result, and reassembled here) is parsed. An
* no rows — an empty array. `params` are bound as `@name`, never written into the text. * empty result yields no output at all — an empty array.
*/ */
async query( async query(
select: string, select: string,
database = "master", database = "master",
params: Record<string, string> = {}, variables: Record<string, string> = {},
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
return parseJsonRows(await this.session(wrapped, database, params)); const stdout = await this.sqlcmd(wrapped, database, variables);
return parseJsonRows(stdout);
} }
/** The one execution boundary: open a session as `as`, run `text`, close it. */ /** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
private async session( private async sqlcmd(
text: string, sql: string,
database: string, database: string,
params: Record<string, string> = {}, variables: Record<string, string> = {},
as: Invocation = { user: this.conn.user, password: this.conn.password }, as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false },
): Promise<Record<string, unknown>[]> { ): Promise<string> {
const session = await this.connect({ // `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database, // JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
}); // cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
try { // with postgres's — the module owns its own code (ADR 0039) and shells out to it.
return await session.run(text, params); const { stdout } = await run(
} finally { "sqlcmd",
await session.close(); [
} "-S", `${this.conn.host},${this.conn.port}`,
"-U", as.user,
"-d", database,
...(as.caller ? ["-x"] : []),
"-C",
"-b",
"-h", "-1",
"-y", "0",
"-Y", "0",
"-W",
"-Q", sql,
],
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
// variables: a value that must not appear on argv, or in the message of a failed command.
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: as.password }, maxBuffer: 16 << 20 },
);
return stdout;
} }
/** /**
@@ -200,7 +173,7 @@ export class MssqlClient {
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
); );
if (dbs.length === 0) { if (dbs.length === 0) {
// CREATE DATABASE must stand alone in its batch; it runs as its own session. // CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation.
await this.exec(`CREATE DATABASE ${ident(database)}`); await this.exec(`CREATE DATABASE ${ident(database)}`);
} }
@@ -233,14 +206,15 @@ export class MssqlClient {
* nothing logs in and no failed-login is recorded (novox/hq issue 120). * nothing logs in and no failed-login is recorded (novox/hq issue 120).
*/ */
async holdsLogin(database: string, login: string, password: string): Promise<boolean> { async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
// The password is a bound parameter, never inside the query text, so it is in no message of a // The password reaches sqlcmd as a scripting variable from the environment, never inside the
// failed statement. // query text, so it is neither on argv nor in the message of a failed command. It is the mesh's
// minted value, which carries no quote.
const server = await this.query( const server = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
`AND is_disabled = 0 AND PWDCOMPARE(@meshholdspw, password_hash) = 1) ` + `AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` +
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
"master", "master",
{ meshholdspw: password }, { MESHHOLDSPW: password },
); );
if (Number(server[0]?.ok) !== 1) return false; if (Number(server[0]?.ok) !== 1) return false;
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the // The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
@@ -320,27 +294,35 @@ export class MssqlClient {
* (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the * (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the
* rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call * rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call
* is refused. * is refused.
*
* The text goes to the server as it is, over the driver: there is no client between that reads a
* line of its own (sqlcmd's `:!!`, which could start a program) or substitutes `$(NAME)` from this
* process's environment, so neither the one-line rule nor `-x` has anything left to guard.
*/ */
async readOnlyQuery(database: string, text: string): Promise<QueryResult> { async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
const password = this.conn.readerPassword; const password = this.conn.readerPassword;
if (!password) throw readerMissing(); if (!password) throw readerMissing();
// **One line, refused otherwise.** sqlcmd reads a line that BEGINS with `:` or `!!` as its own
// command rather than SQL, and `:!!` starts a program in this container, which holds the
// administrator's password. Its switch for refusing those (-X) makes it ignore -Q in the
// version shipped here, so instead no line of a caller's text can begin one: the text follows
// this module's own on the first line, and a line break in it is refused. Proven on a throwaway
// server: the same text at the start of a line ran a program; mid-line it is a syntax error.
if (/[\r\n]/.test(sql)) {
throw new Error(
"mssql_query: the statement must be one line — sqlcmd takes a line beginning with ':' or " +
"'!!' as a command of its own, which can start a program (novox/hq issue 193)",
);
}
this.readerReady ??= this.ensureReader().catch((err) => { this.readerReady ??= this.ensureReader().catch((err) => {
this.readerReady = undefined; // asked again next call, not failed for the process's life this.readerReady = undefined; // asked again next call, not failed for the process's life
throw err; throw err;
}); });
await this.readerReady; await this.readerReady;
const rows = await this.session( const stdout = await this.sqlcmd(
`SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, `SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
database, database,
{}, {},
{ user: READER, password }, { user: READER, password, caller: true },
); );
const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? ""; const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? "";
return { command, rows: parseJsonRows(rows) }; return { command, rows: parseJsonRows(stdout) };
} }
} }
@@ -390,13 +372,18 @@ function safeUrl(raw: string): URL | undefined {
} }
/** /**
* Parse the JSON a FOR JSON query answers. SQL Server splits a large FOR JSON result into * Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result
* ~2033-character chunks, one per row of a single column, so the document is reassembled by * into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so
* concatenating that column in order. No rows (an empty result, or a pure DDL batch) means none. * the document is reassembled by concatenating the non-empty lines. No output (an empty result, or
* a pure DDL batch) means no rows.
*/ */
function parseJsonRows(rows: Record<string, unknown>[]): Record<string, unknown>[] { function parseJsonRows(stdout: string): Record<string, unknown>[] {
const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join(""); const joined = stdout
if (joined.trim().length === 0) return []; .split(/\r?\n/)
.map((l) => l.trimEnd())
.filter((l) => l.length > 0)
.join("");
if (joined.length === 0) return [];
const parsed = JSON.parse(joined); const parsed = JSON.parse(joined);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>]; return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
} }
+3 -3
View File
@@ -1,9 +1,9 @@
// mssql's events entrypoint, launched by the node's runtime beside its tools and provisioner // mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs
// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where // ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042): // the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mssql.database.provisioned — a consumer's database + login/user was created // module.mssql.database.provisioned — a consumer's database + login/user was created
// module.mssql.database.deprovisioned — that database was removed // module.mssql.database.deprovisioned — that database was removed
// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a // Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a
// database and who lost one — observability the provider itself is best placed to log. // database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
+42 -19
View File
@@ -38,9 +38,16 @@
}, },
"own-secrets": { "own-secrets": {
"sa": "${dir:state}/sa.secret", "sa": "${dir:state}/sa.secret",
"broker": "${dir:mesh-state}/broker",
"reader": "${dir:state}/reader.secret" "reader": "${dir:state}/reader.secret"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -86,30 +93,46 @@
"${dir:data}:/var/opt/mssql" "${dir:data}:/var/opt/mssql"
], ],
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint" "secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mssql",
"network": "mssql",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/mssql/grants:ro",
"${dir:state}/sa.secret:/run/secrets/sa:ro",
"${dir:state}/reader.secret:/run/secrets/reader:ro"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json",
"MESH_MSSQL_READER_PASSWORD_FILE": "/run/secrets/reader"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master",
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret"
}
} }
] ]
} }
-32
View File
@@ -1,32 +0,0 @@
// Ambient types for `mssql`, which ships its types only in the separate `@types/mssql` package. This
// declares the slice client.ts uses — the precedent mesh-catalog's pg.d.ts sets — so the module
// type-checks without deciding what runs: the real `mssql` is the package.json dependency the
// builder installs and inlines into the bundle (novox/hq ADR 0198 §4).
declare module "mssql" {
interface Result {
recordsets: unknown;
}
interface Request {
input(name: string, type: unknown, value: unknown): Request;
query(text: string): Promise<Result>;
batch(text: string): Promise<Result>;
}
class ConnectionPool {
constructor(config: {
server: string;
port?: number;
user?: string;
password?: string;
database?: string;
options?: { encrypt?: boolean; trustServerCertificate?: boolean; serverName?: string };
pool?: { min?: number; max?: number };
connectionTimeout?: number;
requestTimeout?: number;
});
connect(): Promise<ConnectionPool>;
request(): Request;
close(): Promise<void>;
}
const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown };
export default sql;
}
+2 -3
View File
@@ -5,12 +5,11 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc mssql.d.ts client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "build": "tsc client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1", "@novox/mesh-sdk": "^0.1.1"
"mssql": "^11.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+64 -51
View File
@@ -1,83 +1,96 @@
// What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the // What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the
// reader login and never as the administrator, with no transaction wrapped around it as text, and // reader login and never as the administrator, with sqlcmd's variable substitution off, on one line
// without the reader's password the statement is refused. // that follows the module's own — a line break is refused before sqlcmd starts — and with no
// transaction wrapped around it as text. Without the reader's password the statement is refused.
// //
// The driver is a fake session that records each call's login, database, text and bound // sqlcmd is a fake on PATH that records each call's login, flags and text. That the reader cannot
// parameters. That the reader cannot write is the server's to enforce and was proven against a real // write is the server's to enforce and was proven against a real server; this holds the module to
// server; this holds the module to asking for it. Run against the compiled module (npm test builds // asking for it. Run against the compiled module (npm test builds first), the way the runtime loads it.
// first), the way the runtime loads it.
import { test } from "node:test"; import { test, before, after } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MssqlClient, READER, type Connect, type Target } from "../dist/client.js"; import { MssqlClient, READER } from "../dist/client.js";
interface Call extends Target { let dir: string;
text: string; let log: string;
params: Record<string, string>; const originalPath = process.env.PATH;
}
function recording(): { connect: Connect; calls: Call[] } { before(async () => {
const calls: Call[] = []; dir = await mkdtemp(join(tmpdir(), "mssql-reader-"));
const connect: Connect = async (to) => ({ log = join(dir, "calls.jsonl");
async run(text, params = {}) { await writeFile(join(dir, "sqlcmd"), `#!/usr/bin/env node
calls.push({ ...to, text, params }); const fs = require("node:fs");
if (/FROM sys.server_principals/.test(text)) return []; const args = process.argv.slice(2);
// FOR JSON answers its document split across rows of one column. const at = (flag) => args[args.indexOf(flag) + 1];
if (/FOR JSON/.test(text)) return [{ JSON_F52E: '[{"name":"al' }, { JSON_F52E: 'pha","n":1}]' }]; fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({
return []; user: at("-U"), database: at("-d"), sql: at("-Q"), noVariables: args.includes("-x"),
}, password: process.env.SQLCMDPASSWORD,
async close() {}, }) + "\\n");
}); const sql = at("-Q");
return { connect, calls }; if (/FROM sys.server_principals/.test(sql)) process.stdout.write("");
else if (/FOR JSON/.test(sql)) process.stdout.write('[{"name":"alpha","n":1}]\\n');
`);
await chmod(join(dir, "sqlcmd"), 0o755);
process.env.PATH = `${dir}:${originalPath}`;
});
after(async () => {
process.env.PATH = originalPath;
await rm(dir, { recursive: true, force: true });
});
async function calls(): Promise<Record<string, unknown>[]> {
const text = await readFile(log, "utf8").catch(() => "");
await writeFile(log, "");
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
} }
const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" }; const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" };
test("a caller's statement runs as the reader, as it was written, on the database it names", async () => { test("a caller's statement runs as the reader, without variables, on the module's first line", async () => {
const { connect, calls } = recording(); const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p"); const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p");
const asked = calls.at(-1)!; const asked = (await calls()).at(-1)!;
assert.equal(asked.user, READER, "the statement never runs as the administrator"); assert.equal(asked.user, READER, "the statement never runs as the administrator");
assert.equal(asked.password, "reader-secret"); assert.equal(asked.password, "reader-secret");
assert.equal(asked.database, "inventory"); assert.equal(asked.noVariables, true, "no $(NAME) is substituted in a caller's text");
assert.ok(asked.text.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)' AS p\nFOR JSON PATH"), const [first] = String(asked.sql).split("\n");
"the caller's text reaches the server unaltered"); assert.ok(first.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)'"), "the caller's text never begins a line");
assert.doesNotMatch(asked.text, /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text"); assert.doesNotMatch(String(asked.sql), /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text");
assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }], "a FOR JSON document split across rows is reassembled"); assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }]);
assert.equal(result.command, "SELECT"); assert.equal(result.command, "SELECT");
}); });
test("a line break in a caller's statement is refused before sqlcmd starts", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
for (const sql of ["SELECT 1\n:!! id", "SELECT 1\r\n:!! id", "SELECT 1\r:!! id"]) {
await assert.rejects(client.readOnlyQuery("inventory", sql), /must be one line/);
}
assert.deepEqual(await calls(), []);
});
test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => { test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => {
const { connect, calls } = recording(); const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
await client.readOnlyQuery("inventory", "SELECT 1 AS x"); await client.readOnlyQuery("inventory", "SELECT 1 AS x");
await client.readOnlyQuery("inventory", "SELECT 2 AS x"); await client.readOnlyQuery("inventory", "SELECT 2 AS x");
const asAdmin = calls.filter((c) => c.user === "sa").map((c) => c.text); const made = await calls();
const asAdmin = made.filter((c) => c.user === "sa").map((c) => String(c.sql));
assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`))); assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`)));
assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s))); assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s)));
assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`)); assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`));
assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`)); assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`));
assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call"); assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call");
assert.equal(calls.filter((c) => c.user === READER).length, 2); assert.equal(made.filter((c) => c.user === READER).length, 2);
}); });
test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => { test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => {
const { connect, calls } = recording(); const client = new MssqlClient(conn);
const client = new MssqlClient(conn, connect);
await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/); await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/);
assert.deepEqual(calls, []); assert.deepEqual(await calls(), []);
});
test("a consumer's password is checked as a bound parameter, never in the text", async () => {
const { connect, calls } = recording();
const client = new MssqlClient(conn, connect);
await client.holdsLogin("shop", "shop_login", "minted-secret");
const asked = calls[0];
assert.equal(asked.params.meshholdspw, "minted-secret");
assert.doesNotMatch(asked.text, /minted-secret/);
assert.match(asked.text, /PWDCOMPARE\(@meshholdspw, password_hash\)/);
}); });
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["mssql.d.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
} }
-173
View File
@@ -1,173 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"math"
"net"
"sort"
"strconv"
"strings"
"time"
)
// Bounds on what a caller may ask: a check is a probe, never a wait anyone can make long.
const (
DefaultTimeout = 3 * time.Second
MostTimeout = 30 * time.Second
)
// TCPResult is what netcheck_tcp answers.
type TCPResult struct {
Host string `json:"host"`
Port int `json:"port"`
Address string `json:"address,omitempty"`
Reachable bool `json:"reachable"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// CheckTCP opens one TCP connection and closes it, sending nothing. A port that refuses or a host
// that does not answer is a result, not a failure of the tool; only a malformed question is.
func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
if port < 1 || port > 65535 {
return TCPResult{}, fmt.Errorf("port %d is not a TCP port (1-65535)", port)
}
out := TCPResult{Host: host, Port: port}
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
out.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
out.Error = err.Error()
return out, nil
}
out.Address = conn.RemoteAddr().String()
out.Reachable = true
_ = conn.Close()
return out, nil
}
// DNSResult is what netcheck_dns answers.
type DNSResult struct {
Name string `json:"name"`
Type string `json:"type"`
Answers []string `json:"answers"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// DNSTypes are the record types netcheck_dns looks up.
var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
// CheckDNS looks a name up with the machine's resolver. Built without cgo, Go's own resolver reads
// the machine's /etc/resolv.conf and /etc/hosts, which is the resolver this machine's programs use.
// A name that does not resolve is a result with its error; an unknown type is refused.
func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
kind = strings.ToUpper(strings.TrimSpace(kind))
if kind == "" {
kind = "A"
}
known := false
for _, t := range DNSTypes {
known = known || t == kind
}
if !known {
return DNSResult{}, fmt.Errorf("type %q is not one netcheck_dns looks up (%s)", kind, strings.Join(DNSTypes, ", "))
}
out := DNSResult{Name: name, Type: kind, Answers: []string{}}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
r := net.DefaultResolver
start := time.Now()
var err error
switch kind {
case "A", "AAAA":
network := "ip4"
if kind == "AAAA" {
network = "ip6"
}
var ips []net.IP
if ips, err = r.LookupIP(ctx, network, name); err == nil {
for _, ip := range ips {
out.Answers = append(out.Answers, ip.String())
}
}
case "CNAME":
var cname string
if cname, err = r.LookupCNAME(ctx, name); err == nil {
out.Answers = append(out.Answers, cname)
}
case "TXT":
var txts []string
if txts, err = r.LookupTXT(ctx, name); err == nil {
out.Answers = append(out.Answers, txts...)
}
case "MX":
var mxs []*net.MX
if mxs, err = r.LookupMX(ctx, name); err == nil {
for _, mx := range mxs {
out.Answers = append(out.Answers, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
}
}
}
out.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
out.Error = err.Error()
}
if kind != "MX" {
sort.Strings(out.Answers)
}
return out, nil
}
// text is a required string argument.
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
s = strings.TrimSpace(s)
if s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is an integer argument, given as a JSON number or a numeric string; fallback when absent.
func whole(args map[string]any, key string, fallback int) (int, error) {
v, given := args[key]
if !given || v == nil {
if fallback == 0 {
return 0, fmt.Errorf("%s is required", key)
}
return fallback, nil
}
switch n := v.(type) {
case float64:
if n != math.Trunc(n) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
}
return int(n), nil
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
}
return i, nil
}
return 0, errors.New(key + " must be a whole number")
}
// timeoutOf is timeout_ms, defaulted and bounded.
func timeoutOf(args map[string]any) (time.Duration, error) {
ms, err := whole(args, "timeout_ms", int(DefaultTimeout/time.Millisecond))
if err != nil {
return 0, err
}
if ms < 1 {
return 0, fmt.Errorf("timeout_ms must be at least 1, not %d", ms)
}
d := time.Duration(ms) * time.Millisecond
if d > MostTimeout {
d = MostTimeout
}
return d, nil
}
@@ -1,68 +0,0 @@
package main
import (
"net"
"testing"
"time"
)
func TestATCPPortThatListensIsReachableAndOneThatDoesNotIsNot(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := l.Addr().(*net.TCPAddr).Port
got, err := CheckTCP("127.0.0.1", port, time.Second)
if err != nil || !got.Reachable || got.Error != "" {
t.Fatalf("a listening port: %+v, %v", got, err)
}
l.Close()
got, err = CheckTCP("127.0.0.1", port, time.Second)
if err != nil || got.Reachable || got.Error == "" {
t.Fatalf("a closed port is reported as a result with its error, not a failure: %+v, %v", got, err)
}
}
func TestAPortOutsideTheRangeIsRefused(t *testing.T) {
for _, p := range []int{0, -1, 65536} {
if _, err := CheckTCP("127.0.0.1", p, time.Second); err == nil {
t.Errorf("port %d was accepted", p)
}
}
}
func TestDNSAnswersFromTheMachinesResolverAndRefusesAnUnknownType(t *testing.T) {
got, err := CheckDNS("localhost", "a", time.Second)
if err != nil || got.Type != "A" || len(got.Answers) == 0 {
t.Fatalf("localhost A: %+v, %v", got, err)
}
if _, err := CheckDNS("localhost", "SRV", time.Second); err == nil {
t.Fatal("an unknown record type was accepted")
}
got, err = CheckDNS("no-such-name.invalid", "A", time.Second)
if err != nil || got.Error == "" || len(got.Answers) != 0 {
t.Fatalf("a name that does not resolve is a result with its error: %+v, %v", got, err)
}
}
func TestTimeoutIsDefaultedAndBounded(t *testing.T) {
if d, _ := timeoutOf(map[string]any{}); d != DefaultTimeout {
t.Errorf("default: %v", d)
}
if d, _ := timeoutOf(map[string]any{"timeout_ms": float64(10 * 60 * 1000)}); d != MostTimeout {
t.Errorf("bounded: %v", d)
}
if _, err := timeoutOf(map[string]any{"timeout_ms": float64(0)}); err == nil {
t.Error("a zero timeout was accepted")
}
}
func TestBothToolsAreListedUnprefixed(t *testing.T) {
names := map[string]bool{}
for _, tool := range tools() {
names[tool.Name] = true
}
if !names["netcheck_tcp"] || !names["netcheck_dns"] || len(names) != 2 {
t.Fatalf("tools: %v", names)
}
}
-72
View File
@@ -1,72 +0,0 @@
// netcheck's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's runtime launches
// and speaks MCP over stdio to, through the Go SDK. It serves the two checks that are the machine's
// own sockets and resolver — a TCP connect and a DNS lookup — and nothing that changes anything.
// The module's HTTP check is its TypeScript bundle; the runtime serves both under one module.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): netcheck.
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "netcheck_tcp",
Description: "Check whether a TCP port is reachable from this machine: opens one connection " +
"and closes it at once, sending nothing. Answers reachable, elapsed_ms and the error when not.",
Input: map[string]any{
"host": map[string]any{"type": "string", "description": "host name or IP address"},
"port": map[string]any{"type": "integer", "description": "TCP port, 1-65535"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
host, err := text(args, "host")
if err != nil {
return nil, err
}
port, err := whole(args, "port", 0)
if err != nil {
return nil, err
}
timeout, err := timeoutOf(args)
if err != nil {
return nil, err
}
return CheckTCP(host, port, timeout)
},
},
{
Name: "netcheck_dns",
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts). " +
"type is A, AAAA, CNAME, TXT or MX; answers the records found, or the error.",
Input: map[string]any{
"name": map[string]any{"type": "string", "description": "the name to look up"},
"type": map[string]any{"type": "string", "enum": []string{"A", "AAAA", "CNAME", "TXT", "MX"}, "description": "record type (default A)"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
name, err := text(args, "name")
if err != nil {
return nil, err
}
kind, _ := args["type"].(string)
timeout, err := timeoutOf(args)
if err != nil {
return nil, err
}
return CheckDNS(name, kind, timeout)
},
},
}
}
-5
View File
@@ -1,5 +0,0 @@
module netcheck
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
-2
View File
@@ -1,2 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-84
View File
@@ -1,84 +0,0 @@
// netcheck's HTTP check — the module's own code, in TypeScript (novox/hq ADR 0039, ADR 0188). One
// request, HEAD or GET, never a body sent and never a body read: the status, how long it took and
// a few headers that say what answered. Redirects are reported, not followed, so a check reaches
// exactly the address it was given.
export const METHODS = ["HEAD", "GET"] as const;
export type Method = (typeof METHODS)[number];
/** The headers worth reporting: what answered and what it says it is, nothing it set for a client. */
export const REPORTED_HEADERS = [
"content-type", "content-length", "server", "location", "date",
"cache-control", "last-modified", "etag",
] as const;
export const DEFAULT_TIMEOUT_MS = 5000;
export const MOST_TIMEOUT_MS = 30000;
export interface HttpResult {
url: string;
method: Method;
status?: number;
statusText?: string;
elapsed_ms: number;
headers: Record<string, string>;
error?: string;
}
/** Only http and https are checked; anything else — file:, data:, ftp: — is refused by name. */
export function checkedUrl(raw: unknown): URL {
const text = typeof raw === "string" ? raw.trim() : "";
if (!text) throw new Error("url is required");
let url: URL;
try {
url = new URL(text);
} catch {
throw new Error(`${JSON.stringify(text)} is not a URL`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`netcheck_http checks http and https URLs only, not ${url.protocol}`);
}
return url;
}
export function checkedMethod(raw: unknown): Method {
const m = (typeof raw === "string" && raw.trim() ? raw.trim() : "HEAD").toUpperCase();
if (!(METHODS as readonly string[]).includes(m)) {
throw new Error(`method ${m} is not one netcheck_http uses (${METHODS.join(", ")}): a check never changes anything`);
}
return m as Method;
}
export function checkedTimeout(raw: unknown): number {
if (raw === undefined || raw === null || raw === "") return DEFAULT_TIMEOUT_MS;
const n = Number(raw);
if (!Number.isInteger(n) || n < 1) throw new Error(`timeout_ms must be a whole number of at least 1, not ${String(raw)}`);
return Math.min(n, MOST_TIMEOUT_MS);
}
/** Make one request and report how it went. A refused connection or a timeout is a result with its
* error; only a malformed question throws. */
export async function checkHttp(args: Readonly<Record<string, unknown>>, fetcher: typeof fetch = fetch): Promise<HttpResult> {
const url = checkedUrl(args.url);
const method = checkedMethod(args.method);
const timeout = checkedTimeout(args.timeout_ms);
const started = performance.now();
const out: HttpResult = { url: url.toString(), method, elapsed_ms: 0, headers: {} };
try {
const res = await fetcher(url, { method, redirect: "manual", signal: AbortSignal.timeout(timeout) });
out.elapsed_ms = Math.round(performance.now() - started);
out.status = res.status;
out.statusText = res.statusText;
for (const h of REPORTED_HEADERS) {
const v = res.headers.get(h);
if (v !== null) out.headers[h] = v;
}
// The body is not read: a check asks whether something answers, not what it says.
await res.body?.cancel().catch(() => {});
} catch (err) {
out.elapsed_ms = Math.round(performance.now() - started);
const e = err as Error & { cause?: { message?: string; code?: string } };
out.error = e.name === "TimeoutError" ? `no answer within ${timeout} ms` : (e.cause?.code ?? e.cause?.message ?? e.message);
}
return out;
}
-35
View File
@@ -1,35 +0,0 @@
{
"module": "netcheck",
"version": "1",
"tools": [
"netcheck_tcp",
"netcheck_dns",
"netcheck_http"
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/netcheck",
"binary": "netcheck",
"loads": [
"netcheck"
]
},
{
"name": "tools-typescript",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
]
}
]
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@novox/module-netcheck",
"version": "0.1.0",
"description": "netcheck — read-only network checks from a machine, as one module carrying a Go tools bundle (TCP, DNS) and a TypeScript one (HTTP) (novox/hq ADR 0188, ADR 0193).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.6"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-52
View File
@@ -1,52 +0,0 @@
// The HTTP check refuses what is not http(s) and what would change something, and reports a status,
// a refusal and a timeout as results (novox/hq ADR 0188: a tools bundle is read-only and harmless).
import { test } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { checkHttp, checkedMethod, checkedUrl } from "../http.ts";
test("only http and https URLs are checked", () => {
for (const bad of ["file:///etc/passwd", "ftp://example.org/", "data:text/plain,hi", "javascript:1", "", "not a url"]) {
assert.throws(() => checkedUrl(bad), `${bad} was accepted`);
}
assert.equal(checkedUrl("https://example.org/x").protocol, "https:");
});
test("only HEAD and GET are used", () => {
assert.equal(checkedMethod(undefined), "HEAD");
assert.equal(checkedMethod("get"), "GET");
for (const bad of ["POST", "PUT", "DELETE", "PATCH"]) assert.throws(() => checkedMethod(bad));
});
test("a status, its headers and a redirect not followed", async () => {
const server = createServer((req, res) => {
if (req.url === "/moved") { res.writeHead(302, { location: "/elsewhere" }); res.end(); return; }
res.writeHead(200, { "content-type": "text/plain", "x-secret": "not reported" });
res.end(req.method === "GET" ? "body" : undefined);
});
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
try {
const head = await checkHttp({ url: base + "/" });
assert.equal(head.status, 200);
assert.equal(head.method, "HEAD");
assert.equal(head.headers["content-type"], "text/plain");
assert.equal(head.headers["x-secret"], undefined);
const moved = await checkHttp({ url: base + "/moved", method: "GET" });
assert.equal(moved.status, 302);
assert.equal(moved.headers.location, "/elsewhere");
} finally {
server.close();
}
});
test("a refused connection is a result with its error", async () => {
const server = createServer();
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
const port = (server.address() as AddressInfo).port;
await new Promise<void>((ok) => server.close(() => ok()));
const got = await checkHttp({ url: `http://127.0.0.1:${port}/`, timeout_ms: 2000 });
assert.equal(got.status, undefined);
assert.ok(got.error, "no error reported");
});
-28
View File
@@ -1,28 +0,0 @@
// netcheck's TypeScript tools bundle (novox/hq ADR 0188, ADR 0193): what the builder's launcher
// imports and serves over MCP on stdio. Its Go bundle serves the TCP and DNS checks; this one the
// HTTP check — one module, two languages, one runtime that knows neither.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { checkHttp, DEFAULT_TIMEOUT_MS, METHODS, MOST_TIMEOUT_MS } from "../http.js";
export function getNetcheckHttpTools(): ToolDefinition[] {
return [
{
name: "netcheck_http",
description:
"Check whether an http(s) URL answers from this machine: one HEAD or GET, no body sent or read, " +
"redirects reported and not followed. Answers status, elapsed_ms and a few headers.",
input: {
url: { type: "string", description: "an http:// or https:// URL" },
method: { type: "string", enum: [...METHODS], description: "HEAD (default) or GET" },
timeout_ms: {
type: "integer",
description: `give up after this long (default ${DEFAULT_TIMEOUT_MS}, at most ${MOST_TIMEOUT_MS})`,
},
},
run: async (args) => checkHttp(args),
},
];
}
registerModuleTools("netcheck", () => getNetcheckHttpTools());
+40
View File
@@ -0,0 +1,40 @@
# nextcloud's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
ARG DOCKER_CLI
# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder
# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM.
FROM ${DOCKER_CLI} AS dockercli
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/nextcloud
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/nextcloud/dist /app/modules/nextcloud/dist
# occ runs inside nextcloud's own container, reached over the mounted docker socket — which needs
# the docker CLI itself present here, not only the socket. Copied from Docker's own official client
# image rather than apt-installed, so this stays the one binary and nothing else (no daemon, no
# systemd unit, no package manager tree pulled in for it).
COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/nextcloud/dist/index.js,/app/modules/nextcloud/dist/tools/index.js
+44 -18
View File
@@ -30,7 +30,8 @@
"share.created" "share.created"
], ],
"own-secrets": { "own-secrets": {
"admin": "${dir:state}/admin.secret" "admin": "${dir:state}/admin.secret",
"broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -93,28 +94,53 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-nextcloud",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/run/docker.sock:/var/run/docker.sock"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NEXTCLOUD_URL": "http://127.0.0.1:${port:80}",
"MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json",
"MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin",
"MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "/run/secrets/admin"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "DOCKER_CLI",
"image": "docker@sha256:018edbc908e08fcc9dbf029c812c34251e9b4719e6f71ca0e5eae2a987d014ca"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"index.js",
"tools/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_NEXTCLOUD_URL": "http://127.0.0.1:${port:80}",
"MESH_NEXTCLOUD_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin",
"MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret"
}
} }
] ]
} }
+10 -257
View File
@@ -1,269 +1,22 @@
// The packet filter's own code, in the module (novox/hq ADR 0039). The mesh computes this node's // The firewall's own code, in the module (novox/hq ADR 0039). The mesh computes this node's whole
// rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module // rule set from every module's `listens` and writes it to /etc/nftables.conf (novox/hq ADR 0045);
// loads it through its own unit. This code reads the filter back as the machine enforces it, reloads // the module loads it through its own mesh-filter unit, reloaded whenever the rules change, whose
// the mesh's own table, and removes one thing the mesh did not write when the operator names it // stop deletes only the mesh's table and never flushes the whole ruleset (novox/hq ADR 0100). This
// (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools. Root is the module's // code exists only to read back what is actually enforced — the enforcement itself is declarative.
// concern (ADR 0175 §4): the runtime loading this bundle runs as the operator's account (to-be 38
// WP4), so the commands go through sudo without a prompt where the account is not root.
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { accessSync, constants } from "node:fs";
import { delimiter, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
const execFileP = promisify(execFile); const run = promisify(execFile);
/** A command runner, so the acts can be tested without a packet filter. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
/** Where the mesh writes this node's filter: the path the manifest's `filtering.into` names. A
* bundle has no environment of its own (to-be 38 WP4), so the path is said here once, and a test
* holds it to the manifest's. */
export const FILTER_FILE = "/etc/nftables.conf";
/** The command as it is run: as given when this process is root, else through sudo without a
* prompt. The packet filter answers only to root, listing included. */
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
/** Whether a tool is on this machine: an executable of that name on the path, or where the
* system keeps its administration. Asked before a tool is run, so "not here" and "refused" are
* never confused — the former is a fact to work around, the latter an error to say. */
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
return dirs.some((dir) => {
try {
accessSync(join(dir, tool), constants.X_OK);
return true;
} catch {
return false;
}
});
}
export const execRunner: Runner = async (cmd, args) => {
const [program, argv] = escalated(cmd, args);
try {
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
return stdout;
} catch (err) {
// What failed is named by how it failed, not by prose: sudo missing is a spawn error; sudo
// refusing speaks on its own stderr line; anything else is the command's own failure.
const e = err as { code?: string | number; stderr?: string };
if (program === "sudo") {
if (e.code === "ENOENT") {
throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
}
const stderr = String(e.stderr ?? "").trim();
if (/^sudo: .*command not found/m.test(stderr)) throw new Error(`${cmd} is not installed here`);
if (/^sudo:/m.test(stderr)) {
throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${stderr}`);
}
}
throw err;
}
};
/** The mesh's own tables, which `remove` never touches. */
const MESH_TABLES = new Set(["inet mesh", "inet mesh_guard"]);
/** The tables iptables-nft manages, spoken through iptables rather than nft. */
const IPTABLES_TABLES = new Set(["filter", "nat", "raw", "mangle", "security"]);
/** The chains the kernel has built in; flushing one is the owner's act, not an operator's removal. */
const BUILT_IN = new Set(["INPUT", "FORWARD", "OUTPUT", "PREROUTING", "POSTROUTING"]);
/** The chain the container runtime leaves for an administrator, which is emptied, never deleted. */
const USER_CHAIN = "DOCKER-USER";
export interface Removal {
where: string;
did: string[];
}
export class FirewallClient { export class FirewallClient {
private readonly run: Runner; static fromEnv(_env: NodeJS.ProcessEnv = process.env): FirewallClient {
private readonly filterFile: string;
private readonly have: (tool: string) => boolean;
constructor(run: Runner = execRunner, filterFile: string = FILTER_FILE, have: (tool: string) => boolean = installed) {
this.run = run;
this.filterFile = filterFile;
this.have = have;
}
/** The filter as this machine has it: its own tools, the mesh's file. */
static onThisMachine(): FirewallClient {
return new FirewallClient(); return new FirewallClient();
} }
/** The mesh's live table — exactly what the mesh's own filter is dropping and accepting. */ /** The mesh's live table — exactly what is dropping and accepting on this node right now. */
async ruleset(): Promise<string> { async ruleset(): Promise<string> {
return this.run("nft", ["list", "table", "inet", "mesh"]); const { stdout } = await run("nft", ["list", "table", "inet", "mesh"]);
} return stdout;
/** The packet filter as the machine enforces it: nftables whole or narrowed, and the legacy filter's
* listings where the tools exist. */
async rules(table?: string, chain?: string): Promise<{ nftables: string; legacy: Record<string, string> }> {
let nftables: string;
if (table && chain) {
const [family, name] = splitTable(table);
nftables = await this.run("nft", ["list", "chain", family, name, chain]);
} else if (table) {
const [family, name] = splitTable(table);
nftables = await this.run("nft", ["list", "table", family, name]);
} else {
nftables = await this.run("nft", ["list", "ruleset"]);
}
const legacy: Record<string, string> = {};
if (!table) {
for (const tool of ["iptables-legacy", "ip6tables-legacy"]) {
if (!this.have(tool)) continue; // no legacy tool, nothing to list
try {
const out = await this.run(tool, ["-S"]);
if (out.trim()) legacy[tool] = out;
} catch (err) {
// The tool is here and would not answer: said, not swallowed — a listing that silently
// leaves out a predecessor's rules reads as "none".
legacy[tool] = `error: ${err instanceof Error ? err.message : String(err)}`;
}
}
}
return { nftables, legacy };
}
/** Load the mesh's own filter again from the file the mesh writes, and answer with the table. */
async reload(): Promise<{ loaded: string; table: string }> {
await this.run("nft", ["-f", this.filterFile]);
return { loaded: this.filterFile, table: await this.ruleset() };
}
/** Whether the found front end is in force, whose chains `remove` leaves alone. Absent, it is
* not; present and not answering, nothing is removed on a guess. */
private async ufwActive(): Promise<boolean> {
if (!this.have("ufw")) return false;
let out: string;
try {
out = await this.run("ufw", ["status"]);
} catch (err) {
throw new Error(`cannot tell whether the found firewall is in force, so nothing of its is removed: ${err instanceof Error ? err.message : String(err)}`);
}
return /^Status:\s*active/m.test(out);
}
/** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */
async remove(where: string): Promise<Removal> {
const did: string[] = [];
const legacy = /^chain (\S+) \((iptables-legacy|ip6tables-legacy|iptables|ip6tables)\)$/.exec(where.trim());
const nft = /^table (\S+) (\S+), chain (\S+)$/.exec(where.trim());
if (legacy) {
const [, chain, tool] = legacy;
await this.refuseOwned(chain, "ip", "filter");
await this.removeChainWith(tool, undefined, chain, did);
return { where, did };
}
if (nft) {
const [, family, name, chain] = nft;
const table = `${family} ${name}`;
if (MESH_TABLES.has(table)) throw new Error(`${where} is the mesh's own table; it is not removed, it is composed`);
await this.refuseOwned(chain, family, name);
if ((family === "ip" || family === "ip6") && IPTABLES_TABLES.has(name)) {
const tool = family === "ip6" ? "ip6tables" : "iptables";
await this.removeChainWith(tool, name, chain, did);
return { where, did };
}
// A table of the machine's own: a chain of it goes, and the table with it when nothing is left.
const listing = await this.run("nft", ["list", "table", family, name]);
const base = new RegExp(`chain ${escape(chain)} \\{[^}]*type \\S+ hook`).test(listing);
for (const from of chainsJumpingTo(listing, chain)) {
await this.deleteNftRules(family, name, from, chain, did);
}
if (base) {
await this.run("nft", ["flush", "chain", family, name, chain]);
did.push(`nft flush chain ${family} ${name} ${chain}`);
} else {
await this.run("nft", ["delete", "chain", family, name, chain]);
did.push(`nft delete chain ${family} ${name} ${chain}`);
}
return { where, did };
}
throw new Error(`${JSON.stringify(where)} is not a rule set as the host reports one: ` +
"`chain X (iptables-legacy)` or `table <family> <name>, chain X`");
}
private async refuseOwned(chain: string, family: string, table: string): Promise<void> {
if (chain !== USER_CHAIN && chain.startsWith("DOCKER")) {
throw new Error(`chain ${chain} is the container runtime's own; it is left`);
}
if (BUILT_IN.has(chain)) {
throw new Error(`chain ${chain} is built in; its policy is its owner's and it is not flushed`);
}
if (chain.startsWith("ufw") && (await this.ufwActive())) {
throw new Error(`chain ${chain} belongs to the found firewall, which is in force; converge retires it`);
}
void family; void table;
}
/** Through an iptables tool: the user chain is emptied back to its one return; another chain loses
* the jumps into it, is flushed and deleted. */
private async removeChainWith(tool: string, table: string | undefined, chain: string, did: string[]): Promise<void> {
const t = table && table !== "filter" ? ["-t", table] : [];
if (chain === USER_CHAIN) {
await this.run(tool, [...t, "-F", chain]);
await this.run(tool, [...t, "-A", chain, "-j", "RETURN"]);
did.push(`${tool} ${[...t, "-F", chain].join(" ")}`, `${tool} ${[...t, "-A", chain, "-j", "RETURN"].join(" ")}`);
return;
}
const listing = await this.run(tool, [...t, "-S"]);
for (const line of listing.split("\n")) {
const fields = line.trim().split(/\s+/);
if (fields[0] !== "-A") continue;
const j = fields.indexOf("-j");
const g = fields.indexOf("-g");
const target = j >= 0 ? fields[j + 1] : g >= 0 ? fields[g + 1] : "";
if (target !== chain) continue;
const args = [...t, "-D", ...fields.slice(1)];
await this.run(tool, args);
did.push(`${tool} ${args.join(" ")}`);
}
await this.run(tool, [...t, "-F", chain]);
await this.run(tool, [...t, "-X", chain]);
did.push(`${tool} ${[...t, "-F", chain].join(" ")}`, `${tool} ${[...t, "-X", chain].join(" ")}`);
}
private async deleteNftRules(family: string, name: string, from: string, target: string, did: string[]): Promise<void> {
const listing = await this.run("nft", ["-a", "list", "chain", family, name, from]);
for (const line of listing.split("\n")) {
if (!new RegExp(`\\b(jump|goto) ${escape(target)}\\b`).test(line)) continue;
const handle = /# handle (\d+)/.exec(line)?.[1];
if (!handle) continue;
await this.run("nft", ["delete", "rule", family, name, from, "handle", handle]);
did.push(`nft delete rule ${family} ${name} ${from} handle ${handle}`);
}
} }
} }
function splitTable(table: string): [string, string] {
const parts = table.trim().split(/\s+/);
if (parts.length !== 2) throw new Error(`a table is \`family name\`, not ${JSON.stringify(table)}`);
return [parts[0], parts[1]];
}
/** Which chains of a listed table jump or go to the named one. */
export function chainsJumpingTo(listing: string, target: string): string[] {
const out: string[] = [];
let chain = "";
for (const raw of listing.split("\n")) {
const line = raw.trim();
const head = /^chain (\S+) \{/.exec(line);
if (head) { chain = head[1]; continue; }
if (line === "}") { chain = ""; continue; }
if (chain && chain !== target && new RegExp(`\\b(jump|goto) ${escape(target)}\\b`).test(line) && !out.includes(chain)) {
out.push(chain);
}
}
return out;
}
function escape(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
}
+3 -34
View File
@@ -7,12 +7,7 @@
"claims": [ "claims": [
{ {
"name": "node-packet-filter", "name": "node-packet-filter",
"scope": "node", "scope": "node"
"serves": [
"rules",
"reload",
"remove"
]
} }
], ],
"filtering": { "filtering": {
@@ -24,11 +19,6 @@
"type": "package", "type": "package",
"package": "nftables" "package": "nftables"
}, },
{
"id": "legacy-tools",
"type": "package",
"package": "iptables"
},
{ {
"id": "unit", "id": "unit",
"type": "file", "type": "file",
@@ -40,7 +30,7 @@
"id": "stock-unit-stop", "id": "stock-unit-stop",
"type": "file", "type": "file",
"path": "/etc/systemd/system/nftables.service.d/mesh.conf", "path": "/etc/systemd/system/nftables.service.d/mesh.conf",
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n", "content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) \u2014 a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
"mode": "0644" "mode": "0644"
}, },
{ {
@@ -56,27 +46,6 @@
"reload-on": [ "reload-on": [
"filtering" "filtering"
] ]
},
{
"id": "front-end",
"type": "package",
"package": "ufw",
"absent": true
} }
], ]
"tools": [
"firewall_rules"
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
]
}
]
}
} }
+3 -7
View File
@@ -1,15 +1,11 @@
{ {
"name": "@novox/module-nftables", "name": "@novox/module-firewall",
"version": "0.1.0", "version": "0.1.0",
"description": "nftables — loads the mesh's packet filter and holds the node-packet-filter seat: its verbs rules, reload and remove (novox/hq ADR 0045, ADR 0170).", "description": "firewall — applies the mesh-computed packet filter (ADR 0045). Its diagnostic tool lives here.
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
-108
View File
@@ -1,108 +0,0 @@
// `remove` acts on one rule set the mesh did not write, named as the host reports it (novox/hq ADR
// 0168, 0169), over the shapes two machines of the first mesh reported live: a predecessor's chain in
// the legacy filter, the runtime's user chain in the IPv6 legacy filter, a leftover front-end chain,
// and the same in an iptables-nft table. It refuses what is not the operator's to remove.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { FILTER_FILE, FirewallClient, chainsJumpingTo, escalated, installed, type Runner } from "../client.ts";
const legacy = [
"-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT",
"-N DOCKER", "-N DOCKER-USER", "-N HAL-MESH-ONLY",
"-A FORWARD -j DOCKER-USER",
"-A DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY",
"-A HAL-MESH-ONLY -m conntrack --ctorigdstport 80 -j RETURN",
"-A HAL-MESH-ONLY -m comment --comment \"HAL: not public -> mesh only\" -j DROP",
].join("\n") + "\n";
function fake(ufwActive = false): { run: Runner; asked: string[] } {
const asked: string[] = [];
const run: Runner = async (cmd, args) => {
asked.push([cmd, ...args].join(" "));
if (cmd === "ufw") return ufwActive ? "Status: active\n" : "Status: inactive\n";
if (args.join(" ") === "-S") return legacy;
if (cmd === "nft" && args[0] === "list" && args[1] === "table") {
return "table ip6 own {\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy accept;\n\t\tjump deny\n\t}\n\tchain deny {\n\t\tdrop\n\t}\n}\n";
}
if (cmd === "nft" && args[0] === "-a") {
return "table ip6 own {\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy accept;\n\t\tjump deny # handle 7\n\t}\n}\n";
}
return "";
};
return { run, asked };
}
test("a predecessor's chain in the legacy filter loses its jumps, is flushed and deleted", async () => {
const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("chain HAL-MESH-ONLY (iptables-legacy)");
assert.deepEqual(out.did, [
"iptables-legacy -D DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY",
"iptables-legacy -F HAL-MESH-ONLY",
"iptables-legacy -X HAL-MESH-ONLY",
]);
});
test("the runtime's user chain is emptied back to its one return, never deleted", async () => {
const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("chain DOCKER-USER (ip6tables-legacy)");
assert.deepEqual(out.did, ["ip6tables-legacy -F DOCKER-USER", "ip6tables-legacy -A DOCKER-USER -j RETURN"]);
const nft = await new FirewallClient(fake().run, undefined, () => true).remove("table ip6 filter, chain DOCKER-USER");
assert.deepEqual(nft.did, ["ip6tables -F DOCKER-USER", "ip6tables -A DOCKER-USER -j RETURN"]);
});
test("a chain of the machine's own nftables table goes with the rules that reach it", async () => {
const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("table ip6 own, chain deny");
assert.deepEqual(out.did, ["nft delete rule ip6 own forward handle 7", "nft delete chain ip6 own deny"]);
});
test("what is not the operator's to remove is refused by name", async () => {
const c = new FirewallClient(fake(true).run, undefined, () => true);
await assert.rejects(c.remove("table inet mesh, chain forward"), /the mesh's own table/);
await assert.rejects(c.remove("chain DOCKER (iptables-legacy)"), /container runtime's own/);
await assert.rejects(c.remove("chain FORWARD (iptables-legacy)"), /built in/);
await assert.rejects(c.remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"), /found firewall, which is in force/);
await assert.rejects(c.remove("something else"), /not a rule set as the host reports one/);
// Retired, a front end's leftover is nobody's and goes.
const retired = await new FirewallClient(fake(false).run, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
});
test("which chains jump to a target is read from a listing", () => {
const listing = "table ip6 own {\n\tchain a {\n\t\tjump deny\n\t}\n\tchain b {\n\t\tgoto deny\n\t}\n\tchain deny {\n\t\tdrop\n\t}\n}\n";
assert.deepEqual(chainsJumpingTo(listing, "deny"), ["a", "b"]);
});
test("the filter's commands run as given by root and through sudo without a prompt by anyone else", () => {
assert.deepEqual(escalated("nft", ["list", "ruleset"], 0), ["nft", ["list", "ruleset"]]);
assert.deepEqual(escalated("nft", ["-f", "/etc/nftables.conf"], 1000), ["sudo", ["-n", "nft", "-f", "/etc/nftables.conf"]]);
assert.deepEqual(escalated("iptables-legacy", ["-S"], undefined), ["sudo", ["-n", "iptables-legacy", "-S"]]);
});
test("the filter file is the one the manifest's filtering names", () => {
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")) as { filtering: { into: string } };
assert.equal(FILTER_FILE, manifest.filtering.into);
});
test("a tool is installed when an executable of its name is on the path, and not otherwise", () => {
assert.equal(installed("sh"), true);
assert.equal(installed("no-such-tool-of-the-mesh"), false);
});
test("a found firewall that is absent guards nothing; one that will not answer stops the removal", async () => {
// Absent: its leftover chain is nobody's and goes, without asking it.
const absent = fake(true);
const out = await new FirewallClient(absent.run, undefined, () => false).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
assert.ok(out.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
assert.ok(!absent.asked.some((a) => a.startsWith("ufw ")));
// Present and failing — refused by sudo, say — nothing is removed on a guess.
const refusing: Runner = async (cmd, args) => {
if (cmd === "ufw") throw new Error("ufw needs root and the runtime's account may not run it without a prompt");
return fake().run(cmd, args);
};
await assert.rejects(
new FirewallClient(refusing, undefined, () => true).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)"),
/cannot tell whether the found firewall is in force/,
);
});
+6 -38
View File
@@ -1,51 +1,19 @@
// The packet filter's tools: the node-packet-filter seat's three verbs — what the machine enforces, // firewall's tools — one, and the useful one: what is actually enforced. The rules are the mesh's,
// reload the mesh's own, remove one thing the mesh did not write — and the module's own reading of // computed from every module's listens; this reads the live table so a declared scope can be checked
// the mesh's table (novox/hq ADR 0045, ADR 0168, ADR 0170). // against what the packet filter is really doing.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { FirewallClient } from "../client.js"; import { FirewallClient } from "../client.js";
export function getSeatVerbs(firewall: FirewallClient): ToolDefinition[] {
return [
{
name: "rules",
description:
"The packet filter as this machine enforces it now: the nftables ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or chain when asked.",
input: {
table: { type: "string", description: "one nftables table, as `family name` (optional)" },
chain: { type: "string", description: "one chain of that table (optional)" },
},
run: async (args) => firewall.rules(args.table ? String(args.table) : undefined, args.chain ? String(args.chain) : undefined),
},
{
name: "reload",
description: "Load the mesh's own filter again from the file the mesh writes, and answer with the mesh's table as loaded.",
input: {},
run: async () => firewall.reload(),
},
{
name: "remove",
description:
"Remove one rule set the mesh did not write, named exactly as `node show` lists it: `chain X (iptables-legacy)` or `table ip6 filter, chain DOCKER-USER`. " +
"Refuses the mesh's tables, the runtime's own chains, a built-in chain and an active found firewall's chains. An operator's act, by name, never a flush.",
input: { where: { type: "string", description: "the rule set, as `node show` lists it" } },
run: async (args) => firewall.remove(String(args.where ?? "")),
},
];
}
export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] { export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] {
return [ return [
{ {
name: "firewall_rules", name: "firewall_rules",
description: "The mesh's live nftables table on this node — what the mesh's own filter is accepting and dropping.", description: "The mesh's live nftables rules on this node — what is actually accepting and dropping.",
input: {}, input: {},
run: async () => ({ ruleset: await firewall.ruleset() }), run: async () => ({ ruleset: await firewall.ruleset() }),
}, },
]; ];
} }
const firewall = FirewallClient.onThisMachine(); registerModuleTools("firewall", () => getFirewallTools(FirewallClient.fromEnv()));
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
// module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall));
registerModuleTools("nftables", () => getFirewallTools(firewall));
+27
View File
@@ -0,0 +1,27 @@
# nodered's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/nodered
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/nodered/dist /app/modules/nodered/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/nodered/dist/tools/index.js
# NOT dist/mqtt/index.js: that is a step the host runs to completion, named by the `mqtt`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+55 -31
View File
@@ -12,7 +12,8 @@
"api-token": { "api-token": {
"path": "${dir:mesh-state}/api-token", "path": "${dir:mesh-state}/api-token",
"taken": "at-start" "taken": "at-start"
} },
"broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -100,31 +101,53 @@
"content": "{\n \"token\": \"${secret:api-token}\"\n}\n" "content": "{\n \"token\": \"${secret:api-token}\"\n}\n"
}, },
{ {
"id": "mqtt-env", "id": "runtime",
"type": "file", "type": "container",
"path": "${dir:state}/mqtt.env", "name": "mesh-nodered",
"mode": "0600", "network": "host",
"content": "MESH_NODERED_URL=http://127.0.0.1:${port:1880}\nMESH_NODERED_CONFIG_FILE=${dir:mesh-state}/config.json\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n" "volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}, },
{ {
"id": "mqtt", "id": "mqtt",
"type": "process", "type": "container",
"name": "nodered-mqtt", "name": "mesh-nodered-mqtt",
"artifact": "code", "network": "host",
"run": [
"node",
"mqtt/index.js"
],
"run-once": true, "run-once": true,
"env-file": [ "volumes": [
"${dir:state}/mqtt.env" "${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:written}:/var/lib/nodered-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/settings.json:/run/provisions/settings.json:ro"
],
"env": {
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json",
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/nodered-provisions"
},
"args": [
"run",
"/app/modules/nodered/dist/mqtt/index.js"
], ],
"restart-on": [ "restart-on": [
"mqtt-env",
"bound-mqtt-topic", "bound-mqtt-topic",
"secret-mqtt-topic", "secret-mqtt-topic",
"settings" "settings"
] ],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -150,22 +173,23 @@
"mqtt-topic": "${dir:state}/mqtt-topic.secret" "mqtt-topic": "${dir:state}/mqtt-topic.secret"
}, },
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"tools/index.js",
"mqtt/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
+22
View File
@@ -0,0 +1,22 @@
# openai-consumer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/openai-consumer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/openai-consumer/dist /app/modules/openai-consumer/dist
# No serve-time entrypoints: every container of this module names its command (`run` on a
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
+31 -18
View File
@@ -28,31 +28,44 @@
}, },
{ {
"id": "apply", "id": "apply",
"type": "process", "type": "container",
"name": "openai-consumer-apply", "name": "mesh-openai-consumer-apply",
"artifact": "code", "network": "host",
"run": [
"node",
"apply/index.js"
],
"schedule": "*/5 * * * *", "schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/openai-consumer/dist/apply/index.js"
],
"volumes": [
"${dir:state}:/run/state"
],
"env": { "env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/api-key", "MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/api-key",
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json", "MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
"MESH_OPENAI_ENV_FILE": "${dir:state}/config/openai.env", "MESH_OPENAI_ENV_FILE": "/run/state/config/openai.env",
"MESH_OPENAI_CREDENTIALS_FILE": "${dir:state}/config/auth.json" "MESH_OPENAI_CREDENTIALS_FILE": "/run/state/config/auth.json"
} },
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "code", "name": "runtime",
"kind": "bundle", "kind": "image",
"language": "typescript", "from": "Dockerfile"
"entrypoints": [
"apply/index.js"
]
} }
] ]
} }
+30
View File
@@ -0,0 +1,30 @@
# portainer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/portainer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/portainer/dist /app/modules/portainer/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/portainer/dist/tools/index.js
+107
View File
@@ -0,0 +1,107 @@
// The Portainer API client — portainer's own code, living in the module (novox/hq ADR 0039).
// portainer is tools-only: its "events" would really be the underlying containers' lifecycle,
// which the host owns and emits — so this module reads Portainer's own resources (endpoints,
// stacks, containers) and exposes them, and stops there.
import { readFileSync } from "node:fs";
export interface PortainerEndpoint {
id: number;
name: string;
type: number;
url: string;
status: number;
}
export interface PortainerStack {
id: number;
name: string;
type: number;
endpointId: number;
status: number;
}
export interface PortainerContainer {
id: string;
names: string[];
image: string;
state: string;
status: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class PortainerClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly token: string,
) {
this.baseUrl = url.replace(/\/+$/, "");
}
/**
* Build from the module's resolved environment. The URL is MESH_PORTAINER_URL (or the local
* dashboard port) and the API token is MESH_PORTAINER_TOKEN — an access token minted in
* Portainer, sent as X-API-Key. Throws when no token is configured, so a misconfigured module
* exposes nothing rather than calling Portainer unauthenticated.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient {
const cfg = meshConfig(env.MESH_PORTAINER_CONFIG_FILE);
const url = cfg.url ?? env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`;
const token = cfg.token ?? env.MESH_PORTAINER_TOKEN;
if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN");
return new PortainerClient(url, token);
}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, { headers: { "X-API-Key": this.token } });
if (!res.ok) throw new Error(`Portainer ${path}: ${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
/** The environments (endpoints) Portainer manages — each a Docker host or cluster it talks to. */
async listEndpoints(): Promise<PortainerEndpoint[]> {
const raw = await this.get<any[]>("/api/endpoints");
return (raw ?? []).map((e) => ({
id: e.Id,
name: e.Name,
type: e.Type,
url: e.URL,
status: e.Status,
}));
}
/** The stacks (compose/swarm deployments) Portainer knows about. */
async listStacks(): Promise<PortainerStack[]> {
const raw = await this.get<any[]>("/api/stacks");
return (raw ?? []).map((s) => ({
id: s.Id,
name: s.Name,
type: s.Type,
endpointId: s.EndpointId,
status: s.Status,
}));
}
/**
* The containers on one endpoint, read through Portainer's Docker API proxy. Includes stopped
* containers, so the caller sees the whole picture rather than only what is running.
*/
async listContainers(endpointId: number): Promise<PortainerContainer[]> {
const raw = await this.get<any[]>(`/api/endpoints/${endpointId}/docker/containers/json?all=1`);
return (raw ?? []).map((c) => ({
id: c.Id,
names: c.Names ?? [],
image: c.Image,
state: c.State,
status: c.Status,
}));
}
}
+114
View File
@@ -0,0 +1,114 @@
{
"module": "portainer",
"version": "1",
"slug": "portain",
"capabilities": [
"container-runtime"
],
"listens": [
{
"name": "web",
"port": 9000,
"protocol": "tcp",
"from": "mesh",
"why": "the dashboard over http; its public name is a route grant and the proxy reaches it here"
},
{
"name": "web-tls",
"port": 9443,
"protocol": "tcp",
"from": "mesh",
"why": "the same dashboard over its own tls; the runtime sidecar talks to it here"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "data",
"type": "directory",
"mode": "0700"
},
{
"id": "server",
"type": "container",
"name": "portainer",
"image": "portainer/portainer-ce@sha256:4d616db18cfeb5dd41a69c0958bc825c84483ea9cde1106eb82a5d26f3bd8b0e",
"ports": [
"9000",
"9443"
],
"volumes": [
"${dir:data}:/data",
"/var/run/docker.sock:/var/run/docker.sock"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-portainer",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_PORTAINER_URL": "https://127.0.0.1:9443",
"MESH_PORTAINER_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
},
"requires": [
"route"
],
"contributes": {
"route": {
"label": "portainer",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:mesh-state}/route.json"
}
}

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