Compare commits

...
Author SHA1 Message Date
mesh-admin c32edcfc6f Merge pull request 'Retire mesh-console: the console is the node-tools runtime's serving mode (hq ADR 0175 §6, to-be 38 WP3)' (#230) from feat/retire-mesh-console into main 2026-10-03 00:40:37 +00:00
mesh-admin 740359ffd9 Merge pull request 'Remove portainer: deprecated, and unassigned everywhere' (#233) from jschoubben/remove-portainer into main 2026-10-02 21:26:03 +00:00
jschoubben a309deb479 Remove portainer: deprecated, and unassigned everywhere
It held the docker socket behind a public name. Nothing depends on it;
it is off both machines that ran it, with its data.
2026-10-02 23:25:45 +02:00
mesh-admin 0fd722e829 Merge pull request 'gitea: the jail also bans what gitea's sshd refuses' (#232) from jschoubben/gitea-ssh-jail into main 2026-10-02 21:23:44 +00:00
jschoubben 2e6cc71f7a gitea: the jail also bans what gitea's sshd refuses
The jail read gitea's container journal, which carries its sshd's lines,
but matched only the web login. 167 ssh attempts an hour from the
internet went unbanned. Two patterns, one per attempt: an unknown user,
and a user sshd refuses; tested against a day of the real log, 946
matches and none on an accepted login.
2026-10-02 23:23:35 +02:00
jochen b28b1b9f24 Retire mesh-console: the console is the node-tools runtime's serving mode (hq ADR 0175 §6, to-be 38 WP3)
The console was a container per node built on the runtime image, calling everything and serving
nothing. node-tools — the runtime as a module, in the mesh-tools repository — answers MCP on the
same loopback port from the same process that serves every module's tools, so the module that was
only that is gone. Merged once node-tools is assigned where mesh-console was, on every machine.
2026-10-02 21:58:21 +02:00
10 changed files with 1 additions and 443 deletions
+1 -1
View File
@@ -242,7 +242,7 @@
"jails": [
{
"name": "gitea",
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$",
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
-13
View File
@@ -1,13 +0,0 @@
# 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
@@ -1,38 +0,0 @@
# 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
@@ -1,64 +0,0 @@
{
"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"
}
]
}
}
-30
View File
@@ -1,30 +0,0 @@
# portainer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/portainer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/portainer/dist /app/modules/portainer/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/portainer/dist/tools/index.js
-107
View File
@@ -1,107 +0,0 @@
// The Portainer API client — portainer's own code, living in the module (novox/hq ADR 0039).
// portainer is tools-only: its "events" would really be the underlying containers' lifecycle,
// which the host owns and emits — so this module reads Portainer's own resources (endpoints,
// stacks, containers) and exposes them, and stops there.
import { readFileSync } from "node:fs";
export interface PortainerEndpoint {
id: number;
name: string;
type: number;
url: string;
status: number;
}
export interface PortainerStack {
id: number;
name: string;
type: number;
endpointId: number;
status: number;
}
export interface PortainerContainer {
id: string;
names: string[];
image: string;
state: string;
status: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class PortainerClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly token: string,
) {
this.baseUrl = url.replace(/\/+$/, "");
}
/**
* Build from the module's resolved environment. The URL is MESH_PORTAINER_URL (or the local
* dashboard port) and the API token is MESH_PORTAINER_TOKEN — an access token minted in
* Portainer, sent as X-API-Key. Throws when no token is configured, so a misconfigured module
* exposes nothing rather than calling Portainer unauthenticated.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): PortainerClient {
const cfg = meshConfig(env.MESH_PORTAINER_CONFIG_FILE);
const url = cfg.url ?? env.MESH_PORTAINER_URL ?? `https://127.0.0.1:${env.PORTAINER_PORT ?? "9443"}`;
const token = cfg.token ?? env.MESH_PORTAINER_TOKEN;
if (!token) throw new Error("no Portainer token — set MESH_PORTAINER_TOKEN");
return new PortainerClient(url, token);
}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, { headers: { "X-API-Key": this.token } });
if (!res.ok) throw new Error(`Portainer ${path}: ${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
/** The environments (endpoints) Portainer manages — each a Docker host or cluster it talks to. */
async listEndpoints(): Promise<PortainerEndpoint[]> {
const raw = await this.get<any[]>("/api/endpoints");
return (raw ?? []).map((e) => ({
id: e.Id,
name: e.Name,
type: e.Type,
url: e.URL,
status: e.Status,
}));
}
/** The stacks (compose/swarm deployments) Portainer knows about. */
async listStacks(): Promise<PortainerStack[]> {
const raw = await this.get<any[]>("/api/stacks");
return (raw ?? []).map((s) => ({
id: s.Id,
name: s.Name,
type: s.Type,
endpointId: s.EndpointId,
status: s.Status,
}));
}
/**
* The containers on one endpoint, read through Portainer's Docker API proxy. Includes stopped
* containers, so the caller sees the whole picture rather than only what is running.
*/
async listContainers(endpointId: number): Promise<PortainerContainer[]> {
const raw = await this.get<any[]>(`/api/endpoints/${endpointId}/docker/containers/json?all=1`);
return (raw ?? []).map((c) => ({
id: c.Id,
names: c.Names ?? [],
image: c.Image,
state: c.State,
status: c.Status,
}));
}
}
-114
View File
@@ -1,114 +0,0 @@
{
"module": "portainer",
"version": "1",
"slug": "portain",
"capabilities": [
"container-runtime"
],
"listens": [
{
"name": "web",
"port": 9000,
"protocol": "tcp",
"from": "mesh",
"why": "the dashboard over http; its public name is a route grant and the proxy reaches it here"
},
{
"name": "web-tls",
"port": 9443,
"protocol": "tcp",
"from": "mesh",
"why": "the same dashboard over its own tls; the runtime sidecar talks to it here"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "data",
"type": "directory",
"mode": "0700"
},
{
"id": "server",
"type": "container",
"name": "portainer",
"image": "portainer/portainer-ce@sha256:4d616db18cfeb5dd41a69c0958bc825c84483ea9cde1106eb82a5d26f3bd8b0e",
"ports": [
"9000",
"9443"
],
"volumes": [
"${dir:data}:/data",
"/var/run/docker.sock:/var/run/docker.sock"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-portainer",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_PORTAINER_URL": "https://127.0.0.1:9443",
"MESH_PORTAINER_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
},
"requires": [
"route"
],
"contributes": {
"route": {
"label": "portainer",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:mesh-state}/route.json"
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-portainer",
"version": "0.1.0",
"description": "portainer — container management UI. Its API client and tools live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-50
View File
@@ -1,50 +0,0 @@
// portainer's tools — its own code (novox/hq ADR 0039), importing portainer's own client. They
// return structured data; the mesh serves them through the sdk's tool harness. portainer is
// tools-only (no events entrypoint): a container starting or stopping is the host's signal to emit,
// not Portainer's to re-announce.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { PortainerClient } from "../client.js";
export function getPortainerTools(portainer: PortainerClient): ToolDefinition[] {
return [
{
name: "portainer_endpoints",
description: "List the environments (endpoints) Portainer manages — each a Docker host or cluster.",
input: {},
run: async () => {
const endpoints = await portainer.listEndpoints();
return { count: endpoints.length, endpoints };
},
},
{
name: "portainer_stacks",
description: "List the stacks (compose/swarm deployments) Portainer knows about.",
input: {},
run: async () => {
const stacks = await portainer.listStacks();
return { count: stacks.length, stacks };
},
},
{
name: "portainer_containers",
description: "List the containers on one Portainer endpoint, including stopped ones.",
input: { endpoint: { type: "number", description: "the endpoint id (see portainer_endpoints)" } },
run: async (args) => {
const endpointId = Number(args.endpoint);
const containers = await portainer.listContainers(endpointId);
return { endpointId, count: containers.length, containers };
},
},
];
}
// The tools exist only when a token is configured; without one, portainer contributes none rather
// than failing the whole runtime.
registerModuleTools("portainer", (env) => {
try {
return getPortainerTools(PortainerClient.fromEnv(env));
} catch {
return [];
}
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "tools/index.ts"]
}