Compare commits

..
Author SHA1 Message Date
jschoubben 7b09125d18 minio declares the bucket it derives; its consumers stop transcribing it (hq ADR 0188)
serves.s3-bucket.bucket is ${consumer:as:dns}; the provisioner uses what it
is given. nextcloud, invoicing and photos ask for ${bound:s3-bucket:bucket}
instead of naming mesh-novox-* literals, which also named this node.
bucketFor and the long-dead accessKeyFor are gone.
2026-10-02 21:25:30 +02:00
56 changed files with 1109 additions and 738 deletions
+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 @@
# 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
+23
View File
@@ -0,0 +1,23 @@
# fail2ban's runtime: the tool runtime, carrying the intrusion prevention's verbs and the client they
# speak through.
#
# Built from this module's own directory and nothing else (novox/hq ADR 0069). Two bases, named in
# module.json's `build.on`: the image this is compiled in and the image it runs in.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/fail2ban
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# The daemon runs on the machine, declared by this module; what runs here is only its client, which
# speaks to the daemon over the socket the machine shares into this container (novox/hq ADR 0179).
# The package brings the client and the daemon together; the daemon is never started here.
RUN apt-get update \
&& apt-get install -y --no-install-recommends fail2ban \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/fail2ban/dist /app/modules/fail2ban/dist
ENV MESH_TOOL_MODULES=/app/modules/fail2ban/dist/tools/index.js
+8 -40
View File
@@ -5,15 +5,12 @@
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the // prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
// mesh composes the jails and never writes the ban list. // mesh composes the jails and never writes the ban list.
// //
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one // Spoken through fail2ban-client over the daemon's socket, which the machine shares into this
// package this module declares on the machine, and the socket is root's: root is the module's // runtime; so the client here is the one from the runtime's own package and the daemon is the
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be // machine's, and the two meet at /var/run/fail2ban/fail2ban.sock.
// 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 { 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 execFileP = promisify(execFile);
@@ -21,44 +18,16 @@ const execFileP = promisify(execFile);
/** A command runner, so the verbs can be tested without a daemon. */ /** A command runner, so the verbs can be tested without a daemon. */
export type Runner = (cmd: string, args: string[]) => Promise<string>; 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) => { 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 { try {
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 }); const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 });
return stdout; return stdout;
} catch (err) { } catch (err) {
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string }; const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim(); const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks if (e.code === "ENOENT") throw new Error(`${cmd} is not in this runtime`);
// on its own stderr line, and the rest is the client's own answer. if (/Failed to access socket path|Is fail2ban running/i.test(said)) {
if (program === "sudo") { throw new Error("fail2ban is not running on this machine, or its socket is not shared with this runtime");
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"). // 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); const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
@@ -107,8 +76,7 @@ export class Fail2banClient {
this.run = run; this.run = run;
} }
/** The daemon as this machine has it, through its own client. */ static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient {
static onThisMachine(): Fail2banClient {
return new Fail2banClient(); return new Fail2banClient();
} }
+38 -6
View File
@@ -19,6 +19,9 @@
"tools": [ "tools": [
"fail2ban_settings" "fail2ban_settings"
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"jailing": { "jailing": {
"into": "/etc/fail2ban/jail.d/mesh.conf", "into": "/etc/fail2ban/jail.d/mesh.conf",
"filter-into": "/etc/fail2ban/filter.d" "filter-into": "/etc/fail2ban/filter.d"
@@ -53,6 +56,12 @@
"path": "/var/run/fail2ban", "path": "/var/run/fail2ban",
"mode": "0755" "mode": "0755"
}, },
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "jail-local", "id": "jail-local",
"type": "file", "type": "file",
@@ -109,17 +118,40 @@
"action-dualchain", "action-dualchain",
"composed-jails" "composed-jails"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-fail2ban",
"artifact": "runtime",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/run/fail2ban:/var/run/fail2ban"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker"
}
} }
], ],
"build": { "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"
]
} }
] ]
} }
+1 -1
View File
@@ -12,7 +12,7 @@
"typescript": "^5.6.0" "typescript": "^5.6.0"
}, },
"scripts": { "scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist", "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'" "test": "node --test --experimental-strip-types 'test/*.test.ts'"
} }
} }
+1 -9
View File
@@ -2,7 +2,7 @@
// on the control node on 2026-10-02 (novox/hq ADR 0179). // on the control node on 2026-10-02 (novox/hq ADR 0179).
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts"; import { Fail2banClient, parseBans, parseJailStatus, type Runner } from "../client.ts";
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n"; const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
const RECIDIVE = const RECIDIVE =
@@ -104,11 +104,3 @@ test("a jail's settings are read from the daemon's listings", async () => {
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd", logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
}); });
}); });
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
assert.equal(installed("sh"), true);
assert.equal(installed("no-such-client-of-the-mesh"), false);
});
+1 -1
View File
@@ -55,7 +55,7 @@ export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
]; ];
} }
const fail2ban = Fail2banClient.onThisMachine(); const fail2ban = Fail2banClient.fromEnv();
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this // 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. // module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban)); registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
+1 -1
View File
@@ -242,7 +242,7 @@
"jails": [ "jails": [
{ {
"name": "gitea", "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 .*$", "failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d" "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"
}
} }
] ]
} }
-178
View File
@@ -1,178 +0,0 @@
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
// Root is the module's concern (ADR 0175 §4): the runtime runs as the operator's account, so the file
// is written through sudo without a prompt where the account is not root, as the packet filter's is.
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { isIP } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
const execFileP = promisify(execFile);
/** Where the file is. The manifest's resource names the same path; a test holds the two together. */
export const HOSTS_FILE = "/etc/hosts";
/** A command runner, so the writes can be tested without a machine. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
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]];
}
const run: Runner = async (cmd, args) => {
const [program, argv] = escalated(cmd, args);
const { stdout } = await execFileP(program, argv);
return stdout;
};
/** One line of the file, as a reader sees it. */
export interface Line {
/** The line exactly as it is in the file. */
text: string;
/** Whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator". */
owner: string;
/** For an entry: its address and names. Absent for a comment or blank line. */
address?: string;
names?: string[];
}
const BEGIN = /^#\s*BEGIN\s+(.+?)\s*$/;
const END = /^#\s*END\s+(.+?)\s*$/;
/** Every line of a hosts file, each marked whose it is. */
export function parse(text: string): Line[] {
const out: Line[] = [];
let block: string | null = null;
for (const raw of text.split("\n")) {
const begin = raw.match(BEGIN);
if (!block && begin) {
block = begin[1];
out.push({ text: raw, owner: block });
continue;
}
const owner = block ?? "operator";
const entry = raw.replace(/#.*/, "").trim().split(/\s+/).filter(Boolean);
const line: Line = { text: raw, owner };
if (entry.length >= 2 && isIP(entry[0])) {
line.address = entry[0];
line.names = entry.slice(1);
}
out.push(line);
const end = raw.match(END);
if (block && end && end[1] === block) block = null;
}
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
if (out.length > 0 && out[out.length - 1].text === "" && text.endsWith("\n")) out.pop();
return out;
}
const NAME = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.?$/;
/** Refused input says why, so a caller is one edit from right. */
function checkAddress(address: string): void {
if (!isIP(address)) throw new Error(`${JSON.stringify(address)} is not an IPv4 or IPv6 address`);
}
function checkName(name: string): void {
if (!NAME.test(name)) throw new Error(`${JSON.stringify(name)} is not a host name`);
}
/** The file with one address and its names added to the operator's lines; unchanged when already there. */
export function withAdded(text: string, address: string, names: string[]): string {
checkAddress(address);
if (names.length === 0) throw new Error("add names at least one name for the address");
names.forEach(checkName);
const lines = parse(text);
const have = new Set(
lines.filter((l) => l.owner === "operator" && l.address === address).flatMap((l) => l.names ?? []),
);
const missing = names.filter((n) => !have.has(n));
if (missing.length === 0) return text;
const body = text.endsWith("\n") || text === "" ? text : text + "\n";
return body + `${address}\t${missing.join(" ")}\n`;
}
/** The file with one name, or every line of one address, taken out of the operator's lines. Blocks are
* never touched: a name only the mesh or another tool writes is refused, naming whose it is. */
export function withRemoved(text: string, what: string): { text: string; removed: number } {
const byAddress = isIP(what) !== 0;
if (!byAddress) checkName(what);
const lines = parse(text);
let removed = 0;
const kept: string[] = [];
for (const l of lines) {
if (l.owner !== "operator" || !l.address) {
kept.push(l.text);
continue;
}
if (byAddress && l.address === what) {
removed++;
continue;
}
if (!byAddress && l.names?.includes(what)) {
removed++;
const rest = l.names.filter((n) => n !== what);
if (rest.length > 0) kept.push(`${l.address}\t${rest.join(" ")}`);
continue;
}
kept.push(l.text);
}
if (removed === 0) {
const elsewhere = lines.find((l) => l.owner !== "operator" && (byAddress ? l.address === what : l.names?.includes(what)));
if (elsewhere) throw new Error(`${what} is written by ${elsewhere.owner}, not the operator; it is not this verb's to remove`);
}
return { text: kept.join("\n") + "\n", removed };
}
export class HostsFile {
private readonly path: string;
private readonly runner: Runner;
constructor(path: string = HOSTS_FILE, runner: Runner = run) {
this.path = path;
this.runner = runner;
}
static onThisMachine(): HostsFile {
return new HostsFile();
}
async read(): Promise<string> {
return readFile(this.path, "utf8");
}
async entries(): Promise<{ path: string; lines: Line[] }> {
return { path: this.path, lines: parse(await this.read()) };
}
async add(address: string, names: string[]): Promise<{ added: boolean; line?: string }> {
const before = await this.read();
const after = withAdded(before, address, names);
if (after === before) return { added: false };
await this.write(after);
return { added: true, line: after.slice(before.length).trim() };
}
async remove(what: string): Promise<{ removed: number }> {
const before = await this.read();
const { text, removed } = withRemoved(before, what);
if (removed > 0) await this.write(text);
return { removed };
}
/** Written whole through a copy beside it, so a reader never sees half a file. */
private async write(content: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), "hosts-"));
const staged = join(dir, "hosts");
try {
await writeFile(staged, content, { mode: 0o644 });
await this.runner("install", ["-m", "0644", staged, this.path]);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
}
-41
View File
@@ -1,41 +0,0 @@
{
"module": "hosts",
"version": "1",
"claims": [
{
"name": "node-hosts-file",
"scope": "node",
"serves": [
"entries",
"add",
"remove"
]
}
],
"resources": [
{
"id": "own",
"type": "file",
"path": "/etc/hosts",
"mode": "0644",
"into": "block",
"at": "start",
"content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
]
}
]
}
}
-18
View File
@@ -1,18 +0,0 @@
{
"name": "@novox/module-hosts",
"version": "0.1.0",
"description": "hosts — holds the node-hosts-file seat: writes the machine's own lines into /etc/hosts and serves the verbs entries, add and remove over the operator's lines (novox/hq ADR 0199).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
},
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
}
}
-69
View File
@@ -1,69 +0,0 @@
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
// distribution lines, an operator's development names, the mesh's block and another tool's.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { HOSTS_FILE, escalated, parse, withAdded, withRemoved } from "../client.ts";
const FILE =
"# Static table lookup for hostnames.\n" +
"127.0.0.1\tlocaldev.example.com\n" +
"127.0.0.1 a.example.com b.example.com\n" +
"# BEGIN mesh hosts.own\n" +
"127.0.0.1\tlocalhost\n" +
"::1\tlocalhost\n" +
"# END mesh hosts.own\n" +
"# BEGIN other-tool\n" +
"192.0.2.7\tproject.test\n" +
"# END other-tool\n";
const blocks = (text: string) => parse(text).filter((l) => l.owner !== "operator").map((l) => l.text);
test("every line says whose it is", () => {
const lines = parse(FILE);
assert.equal(lines.length, 10);
assert.deepEqual(lines[1], { text: "127.0.0.1\tlocaldev.example.com", owner: "operator", address: "127.0.0.1", names: ["localdev.example.com"] });
assert.equal(lines[4].owner, "mesh hosts.own");
assert.equal(lines[8].owner, "other-tool");
assert.deepEqual(lines[8].names, ["project.test"]);
});
test("add appends an operator line, and is a no-op when the names are there", () => {
const after = withAdded(FILE, "192.0.2.9", ["lab.test", "www.lab.test"]);
assert.ok(after.endsWith("192.0.2.9\tlab.test www.lab.test\n"));
assert.deepEqual(blocks(after), blocks(FILE));
assert.equal(withAdded(FILE, "127.0.0.1", ["a.example.com"]), FILE);
assert.ok(withAdded(FILE, "127.0.0.1", ["a.example.com", "c.example.com"]).endsWith("127.0.0.1\tc.example.com\n"));
});
test("add refuses what is not an address or a host name", () => {
assert.throws(() => withAdded(FILE, "not-an-ip", ["x.test"]), /not an IPv4 or IPv6 address/);
assert.throws(() => withAdded(FILE, "192.0.2.9", ["bad name\n10.0.0.1 evil"]), /not a host name/);
assert.throws(() => withAdded(FILE, "192.0.2.9", []), /at least one name/);
});
test("remove takes one name or one address from the operator's lines, and blocks stay byte for byte", () => {
const one = withRemoved(FILE, "a.example.com");
assert.equal(one.removed, 1);
assert.ok(one.text.includes("127.0.0.1\tb.example.com\n"));
assert.ok(!one.text.includes("a.example.com"));
assert.deepEqual(blocks(one.text), blocks(FILE));
const all = withRemoved(FILE, "127.0.0.1");
assert.equal(all.removed, 2);
assert.ok(all.text.includes("# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n"), "the mesh's own localhost is not the operator's to remove");
});
test("remove refuses a name only a block writes, naming whose", () => {
assert.throws(() => withRemoved(FILE, "project.test"), /written by other-tool/);
assert.equal(withRemoved(FILE, "nowhere.test").removed, 0);
});
test("the file is written as root through sudo where the account is not root", () => {
assert.deepEqual(escalated("install", ["x"], 1000), ["sudo", ["-n", "install", "x"]]);
assert.deepEqual(escalated("install", ["x"], 0), ["install", ["x"]]);
});
test("the path the code writes is the path the manifest's resource declares", () => {
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
assert.equal(manifest.resources.find((r: { id: string }) => r.id === "own").path, HOSTS_FILE);
});
-40
View File
@@ -1,40 +0,0 @@
// The hosts file's tools: the node-hosts-file seat's three verbs (novox/hq ADR 0199) — the file's lines
// with whose each is, add an operator's line, remove one. They change the machine's file and nothing
// else; the controller holds none of it.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { HostsFile } from "../client.js";
export function getSeatVerbs(hosts: HostsFile): ToolDefinition[] {
return [
{
name: "entries",
description:
"Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
input: {},
run: async () => hosts.entries(),
},
{
name: "add",
description:
"Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
input: {
address: { type: "string", description: "the IPv4 or IPv6 address" },
names: { type: "string", description: "the names for it, separated by spaces" },
},
run: async (args) =>
hosts.add(String(args.address ?? ""), String(args.names ?? "").split(/[\s,]+/).filter(Boolean)),
},
{
name: "remove",
description:
"Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
input: { name: { type: "string", description: "a host name, or an address to remove every line of" } },
run: async (args) => hosts.remove(String(args.name ?? "")),
},
];
}
const hosts = HostsFile.onThisMachine();
// The seat's verbs under the seat's name: the runtime serves them as <node>/node-hosts-file.<verb>.
registerModuleTools("node-hosts-file", () => getSeatVerbs(hosts));
+1 -1
View File
@@ -68,7 +68,7 @@
"type": "file", "type": "file",
"path": "${dir:state}/api.env", "path": "${dir:state}/api.env",
"mode": "0600", "mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
}, },
{ {
"id": "net", "id": "net",
+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"
}
} }
] ]
} }
+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"
}
} }
] ]
} }
+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"
}
]
}
}
+5 -15
View File
@@ -303,21 +303,11 @@ export class MinioClient {
// --- module-scoped helpers ------------------------------------------------- // --- module-scoped helpers -------------------------------------------------
/** A deterministic 20-char access key id from a consumer name, so removal needs no stored state: // **Neither the access key nor the bucket is derived here any more.** `accessKeyFor` minted an id
* the provisioner recomputes the same id at teardown that it minted at creation. */ // of its own until the mesh took that over (ADR 0048: the login is the mesh's, handed to both
export function accessKeyFor(consumer: string): string { // ends), and `bucketFor` derived the bucket until the mesh took that over too (ADR 0188: the rule
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // is a line of this module's manifest, filled per consumer and delivered to both ends). Both
const digest = createHash("sha256").update(consumer).digest(); // survived with no callers, which is the state a rule comes back from; they are gone.
let out = "";
for (let i = 0; i < 20; i++) out += chars[digest[i] % chars.length];
return out;
}
/** A DNS-safe bucket name derived from a consumer — the removable identity of its storage. */
export function bucketFor(consumer: string): string {
const name = consumer.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
return name.length >= 3 ? name : `mesh-${name}`;
}
function bucketPolicy(bucket: string): string { function bucketPolicy(bucket: string): string {
return JSON.stringify({ return JSON.stringify({
+2 -1
View File
@@ -49,7 +49,8 @@
"s3-bucket": { "s3-bucket": {
"scheme": "http", "scheme": "http",
"region": "eu-west", "region": "eu-west",
"port": 9000 "port": 9000,
"bucket": "${consumer:as:dns}"
} }
}, },
"receives": { "receives": {
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+31 -7
View File
@@ -10,18 +10,24 @@
// **The access key and its secret are the mesh's, not the provisioner's (ADR 0048).** The mesh // **The access key and its secret are the mesh's, not the provisioner's (ADR 0048).** The mesh
// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio // derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio
// creates the service account under exactly that access key with exactly that secret — a credential // creates the service account under exactly that access key with exactly that secret — a credential
// the provisioner invented is one the consumer could never present. The bucket is derived from the // the provisioner invented is one the consumer could never present.
// login, so teardown recomputes it with nothing to persist. //
// **The bucket name is the mesh's too (ADR 0188).** It used to be computed here, from the login,
// and every consumer transcribed the same rule into its own definition by hand — two copies of
// one rule with nothing comparing them, and one of three was wrong for months. Now the rule is a
// line of this module's manifest (`serves.s3-bucket.bucket: ${consumer:as:dns}`), the mesh fills
// it per consumer, and the same filled value reaches this provisioner and the consumer's own
// configuration. There is no second computation to disagree with.
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";
import { MinioClient, bucketFor } from "../client.js"; import { MinioClient } from "../client.js";
const minio = MinioClient.fromEnv(); const minio = MinioClient.fromEnv();
runProvisioner("s3-bucket", { runProvisioner("s3-bucket", {
async create(p: Provision): Promise<void> { async create(p: Provision): Promise<void> {
const bucket = bucketFor(p.as); const bucket = bucketNamed(p.derived);
const accessKeyId = p.as; const accessKeyId = p.as;
if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket); if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket);
@@ -38,8 +44,8 @@ runProvisioner("s3-bucket", {
}); });
}, },
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> {
const bucket = bucketFor(p.as); const bucket = bucketNamed(p.derived);
// Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if
// empty; a bucket that still holds objects is left for an operator rather than erroring on every // empty; a bucket that still holds objects is left for an operator rather than erroring on every
@@ -57,10 +63,28 @@ runProvisioner("s3-bucket", {
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> { async holds(p: Provision): Promise<boolean> {
return minio.canReachAs(bucketFor(p.as), p.as, p.password); return minio.canReachAs(bucketNamed(p.derived), p.as, p.password);
}, },
}); });
/** The bucket the mesh derived for this consumer.
*
* Absent means this module is running against a control plane that does not fill `${consumer:…}`
* yet, or a manifest whose `serves` block lost the line. Both are the same mistake from here —
* nobody said which bucket — and both are said rather than guessed: a provisioner that fell back
* to deriving one would restore the second rule and hide the fault behind a bucket that happens
* to be right. */
function bucketNamed(derived: Readonly<Record<string, unknown>>): string {
const bucket = derived.bucket;
if (typeof bucket !== "string" || bucket === "") {
throw new Error(
"the mesh did not say which bucket this consumer gets: minio's manifest must serve " +
"`bucket` under s3-bucket (novox/hq ADR 0188)",
);
}
return bucket;
}
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a /** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
* bucket that was made. */ * bucket that was made. */
async function announce(type: string, body: unknown): Promise<void> { async function announce(type: string, body: unknown): Promise<void> {
+1 -1
View File
@@ -63,7 +63,7 @@
"type": "file", "type": "file",
"path": "${dir:state}/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=mesh-novox-ncloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n" "content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=${bound:s3-bucket:bucket}\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
}, },
{ {
"id": "html", "id": "html",
+23
View File
@@ -0,0 +1,23 @@
# nftables' runtime: the tool runtime, carrying the packet filter's tools and the binaries they speak.
#
# Built from this module's own directory and nothing else (novox/hq ADR 0069). Two bases, named in
# module.json's `build.on`: the image this is compiled in and the image it runs in.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/nftables
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# The filter's own tools: nft for the machine's ruleset and the mesh's table, iptables for the
# legacy filter and the tables iptables-nft manages — a predecessor's rules live there (ADR 0168).
# The container runs on the machine's network with NET_ADMIN (ADR 0170), so these act on the
# machine's packet filter, not on a namespace of their own.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nftables iptables \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/nftables/dist /app/modules/nftables/dist
ENV MESH_TOOL_MODULES=/app/modules/nftables/dist/tools/index.js
+13 -71
View File
@@ -2,13 +2,9 @@
// rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module // rule set from every module's `listens` and writes it to the filter file (ADR 0045); the module
// loads it through its own unit. This code reads the filter back as the machine enforces it, reloads // loads it through its own unit. This code reads the filter back as the machine enforces it, reloads
// the mesh's own table, and removes one thing the mesh did not write when the operator names it // the mesh's own table, and removes one thing the mesh did not write when the operator names it
// (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools. Root is the module's // (ADR 0168, ADR 0170) — the seat's three verbs, over the machine's own tools.
// 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 execFileP = promisify(execFile);
@@ -16,54 +12,9 @@ const execFileP = promisify(execFile);
/** A command runner, so the acts can be tested without a packet filter. */ /** A command runner, so the acts can be tested without a packet filter. */
export type Runner = (cmd: string, args: string[]) => Promise<string>; 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) => { export const execRunner: Runner = async (cmd, args) => {
const [program, argv] = escalated(cmd, args); const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 });
try { return stdout;
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. */ /** The mesh's own tables, which `remove` never touches. */
@@ -83,17 +34,14 @@ export interface Removal {
export class FirewallClient { export class FirewallClient {
private readonly run: Runner; private readonly run: Runner;
private readonly filterFile: string; private readonly filterFile: string;
private readonly have: (tool: string) => boolean;
constructor(run: Runner = execRunner, filterFile: string = FILTER_FILE, have: (tool: string) => boolean = installed) { constructor(run: Runner = execRunner, filterFile: string = process.env.MESH_FILTER_FILE ?? "/etc/nftables.conf") {
this.run = run; this.run = run;
this.filterFile = filterFile; this.filterFile = filterFile;
this.have = have;
} }
/** The filter as this machine has it: its own tools, the mesh's file. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): FirewallClient {
static onThisMachine(): FirewallClient { return new FirewallClient(execRunner, env.MESH_FILTER_FILE ?? "/etc/nftables.conf");
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 the mesh's own filter is dropping and accepting. */
@@ -117,14 +65,11 @@ export class FirewallClient {
const legacy: Record<string, string> = {}; const legacy: Record<string, string> = {};
if (!table) { if (!table) {
for (const tool of ["iptables-legacy", "ip6tables-legacy"]) { for (const tool of ["iptables-legacy", "ip6tables-legacy"]) {
if (!this.have(tool)) continue; // no legacy tool, nothing to list
try { try {
const out = await this.run(tool, ["-S"]); const out = await this.run(tool, ["-S"]);
if (out.trim()) legacy[tool] = out; if (out.trim()) legacy[tool] = out;
} catch (err) { } catch {
// The tool is here and would not answer: said, not swallowed — a listing that silently // the tool is not here, or the legacy filter is empty: nothing to list
// leaves out a predecessor's rules reads as "none".
legacy[tool] = `error: ${err instanceof Error ? err.message : String(err)}`;
} }
} }
} }
@@ -137,17 +82,14 @@ export class FirewallClient {
return { loaded: this.filterFile, table: await this.ruleset() }; return { loaded: this.filterFile, table: await this.ruleset() };
} }
/** Whether the found front end is in force, whose chains `remove` leaves alone. Absent, it is /** Whether the found front end is in force, whose chains `remove` leaves alone. */
* not; present and not answering, nothing is removed on a guess. */
private async ufwActive(): Promise<boolean> { private async ufwActive(): Promise<boolean> {
if (!this.have("ufw")) return false;
let out: string;
try { try {
out = await this.run("ufw", ["status"]); const out = await this.run("ufw", ["status"]);
} catch (err) { return /^Status:\s*active/m.test(out);
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)}`); } catch {
return false;
} }
return /^Status:\s*active/m.test(out);
} }
/** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */ /** Remove one rule set the mesh did not write, named as the host reports it (ADR 0168). */
+45 -13
View File
@@ -2,7 +2,8 @@
"module": "nftables", "module": "nftables",
"version": "1", "version": "1",
"capabilities": [ "capabilities": [
"firewall" "firewall",
"container-runtime"
], ],
"claims": [ "claims": [
{ {
@@ -19,16 +20,17 @@
"into": "/etc/nftables.conf" "into": "/etc/nftables.conf"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "package", "id": "package",
"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 +42,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"
}, },
{ {
@@ -62,20 +64,50 @@
"type": "package", "type": "package",
"package": "ufw", "package": "ufw",
"absent": true "absent": true
},
{
"id": "runtime",
"type": "container",
"name": "mesh-nftables",
"network": "host",
"capabilities": [
"NET_ADMIN"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/etc/nftables.conf:/etc/nftables.conf:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_FILTER_FILE": "/etc/nftables.conf"
},
"artifact": "runtime"
} }
], ],
"tools": [ "tools": [
"firewall_rules" "firewall_rules"
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"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"
]
} }
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist", "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'" "test": "node --test --experimental-strip-types 'test/*.test.ts'"
}, },
"dependencies": { "dependencies": {
+7 -41
View File
@@ -4,8 +4,7 @@
// and the same in an iptables-nft table. It refuses what is not the operator's to remove. // and the same in an iptables-nft table. It refuses what is not the operator's to remove.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { FirewallClient, chainsJumpingTo, type Runner } from "../client.ts";
import { FILTER_FILE, FirewallClient, chainsJumpingTo, escalated, installed, type Runner } from "../client.ts";
const legacy = [ const legacy = [
"-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT", "-P INPUT ACCEPT", "-P FORWARD DROP", "-P OUTPUT ACCEPT",
@@ -35,7 +34,7 @@ function fake(ufwActive = false): { run: Runner; asked: string[] } {
test("a predecessor's chain in the legacy filter loses its jumps, is flushed and deleted", async () => { test("a predecessor's chain in the legacy filter loses its jumps, is flushed and deleted", async () => {
const f = fake(); const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("chain HAL-MESH-ONLY (iptables-legacy)"); const out = await new FirewallClient(f.run).remove("chain HAL-MESH-ONLY (iptables-legacy)");
assert.deepEqual(out.did, [ assert.deepEqual(out.did, [
"iptables-legacy -D DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY", "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 -F HAL-MESH-ONLY",
@@ -45,27 +44,27 @@ test("a predecessor's chain in the legacy filter loses its jumps, is flushed and
test("the runtime's user chain is emptied back to its one return, never deleted", async () => { test("the runtime's user chain is emptied back to its one return, never deleted", async () => {
const f = fake(); const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("chain DOCKER-USER (ip6tables-legacy)"); const out = await new FirewallClient(f.run).remove("chain DOCKER-USER (ip6tables-legacy)");
assert.deepEqual(out.did, ["ip6tables-legacy -F DOCKER-USER", "ip6tables-legacy -A DOCKER-USER -j RETURN"]); 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"); const nft = await new FirewallClient(fake().run).remove("table ip6 filter, chain DOCKER-USER");
assert.deepEqual(nft.did, ["ip6tables -F DOCKER-USER", "ip6tables -A DOCKER-USER -j RETURN"]); 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 () => { test("a chain of the machine's own nftables table goes with the rules that reach it", async () => {
const f = fake(); const f = fake();
const out = await new FirewallClient(f.run, undefined, () => true).remove("table ip6 own, chain deny"); const out = await new FirewallClient(f.run).remove("table ip6 own, chain deny");
assert.deepEqual(out.did, ["nft delete rule ip6 own forward handle 7", "nft delete chain ip6 own 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 () => { test("what is not the operator's to remove is refused by name", async () => {
const c = new FirewallClient(fake(true).run, undefined, () => true); const c = new FirewallClient(fake(true).run);
await assert.rejects(c.remove("table inet mesh, chain forward"), /the mesh's own table/); 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 DOCKER (iptables-legacy)"), /container runtime's own/);
await assert.rejects(c.remove("chain FORWARD (iptables-legacy)"), /built in/); 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("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/); 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. // 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)"); const retired = await new FirewallClient(fake(false).run).remove("chain ufw6-docker-logging-deny (ip6tables-legacy)");
assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny")); assert.ok(retired.did.includes("ip6tables-legacy -X ufw6-docker-logging-deny"));
}); });
@@ -73,36 +72,3 @@ 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"; 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"]); 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/,
);
});
+1 -1
View File
@@ -44,7 +44,7 @@ export function getFirewallTools(firewall: FirewallClient): ToolDefinition[] {
]; ];
} }
const firewall = FirewallClient.onThisMachine(); const firewall = FirewallClient.fromEnv();
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this // 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. // module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall)); registerModuleTools("node-packet-filter", () => getSeatVerbs(firewall));
+1 -1
View File
@@ -58,7 +58,7 @@
"type": "file", "type": "file",
"path": "${dir:state}/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=mesh-novox-photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n" "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
}, },
{ {
"id": "net", "id": "net",
+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"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-portainer",
"version": "0.1.0",
"description": "portainer — container management UI. Its API client and tools live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+50
View File
@@ -0,0 +1,50 @@
// portainer's tools — its own code (novox/hq ADR 0039), importing portainer's own client. They
// return structured data; the mesh serves them through the sdk's tool harness. portainer is
// tools-only (no events entrypoint): a container starting or stopping is the host's signal to emit,
// not Portainer's to re-announce.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { PortainerClient } from "../client.js";
export function getPortainerTools(portainer: PortainerClient): ToolDefinition[] {
return [
{
name: "portainer_endpoints",
description: "List the environments (endpoints) Portainer manages — each a Docker host or cluster.",
input: {},
run: async () => {
const endpoints = await portainer.listEndpoints();
return { count: endpoints.length, endpoints };
},
},
{
name: "portainer_stacks",
description: "List the stacks (compose/swarm deployments) Portainer knows about.",
input: {},
run: async () => {
const stacks = await portainer.listStacks();
return { count: stacks.length, stacks };
},
},
{
name: "portainer_containers",
description: "List the containers on one Portainer endpoint, including stopped ones.",
input: { endpoint: { type: "number", description: "the endpoint id (see portainer_endpoints)" } },
run: async (args) => {
const endpointId = Number(args.endpoint);
const containers = await portainer.listContainers(endpointId);
return { endpointId, count: containers.length, containers };
},
},
];
}
// The tools exist only when a token is configured; without one, portainer contributes none rather
// than failing the whole runtime.
registerModuleTools("portainer", (env) => {
try {
return getPortainerTools(PortainerClient.fromEnv(env));
} catch {
return [];
}
});
@@ -8,8 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": [ "include": ["client.ts", "tools/index.ts"]
"client.ts",
"tools/index.ts"
]
} }
+1 -18
View File
@@ -17,24 +17,7 @@
"type": "file", "type": "file",
"path": "/etc/resolv.conf", "path": "/etc/resolv.conf",
"mode": "0644", "mode": "0644",
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's one resolver first (novox/hq ADR 0194, 0196), by address — a machine\n# cannot resolve the name of the thing it resolves names with. It answers the\n# mesh's names itself and forwards every other name. A public resolver second,\n# asked only when the first does not answer at all — its machine or the tunnel\n# down, a captive portal holding the tunnel back — so public names keep\n# resolving then. An answer from the first, \"no such name\" included, is final,\n# so a mesh name is never asked of the public one while the mesh's answers. One\n# second and one attempt, so the wait before the fallback is short. Containers\n# copy these two lines from their machine.\nnameserver ${bound:wildcard-resolution:address}\nnameserver 1.1.1.1\noptions timeout:1 attempts:1 edns0\n" "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"
},
{
"id": "runtime-config",
"type": "file",
"path": "/etc/docker/daemon.json",
"mode": "0644",
"into": "json",
"content": "{\"live-restore\": true}\n"
},
{
"id": "runtime",
"type": "service",
"unit": "docker.service",
"state": "running",
"reload-on": [
"runtime-config"
]
} }
] ]
} }
+1 -1
View File
@@ -23,7 +23,7 @@
"type": "file", "type": "file",
"path": "/etc/systemd/resolved.conf.d/mesh.conf", "path": "/etc/systemd/resolved.conf.d/mesh.conf",
"mode": "0644", "mode": "0644",
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# The mesh's one resolver (novox/hq ADR 0194), by its private address — a\n# machine cannot resolve the name of the thing it resolves names with.\n[Resolve]\nDNS=${bound:wildcard-resolution:address}\nDomains=~internal\n" "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"
}, },
{ {
"id": "resolved", "id": "resolved",
+24
View File
@@ -0,0 +1,24 @@
# searxng'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/searxng
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/searxng/dist /app/modules/searxng/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/searxng/dist/tools/index.js
+36 -14
View File
@@ -8,7 +8,8 @@
"secret": { "secret": {
"path": "${dir:mesh-state}/secret", "path": "${dir:mesh-state}/secret",
"taken": "at-start" "taken": "at-start"
} },
"broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -90,6 +91,25 @@
"path": "${dir:mesh-state}/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n" "content": "{}\n"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-searxng",
"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_SEARXNG_URL": "http://127.0.0.1:${port:8080}",
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -105,21 +125,23 @@
"route": "${dir:state}/route.json" "route": "${dir:state}/route.json"
}, },
"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_SEARXNG_URL": "http://127.0.0.1:${port:8080}",
"MESH_SEARXNG_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
-21
View File
@@ -1,21 +0,0 @@
{
"module": "ssh-client",
"version": "1",
"resources": [
{
"id": "ssh-dir",
"type": "directory",
"path": "${machine:account-home}/.ssh",
"owner": "${machine:account}",
"mode": "0700"
}
],
"facts": {
"ssh-config": {
"path": ".ssh/config",
"home": true,
"shared": true,
"template": "# The mesh's Host blocks — every other node, so `ssh <node>` reaches it as the\n# right account. This region is replaced whenever a node joins, leaves or is\n# renamed; the rest of this file is yours and is kept untouched.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}"
}
}
}
+8
View File
@@ -5,12 +5,20 @@
"container-runtime" "container-runtime"
], ],
"provides": [ "provides": [
{
"name": "acme-ca",
"scope": "mesh"
},
{ {
"name": "internal-acme-ca", "name": "internal-acme-ca",
"scope": "mesh" "scope": "mesh"
} }
], ],
"serves": { "serves": {
"acme-ca": {
"path": "/acme/acme/directory",
"roots": "/roots.pem"
},
"internal-acme-ca": { "internal-acme-ca": {
"path": "/acme/acme/directory", "path": "/acme/acme/directory",
"roots": "/roots.pem" "roots": "/roots.pem"
+24
View File
@@ -0,0 +1,24 @@
# unifi'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/unifi
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/unifi/dist /app/modules/unifi/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/unifi/dist/tools/index.js
+35 -13
View File
@@ -122,6 +122,25 @@
"mode": "0600", "mode": "0600",
"content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n", "content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-unifi",
"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_UNIFI_URL": "https://127.0.0.1:${port:8443}",
"MESH_UNIFI_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -139,24 +158,27 @@
"route": "${dir:state}/route.json" "route": "${dir:state}/route.json"
}, },
"own-secrets": { "own-secrets": {
"broker": "${dir:mesh-state}/broker",
"controller": "${dir:mesh-state}/controller" "controller": "${dir:mesh-state}/controller"
}, },
"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_UNIFI_URL": "https://127.0.0.1:${port:8443}",
"MESH_UNIFI_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }