Compare commits

..
6 Commits
Author SHA1 Message Date
jschoubben 8148678af8 bazarr: reach sonarr and radarr through the mesh; placed dirs; the image ace runs
bazarr reached sonarr and radarr as `sonarr:8989` and `radarr:7878`,
container names on HAL's shared network, which the mesh does not have.
It now requires sonarr-api and radarr-api (provided since #156) and a
run-once step writes host, port, TLS, base path and key into bazarr
through bazarr's own POST /api/system/settings - the call its settings
screen makes - only for the fields that differ, and reads them back.
bazarr's config.yaml is not written by the mesh: bazarr holds it in
memory and rewrites it, so the two would overwrite each other. The key is
tried against the app first; a refused key (a minted pair credential
before the operator accepts the app's own) is never written, and the step
fails naming the `secret accept` that fixes it. Declared last so its
failing gates nothing else (ADR 0136); restart-on its four inputs.

The api-key own-secret is gone. bazarr makes its own key and nothing lets
the mesh set it, so a minted one could never work; the tools and the step
read auth.apikey from bazarr's own config/config.yaml (mounted read-only),
which also stays right if the key is regenerated. Nothing to accept.

The config dir is a pathless ${dir:config}; config.json and the bindings
live in a placed state dir; /var/lib/mesh/bazarr keeps only the broker.
The image is pinned to the digest ace runs (v1.6.1-ls364); the old pin was
v1.6.0-ls361, older than ace's database.

Based on feat/servarr-api-provision (#156); this branch contains it.

Verified: catalogue tests with MESH_CATALOGUE pass (not skipped); a
resolve of sonarr+radarr+bazarr on a fake ace renders both bindings and
sealed credentials into the state dir with every ${} filled, and bazarr
alone is refused naming sonarr and radarr; strict tsc passes and the
Dockerfile's non-strict compile builds; 8 node tests pass; the compiled
step against the pinned image in a throwaway container with fake
sonarr/radarr wrote sonarr (ip, port, apikey), refused radarr's minted
key and wrote nothing for it, wrote radarr once the key was right, and
changed nothing on a third run - the values landed in config.yaml.
2026-09-30 11:58:52 +02:00
jschoubben f14c763463 ombi: reach sonarr, radarr and lidarr through the mesh
ombi keeps its Servarr connections in its own database, so the mesh has no
file to write them into. A run-once step reads the three bindings and pair
credentials and writes host, port, TLS, base path and key into ombi through
ombi's own API - only when they differ, and nothing else ombi keeps.

Until the operator accepts an app's key for this pair the mesh delivers a
value it minted, which no Servarr app accepts. The step tries the key against
the app first and, refused, writes nothing and fails naming the secret accept
that fixes it, so the old working key in ombi is never replaced by a dead one.

Declared last so its failing gates nothing else of ombi (ADR 0136), and
restart-on its six inputs so it runs again when a provider moves (ADR 0099).
2026-09-30 00:39:19 +02:00
jschoubben ab44ff02e1 sonarr, radarr, lidarr: provide their API to the mesh
A consumer on another machine (ombi first; jackett, bazarr and home-assistant
later) reached these by container name on HAL's shared network, which the mesh
does not have. Each app now provides <app>-api at mesh scope and serves the
software port, so the mesh tells a consumer where it is and redirects the
port to where the machine published it.

Named per app, not one servarr-api: a requirement is matched by name and
answered by exactly one provider per node, so a consumer cannot require one
name from three providers - and ombi's code is written against each app's
own API version (ADR 0027).

No grants and no provisioner: a Servarr instance has one API key, which the
mesh cannot mint. The operator accepts it as the pair credential for each
consumer (ADR 0092).
2026-09-30 00:39:19 +02:00
jschoubben c4c44efb1b Merge remote-tracking branch 'origin/fix/sidecars-dial-the-port-they-were-given' into feat/servarr-api-provision 2026-09-30 00:25:55 +02:00
jschoubben 5cc6258326 Sidecars dial the port they were given, not the software's
A host-network sidecar reaches its service over the machine's loopback, and
the mesh publishes that service on a machine port it assigns (ADR 0038) —
so dialling the software's port reaches whatever else holds it. On ace,
searxng's sidecar dialled 127.0.0.1:8080 and got unifi's inform port. The
same shape in bazarr, bookshelf, lidarr, nzbget, qbittorrent, radarr and
sonarr; each now asks with ${port:N} (hq 088). Found in review of ace's
module preparation.
2026-09-29 23:50:19 +02:00
jschoubben 21f5301268 ombi: its config is placed, its image is the one ace runs
ombi's definition named /services/ombi/config (a HAL machine path) in three
places and pinned an image older than the one ace runs. Ombi migrates its
own SQLite schema, so a take onto the older pin (v4.53.10-ls267) would start
it on a database the newer build (ls269) already touched.

- config is a pathless placed directory, mounted as ${dir:config}
- a state directory placed at the assignment root carries route.json
- image pinned to the digest ace runs today (v4.53.10-ls269)
- the sidecar reaches ombi on the machine port the mesh assigns
  (${port:3579}) rather than assuming 3579 is free
- the sidecar no longer mounts ombi's data directory: MESH_OMBI_CONFIG_DIR
  is read by no code, and the mount exposed the databases for nothing

Verified: catalogue tests (MESH_CATALOGUE set, 6 pass, none skipped); the
pinned image starts as PUID 1000 in a 0700 dir and answers /api/v1/Status
200; data owned 1001:2000 (ace's media ids) under a 1000:1000 dir is
re-owned by the image's init and serves 200; a minted ApiKey is refused
(401) - the api-key secret must be accepted from ombi's own settings.
2026-09-29 23:38:56 +02:00
63 changed files with 1479 additions and 2050 deletions
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/bazarr
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
RUN node /app/node_modules/typescript/bin/tsc apikey.ts client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/bazarr/dist /app/modules/bazarr/dist
# 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/bazarr/dist/index.js,/app/modules/bazarr/dist/tools/index.js
# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+37
View File
@@ -0,0 +1,37 @@
// bazarr's own API key, found where bazarr keeps it. Shared by the client (tools, events) and the
// Servarr step, and kept apart from client.ts so the step and its test load it without the client.
import { readFileSync } from "node:fs";
/**
* bazarr's own API key, read from where bazarr keeps it: `auth.apikey` in `config/config.yaml` under
* its config directory. bazarr makes this key itself on first start and nothing lets the mesh set it,
* so a key the mesh minted could never work; reading bazarr's own file needs nothing accepted and
* stays right if the operator regenerates the key in bazarr's settings screen. The file is read, never
* written. Undefined when the file or the key is not there.
*/
export function apiKeyFromConfigDir(configDir?: string): string | undefined {
if (!configDir) return undefined;
let text: string;
try { text = readFileSync(`${configDir.replace(/\/$/, "")}/config/config.yaml`, "utf8"); }
catch { return undefined; }
return apiKeyFromConfigYaml(text);
}
/** `auth.apikey` from the text of bazarr's config.yaml — a top-level `auth:` mapping, one level deep. */
export function apiKeyFromConfigYaml(text: string): string | undefined {
let inAuth = false;
for (const line of text.split(/\r?\n/)) {
if (/^\S/.test(line)) {
inAuth = /^auth:\s*$/.test(line);
continue;
}
if (!inAuth) continue;
const m = line.match(/^\s+apikey:\s*(.*?)\s*$/);
if (m) {
const v = m[1].replace(/^(['"])(.*)\1$/, "$2").trim();
return v || undefined;
}
}
return undefined;
}
+9 -3
View File
@@ -5,6 +5,8 @@
import { readFileSync } from "node:fs";
import { apiKeyFromConfigDir } from "./apikey.js";
export interface WantedSubtitle {
kind: "episode" | "movie";
title: string; // series + episode, or movie title
@@ -47,7 +49,7 @@ function meshConfig(file?: string): Record<string, string> {
* absent or unreadable yields undefined so callers fall back rather than crash. */
function readSecret(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").trim(); }
try { return readFileSync(file, "utf8").trim() || undefined; }
catch { return undefined; }
}
@@ -66,9 +68,13 @@ export class BazarrClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
const url = cfg.url ?? env.MESH_BAZARR_URL;
const apiKey = cfg.apiKey ?? readSecret(env.MESH_BAZARR_API_KEY_FILE) ?? env.MESH_BAZARR_API_KEY;
const apiKey =
cfg.apiKey ??
apiKeyFromConfigDir(env.MESH_BAZARR_CONFIG_DIR) ??
readSecret(env.MESH_BAZARR_API_KEY_FILE) ??
env.MESH_BAZARR_API_KEY;
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY");
if (!apiKey) throw new Error("no Bazarr API key — bazarr's config/config.yaml under MESH_BAZARR_CONFIG_DIR has none");
return new BazarrClient(url, apiKey);
}
+53 -13
View File
@@ -8,8 +8,7 @@
"subtitle.downloaded"
],
"own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker",
"api-key": "/var/lib/mesh/bazarr/api-key"
"broker": "/var/lib/mesh/bazarr/broker"
},
"listens": [
{
@@ -45,10 +44,15 @@
"path": "/var/lib/mesh/bazarr",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/bazarr/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -56,7 +60,7 @@
"id": "server",
"type": "container",
"name": "bazarr",
"image": "lscr.io/linuxserver/bazarr@sha256:3a820372f19fcb2981ea19fe4b5382934d67414afaba974bce831ddda0a64a02",
"image": "lscr.io/linuxserver/bazarr@sha256:d24bd0048c759a468970989e9df11a6b96a7628d556d00f923e60a35ba59237b",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -66,7 +70,7 @@
"6767"
],
"volumes": [
"/services/bazarr/config:/config",
"${dir:config}:/config",
"/services/media/movies:/movies",
"/services/media/series:/series",
"/services/media/anime:/anime",
@@ -76,7 +80,7 @@
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/bazarr/config.json",
"path": "${dir:state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
@@ -88,14 +92,12 @@
"network": "host",
"volumes": [
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
"/var/lib/mesh/bazarr/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro",
"/services/bazarr/config:/var/lib/bazarr/config:ro"
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/bazarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
},
@@ -103,10 +105,42 @@
"runtime-config"
],
"artifact": "runtime"
},
{
"id": "servarr",
"type": "container",
"name": "mesh-bazarr-servarr",
"network": "host",
"run-once": true,
"volumes": [
"${dir:config}:/var/lib/bazarr/config:ro",
"${dir:state}/sonarr-api.json:/run/servarr/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/servarr/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/servarr/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/servarr/radarr-api.secret:ro"
],
"env": {
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config",
"MESH_SERVARR_DIR": "/run/servarr"
},
"args": [
"run",
"/app/modules/bazarr/dist/servarr/index.js"
],
"restart-on": [
"bound-sonarr-api",
"secret-sonarr-api",
"bound-radarr-api",
"secret-radarr-api"
],
"artifact": "runtime"
}
],
"requires": [
"route"
"radarr-api",
"route",
"sonarr-api"
],
"contributes": {
"route": {
@@ -115,7 +149,13 @@
}
},
"binds": {
"route": "/var/lib/mesh/bazarr/route.json"
"route": "${dir:state}/route.json",
"sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json"
},
"secrets": {
"sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret"
},
"build": {
"on": [
+5
View File
@@ -4,6 +4,11 @@
"description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc apikey.ts client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
+63
View File
@@ -0,0 +1,63 @@
// bazarr's Servarr step — run once by the host after bazarr's server starts, and run again whenever a
// binding or pair credential it reads changes (the container's `restart-on`, novox/hq ADR 0099).
//
// **A step, not a loop**: everything it does is a function of files the mesh writes, and the host
// already knows when they change. It connects to no broker.
//
// Exits non-zero when any app could not be put right — a refused credential, an unreachable app, a
// bazarr that would not keep the settings — so the node reports the step failed and the host runs it
// again on the next apply. Declared last in the manifest, so its failing gates nothing else of
// bazarr's (novox/hq ADR 0136).
//
// Reads, per app, `<dir>/<provision>.json` (the binding) and `<dir>/<provision>.secret` (the pair
// credential), where <dir> is MESH_SERVARR_DIR; and bazarr's own key from bazarr's own config.yaml
// under MESH_BAZARR_CONFIG_DIR. Never prints a key.
import { join } from "node:path";
import { apiKeyFromConfigDir } from "../apikey.js";
import { APPS, bazarrReady, readBinding, readIfThere, reconcileApp, type Http } from "./settings.js";
const dir = process.env.MESH_SERVARR_DIR ?? "/run/servarr";
const url = process.env.MESH_BAZARR_URL ?? "http://127.0.0.1:6767";
const waitSeconds = Number(process.env.MESH_BAZARR_WAIT_SECONDS ?? "180");
const http: Http = { fetch: (u, init) => fetch(u, init) };
const bazarrUp = await bazarrReady(http, { url, apiKey: "" }, waitSeconds * 1000);
if (!bazarrUp) {
console.error(`[bazarr-servarr] bazarr did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
// Read after bazarr answers: on a first start bazarr writes its config.yaml, key included, as it boots.
const apiKey = apiKeyFromConfigDir(process.env.MESH_BAZARR_CONFIG_DIR);
if (!apiKey) {
console.error("[bazarr-servarr] no bazarr API key in bazarr's config/config.yaml under MESH_BAZARR_CONFIG_DIR");
process.exit(1);
}
const bazarr = { url, apiKey };
let failed = 0;
for (const spec of APPS) {
const outcome = await reconcileApp(
http,
bazarr,
spec,
await readBinding(join(dir, `${spec.provision}.json`)),
await readIfThere(join(dir, `${spec.provision}.secret`)),
);
switch (outcome.result) {
case "unchanged":
console.log(`[bazarr-servarr] ${outcome.app}: already as the mesh says; key taken by ${outcome.app}`);
break;
case "written":
console.log(`[bazarr-servarr] ${outcome.app}: wrote ${outcome.fields.join(", ")}; key taken by ${outcome.app}`);
break;
case "refused":
failed++;
console.error(`[bazarr-servarr] ${outcome.app}: ${outcome.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
+290
View File
@@ -0,0 +1,290 @@
// Where bazarr reaches Sonarr and Radarr — decided by the mesh, written into bazarr by bazarr's own
// API.
//
// **Why this exists.** bazarr keeps its connection to each app in its own `config/config.yaml`, which
// it holds in memory and writes back whenever its settings change — so the mesh cannot own that file
// without the two overwriting each other. bazarr requires `sonarr-api` and `radarr-api`; the mesh
// delivers, for each, a binding (where the app is: `at`, and what it serves: `port`, `scheme`,
// `url-base`) and a pair credential (the app's API key, accepted by the operator — a Servarr app has
// exactly one key and the mesh cannot mint it, novox/hq ADR 0092). This step reads those files and
// makes bazarr's settings say the same thing, through `POST /api/system/settings` — the call bazarr's
// own settings screen makes, which also restarts bazarr's SignalR feed from the app.
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path and API key. Whether
// bazarr uses the app at all (`general.use_sonarr`), sync intervals, excluded tags, path mappings and
// every other choice the operator made are left exactly as they are: the mesh knows where the app is,
// not what bazarr should do with it.
//
// **A credential the app refuses is never written.** Until the operator accepts the app's key for this
// pair the mesh delivers a value it minted, which no Servarr app accepts. Writing it would replace a
// working key in bazarr with a dead one, so the key is tried against the app first; refused, nothing
// for that app is written and the step fails naming the `secret accept` that fixes it.
//
// The same shape as ombi's step (modules/ombi/servarr), repeated rather than shared because a module
// is built from its own directory and nothing else (novox/hq ADR 0069). Pure logic and a small HTTP
// seam, tested against fakes (test/servarr.test.ts).
import { readFile } from "node:fs/promises";
/** One Servarr app bazarr connects to. */
export interface ServarrApp {
/** The section of bazarr's settings that holds the connection: settings.<app>.* */
app: "sonarr" | "radarr";
/** The provision it is required as — the manifest's `requires`, `binds` and `secrets` key. */
provision: string;
/** The app's own status endpoint, which answers 401 to a wrong key. */
statusPath: string;
}
export const APPS: readonly ServarrApp[] = [
{ app: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" },
{ app: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status" },
];
/** The connection fields bazarr keeps for an app — the only ones this step ever writes. */
export interface Connection {
ip: string;
port: number;
ssl: boolean;
/** bazarr's base_url: "" at the root, otherwise "/base". */
base_url: string;
apikey: string;
}
/** What the mesh wrote at `binds.<provision>`: the binding document. */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export type Wanted = { ok: true; connection: Connection; from: string } | { ok: false; problem: string };
/**
* The connection the mesh says bazarr should use. Refused rather than guessed when the binding cannot
* be dialled from bazarr's own container: a loopback `at` is bazarr's container itself.
*/
export function wanted(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Wanted {
if (!binding) {
return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` };
}
const at = typeof binding.at === "string" ? binding.at.trim() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!at) return { ok: false, problem: `the ${spec.provision} binding names no host (at)` };
if (isLoopback(at)) {
return {
ok: false,
problem:
`the ${spec.provision} binding says ${spec.app} is at ${at}, which from bazarr's own container is ` +
`bazarr itself. The mesh hands loopback to a machine that is not on the private network; put it ` +
`on the private network so ${spec.app} has an address bazarr can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(serves.port)})` };
}
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") {
return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}, which bazarr cannot dial` };
}
const key = (credential ?? "").trim();
if (!key) return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` };
return {
ok: true,
from: typeof binding.from === "string" ? binding.from : "",
connection: { ip: at, port, ssl: scheme === "https", base_url: baseUrlOf(serves["url-base"]), apikey: key },
};
}
/** A URL base as bazarr stores it: "" for none, else one leading slash and no trailing one. */
export function baseUrlOf(urlBase: unknown): string {
const trimmed = typeof urlBase === "string" ? urlBase.trim().replace(/^\/+|\/+$/g, "") : "";
return trimmed === "" ? "" : `/${trimmed}`;
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/** Which connection fields differ between what bazarr holds and what the mesh says. Names only. */
export function differing(current: Record<string, unknown> | undefined, want: Connection): (keyof Connection)[] {
const now = current ?? {};
const out: (keyof Connection)[] = [];
if (String(now.ip ?? "") !== want.ip) out.push("ip");
if (Number(now.port ?? 0) !== want.port) out.push("port");
if (Boolean(now.ssl) !== want.ssl) out.push("ssl");
if (baseUrlOf(now.base_url) !== want.base_url) out.push("base_url");
if (String(now.apikey ?? "") !== want.apikey) out.push("apikey");
return out;
}
/**
* The form bazarr's settings endpoint takes for the differing fields: `settings-<app>-<field>`.
* bazarr casts "true"/"false" to booleans and digit strings to integers itself.
*/
export function settingsForm(spec: ServarrApp, want: Connection, fields: readonly (keyof Connection)[]): URLSearchParams {
const form = new URLSearchParams();
for (const f of fields) {
const v = want[f];
form.append(`settings-${spec.app}-${f}`, typeof v === "boolean" ? (v ? "true" : "false") : String(v));
}
return form;
}
/** The app's base URL as the step dials it — the same host and port bazarr will be given. */
export function appUrl(want: Connection): string {
const scheme = want.ssl ? "https" : "http";
const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip;
return `${scheme}://${host}:${want.port}${want.base_url}`;
}
/** How one app came out. */
export type Outcome =
| { app: string; result: "unchanged" }
| { app: string; result: "written"; fields: string[] }
| { app: string; result: "refused"; problem: string };
/** The HTTP the step needs, so a test can stand fakes in for bazarr and the apps. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<{
status: number;
text(): Promise<string>;
}>;
}
export interface Bazarr {
url: string;
apiKey: string;
}
async function bazarrCall(http: Http, bazarr: Bazarr, method: string, path: string, form?: URLSearchParams): Promise<unknown> {
const res = await http.fetch(`${bazarr.url.replace(/\/$/, "")}/api${path}`, {
method,
headers: {
"X-API-KEY": bazarr.apiKey,
Accept: "application/json",
...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
},
body: form ? form.toString() : undefined,
});
const text = await res.text();
if (res.status < 200 || res.status >= 300) {
// bazarr's error body is a message, never a request echo, so it carries no key.
throw new Error(`bazarr ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
}
return text ? (JSON.parse(text) as unknown) : undefined;
}
/**
* Does the app take this key? `true` it does, `false` it refused it (401/403), and a thrown error
* when it could not be asked.
*/
export async function appTakes(http: Http, spec: ServarrApp, want: Connection): Promise<boolean> {
const res = await http.fetch(`${appUrl(want)}${spec.statusPath}`, {
method: "GET",
headers: { "X-Api-Key": want.apikey, Accept: "application/json" },
});
if (res.status === 401 || res.status === 403) return false;
if (res.status >= 200 && res.status < 300) return true;
throw new Error(`${spec.app} answered ${res.status} at ${spec.statusPath}`);
}
/** The remedy for a refused key, in the controller's own words (ADR 0092). */
export function acceptRemedy(spec: ServarrApp, from: string): string {
return (
`${spec.app} refuses the ${spec.provision} credential the mesh delivered, so it was not written ` +
`into bazarr. A Servarr app has one API key and the mesh cannot make it: accept ${spec.app}'s own ` +
`key for this pair — \`secret accept <this node> bazarr ${spec.provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${spec.app}'s ApiKey>\``
);
}
/**
* Bring bazarr's connection to one app in line with the mesh: check the key against the app, compare,
* write only the differing connection fields, then read bazarr's settings back to confirm they took.
* Never throws: every failure is an outcome with a reason.
*/
export async function reconcileApp(
http: Http,
bazarr: Bazarr,
spec: ServarrApp,
binding: Binding | undefined,
credential: string | undefined,
): Promise<Outcome> {
const w = wanted(spec, binding, credential);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) return { app: spec.app, result: "refused", problem: w.problem };
const want = w.connection;
try {
if (!(await appTakes(http, spec, want))) {
return { app: spec.app, result: "refused", problem: acceptRemedy(spec, w.from) };
}
} catch (err) {
return {
app: spec.app,
result: "refused",
problem: `${spec.app} could not be asked whether it takes the key at ${want.ip}:${want.port}: ${message(err)}`,
};
}
try {
const before = await sectionOf(http, bazarr, spec);
const fields = differing(before, want);
if (fields.length === 0) return { app: spec.app, result: "unchanged" };
await bazarrCall(http, bazarr, "POST", "/system/settings", settingsForm(spec, want, fields));
const still = differing(await sectionOf(http, bazarr, spec), want);
if (still.length > 0) {
return { app: spec.app, result: "refused", problem: `bazarr did not keep its ${spec.app} settings (${still.join(", ")})` };
}
return { app: spec.app, result: "written", fields };
} catch (err) {
return { app: spec.app, result: "refused", problem: message(err) };
}
}
async function sectionOf(http: Http, bazarr: Bazarr, spec: ServarrApp): Promise<Record<string, unknown> | undefined> {
const doc = (await bazarrCall(http, bazarr, "GET", "/system/settings")) as Record<string, unknown> | undefined;
return doc?.[spec.app] as Record<string, unknown> | undefined;
}
/** Wait for bazarr to answer, because the step runs right after its container starts. */
export async function bazarrReady(http: Http, bazarr: Bazarr, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const res = await http.fetch(`${bazarr.url.replace(/\/$/, "")}/api/system/ping`, { method: "GET" });
if (res.status === 200) return true;
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, pauseMs));
}
}
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A binding file parsed, or undefined when absent or not JSON. */
export async function readBinding(path: string | undefined): Promise<Binding | undefined> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as Binding;
} catch {
return undefined;
}
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+156
View File
@@ -0,0 +1,156 @@
// What holds bazarr's Servarr step (servarr/settings.ts): the connection bazarr keeps for Sonarr and
// Radarr is made to say what the mesh bound — host, port, TLS, base path, key — and nothing else
// bazarr keeps is sent; nothing is written when nothing differs; and a key the app refuses (the mesh's
// own minted value, before the operator accepts the app's key) is never written, with the
// `secret accept` that fixes it named. Also: bazarr's own key is found in its config.yaml's `auth`
// section and not in the `sonarr`/`radarr` sections that also carry an `apikey`.
//
// bazarr and the apps are fakes answering as the real ones do (checked against
// lscr.io/linuxserver/bazarr v1.6.1-ls364: GET/POST /api/system/settings with X-API-KEY, the form
// keys `settings-<section>-<field>`, 204 on save).
import { test } from "node:test";
import assert from "node:assert/strict";
import { apiKeyFromConfigYaml } from "../apikey.ts";
import { APPS, baseUrlOf, differing, reconcileApp, wanted, type Binding, type Http, type ServarrApp } from "../servarr/settings.ts";
const SONARR = APPS.find((a) => a.app === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.app === "radarr") as ServarrApp;
const THE_KEY = "the-apps-own-key";
const BAZARR = { url: "http://127.0.0.1:6767", apiKey: "bazarr-key" };
function binding(provision: string, port: number, at = "ace.internal"): Binding {
return { binding: 1, provision, from: "ace", at, as: "mesh_ace_bazarr", serves: { scheme: "http", port, "url-base": "" } } as Binding;
}
interface Call {
method: string;
url: string;
body?: string;
}
/** bazarr's settings (one document, sections per app) and the apps' key check, behind one fetch. */
function fakes(settings: Record<string, Record<string, unknown>>, opts: { appKey?: string; reachable?: boolean } = {}) {
const calls: Call[] = [];
const appKey = opts.appKey ?? THE_KEY;
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
calls.push({ method, url, body: init?.body });
const reply = (status: number, value?: unknown) => ({
status,
text: async () => (value === undefined ? "" : JSON.stringify(value)),
});
const u = new URL(url);
if (u.pathname.endsWith("/system/status")) {
if (opts.reachable === false) throw new Error("connect ECONNREFUSED");
return init?.headers?.["X-Api-Key"] === appKey ? reply(200, { version: "4" }) : reply(401);
}
if (init?.headers?.["X-API-KEY"] !== BAZARR.apiKey) return reply(401);
if (u.pathname !== "/api/system/settings") return reply(404);
if (method === "GET") return reply(200, settings);
// bazarr's save_settings: split the key, cast as bazarr casts, store.
for (const [k, raw] of new URLSearchParams(init?.body ?? "")) {
const [, section, field] = k.split("-");
let v: unknown = raw;
if (raw === "true") v = true;
else if (raw === "false") v = false;
else if (/^\d+$/.test(raw)) v = Number(raw);
settings[section] = { ...(settings[section] ?? {}), [field]: v };
}
return reply(204);
},
};
return { http, calls, settings };
}
/** ace's bazarr today: the apps by container name on HAL's shared network. */
function aceToday(): Record<string, Record<string, unknown>> {
return {
general: { use_sonarr: true, use_radarr: true, port: 6767 },
sonarr: { ip: "sonarr", port: 8989, ssl: false, base_url: "", apikey: THE_KEY, series_sync: 15, excluded_series_types: ["anime"] },
radarr: { ip: "radarr", port: 7878, ssl: false, base_url: "", apikey: THE_KEY, movies_sync: 15 },
};
}
test("moving an app writes only host and port, and leaves every other setting alone", async () => {
const f = fakes(aceToday());
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["ip", "port"] });
const post = f.calls.find((c) => c.method === "POST");
assert.ok(post);
assert.deepEqual([...new URLSearchParams(post.body ?? "").keys()].sort(), ["settings-sonarr-ip", "settings-sonarr-port"]);
assert.equal(f.settings.sonarr.ip, "ace.internal");
assert.equal(f.settings.sonarr.port, 20010);
assert.deepEqual(f.settings.sonarr.excluded_series_types, ["anime"]);
assert.equal(f.settings.radarr.ip, "radarr", "radarr is its own app and was not touched");
});
test("nothing is written when bazarr already says what the mesh says", async () => {
const s = aceToday();
s.radarr = { ...s.radarr, ip: "ace.internal", port: 20011 };
const f = fakes(s);
const out = await reconcileApp(f.http, BAZARR, RADARR, binding("radarr-api", 20011), THE_KEY);
assert.deepEqual(out, { app: "radarr", result: "unchanged" });
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
});
test("a key the app refuses is never written, and the remedy is named", async () => {
const f = fakes(aceToday());
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), "a-value-the-mesh-minted");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> bazarr sonarr-api --provider ace/);
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
assert.equal(f.settings.sonarr.apikey, THE_KEY, "the working key stays");
assert.equal(f.settings.sonarr.ip, "sonarr", "nothing moved either");
});
test("an unreachable app writes nothing", async () => {
const f = fakes(aceToday(), { reachable: false });
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
assert.equal(out.result, "refused");
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
});
test("a loopback binding is refused: from bazarr's container that is bazarr", () => {
const w = wanted(SONARR, binding("sonarr-api", 8989, "127.0.0.1"), THE_KEY);
assert.equal(w.ok, false);
});
test("a new key is written when the operator accepted a different one", async () => {
const s = aceToday();
s.sonarr = { ...s.sonarr, ip: "ace.internal", port: 20010, apikey: "an-old-key" };
const f = fakes(s);
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["apikey"] });
assert.equal(f.settings.sonarr.apikey, THE_KEY);
});
test("base paths compare as bazarr stores them", () => {
assert.equal(baseUrlOf(""), "");
assert.equal(baseUrlOf("/"), "");
assert.equal(baseUrlOf("sonarr/"), "/sonarr");
const want = { ip: "a", port: 1, ssl: false, base_url: "", apikey: "k" };
assert.deepEqual(differing({ ip: "a", port: 1, ssl: false, base_url: "/", apikey: "k" }, want), []);
});
test("bazarr's own key is auth.apikey, not an app's", () => {
const yaml = [
"analytics:",
" enabled: false",
"auth:",
" apikey: 0123456789abcdef0123456789abcdef",
" password: ''",
" type: form",
"general:",
" port: 6767",
"sonarr:",
" apikey: not-this-one",
"",
].join("\n");
assert.equal(apiKeyFromConfigYaml(yaml), "0123456789abcdef0123456789abcdef");
assert.equal(apiKeyFromConfigYaml("sonarr:\n apikey: x\n"), undefined);
assert.equal(apiKeyFromConfigYaml("auth:\n apikey: ''\n"), undefined);
assert.equal(apiKeyFromConfigYaml("auth:\r\n apikey: 'abc'\r\n"), "abc");
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
"include": ["apikey.ts", "client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"]
}
+1 -1
View File
@@ -76,7 +76,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:${port:8787}",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
},
"artifact": "runtime"
File diff suppressed because one or more lines are too long
+22 -47
View File
@@ -1,26 +1,6 @@
{
"module": "icecast",
"version": "1",
"requires": [
"route",
"secret"
],
"contributes": {
"route": {
"label": "icecast",
"endpoint": "stream"
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"secrets": {
"secret": {
"source": "${dir:state}/source.secret",
"admin": "${dir:state}/admin.secret",
"relay": "${dir:state}/relay.secret"
}
},
"capabilities": [
"container-runtime"
],
@@ -37,7 +17,7 @@
"port": 8000,
"protocol": "tcp",
"from": "mesh",
"why": "streams in from sources (HTTP PUT) and out to listeners, plus the status and admin pages; a public name is its route"
"why": "streams in from sources and out to listeners"
}
],
"resources": [
@@ -50,43 +30,28 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/icecast-module",
"mode": "0700"
},
{
"id": "logs",
"type": "directory",
"mode": "0700",
"owner": "100:101"
},
{
"id": "server-conf",
"id": "server-env",
"type": "file",
"path": "${dir:state}/icecast.xml",
"path": "/var/lib/icecast-module/server.env",
"mode": "0600",
"content": "<icecast>\n <!-- Written by the mesh (modules/icecast). Passwords arrive as secrets rendered into this file,\n never as environment: the image's entrypoint seds ICECAST_* variables into the file only\n when they are set, and none are. -->\n <location>Earth</location>\n <admin>icemaster@localhost</admin>\n <limits>\n <clients>100</clients>\n <sources>2</sources>\n <queue-size>524288</queue-size>\n <client-timeout>30</client-timeout>\n <header-timeout>15</header-timeout>\n <source-timeout>10</source-timeout>\n <burst-on-connect>1</burst-on-connect>\n <burst-size>65535</burst-size>\n </limits>\n <authentication>\n <source-password>${secret:source}</source-password>\n <relay-password>${secret:relay}</relay-password>\n <admin-user>admin</admin-user>\n <admin-password>${secret:admin}</admin-password>\n </authentication>\n <!-- The name icecast writes into playlists (.m3u/.xspf: http://<hostname>:<port>/<mount>) and\n would announce to YP (none configured). A machine's own name belongs to its assignment, and\n an assignment merges only into JSON; this XML cannot take it, so the neutral default stays. -->\n <hostname>localhost</hostname>\n <listen-socket>\n <port>8000</port>\n </listen-socket>\n <http-headers>\n <header name=\"Access-Control-Allow-Origin\" value=\"*\" />\n </http-headers>\n <fileserve>1</fileserve>\n <paths>\n <basedir>/usr/share/icecast</basedir>\n <logdir>/var/log/icecast</logdir>\n <webroot>/usr/share/icecast/web</webroot>\n <adminroot>/usr/share/icecast/admin</adminroot>\n <alias source=\"/\" destination=\"/status.xsl\"/>\n </paths>\n <logging>\n <accesslog>access.log</accesslog>\n <errorlog>error.log</errorlog>\n <loglevel>3</loglevel>\n <logsize>10000</logsize>\n </logging>\n <security>\n <chroot>0</chroot>\n <!-- Starts as root, reads this 0600 root-owned file, then drops to the image's icecast user\n (uid 100, group icecast 101) before serving. -->\n <changeowner>\n <user>icecast</user>\n <group>icecast</group>\n </changeowner>\n </security>\n</icecast>\n"
},
{
"id": "net",
"type": "network",
"name": "icecast"
"content": "ICECAST_SOURCE_PASSWORD=${secret:source}\nICECAST_ADMIN_PASSWORD=${secret:admin}\nICECAST_RELAY_PASSWORD=${secret:relay}\nICECAST_ADMIN_USERNAME=admin\n"
},
{
"id": "server",
"type": "container",
"name": "icecast",
"image": "infiniteproject/icecast@sha256:cd506cf3dfe31ce05fd37d7e672dbd1213e7255cc93d28ecf5a3b547af4e162c",
"network": "icecast",
"env-file": [
"/var/lib/icecast-module/server.env"
],
"ports": [
"8000"
],
"volumes": [
"${dir:state}/icecast.xml:/etc/icecast.xml:ro",
"${dir:logs}:/var/log/icecast"
],
"restart-on": [
"server-conf"
]
"secrets-in-environment": "the image seds ICECAST_*_PASSWORD into icecast.xml and has no _FILE; convertible by mounting a generated icecast.xml, not yet done"
},
{
"id": "runtime-config",
@@ -100,14 +65,14 @@
"id": "runtime",
"type": "container",
"name": "mesh-icecast",
"network": "icecast",
"network": "host",
"volumes": [
"/var/lib/mesh/icecast/broker:/run/secrets/broker:ro",
"/var/lib/mesh/icecast/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_ICECAST_URL": "http://icecast:8000",
"MESH_ICECAST_URL": "http://127.0.0.1:8000",
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
@@ -136,5 +101,15 @@
"from": "Dockerfile"
}
]
},
"requires": [
"secret"
],
"secrets": {
"secret": {
"source": "/var/lib/icecast-module/source.secret",
"admin": "/var/lib/icecast-module/admin.secret",
"relay": "/var/lib/icecast-module/relay.secret"
}
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/influxdb
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -21,4 +21,4 @@ COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js
+3 -118
View File
@@ -17,25 +17,6 @@ export interface InfluxBucket {
retentionSeconds?: number;
}
/** One permission of an authorization, as InfluxDB represents it: an action on a resource type,
* in one org, optionally narrowed to one resource by id (no id = every resource of that type). */
export interface InfluxPermission {
action: "read" | "write";
resource: { type: string; orgID?: string; id?: string; name?: string; org?: string };
}
/** A v1-compatibility ("legacy") authorization: a username (InfluxDB calls it `token`) and a
* password the caller chooses, scoped by permissions. The one credential InfluxDB 2.x lets a
* caller set to a value it did not generate — which is what a mesh-minted password needs. */
export interface LegacyAuthorization {
id: string;
token: string;
orgID: string;
status?: "active" | "inactive";
description?: string;
permissions: InfluxPermission[];
}
/** 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 {};
@@ -43,20 +24,13 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/** A secret delivered as a file, trimmed; undefined when there is none, so the caller can fall back. */
function tokenFromFile(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").trim() || undefined; }
catch { return undefined; }
}
export class InfluxDBClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly token: string,
readonly org: string,
private readonly org: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
@@ -69,10 +43,8 @@ export class InfluxDBClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): InfluxDBClient {
const cfg = meshConfig(env.MESH_INFLUXDB_CONFIG_FILE);
const url = cfg.url ?? env.MESH_INFLUXDB_URL ?? `http://127.0.0.1:${env.INFLUXDB_PORT ?? "8086"}`;
// The token reaches the process as a file (novox/hq ADR 0086); the environment variable stays
// only for a workstation running the tools by hand.
const token = cfg.token ?? tokenFromFile(env.MESH_INFLUXDB_TOKEN_FILE) ?? env.MESH_INFLUXDB_TOKEN;
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN_FILE");
const token = cfg.token ?? env.MESH_INFLUXDB_TOKEN;
if (!token) throw new Error("no InfluxDB token — set MESH_INFLUXDB_TOKEN");
const org = cfg.org ?? env.MESH_INFLUXDB_ORG ?? "mesh";
return new InfluxDBClient(url, token, org);
}
@@ -89,93 +61,6 @@ export class InfluxDBClient {
return res;
}
/** Like request, but the answer is returned whatever its status, for the caller to read. */
private async raw(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${this.baseUrl}${path}`, {
...init,
headers: { Authorization: `Token ${this.token}`, ...(init?.headers ?? {}) },
});
}
private async send(path: string, method: string, body?: unknown): Promise<Response> {
return this.request(path, {
method,
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** The id of the org of this name, or undefined when there is none. */
async orgID(name: string): Promise<string | undefined> {
const res = await this.raw(`/api/v2/orgs?org=${encodeURIComponent(name)}`);
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API /api/v2/orgs: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { orgs?: { id: string; name: string }[] };
return body.orgs?.find((o) => o.name === name)?.id;
}
/** The bucket of exactly this name in the org, or undefined. */
async findBucket(orgID: string, name: string): Promise<InfluxBucket | undefined> {
const res = await this.raw(`/api/v2/buckets?orgID=${encodeURIComponent(orgID)}&name=${encodeURIComponent(name)}`);
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API /api/v2/buckets: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { buckets?: { id: string; name: string; orgID?: string }[] };
const b = body.buckets?.find((x) => x.name === name);
return b ? { id: b.id, name: b.name, orgID: b.orgID } : undefined;
}
/** Create a bucket that keeps its data for ever — retention is the operator's choice, never the mesh's. */
async createBucket(orgID: string, name: string, description: string): Promise<InfluxBucket> {
const b = (await (await this.send("/api/v2/buckets", "POST", {
orgID, name, description, retentionRules: [],
})).json()) as { id: string; name: string; orgID?: string };
return { id: b.id, name: b.name, orgID: b.orgID };
}
/** The v1 authorization whose username is exactly this, or undefined. */
async findLegacy(username: string): Promise<LegacyAuthorization | undefined> {
const path = `/private/legacy/authorizations?token=${encodeURIComponent(username)}`;
const res = await this.raw(path);
// InfluxDB answers a filter matching nothing with 404, not an empty list.
if (res.status === 404) return undefined;
if (!res.ok) throw new Error(`InfluxDB API ${path}: ${res.status} ${await res.text()}`);
const body = (await res.json()) as { authorizations?: LegacyAuthorization[] };
return body.authorizations?.find((a) => a.token === username);
}
async createLegacy(a: Omit<LegacyAuthorization, "id">): Promise<LegacyAuthorization> {
return (await (await this.send("/private/legacy/authorizations", "POST", a)).json()) as LegacyAuthorization;
}
/** Set a v1 authorization's password. InfluxDB keeps only a hash of it, so it can be set, never read. */
async setLegacyPassword(id: string, password: string): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}/password`, "POST", { password });
}
async updateLegacy(id: string, patch: { status?: "active" | "inactive"; description?: string }): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "PATCH", patch);
}
async deleteLegacy(id: string): Promise<void> {
await this.send(`/private/legacy/authorizations/${encodeURIComponent(id)}`, "DELETE");
}
/**
* Whether this username and password sign in on the v1 API — the consumer's own view. Asked with
* a statement that reads nothing (`SHOW DATABASES` lists only what the credential may read), sent
* with Basic auth so the password is never in a URL. 401 is a wrong password or no such user;
* anything else that is not a server error means InfluxDB knew who was asking.
*/
async legacySignsIn(username: string, password: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/query?q=${encodeURIComponent("SHOW DATABASES")}`, {
headers: { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` },
});
await res.arrayBuffer();
if (res.status === 401) return false;
if (res.status >= 500) throw new Error(`InfluxDB v1 /query: ${res.status}`);
return true;
}
/** Server health — the one endpoint that needs no token, but we send it anyway. */
async health(): Promise<InfluxHealth> {
return (await (await this.request("/health")).json()) as InfluxHealth;
-186
View File
@@ -1,186 +0,0 @@
// What the `influxdb-api` provision means in InfluxDB: one v1-compatibility authorization per
// consumer, in the org this module serves, under the username and password the mesh gave both ends,
// allowed exactly the access the consumer contributed. The provisioner (provisioner/index.ts) is the
// sdk harness calling these; they are here, apart from it, so they can be exercised against a fake
// InfluxDB without a broker or a contributions file.
//
// **Why a v1 authorization and not a v2 API token.** The mesh mints the consumer's password and
// hands it to both ends (novox/hq ADR 0048); the provider sets it, and never hands one back. An
// InfluxDB 2.x API token is generated by the server — `POST /api/v2/authorizations` ignores a token
// the caller sends — so a token could only ever be the operator's to accept, one per pair, by hand.
// A v1 authorization is a username and a password the caller chooses (8–72 characters; the mesh
// mints 40), stored hashed, and it reads and writes through InfluxQL (`/query`) and line protocol
// (`/write`), which every bucket answers under its own name as a database (InfluxDB maps each
// bucket to a database of the same name by itself). That is what grafana's InfluxDB data source
// speaks, and what Node-RED's influxdb nodes speak in their 1.x mode — so the mesh can make every
// consumer's credential, rotate it and withdraw it, with no person in the loop.
//
// **What a consumer contributes.** `access`: "read" (the default), "write" or "read-write".
// `buckets`: the buckets it may use, by name. A reader that names none may read every bucket of the
// org — a dashboard is pointed at data, it does not own it. A writer must name its buckets: writing
// everywhere, the org's system buckets included, is never what a consumer means. A named bucket
// that does not exist is created, keeping its data for ever; the mesh never deletes a bucket.
//
// **Only what the mesh made is touched.** An authorization this module creates is named with the
// mesh's identity prefix and its description starts with MARK. One with the same username that
// lacks the mark is somebody else's: it is refused, never adopted, never updated, never deleted.
// Every other authorization, token, user and bucket in the instance is left exactly as it was.
import type { InfluxDBClient, InfluxPermission, LegacyAuthorization } from "./client.js";
/** How a description marks an authorization as the mesh's own work. */
export const MARK = "[mesh]";
/** The prefix the mesh gives every consumer identity (novox/hq ADR 0049). */
const IDENTITY_PREFIX = "mesh_";
/** One consumer, as the harness hands it over. */
export interface ApiGrant {
readonly as: string;
readonly password: string;
readonly values: Readonly<Record<string, unknown>>;
readonly consumer?: string;
}
export type Access = "read" | "write" | "read-write";
/** What a contribution asks for, checked. Refused when it cannot be served as asked. */
export function askedFor(values: Readonly<Record<string, unknown>>): { access: Access; buckets: string[] } {
const access = values.access ?? "read";
if (access !== "read" && access !== "write" && access !== "read-write") {
throw new Error(`contributes an access of ${JSON.stringify(access)} — it is "read", "write" or "read-write"`);
}
const raw = values.buckets ?? [];
if (!Array.isArray(raw) || raw.some((b) => typeof b !== "string" || b.trim() === "")) {
throw new Error(`contributes buckets of ${JSON.stringify(raw)} — a list of bucket names`);
}
const buckets = [...new Set((raw as string[]).map((b) => b.trim()))].sort();
if (access !== "read" && buckets.length === 0) {
throw new Error(`asks to write and names no bucket (\`buckets\`) — a writer names what it writes to`);
}
if (buckets.some((b) => b.startsWith("_"))) {
throw new Error(`names a system bucket (${buckets.filter((b) => b.startsWith("_")).join(", ")}) — those are InfluxDB's own`);
}
return { access: access as Access, buckets };
}
/** The permissions a grant resolves to, given each named bucket's id. */
export function permissionsFor(orgID: string, access: Access, bucketIDs: string[]): InfluxPermission[] {
const actions: ("read" | "write")[] = access === "read-write" ? ["read", "write"] : [access];
const out: InfluxPermission[] = [];
for (const action of actions) {
if (bucketIDs.length === 0) {
out.push({ action, resource: { type: "buckets", orgID } });
continue;
}
for (const id of bucketIDs) out.push({ action, resource: { type: "buckets", orgID, id } });
}
return out;
}
/** A permission as a comparable string: what InfluxDB answers carries names and links besides. */
function key(p: InfluxPermission): string {
return `${p.action}:${p.resource.type}:${p.resource.orgID ?? ""}:${p.resource.id ?? "*"}`;
}
function samePermissions(a: readonly InfluxPermission[], b: readonly InfluxPermission[]): boolean {
const x = a.map(key).sort();
const y = b.map(key).sort();
return x.length === y.length && x.every((v, i) => v === y[i]);
}
export function marked(a: Pick<LegacyAuthorization, "token" | "description">): boolean {
return a.token.startsWith(IDENTITY_PREFIX) && (a.description ?? "").startsWith(MARK);
}
function describe(g: ApiGrant): string {
return `${MARK} made by the mesh for ${g.consumer ? `a module on ${g.consumer}` : "a consumer"} — do not edit; it is reset`;
}
export class ApiGrants {
constructor(private readonly influx: InfluxDBClient, readonly org: string) {}
private async orgID(): Promise<string> {
const id = await this.influx.orgID(this.org);
if (!id) throw new Error(`InfluxDB has no org ${JSON.stringify(this.org)} — the org this module serves must exist`);
return id;
}
/** The ids of the named buckets, creating any that are missing when `create` says so. Undefined
* when one is missing and may not be created (a read-only question). */
private async bucketIDs(orgID: string, names: string[], create: ApiGrant | undefined): Promise<string[] | undefined> {
const ids: string[] = [];
for (const name of names) {
let b = await this.influx.findBucket(orgID, name);
if (!b) {
if (!create) return undefined;
b = await this.influx.createBucket(orgID, name, `${MARK} made by the mesh for ${create.as}; the mesh never deletes it`);
}
ids.push(b.id);
}
return ids.sort();
}
/** Create the consumer's authorization, or bring the mesh's existing one back to what the grant
* says. Idempotent: a second apply of the same grant changes nothing beyond re-asserting the
* password, which InfluxDB can be told but never asked. */
async ensure(g: ApiGrant): Promise<"created" | "updated" | "unchanged"> {
if (!g.as.startsWith(IDENTITY_PREFIX)) {
throw new Error(`${g.as} is not a mesh identity — the mesh names every consumer ${IDENTITY_PREFIX}<node>_<module>`);
}
const { access, buckets } = askedFor(g.values);
const orgID = await this.orgID();
const found = await this.influx.findLegacy(g.as);
if (found && !marked(found)) {
throw new Error(
`InfluxDB already has a v1 authorization ${g.as} the mesh did not make — left alone; ` +
`delete it if the mesh should own that name`);
}
const want = permissionsFor(orgID, access, (await this.bucketIDs(orgID, buckets, g))!);
if (found && found.orgID === orgID && samePermissions(found.permissions, want)) {
// Only what differs is written. The password cannot be read back, so it is tried instead.
let changed = false;
if (found.status === "inactive") {
await this.influx.updateLegacy(found.id, { status: "active" });
changed = true;
}
if (!(await this.influx.legacySignsIn(g.as, g.password))) {
await this.influx.setLegacyPassword(found.id, g.password);
changed = true;
}
return changed ? "updated" : "unchanged";
}
// InfluxDB cannot change an authorization's permissions in place, so the mesh's own is made
// again. Only ever one the mesh made: a foreign one was refused above.
if (found) await this.influx.deleteLegacy(found.id);
const made = await this.influx.createLegacy({
token: g.as, orgID, status: "active", description: describe(g), permissions: want,
});
await this.influx.setLegacyPassword(made.id, g.password);
return found ? "updated" : "created";
}
/** Whether InfluxDB still holds this consumer's authorization exactly as the grant says: present,
* the mesh's, active, allowed what was asked and nothing more, and signing in with the mesh's
* password. Reads only — a missing bucket is "not held", never created here. */
async holds(g: ApiGrant): Promise<boolean> {
const { access, buckets } = askedFor(g.values);
const orgID = await this.influx.orgID(this.org);
if (!orgID) return false;
const found = await this.influx.findLegacy(g.as);
if (!found || !marked(found) || found.status === "inactive" || found.orgID !== orgID) return false;
const ids = await this.bucketIDs(orgID, buckets, undefined);
if (!ids || !samePermissions(found.permissions, permissionsFor(orgID, access, ids))) return false;
return this.influx.legacySignsIn(g.as, g.password);
}
/** Withdraw a consumer's authorization — only one the mesh made. Its buckets and their data stay. */
async remove(as: string): Promise<"removed" | "absent" | "not ours"> {
const found = await this.influx.findLegacy(as);
if (!found) return "absent";
if (!marked(found)) return "not ours";
await this.influx.deleteLegacy(found.id);
return "removed";
}
}
+30 -58
View File
@@ -1,19 +1,11 @@
{
"module": "influxdb",
"version": "1",
"provides": [
{
"name": "influxdb-api",
"scope": "mesh"
}
],
"capabilities": [
"container-runtime"
],
"own-secrets": {
"broker": "/var/lib/mesh/influxdb/broker",
"admin": "${dir:state}/admin.secret",
"admin-token": "${dir:state}/admin-token.secret"
"broker": "/var/lib/mesh/influxdb/broker"
},
"listens": [
{
@@ -21,23 +13,9 @@
"port": 8086,
"protocol": "tcp",
"from": "mesh",
"why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant"
"why": "queries and writes, over http"
}
],
"serves": {
"influxdb-api": {
"scheme": "http",
"port": 8086,
"org": "mesh",
"bucket": "default"
}
},
"receives": {
"influxdb-api": "${dir:grants}/mesh.json"
},
"grants": {
"influxdb-api": "${dir:grants}"
},
"resources": [
{
"id": "mesh-state",
@@ -48,50 +26,46 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/influxdb-module",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/influxdb-module/server.env",
"mode": "0600",
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD=${secret:admin}\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${secret:admin-token}\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
},
{
"id": "data",
"type": "directory",
"path": "/services/influxdb/data",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "config",
"type": "directory",
"path": "/services/influxdb/config",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "grants",
"type": "directory",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "DOCKER_INFLUXDB_INIT_MODE=setup\nDOCKER_INFLUXDB_INIT_USERNAME=admin\nDOCKER_INFLUXDB_INIT_PASSWORD_FILE=/run/secrets/admin\nDOCKER_INFLUXDB_INIT_ADMIN_TOKEN_FILE=/run/secrets/admin-token\nDOCKER_INFLUXDB_INIT_ORG=mesh\nDOCKER_INFLUXDB_INIT_BUCKET=default\n"
},
{
"id": "server",
"type": "container",
"name": "influxdb",
"image": "influxdb@sha256:f75e48af0598e8aec7986e991a848d19a119101a7d563a2e5db1dfaac9c45daa",
"env-file": [
"${dir:state}/server.env"
"/var/lib/influxdb-module/server.env"
],
"ports": [
"8086"
],
"volumes": [
"${dir:data}:/var/lib/influxdb2",
"${dir:config}:/etc/influxdb2",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro"
]
"/services/influxdb/data:/var/lib/influxdb2",
"/services/influxdb/config:/etc/influxdb2"
],
"secrets-in-environment": "the image honours DOCKER_INFLUXDB_INIT_PASSWORD_FILE and _ADMIN_TOKEN_FILE; convertible, awaiting a bed that proves it"
},
{
"id": "runtime-config",
@@ -109,15 +83,13 @@
"volumes": [
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro",
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
"${dir:grants}:${dir:grants}:ro"
"/services/influxdb/config:/var/lib/influxdb/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_URL": "http://127.0.0.1:8086",
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
"MESH_INFLUXDB_CONFIG_DIR": "/var/lib/influxdb/config"
},
"restart-on": [
"runtime-config"
@@ -125,15 +97,6 @@
"artifact": "runtime"
}
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "influxdb",
"endpoint": "api"
}
},
"build": {
"on": [
{
@@ -154,5 +117,14 @@
"from": "Dockerfile"
}
]
},
"requires": [
"secret"
],
"secrets": {
"secret": {
"admin": "/var/lib/influxdb-module/admin.secret",
"admin-token": "/var/lib/influxdb-module/admin-token.secret"
}
}
}
+1 -6
View File
@@ -1,14 +1,9 @@
{
"name": "@novox/module-influxdb",
"version": "0.1.0",
"description": "influxdb — time-series database; provides the mesh influxdb-api interface. Its API client, provisioner and tools live here (novox/hq ADR 0039).",
"description": "influxdb — time-series database. Its API client and tools live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts grants.ts provisioner/index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
-54
View File
@@ -1,54 +0,0 @@
// influxdb's provisioner — the adapter that makes influxdb a provider of the mesh `influxdb-api`
// interface. The reconcile loop, the contributions file and reading the mesh's minted secret are the
// sdk harness's; this writes only the per-service half: how InfluxDB creates, checks and removes a
// consumer's credential (novox/hq ADR 0039/0040/0048). What that credential is, and why it is a v1
// authorization, is in ../grants.ts.
//
// The `influxdb-api` interface: a consumer reaches `${bound:influxdb-api:scheme}://…:at:…:port`,
// signs in as `${bound:influxdb-api:as}` with the password the mesh minted for the pair, and reads
// or writes the org's buckets as databases of the same name — `${bound:influxdb-api:bucket}` being
// the one this instance serves by default. The org and the default bucket are the assignment's
// settings, which reach both what is served and this module's config.json, so the org a consumer is
// told and the org its credential is made in cannot disagree.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { InfluxDBClient } from "../client.js";
import { ApiGrants } from "../grants.js";
let grants: ApiGrants | undefined;
try {
const influx = InfluxDBClient.fromEnv();
grants = new ApiGrants(influx, influx.org);
} catch (err) {
// No admin token: nothing can be provisioned, and the tools loaded beside this must still serve.
console.error(`[provisioner:influxdb-api] not started: ${err instanceof Error ? err.message : err}`);
}
if (grants) serve(grants);
function serve(grants: ApiGrants): void {
runProvisioner("influxdb-api", {
async create(p: Provision): Promise<void> {
const done = await grants.ensure(p);
if (done !== "unchanged") {
console.log(`[provisioner:influxdb-api] ${done} v1 authorization ${p.as} in org ${grants.org}`);
}
},
async remove(p: { as: string }): Promise<void> {
const done = await grants.remove(p.as);
if (done === "not ours") {
console.error(`[provisioner:influxdb-api] ${p.as}: an authorization of that name exists that the mesh did not make — left alone`);
} else if (done === "removed") {
console.log(`[provisioner:influxdb-api] removed v1 authorization ${p.as}; its buckets and their data stay`);
}
},
// Asked every minute by the harness: whether InfluxDB still holds this consumer's authorization
// exactly as the mesh gave it, so one deleted, disabled or re-passworded behind the mesh's back is
// made whole again (hq issue 120).
async holds(p: Provision): Promise<boolean> {
return grants.holds(p);
},
});
}
-246
View File
@@ -1,246 +0,0 @@
// What holds influxdb to the `influxdb-api` provision (grants.ts): one v1 authorization per consumer,
// under the username and password the mesh gave, allowed only what the consumer contributed; made
// once and brought back on every apply; buckets created when missing and never deleted; and an
// authorization the mesh did not make — same name or not — never adopted, changed or deleted.
//
// InfluxDB is a fake: the routes the module touches, answering with the status codes and shapes
// InfluxDB 2.9 gives (a filter matching nothing is a 404, a password outside 8–72 characters a 400,
// an inactive authorization or a wrong password a 401 on /query). Run against the compiled module
// (npm test builds first), the way the runtime loads it.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { InfluxDBClient } from "../dist/client.js";
import { ApiGrants, MARK, askedFor, marked } from "../dist/grants.js";
type Rec = Record<string, any>;
const ADMIN = "operator-token";
const orgs = new Map<string, string>([["zurag", "org1"]]);
let buckets: Rec[] = [];
let auths: Rec[] = [];
let calls: string[] = [];
let seq = 0;
function body(req: IncomingMessage): Promise<any> {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw ? JSON.parse(raw) : undefined));
});
}
function send(res: ServerResponse, status: number, value?: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(value === undefined ? "" : JSON.stringify(value));
}
const server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://fake");
const p = url.pathname;
calls.push(`${req.method} ${p}`);
if (p === "/query") {
const basic = (req.headers.authorization ?? "").replace(/^Basic /, "");
const [u, pw] = Buffer.from(basic, "base64").toString().split(":");
const a = auths.find((x) => x.token === u);
if (!a || a.status !== "active" || a.password === undefined || a.password !== pw) {
return send(res, 401, { code: "unauthorized", message: "Unauthorized" });
}
return send(res, 200, { results: [{ statement_id: 0 }] });
}
if (req.headers.authorization !== `Token ${ADMIN}`) return send(res, 401, { code: "unauthorized" });
if (p === "/api/v2/orgs") {
const id = orgs.get(url.searchParams.get("org") ?? "");
if (!id) return send(res, 404, { code: "not found", message: "organization name not found" });
return send(res, 200, { orgs: [{ id, name: url.searchParams.get("org") }] });
}
if (p === "/api/v2/buckets" && req.method === "GET") {
const found = buckets.filter((b) => b.orgID === url.searchParams.get("orgID") && b.name === url.searchParams.get("name"));
if (found.length === 0) return send(res, 404, { code: "not found", message: "bucket not found" });
return send(res, 200, { buckets: found });
}
if (p === "/api/v2/buckets" && req.method === "POST") {
const b = { ...(await body(req)), id: `b${++seq}` };
buckets.push(b);
return send(res, 201, b);
}
if (p === "/private/legacy/authorizations" && req.method === "GET") {
const found = auths.filter((a) => a.token === url.searchParams.get("token"));
if (found.length === 0) return send(res, 404, { code: "not found", message: "authorization not found" });
// Never answers with the password: InfluxDB keeps only its hash.
return send(res, 200, { authorizations: found.map(({ password, ...a }) => ({ ...a, links: {} })) });
}
if (p === "/private/legacy/authorizations" && req.method === "POST") {
const a = await body(req);
if (auths.some((x) => x.token === a.token)) return send(res, 409, { code: "conflict", message: "token already exists" });
const made = { ...a, id: `a${++seq}`, status: a.status ?? "active" };
auths.push(made);
return send(res, 201, made);
}
const m = /^\/private\/legacy\/authorizations\/([^/]+)(\/password)?$/.exec(p);
const a = m && auths.find((x) => x.id === m[1]);
if (!a) return send(res, 404, { code: "not found" });
if (m![2] && req.method === "POST") {
const { password } = await body(req);
if (typeof password !== "string" || password.length < 8 || password.length > 72) {
return send(res, 400, { code: "invalid", message: "passwords must be between 8 and 72 characters long" });
}
a.password = password;
return send(res, 204);
}
if (req.method === "PATCH") {
Object.assign(a, await body(req));
return send(res, 200, a);
}
if (req.method === "DELETE") {
auths = auths.filter((x) => x !== a);
return send(res, 204);
}
send(res, 405);
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
after(() => server.close());
const port = (server.address() as { port: number }).port;
const grants = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "zurag"), "zurag");
const PW = "mesh-minted-password-of-forty-characters";
/** Grafana on ace, as the mesh hands it to the provisioner. */
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
return { as: "mesh_ace_grafana", password, consumer: "ace", values };
}
/** Node-RED on ace: writes one bucket. */
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
return { as: "mesh_ace_nodered", password, consumer: "ace", values };
}
function only(token: string): Rec {
const found = auths.filter((a) => a.token === token);
assert.equal(found.length, 1, `exactly one authorization ${token}, found ${found.length}`);
return found[0];
}
function perms(a: Rec): string[] {
return a.permissions.map((p: Rec) => `${p.action}:${p.resource.type}:${p.resource.id ?? "*"}`).sort();
}
beforeEach(() => {
buckets = [{ id: "zb", orgID: "org1", name: "zurag" }];
auths = [];
calls = [];
});
test("what a contribution may ask for, and what is refused", () => {
assert.deepEqual(askedFor({}), { access: "read", buckets: [] });
assert.deepEqual(askedFor({ access: "read-write", buckets: ["b", "a", "a"] }), { access: "read-write", buckets: ["a", "b"] });
assert.throws(() => askedFor({ access: "admin" }), /access/);
assert.throws(() => askedFor({ access: "write" }), /names no bucket/);
assert.throws(() => askedFor({ buckets: "zurag" }), /list of bucket names/);
assert.throws(() => askedFor({ access: "write", buckets: ["_monitoring"] }), /system bucket/);
});
test("a reader is given one authorization, reading every bucket of the org, under the mesh's password", async () => {
assert.equal(await grants.ensure(grafana()), "created");
const a = only("mesh_ace_grafana");
assert.equal(a.orgID, "org1");
assert.equal(a.status, "active");
assert.ok(a.description.startsWith(MARK));
assert.deepEqual(perms(a), ["read:buckets:*"]);
assert.equal(a.password, PW);
assert.equal(await grants.holds(grafana()), true);
});
test("a writer is allowed its own buckets only, and a missing one is made — never deleted", async () => {
assert.equal(await grants.ensure(nodered(PW, { access: "write", buckets: ["zurag", "printer"] })), "created");
const made = buckets.find((b) => b.name === "printer");
assert.ok(made, "the missing bucket was created");
assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice");
assert.deepEqual(perms(only("mesh_ace_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
assert.equal(await grants.remove("mesh_ace_nodered"), "removed");
assert.equal(buckets.length, 2, "withdrawing the consumer leaves every bucket and its data");
});
test("applying the same grant again writes nothing", async () => {
await grants.ensure(grafana());
calls = [];
assert.equal(await grants.ensure(grafana()), "unchanged");
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
only("mesh_ace_grafana");
});
test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => {
await grants.ensure(nodered());
const id = only("mesh_ace_nodered").id;
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false);
assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated");
assert.equal(only("mesh_ace_nodered").id, id, "updated, not replaced");
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true);
await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] }));
assert.deepEqual(perms(only("mesh_ace_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
assert.equal(await grants.holds(nodered(PW, { access: "read-write", buckets: ["zurag"] })), true);
});
test("an authorization disabled, re-passworded or deleted behind the mesh's back is not held, and is made whole", async () => {
await grants.ensure(grafana());
only("mesh_ace_grafana").status = "inactive";
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.ensure(grafana()), "updated");
assert.equal(await grants.holds(grafana()), true);
only("mesh_ace_grafana").password = "somebody-else-set-this";
assert.equal(await grants.holds(grafana()), false);
await grants.ensure(grafana());
assert.equal(await grants.holds(grafana()), true);
auths = [];
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.ensure(grafana()), "created");
});
test("holds only reads, and a bucket gone missing is not held rather than made", async () => {
await grants.ensure(nodered());
buckets = [];
calls = [];
assert.equal(await grants.holds(nodered()), false);
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
assert.equal(buckets.length, 0);
});
test("an authorization of the same name the mesh did not make is refused, and left exactly as it was", async () => {
auths = [{ id: "theirs", token: "mesh_ace_grafana", orgID: "org1", status: "active", description: "hand-made",
permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }];
const before = JSON.stringify(auths);
await assert.rejects(grants.ensure(grafana()), /did not make/);
assert.equal(JSON.stringify(auths), before);
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.remove("mesh_ace_grafana"), "not ours");
assert.equal(auths.length, 1, "never deleted");
});
test("the predecessor's own v1 users and tokens are never touched", async () => {
auths = [{ id: "hal", token: "grafana", orgID: "org1", status: "active", description: "",
permissions: [{ action: "read", resource: { type: "buckets", orgID: "org1" } }], password: "old-password" }];
await grants.ensure(grafana());
assert.equal(auths.find((a) => a.id === "hal")!.password, "old-password");
assert.equal(await grants.remove("grafana"), "not ours");
assert.equal(marked({ token: "grafana", description: `${MARK} x` }), false, "the mark needs the mesh's name too");
});
test("an org the instance does not have, or a non-mesh name, makes nothing", async () => {
const elsewhere = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMIN, "nope"), "nope");
await assert.rejects(elsewhere.ensure(grafana()), /no org "nope"/);
await assert.rejects(grants.ensure({ ...grafana(), as: "grafana" }), /not a mesh identity/);
assert.equal(auths.length, 0);
});
test("a withdrawn consumer's authorization is removed, and an absent one is not an error", async () => {
await grants.ensure(grafana());
assert.equal(await grants.remove("mesh_ace_grafana"), "removed");
assert.equal(auths.length, 0);
assert.equal(await grants.remove("mesh_ace_grafana"), "absent");
});
+1 -6
View File
@@ -8,10 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"grants.ts",
"provisioner/index.ts",
"tools/index.ts"
]
"include": ["client.ts", "tools/index.ts"]
}
+16 -53
View File
@@ -2,8 +2,7 @@
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client
// talks its /api/v2.0 REST API, and only jackett's tools import it.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { readFileSync } from "node:fs";
export interface JackettIndexer {
id: string;
@@ -44,36 +43,18 @@ export class JackettClient {
/**
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and
* the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY,
* or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR
* — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running
* server needs no key configured by hand and no secret has to be put in an assignment. Without a
* URL or key there is nothing to talk to, so this throws and the module contributes no tools
* rather than failing half-configured.
* the key must be present — without them there is nothing to talk to, so this throws and the
* module contributes no tools rather than failing half-configured.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_JACKETT_URL;
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY;
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY");
return new JackettClient(url, apiKey);
}
/** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at
* <config>/Jackett/ServerConfig.json), falling back to null. */
static detectApiKey(configDir: string): string | null {
for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) {
if (!existsSync(file)) continue;
try {
const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey;
if (typeof key === "string" && key) return key;
} catch { /* unreadable or mid-write: try the next, then give up */ }
}
return null;
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}${path}`);
url.searchParams.set("apikey", this.apiKey);
@@ -83,36 +64,18 @@ export class JackettClient {
return res.json();
}
/**
* The configured indexers Jackett proxies. `configured=false` also lists the ones not set up.
* Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and
* wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is
* what the key is for. The feed carries no last error, so `lastError` stays unset.
*/
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`);
url.searchParams.set("apikey", this.apiKey);
url.searchParams.set("t", "indexers");
url.searchParams.set("configured", configuredOnly ? "true" : "false");
const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } });
if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`);
const xml = await res.text();
// Torznab reports failures (a wrong key among them) as 200 with an <error> body.
const err = xml.match(/<error code="(\d+)" description="([^"]*)"/);
if (err) throw new Error(`Jackett API torznab t=indexers: error ${err[1]} ${err[2]}`);
const text = (block: string, tag: string) =>
block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
const out: JackettIndexer[] = [];
for (const m of xml.matchAll(/<indexer id="([^"]+)" configured="([^"]+)">([\s\S]*?)<\/indexer>/g)) {
out.push({
id: m[1],
name: text(m[3], "title") ?? m[1],
type: text(m[3], "type") ?? "unknown",
configured: m[2] === "true",
siteLink: text(m[3], "link"),
});
}
return out;
const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" });
const list = Array.isArray(raw) ? raw : [];
return list.map((i: any) => ({
id: i.id,
name: i.name,
type: i.type,
configured: i.configured ?? false,
siteLink: i.site_link,
lastError: i.last_error || undefined,
}));
}
/**
+9 -27
View File
@@ -1,19 +1,6 @@
{
"module": "jackett",
"version": "1",
"provides": [
{
"name": "jackett-api",
"scope": "mesh"
}
],
"serves": {
"jackett-api": {
"scheme": "http",
"port": 9117,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -23,7 +10,7 @@
"port": 9117,
"protocol": "tcp",
"from": "mesh",
"why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api"
"why": "the indexer proxy"
}
],
"resources": [
@@ -33,15 +20,10 @@
"path": "/var/lib/mesh/jackett",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/jackett/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -49,7 +31,7 @@
"id": "server",
"type": "container",
"name": "jackett",
"image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
"image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -59,13 +41,13 @@
"9117"
],
"volumes": [
"${dir:config}:/config"
"/services/jackett/config:/config"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:state}/config.json",
"path": "/var/lib/mesh/jackett/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
@@ -77,12 +59,12 @@
"network": "host",
"volumes": [
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/jackett/config:ro"
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro",
"/services/jackett/config:/var/lib/jackett/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
"MESH_JACKETT_URL": "http://127.0.0.1:9117",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
},
@@ -105,7 +87,7 @@
}
},
"binds": {
"route": "${dir:state}/route.json"
"route": "/var/lib/mesh/jackett/route.json"
},
"build": {
"on": [
+1 -1
View File
@@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
return [
{
name: "jackett_indexers",
description: "List the indexers Jackett proxies, with their type and site.",
description: "List the indexers Jackett proxies, with their type and any last error.",
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
run: async (args) => {
const indexers = await jackett.getIndexers(!args.all);
+4 -8
View File
@@ -1,10 +1,10 @@
// The Letta API client — letta's own code, living in the module (novox/hq ADR 0039). Its tools
// import it; nothing outside letta does.
//
// Letta authenticates with a single server password. That password is a mesh own-secret handed to
// both the server (LETTA_SERVER_PASSWORD) and this client, through the runtime config file the mesh
// mounts (its `password` key) — so the module's tools are live without anything configured by hand.
// Where a server already has clients, the password is accepted rather than minted.
// Letta authenticates with a single server password, presented as a Bearer token. That password is
// a mesh own-secret, minted once and handed to both the server (LETTA_SERVER_PASSWORD) and this
// client (MESH_LETTA_PASSWORD) — so the module's tools are live without anything configured by hand.
// The runtime config file may still override the URL or password.
import { readFileSync } from "node:fs";
@@ -58,10 +58,6 @@ export class LettaClient {
...options,
headers: {
"Content-Type": "application/json",
// The server's --secure mode checks X-BARE-PASSWORD ("password <it>") and answers a Bearer
// token alone with 401 (letta/server/rest_api/app.py, 0.6.x). Both are sent: Bearer is what
// later servers read.
"X-BARE-PASSWORD": `password ${this.password}`,
Authorization: `Bearer ${this.password}`,
...(options.headers as Record<string, string> | undefined),
},
+25 -21
View File
@@ -5,28 +5,21 @@
"container-runtime"
],
"requires": [
"postgres-database",
"route"
"postgres-database"
],
"contributes": {
"postgres-database": {
"name": "letta"
},
"route": {
"label": "letta",
"endpoint": "web"
}
},
"binds": {
"postgres-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
"postgres-database": "/var/lib/letta/database.json"
},
"secrets": {
"postgres-database": "${dir:state}/database.secret"
"postgres-database": "/var/lib/letta/database.secret"
},
"own-secrets": {
"server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret",
"server-password": "/var/lib/letta/server-password.secret",
"broker": "/var/lib/mesh/letta/broker"
},
"listens": [
@@ -35,7 +28,7 @@
"port": 8283,
"protocol": "tcp",
"from": "mesh",
"why": "the Letta agent server REST API and web UI, password-protected (--secure); a public name is the route's"
"why": "the Letta agent server REST API and web UI; a public name is a route grant later"
}
],
"resources": [
@@ -48,15 +41,15 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/letta",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"path": "/var/lib/letta/server.env",
"mode": "0600",
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nOPENAI_API_KEY=${secret:openai-api-key}\nSECURE=true\nTZ=Europe/Brussels\n"
"content": "LETTA_PG_URI=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nLETTA_SERVER_PASSWORD=${secret:server-password}\nSECURE=true\nTZ=Europe/Brussels\n"
},
{
"id": "net",
@@ -67,24 +60,31 @@
"id": "server",
"type": "container",
"name": "letta",
"image": "letta/letta@sha256:bfd1e49ce45b9a208c941e832c1d1d194017ff210a3784b0ca6c323aed767a29",
"image": "letta/letta@sha256:1d2e0692514287c5ed1a483e14e16ed945f8632d315539f5e66373bb7d7c471b",
"network": "letta",
"env-file": [
"${dir:state}/server.env"
"/var/lib/letta/server.env"
],
"ports": [
"8283"
],
"secrets-in-environment": "letta 0.6.x reads its settings from the environment only (pydantic settings, no secrets_dir or _FILE twin), and its startup.sh starts an embedded PostgreSQL unless LETTA_PG_URI is set - so the database password travels inside that URI (startup.sh also echoes it to the log); LETTA_SERVER_PASSWORD and OPENAI_API_KEY have no file source either"
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/letta/config.json",
"mode": "0600",
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime-env",
"type": "file",
"path": "/var/lib/letta/runtime.env",
"mode": "0600",
"content": "MESH_LETTA_PASSWORD=${secret:server-password}\n"
},
{
"id": "runtime",
"type": "container",
@@ -99,10 +99,14 @@
"MESH_LETTA_URL": "http://letta:8283",
"MESH_LETTA_CONFIG_FILE": "/run/config/config.json"
},
"env-file": [
"/var/lib/letta/runtime.env"
],
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
"artifact": "runtime",
"secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted"
}
],
"build": {
+14 -1
View File
@@ -1,6 +1,19 @@
{
"module": "lidarr",
"version": "1",
"provides": [
{
"name": "lidarr-api",
"scope": "mesh"
}
],
"serves": {
"lidarr-api": {
"scheme": "http",
"port": 8686,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -75,7 +88,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LIDARR_URL": "http://127.0.0.1:8686",
"MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}",
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
},
"artifact": "runtime"
+5 -2
View File
@@ -120,7 +120,7 @@
"port": 7080,
"protocol": "tcp",
"from": "mesh",
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here — everything else 301s to https and would loop a proxy"
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
},
{
"name": "web-tls",
@@ -315,7 +315,10 @@
"${dir:data-data}:/data",
"${dir:data-dkim}:/dkim"
],
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
"dns": [
"192.168.203.254"
]
},
{
"id": "imap",
-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": "/var/lib/mesh/mesh-console/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",
"path": "/var/lib/mesh/mesh-console",
"mode": "0700"
},
{
"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": [
"/var/lib/mesh/mesh-console/broker:/run/secrets/broker:ro"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/mosquitto
COPY . .
RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
+24 -38
View File
@@ -20,8 +20,6 @@ import { readFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
const run = promisify(execFile);
export interface MqttConn {
@@ -143,19 +141,14 @@ export class MosquittoClient {
}
/**
* Create (or reset to a known state) a client granted exactly these topic filters, idempotently.
* The grant is a same-named role carrying, for every filter, publish, receive and subscribe — and
* nothing else: an ACL the role carries that the filters no longer name is removed, so narrowing a
* consumer's `topics` narrows what it may do. By default the filters are the consumer's own
* subtree, `<as>/#` (see topics.ts). Called again for an existing client, it resets the password
* and re-asserts the ACLs.
*
* Only the role named for this client is ever changed. A client or role the mesh did not make —
* a device carried from the predecessor's password file, its `legacy-full-access` role — is never
* read, changed or removed here.
* Create (or reset to a known state) a client scoped to one topic namespace, idempotently. The
* client is confined to `<prefix>/#` by a same-named role: it may publish to, subscribe to and
* receive on exactly its own subtree and nothing else — the MQTT analog of redis's keyspace-scoped
* ACL user. Called again for an existing client, it resets the password and re-asserts the ACLs.
*/
async createScopedClient(username: string, password: string, filters: readonly string[]): Promise<void> {
async createScopedClient(username: string, password: string, topicPrefix: string): Promise<void> {
const role = username; // one role per client, named for it
const pattern = `${topicPrefix}/#`;
if (await this.clientExists(username)) {
await this.ctl("setClientPassword", username, password);
@@ -168,18 +161,17 @@ export class MosquittoClient {
await this.ctl("createClient", username, "-p", password);
}
// createRole and addRoleACL are one-shot: each rejects with an "already exists" when re-run
// against a role/ACL it created on a previous reconcile. That rejection is the intended terminal
// state, so it is swallowed.
// A role carrying exactly this client's topic ACLs. createRole, addRoleACL and addClientRole are
// all one-shot: each rejects with an "already exists" when re-run against a role/ACL/binding it
// created on a previous reconcile. That rejection is the intended terminal state — the ACL is
// deterministic (`<prefix>/#`, allow), so re-adding the identical entry is a no-op — so it is
// swallowed. (Until the exit code was fixed this was invisible: the tool returned 0 and the
// rejection was lost; now it surfaces, and each of these adds must tolerate its own idempotent
// re-run explicitly.)
await ignoreExisting(this.ctl("createRole", role));
const wanted = wantedAcls(filters);
const current = parseRoleAcls(await this.ctl("getRole", role));
for (const acl of missingAcls(current, wanted)) {
await ignoreExisting(this.ctl("addRoleACL", role, acl.type, acl.topic, "allow"));
}
// What the consumer no longer asks for — added before it narrowed its topics — is taken away.
for (const acl of staleAcls(current, wanted)) {
await ignoreMissing(this.ctl("removeRoleACL", role, acl.type, acl.topic));
for (const acl of ["publishClientSend", "publishClientReceive", "subscribePattern"]) {
// allow (1) this client to send to, receive on, and subscribe under its own subtree.
await ignoreExisting(this.ctl("addRoleACL", role, acl, pattern, "allow"));
}
// Bind the role only when it is not already bound — addClientRole is the one call whose
// idempotent re-run cannot be recognised by message (see clientHasRole).
@@ -189,31 +181,25 @@ export class MosquittoClient {
}
/**
* Whether a consumer's client accepts exactly this password, still carries its own role, and that
* role grants exactly these filters. Read-only. The password is checked the way the consumer is
* checked, by an MQTT CONNECT as it, and the broker's CONNACK code is the answer: 0 accepted,
* 4 bad credentials, 5 not authorised. Nothing rides on argv. An unreachable broker rejects
* (novox/hq issue 120).
* Whether a consumer's client accepts exactly this password and still carries its own role.
* Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it,
* and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised.
* Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120).
*/
async holdsClient(username: string, password: string, filters: readonly string[]): Promise<boolean> {
async holdsClient(username: string, password: string): Promise<boolean> {
const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
if (code === 4 || code === 5) return false;
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`);
// The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
// unlike clientHasRole, which reads every failure as "no role".
let client: string;
let role: string;
let out: string;
try {
client = await this.ctl("getClient", username);
role = await this.ctl("getRole", username);
out = await this.ctl("getClient", username);
} catch (err) {
if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
throw err;
}
if (!new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(client)) return false;
const current = parseRoleAcls(role);
const wanted = wantedAcls(filters);
return missingAcls(current, wanted).length === 0 && staleAcls(current, wanted).length === 0;
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
}
/** Remove a client and the per-client role created for it, idempotently. */
+18 -19
View File
@@ -20,19 +20,16 @@
"mosquitto.topic.deprovisioned"
],
"serves": {
"mqtt-topic": {
"scheme": "mqtt",
"port": 1883
}
"mqtt-topic": {}
},
"receives": {
"mqtt-topic": "${dir:grants}/mesh.json"
"mqtt-topic": "/var/lib/mosquitto-module/grants/mesh.json"
},
"grants": {
"mqtt-topic": "${dir:grants}"
"mqtt-topic": "/var/lib/mosquitto-module/grants"
},
"own-secrets": {
"admin": "/var/lib/mesh/mosquitto/admin",
"admin": "/var/lib/mosquitto-module/admin.secret",
"broker": "/var/lib/mesh/mosquitto/broker"
},
"listens": [
@@ -61,24 +58,26 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/mosquitto-module",
"mode": "0700"
},
{
"id": "grants",
"id": "grants-dir",
"type": "directory",
"path": "/var/lib/mosquitto-module/grants",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
"path": "/services/mosquitto/data",
"mode": "0700",
"owner": "1883:1883"
},
{
"id": "server-conf",
"type": "file",
"path": "${dir:state}/mosquitto.conf",
"path": "/var/lib/mosquitto-module/mosquitto.conf",
"mode": "0600",
"owner": "1883:1883",
"content": "persistence true\npersistence_location /mosquitto/data\n\nlog_dest stdout\nlog_type warning\nlog_type error\nlog_type notice\n\n# Every client authenticates; identities and their per-topic ACLs are managed\n# at runtime by the dynamic security plugin, whose store the plugin itself owns.\nallow_anonymous false\nplugin /usr/lib/mosquitto_dynamic_security.so\nplugin_opt_config_file /mosquitto/data/dynamic-security.json\n\n# MQTT listener\nlistener 1883\n\n# MQTT-over-WebSockets listener\nlistener 8081\nprotocol websockets\n"
@@ -94,8 +93,8 @@
"name": "mosquitto-bootstrap",
"run-once": true,
"volumes": [
"${dir:data}:/mosquitto/data",
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
"/services/mosquitto/data:/mosquitto/data",
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
],
"env": {
"MESH_PROVISION_MQTT": "mosquitto:1883",
@@ -113,15 +112,15 @@
"id": "server",
"type": "container",
"name": "mosquitto",
"image": "eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408",
"image": "eclipse-mosquitto@sha256:6f8d8a947c506f8a2290ec65cd4bd2bc7cb4d43fb5f6271f861cb013e2ef9797",
"network": "mosquitto",
"ports": [
"1883",
"8081"
],
"volumes": [
"${dir:data}:/mosquitto/data",
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
"/services/mosquitto/data:/mosquitto/data",
"/var/lib/mosquitto-module/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
]
},
{
@@ -131,12 +130,12 @@
"network": "mosquitto",
"volumes": [
"/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"/var/lib/mesh/mosquitto/admin:/run/secrets/admin:ro"
"/var/lib/mosquitto-module/grants:/var/lib/mosquitto-module/grants:ro",
"/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_RECEIVES": "/var/lib/mosquitto-module/grants/mesh.json",
"MESH_PROVISION_MQTT": "mosquitto:1883",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
+1 -6
View File
@@ -1,14 +1,9 @@
{
"name": "@novox/module-mosquitto",
"version": "0.1.0",
"description": "mosquitto \u2014 provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).",
"description": "mosquitto — provides the mesh mqtt-topic interface. Its admin client, provisioner, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
},
+6 -24
View File
@@ -5,14 +5,7 @@
//
// The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and
// publishes and subscribes under `<as>/#`, isolated from every other consumer by a Dynamic Security
// role scoped to exactly that subtree — unless it contributed `topics`, the MQTT topic filters its
// work needs (a home-automation hub needs the devices' topics); then the role grants exactly those
// (topics.ts). A list that is not valid topic filters is refused, and the consumer is not created
// or changed until it is fixed.
//
// What a consumer is told (its binding): `at` — the broker's machine — and `port`, the machine port
// of the MQTT listener (the manifest's `serves`); `as` is its login, and its copy of the password is
// the pair credential the mesh delivers to it.
// role scoped to exactly that subtree.
//
// **The login and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives the
// login and hands it to both ends so they agree, and mints the password and delivers a copy to each.
@@ -22,7 +15,6 @@
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { MosquittoClient } from "../client.js";
import { topicFilters } from "../topics.js";
const mosquitto = MosquittoClient.fromEnv();
@@ -37,20 +29,13 @@ async function announce(type: string, body: Record<string, string>): Promise<voi
runProvisioner("mqtt-topic", {
async create(p: Provision): Promise<void> {
// By default the consumer's own subtree, so one cannot read another's topics; what it
// contributed as `topics` otherwise.
const granted = topicFilters(p.values, p.as);
if ("problem" in granted) {
// Thrown, so the harness logs it and retries: the consumer stays as it was (or absent) until
// its contribution is valid, rather than being given a grant it did not ask for.
throw new Error(`${p.as}: ${granted.problem}`);
}
await mosquitto.createScopedClient(p.as, p.password, granted.filters);
// The topic subtree is scoped to the consumer's own login, so one cannot read another's topics.
const topicPrefix = p.as;
await mosquitto.createScopedClient(p.as, p.password, topicPrefix);
await announce("topic.provisioned", {
consumer: p.consumer ?? "",
username: p.as,
topicPrefix: granted.own ? p.as : "",
topics: granted.filters.join(" "),
topicPrefix,
});
},
@@ -61,9 +46,6 @@ runProvisioner("mqtt-topic", {
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
const granted = topicFilters(p.values, p.as);
// An invalid list was never applied; create refuses it again, loudly, on every pass.
if ("problem" in granted) return false;
return mosquitto.holdsClient(p.as, p.password, granted.filters);
return mosquitto.holdsClient(p.as, p.password);
},
});
-72
View File
@@ -1,72 +0,0 @@
// What a consumer of mqtt-topic is granted (topics.ts): its own subtree unless it contributed
// `topics`; a contributed list is granted exactly, refused whole when it is not topic filters; and
// the role is brought to exactly the wanted ACLs — missing ones added, stale ones removed — read from
// `mosquitto_ctrl dynsec getRole` as eclipse-mosquitto 2.1.2 prints it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { filterProblem, missingAcls, parseRoleAcls, staleAcls, topicFilters, wantedAcls } from "../topics.ts";
test("a consumer that contributed nothing gets its own subtree", () => {
assert.deepEqual(topicFilters({}, "mesh_ace_hass"), { ok: true, filters: ["mesh_ace_hass/#"], own: true });
assert.deepEqual(topicFilters(undefined, "x"), { ok: true, filters: ["x/#"], own: true });
// Settings merge into every contribution: keys that are not `topics` change nothing.
assert.deepEqual(topicFilters({ endpoints: { web: {} } }, "x"), { ok: true, filters: ["x/#"], own: true });
});
test("a contributed list is granted exactly, duplicates once", () => {
assert.deepEqual(topicFilters({ topics: ["#"] }, "x"), { ok: true, filters: ["#"], own: false });
assert.deepEqual(topicFilters({ topics: ["stat/+/POWER", "tele/#", "tele/#", "/octoprint/x"] }, "x"), {
ok: true,
filters: ["stat/+/POWER", "tele/#", "/octoprint/x"],
own: false,
});
});
test("a list that is not topic filters is refused whole", () => {
for (const topics of [[], "#", [""], ["a/#/b"], ["a#"], ["a/b+"], [42], ["a\u0000b"], {}]) {
const out = topicFilters({ topics } as Record<string, unknown>, "x");
assert.equal(out.ok, false, JSON.stringify(topics));
}
assert.equal(filterProblem("+/+/#"), undefined);
assert.equal(filterProblem("#"), undefined);
});
const GET_ROLE = `Warning: You are running mosquitto_ctrl without encryption.
This means all of the configuration changes you are making are visible on the network, including passwords.
Rolename: u1
ACLs: publishClientSend : allow : # (priority: 0)
subscribePattern : allow : u1/# (priority: 0)
publishClientReceive : deny : secret topic/with space (priority: -1)
`;
test("getRole's ACL lines are read, the warning and headings are not", () => {
assert.deepEqual(parseRoleAcls(GET_ROLE), [
{ type: "publishClientSend", allow: true, topic: "#" },
{ type: "subscribePattern", allow: true, topic: "u1/#" },
{ type: "publishClientReceive", allow: false, topic: "secret topic/with space" },
]);
assert.deepEqual(parseRoleAcls("Rolename: empty\nACLs:\n"), []);
});
test("the role is brought to exactly the wanted ACLs", () => {
const current = parseRoleAcls(GET_ROLE);
const wanted = wantedAcls(["u1/#"]);
assert.deepEqual(wanted, [
{ type: "publishClientSend", allow: true, topic: "u1/#" },
{ type: "publishClientReceive", allow: true, topic: "u1/#" },
{ type: "subscribePattern", allow: true, topic: "u1/#" },
]);
assert.deepEqual(missingAcls(current, wanted), [
{ type: "publishClientSend", allow: true, topic: "u1/#" },
{ type: "publishClientReceive", allow: true, topic: "u1/#" },
]);
assert.deepEqual(staleAcls(current, wanted), [
{ type: "publishClientSend", allow: true, topic: "#" },
{ type: "publishClientReceive", allow: false, topic: "secret topic/with space" },
]);
assert.deepEqual(staleAcls(wanted, wanted), []);
assert.deepEqual(missingAcls(wanted, wanted), []);
});
-107
View File
@@ -1,107 +0,0 @@
// Which topics a consumer of `mqtt-topic` may use — the one choice a consumer makes about its grant.
//
// **By default, its own subtree and nothing else.** A consumer connects as the login the mesh derived
// (`as`) and may publish, receive and subscribe under `<as>/#` — isolated from every other consumer,
// which is the point of a per-consumer client (novox/hq ADR 0039/0048).
//
// **A consumer whose work IS the shared topic space says so.** Home Assistant discovers devices
// under `homeassistant/#` and `tasmota/discovery/#` and follows whatever state topics they announce;
// Node-RED's flows subscribe to the topics devices publish on (`stat/<device>/POWER`, …). Confined
// to `<as>/#` neither could do its job. So a consumer contributes `topics` to its `mqtt-topic`
// requirement — a list of MQTT topic filters — and the provisioner grants exactly those, both ways.
// Because assignment settings merge into every contribution, an operator narrows (or widens) the
// list per machine with the same key, without editing a manifest.
//
// Pure, so it is tested without a broker (test/topics.test.ts).
/** The dynsec ACL types a granted filter carries: send to it, receive from it, subscribe to it. */
export const GRANTED_ACL_TYPES = ["publishClientSend", "publishClientReceive", "subscribePattern"] as const;
/** One ACL on a role, as `mosquitto_ctrl dynsec getRole` reports it. */
export interface Acl {
type: string;
allow: boolean;
topic: string;
}
export type Filters = { ok: true; filters: string[]; own: boolean } | { ok: false; problem: string };
/**
* The topic filters a consumer is granted: what it contributed as `topics`, or its own subtree when
* it contributed nothing. Refused — never silently narrowed or widened — when the list is not a
* list of valid MQTT topic filters: a grant that quietly differs from what was asked is a consumer
* that fails somewhere far from the cause.
*/
export function topicFilters(values: Readonly<Record<string, unknown>> | undefined, as: string): Filters {
const given = values?.topics;
if (given === undefined || given === null) {
return { ok: true, filters: [`${as}/#`], own: true };
}
if (!Array.isArray(given) || given.length === 0) {
return { ok: false, problem: `topics must be a non-empty list of MQTT topic filters, not ${JSON.stringify(given)}` };
}
const out: string[] = [];
for (const f of given) {
if (typeof f !== "string") {
return { ok: false, problem: `topics holds ${JSON.stringify(f)}, which is not a topic filter` };
}
const problem = filterProblem(f);
if (problem) return { ok: false, problem: `topic filter ${JSON.stringify(f)}: ${problem}` };
if (!out.includes(f)) out.push(f);
}
return { ok: true, filters: out, own: out.length === 1 && out[0] === `${as}/#` };
}
/** Why a string is not a valid MQTT topic filter (MQTT 3.1.1 §4.7), or undefined when it is one. */
export function filterProblem(filter: string): string | undefined {
if (filter.length === 0) return "it is empty";
if (Buffer.byteLength(filter, "utf8") > 65535) return "it is longer than MQTT allows";
if (filter.includes("\u0000")) return "it contains a NUL character";
const levels = filter.split("/");
for (let i = 0; i < levels.length; i++) {
const level = levels[i];
if (level.includes("#") && (level !== "#" || i !== levels.length - 1)) {
return "'#' must be a whole level, and the last one";
}
if (level.includes("+") && level !== "+") return "'+' must be a whole level";
}
return undefined;
}
/** The ACLs a role must carry to grant these filters: every granted type, allowed, on every filter. */
export function wantedAcls(filters: readonly string[]): Acl[] {
const out: Acl[] = [];
for (const topic of filters) {
for (const type of GRANTED_ACL_TYPES) out.push({ type, allow: true, topic });
}
return out;
}
/**
* The ACLs `mosquitto_ctrl dynsec getRole` lists, one per line under its "ACLs:" heading:
* `ACLs: publishClientSend : allow : # (priority: 0)`
* ` subscribePattern : allow : u1/# (priority: 0)`
*/
export function parseRoleAcls(output: string): Acl[] {
const out: Acl[] = [];
const line = /^(?:ACLs:)?\s*([A-Za-z]+)\s*:\s*(allow|deny)\s*:\s*(.*?)\s+\(priority:\s*-?\d+\)\s*$/;
for (const raw of output.split(/\r?\n/)) {
const m = raw.match(line);
if (m) out.push({ type: m[1], allow: m[2] === "allow", topic: m[3] });
}
return out;
}
const key = (a: Acl): string => `${a.type}\u0000${a.allow ? "allow" : "deny"}\u0000${a.topic}`;
/** ACLs a role carries that it should not: in `current` and not in `wanted`. */
export function staleAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] {
const want = new Set(wanted.map(key));
return current.filter((a) => !want.has(key(a)));
}
/** ACLs a role should carry and does not. */
export function missingAcls(current: readonly Acl[], wanted: readonly Acl[]): Acl[] {
const have = new Set(current.map(key));
return wanted.filter((a) => !have.has(key(a)));
}
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["topics.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts", "bootstrap/index.ts"]
}
+12 -11
View File
@@ -21,23 +21,23 @@
"listens": [
{
"name": "database",
"port": 1433,
"port": 4848,
"protocol": "tcp",
"from": "mesh",
"why": "modules on any machine that were granted a database, and the people who were given its address; the machine port is the assignment's to pin"
"why": "modules on any machine that were granted a database. 4848, not 1433: the machine this replaces has served it there since it was installed, and every consumer was handed that number"
}
],
"serves": {
"mssql-database": {}
},
"receives": {
"mssql-database": "${dir:grants}/mesh.json"
"mssql-database": "/var/lib/mssql/grants/mesh.json"
},
"grants": {
"mssql-database": "${dir:grants}"
"mssql-database": "/var/lib/mssql/grants"
},
"own-secrets": {
"sa": "${dir:state}/sa.secret",
"sa": "/var/lib/mssql/sa.secret",
"broker": "/var/lib/mesh/mssql/broker"
},
"resources": [
@@ -50,18 +50,19 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/mssql",
"mode": "0700"
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/mssql/grants",
"mode": "0700"
},
{
"id": "sa-env",
"type": "file",
"path": "${dir:state}/sa.env",
"path": "/var/lib/mssql/sa.env",
"mode": "0600",
"content": "ACCEPT_EULA=Y\nMSSQL_SA_PASSWORD=${secret:sa}\n"
},
@@ -83,7 +84,7 @@
"image": "mcr.microsoft.com/mssql/server@sha256:4402d880dd4c34bfa7d8705e56a86cd6c88da80a1f6bbbe741f999e76264a090",
"network": "mssql",
"env-file": [
"${dir:state}/sa.env"
"/var/lib/mssql/sa.env"
],
"ports": [
"1433"
@@ -100,8 +101,8 @@
"network": "mssql",
"volumes": [
"/var/lib/mesh/mssql/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/mssql/grants:ro",
"${dir:state}/sa.secret:/run/secrets/sa:ro"
"/var/lib/mssql/grants:/var/lib/mssql/grants:ro",
"/var/lib/mssql/sa.secret:/run/secrets/sa:ro"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
+1 -4
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/nodered
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,6 +22,3 @@ COPY --from=build /app/modules/nodered/dist /app/modules/nodered/dist
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/nodered/dist/tools/index.js
# NOT dist/mqtt/index.js: that is a step the host runs to completion, named by the `mqtt`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+3 -38
View File
@@ -3,8 +3,7 @@
//
// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow
// configuration, GET /nodes for installed node modules. A default install has no auth; when
// adminAuth is on, a bearer token is required — the module's settings accept the mesh-minted
// api-token, which the runtime config file carries as `token`.
// adminAuth is on, a bearer token (minted at /auth/token) is required.
import { readFileSync } from "node:fs";
@@ -82,35 +81,6 @@ export class NodeRedClient {
return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] }));
}
/** The whole flow configuration with its revision (API v2), for a deploy that must not clobber
* a change made meanwhile. */
async flowsWithRev(): Promise<{ rev: string; flows: any[] }> {
const body = await this.req("/flows", { headers: this.headers({ "Node-RED-API-Version": "v2" }) });
return { rev: String(body?.rev ?? ""), flows: Array.isArray(body?.flows) ? body.flows : [] };
}
/** A node's stored credentials as Node-RED shows them: plain fields, and `has_<field>` for secret ones. */
async credentials(type: string, id: string): Promise<{ user?: string; has_password?: boolean }> {
return (await this.req(`/credentials/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, { headers: this.headers() })) ?? {};
}
/**
* Deploy the flow configuration read at `rev`. Node-RED answers 409 when the flows changed since,
* rather than overwriting what someone deployed in between. A node carrying `credentials` has them
* stored (encrypted) and counts as changed, so a "nodes" deploy restarts it and nothing else.
*/
async deployFlowsAt(rev: string, config: any[], type = "nodes"): Promise<void> {
await this.req("/flows", {
method: "POST",
headers: this.headers({
"Content-Type": "application/json",
"Node-RED-API-Version": "v2",
"Node-RED-Deployment-Type": type,
}),
body: JSON.stringify({ rev, flows: config }),
});
}
/**
* Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to
* Node-RED's deployment types — "full" (default), "nodes", or "flows".
@@ -118,13 +88,8 @@ export class NodeRedClient {
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
const body = await this.req("/flows", {
method: "POST",
// v2 answers { rev }; v1 answers 204 with no body, which req() cannot parse.
headers: this.headers({
"Content-Type": "application/json",
"Node-RED-API-Version": "v2",
"Node-RED-Deployment-Type": type,
}),
body: JSON.stringify({ flows: config }),
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }),
body: JSON.stringify(config),
});
return { rev: body?.rev, nodeCount: config.length };
}
+7 -85
View File
@@ -5,8 +5,6 @@
"flows.deployed"
],
"own-secrets": {
"admin": "/var/lib/mesh/nodered/admin",
"api-token": "/var/lib/mesh/nodered/api-token",
"broker": "/var/lib/mesh/nodered/broker"
},
"capabilities": [
@@ -28,63 +26,26 @@
"path": "/var/lib/mesh/nodered",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "data",
"type": "directory",
"path": "/services/nodered/data",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "written",
"type": "directory",
"mode": "0700"
},
{
"id": "settings-code",
"type": "file",
"path": "${dir:state}/settings.js",
"mode": "0600",
"owner": "1000:1000",
"content": "// Node-RED's settings, written by the mesh from the nodered module. What an assignment may change\n// is settings.json beside this file (merged key by key); the credentials are the mesh's secrets and\n// reach Node-RED only through this file. The flows' own credentials stay encrypted in the user\n// directory under the key Node-RED keeps there (.config.runtime.json), which is data, not this.\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\n\nconst ADMIN_PASSWORD = \"${secret:admin}\";\nconst API_TOKEN = \"${secret:api-token}\";\nconst ADMIN = { username: \"admin\", permissions: \"*\" };\n\nconst settings = JSON.parse(fs.readFileSync(path.join(__dirname, \"settings.json\"), \"utf8\"));\n// The mesh's keys, not Node-RED's: endpoints lands in every merged file; timeZone is the\n// assignment's way to set the zone flows schedule and format in; mqtt names the broker nodes the\n// module's MQTT step keeps pointed at the mesh's broker; topics is what nodered asks the broker for.\nif (settings.timeZone) process.env.TZ = settings.timeZone;\ndelete settings.timeZone;\ndelete settings.endpoints;\ndelete settings.mqtt;\ndelete settings.topics;\n\nfunction same(a, b) {\n const x = crypto.createHash(\"sha256\").update(String(a)).digest();\n const y = crypto.createHash(\"sha256\").update(String(b)).digest();\n return crypto.timingSafeEqual(x, y);\n}\n\n// The admin secret is a password, or, accepted from an existing install, the bcrypt hash its\n// settings held, so the password people already use keeps working.\nfunction passwordMatches(given) {\n if (/^\\$2[aby]\\$\\d\\d\\$/.test(ADMIN_PASSWORD)) return require(\"bcryptjs\").compare(String(given), ADMIN_PASSWORD);\n return Promise.resolve(same(given, ADMIN_PASSWORD));\n}\n\nmodule.exports = Object.assign(settings, {\n uiPort: 1880,\n adminAuth: {\n type: \"credentials\",\n users: (username) => Promise.resolve(username === ADMIN.username ? ADMIN : null),\n authenticate: (username, password) =>\n username === ADMIN.username\n ? passwordMatches(password).then((ok) => (ok ? ADMIN : null))\n : Promise.resolve(null),\n // The module's own tools call the admin API with this bearer token.\n tokens: (token) => Promise.resolve(same(token, API_TOKEN) ? { username: \"mesh\", permissions: \"*\" } : null),\n },\n});\n"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "1000:1000",
"merge": "json",
"content": "{\n \"flowFile\": \"flows.json\",\n \"flowFilePretty\": true,\n \"diagnostics\": { \"enabled\": true, \"ui\": true },\n \"runtimeState\": { \"enabled\": false, \"ui\": false },\n \"logging\": { \"console\": { \"level\": \"info\", \"metrics\": false, \"audit\": false } },\n \"exportGlobalContextKeys\": false,\n \"externalModules\": {},\n \"editorTheme\": { \"projects\": { \"enabled\": false } },\n \"functionExternalModules\": true,\n \"debugMaxLength\": 1000,\n \"mqttReconnectTime\": 15000,\n \"serialReconnectTime\": 15000\n}\n"
},
{
"id": "server",
"type": "container",
"name": "nodered",
"image": "nodered/node-red@sha256:a649dd711d55490151a2c39a8e48ad0c44325488fbc0e66315f2d2e19e5e1ace",
"image": "nodered/node-red@sha256:02a2b92a41b73d2bc388238b86e4fcaab7fb5466373adb24e1df6aa5845265ff",
"env": {
"TZ": "Etc/UTC"
},
"ports": [
"1880"
],
"args": [
"--settings",
"/config/settings.js"
],
"volumes": [
"${dir:data}:/data",
"${dir:state}/settings.js:/config/settings.js:ro",
"${dir:state}/settings.json:/config/settings.json:ro"
],
"restart-on": [
"settings-code",
"settings"
"/services/nodered/data:/data"
]
},
{
@@ -92,7 +53,8 @@
"type": "file",
"path": "/var/lib/mesh/nodered/config.json",
"mode": "0600",
"content": "{\n \"token\": \"${secret:api-token}\"\n}\n"
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
@@ -105,66 +67,26 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_URL": "http://127.0.0.1:1880",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{
"id": "mqtt",
"type": "container",
"name": "mesh-nodered-mqtt",
"network": "host",
"run-once": true,
"volumes": [
"/var/lib/mesh/nodered/config.json:/run/config/config.json:ro",
"${dir:written}:/var/lib/nodered-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/settings.json:/run/provisions/settings.json:ro"
],
"env": {
"MESH_NODERED_URL": "http://127.0.0.1:${port:1880}",
"MESH_NODERED_CONFIG_FILE": "/run/config/config.json",
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/nodered-provisions"
},
"args": [
"run",
"/app/modules/nodered/dist/mqtt/index.js"
],
"restart-on": [
"bound-mqtt-topic",
"secret-mqtt-topic",
"settings"
],
"artifact": "runtime"
}
],
"requires": [
"mqtt-topic",
"route"
],
"contributes": {
"mqtt-topic": {
"topics": [
"#"
]
},
"route": {
"label": "nodered",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:state}/route.json",
"mqtt-topic": "${dir:state}/mqtt-topic.json"
},
"secrets": {
"mqtt-topic": "${dir:state}/mqtt-topic.secret"
"route": "/var/lib/mesh/nodered/route.json"
},
"build": {
"on": [
-232
View File
@@ -1,232 +0,0 @@
// Node-RED's MQTT broker config node, pointed at the broker the mesh bound — `mqtt-topic`.
//
// **Why a step.** Node-RED keeps a broker as a config node in its flows (`flows.json`) and the login
// and password in its encrypted credentials file, both of them Node-RED's to write. So this reads the
// binding and the pair credential and makes the broker node say the same thing through Node-RED's
// admin API — `GET /flows`, then `POST /flows` with the changed node and its `credentials`, deployed
// as "nodes" so only what changed restarts — with the module's own `api-token`.
//
// **Which broker nodes are the mesh's.** Never guessed: a flow may talk to a broker that has nothing
// to do with this mesh. The step owns the node it creates itself (id `mesh-mqtt-topic`, "mesh:
// mqtt-topic") and the ones an assignment names in settings (`mqtt.brokers`: node ids — how ace's
// existing broker node, which every one of its MQTT flows uses, is handed over). With none named and
// none made yet, it makes one, so a fresh Node-RED has a broker the flows can pick.
//
// **Only the connection, and only when it differs.** Host, port, TLS off (the broker serves plain
// MQTT), login, password. Every other field of the node — client id, keepalive, birth/close/will
// messages — is left as it is. Node-RED never hands a stored password back, so the step keeps a
// digest of what it last wrote: equal host/port/login and an equal digest is "already as the mesh
// says".
//
// **Nothing loses its connection without someone seeing it.** The broker is asked first whether it
// takes the delivered login; if not, nothing is written and the step fails saying why.
//
// Pure logic over two seams (Node-RED, the broker), tested against fakes (test/mqtt.test.ts).
import { createHash } from "node:crypto";
import type { Probe } from "./probe.js";
export const PROVISION = "mqtt-topic";
/** The id and name of the broker node the step makes when none is named. */
export const MESH_BROKER_ID = "mesh-mqtt-topic";
export const MESH_BROKER_NAME = "mesh: mqtt-topic";
/** What the mesh wrote at `binds.mqtt-topic`. */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export type Outcome =
| { what: string; result: "unchanged"; note?: string }
| { what: string; result: "written"; fields: string[]; note?: string }
| { what: string; result: "refused"; problem: string };
/** A flow node; a broker config node carries `broker`, `port`, `usetls`. */
export interface FlowNode {
id: string;
type: string;
[key: string]: unknown;
}
/** Node-RED's admin API, as the step uses it. */
export interface NodeRed {
/** The whole flow configuration and its revision (API v2). */
flows(): Promise<{ rev: string; flows: FlowNode[] }>;
/** A node's stored credentials as Node-RED shows them: the user, and only whether a password is set. */
credentials(type: string, id: string): Promise<{ user?: string; has_password?: boolean }>;
/** Deploy the configuration against the revision it was read at; "nodes" restarts only what changed. */
deploy(rev: string, flows: FlowNode[]): Promise<void>;
}
export interface Marks {
get(name: string): Promise<string | undefined>;
set(name: string, digest: string): Promise<void>;
}
export interface Deps {
nodered: NodeRed;
probe: Probe;
marks: Marks;
}
export interface Wanted {
host: string;
port: number;
user: string;
password: string;
}
export function digest(...parts: (string | number)[]): string {
return createHash("sha256").update(parts.map(String).join("\u0000")).digest("hex");
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/**
* The broker and login the mesh says Node-RED uses. A loopback `at` — what the mesh hands a machine
* that is not on the private network — is refused: from Node-RED's own container it is Node-RED.
*/
export function wanted(binding: Binding | undefined, credential: string | undefined): { ok: true; want: Wanted } | { ok: false; problem: string } {
if (!binding) return { ok: false, problem: `no binding for ${PROVISION} was delivered — the mesh writes it before this step runs` };
const host = typeof binding.at === "string" ? binding.at.trim() : "";
if (!host) return { ok: false, problem: `the ${PROVISION} binding names no host (at)` };
if (isLoopback(host)) {
return {
ok: false,
problem:
`the ${PROVISION} binding says the broker is at ${host}, which from Node-RED's own container is Node-RED ` +
`itself; put the machine on the private network so the broker has an address Node-RED can dial`,
};
}
const port = Number(binding.serves?.port);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${PROVISION} binding serves no usable port (${String(binding.serves?.port)})` };
}
const scheme = binding.serves?.scheme;
if (scheme !== undefined && scheme !== "mqtt") return { ok: false, problem: `the ${PROVISION} binding serves scheme ${String(scheme)}; this step writes plain MQTT` };
const user = typeof binding.as === "string" ? binding.as.trim() : "";
if (!user) return { ok: false, problem: `the ${PROVISION} binding names no login (as)` };
const password = (credential ?? "").replace(/\n$/, "");
if (!password) return { ok: false, problem: `the ${PROVISION} credential is empty or was not delivered` };
return { ok: true, want: { host, port, user, password } };
}
/** The broker node ids an assignment named in settings (`mqtt.brokers`), or none. */
export function namedBrokers(settings: unknown): string[] {
const brokers = (settings as { mqtt?: { brokers?: unknown } } | undefined)?.mqtt?.brokers;
return Array.isArray(brokers) ? brokers.filter((b): b is string => typeof b === "string" && b.length > 0) : [];
}
/** A new broker node, Node-RED 5's defaults, pointed at the broker. */
export function newBrokerNode(want: Wanted): FlowNode {
return {
id: MESH_BROKER_ID, type: "mqtt-broker", name: MESH_BROKER_NAME,
broker: want.host, port: String(want.port), clientid: "", autoConnect: true, usetls: false,
protocolVersion: "4", keepalive: "60", cleansession: true, autoUnsubscribe: true,
birthTopic: "", birthQos: "0", birthRetain: "false", birthPayload: "", birthMsg: {},
closeTopic: "", closeQos: "0", closeRetain: "false", closePayload: "", closeMsg: {},
willTopic: "", willQos: "0", willRetain: "false", willPayload: "", willMsg: {},
userProps: "", sessionExpiry: "",
};
}
const markFor = (id: string, w: Wanted): string => digest("nodered-mqtt", id, w.host, w.port, w.user, w.password);
function scrub(err: unknown, secret: string): string {
let text = err instanceof Error ? err.message : String(err);
for (const form of new Set([secret, encodeURIComponent(secret)])) text = text.split(form).join("***");
return text;
}
/**
* Bring the mesh's broker nodes in line with the binding: one outcome per node. Never throws. A node
* the settings name that is not in the flows is refused (the others are still put right).
*/
export async function reconcileBrokers(deps: Deps, binding: Binding | undefined, credential: string | undefined, named: readonly string[]): Promise<Outcome[]> {
const w = wanted(binding, credential);
if ("problem" in w) return [{ what: "mqtt", result: "refused", problem: w.problem }];
const want = w.want;
let note: string | undefined;
try {
const probe = await deps.probe(want.host, want.port, want.user, want.password, "#");
if (probe.connack === 4 || probe.connack === 5) {
return [{
what: "mqtt",
result: "refused",
problem:
`the broker at ${want.host}:${want.port} does not (yet) take the login ${want.user} with the delivered password ` +
`(CONNACK ${probe.connack}); mosquitto's provisioner creates it from the grant — nothing was written`,
}];
}
if (probe.connack !== 0) return [{ what: "mqtt", result: "refused", problem: `the broker at ${want.host}:${want.port} answered CONNACK ${probe.connack}; nothing was written` }];
if (probe.suback === 0x80) note = `warning: ${want.user} may not subscribe to every topic; flows subscribing outside its grant will get nothing`;
} catch (err) {
return [{ what: "mqtt", result: "refused", problem: `the broker at ${want.host}:${want.port} could not be asked: ${scrub(err, want.password)}; nothing was written` }];
}
const outcomes: Outcome[] = [];
for (let attempt = 0; attempt < 2; attempt++) {
outcomes.length = 0;
try {
const { rev, flows } = await deps.nodered.flows();
const targets = named.length > 0 ? [...named] : [MESH_BROKER_ID];
const changed: { id: string; fields: string[] }[] = [];
for (const id of targets) {
let node = flows.find((n) => n.id === id);
if (node && node.type !== "mqtt-broker") {
outcomes.push({ what: `broker ${id}`, result: "refused", problem: `node ${id} is a ${node.type}, not an mqtt-broker` });
continue;
}
if (!node) {
if (id !== MESH_BROKER_ID) {
outcomes.push({ what: `broker ${id}`, result: "refused", problem: `the settings name broker node ${id}, and Node-RED's flows have no such node` });
continue;
}
node = newBrokerNode(want);
flows.push(node);
node.credentials = { user: want.user, password: want.password };
changed.push({ id, fields: ["node"] });
continue;
}
const fields: string[] = [];
if (String(node.broker ?? "") !== want.host) fields.push("broker");
if (Number(node.port ?? 0) !== want.port) fields.push("port");
if (node.usetls === true) fields.push("usetls");
const creds = await deps.nodered.credentials("mqtt-broker", id);
if ((creds.user ?? "") !== want.user) fields.push("user");
if (!creds.has_password || (await deps.marks.get(`broker-${id}`)) !== markFor(id, want)) fields.push("password");
if (fields.length === 0) {
outcomes.push(note ? { what: `broker ${id}`, result: "unchanged", note } : { what: `broker ${id}`, result: "unchanged" });
continue;
}
node.broker = want.host;
node.port = String(want.port);
node.usetls = false;
node.credentials = { user: want.user, password: want.password };
changed.push({ id, fields });
}
if (changed.length > 0) {
await deps.nodered.deploy(rev, flows);
for (const c of changed) {
await deps.marks.set(`broker-${c.id}`, markFor(c.id, want));
outcomes.push({ what: `broker ${c.id}`, result: "written", fields: c.fields, ...(note ? { note } : {}) });
}
}
return outcomes;
} catch (err) {
// A deploy against a revision someone else changed meanwhile (409) is read again once.
if (attempt === 0 && /\b409\b/.test(String(err))) continue;
return [...outcomes, { what: "mqtt", result: "refused", problem: scrub(err, want.password) }];
}
}
return outcomes;
}
-102
View File
@@ -1,102 +0,0 @@
// nodered's MQTT step — run once by the host after Node-RED starts, and again whenever the
// `mqtt-topic` binding, its pair credential or the settings change (the container's `restart-on`,
// novox/hq ADR 0099). It points the mesh's broker config nodes at the broker the mesh bound, through
// Node-RED's admin API (connection.ts). It connects to no mesh broker.
//
// Exits non-zero when anything could not be put right, so the node reports the step failed and the
// host runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
// else of nodered's (novox/hq ADR 0136). Never prints a password.
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { NodeRedClient } from "../client.js";
import { namedBrokers, reconcileBrokers, type Binding, type Marks } from "./connection.js";
import { probeBroker } from "./probe.js";
const dir = process.env.MESH_PROVISIONS_DIR ?? "/run/provisions";
const writtenDir = process.env.MESH_WRITTEN_DIR ?? "/var/lib/nodered-provisions";
const waitSeconds = Number(process.env.MESH_NODERED_WAIT_SECONDS ?? "180");
const readIfThere = (path: string): Promise<string | undefined> => readFile(path, "utf8").catch(() => undefined);
const parse = <T>(raw: string | undefined): T | undefined => {
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as T;
} catch {
return undefined;
}
};
const marks: Marks = {
async get(name) {
return (await readIfThere(join(writtenDir, `${name}.digest`)))?.trim() || undefined;
},
async set(name, value) {
await mkdir(writtenDir, { recursive: true, mode: 0o700 });
const path = join(writtenDir, `${name}.digest`);
await writeFile(`${path}.tmp`, `${value}\n`, { mode: 0o600 });
await rename(`${path}.tmp`, path);
},
};
let client: NodeRedClient;
try {
client = NodeRedClient.fromEnv();
} catch (err) {
console.error(`[nodered-mqtt] ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
/** Node-RED answers the admin API once its flows are loaded and the token is good. */
async function ready(): Promise<boolean> {
const until = Date.now() + waitSeconds * 1000;
for (;;) {
try {
await client.flowsWithRev();
return true;
} catch (err) {
if (/\b(401|403)\b/.test(String(err))) {
console.error("[nodered-mqtt] Node-RED refuses the api-token — settings.js and this step disagree");
return false;
}
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, 2000));
}
}
if (!(await ready())) {
console.error(`[nodered-mqtt] Node-RED's admin API did not answer at ${client.baseUrl} within ${waitSeconds}s`);
process.exit(1);
}
const binding = parse<Binding>(await readIfThere(join(dir, "mqtt-topic.json")));
const secret = await readIfThere(join(dir, "mqtt-topic.secret"));
const settings = parse<unknown>(await readIfThere(join(dir, "settings.json")));
const outcomes = await reconcileBrokers(
{
nodered: {
flows: () => client.flowsWithRev(),
credentials: (type, id) => client.credentials(type, id),
deploy: (rev, flows) => client.deployFlowsAt(rev, flows, "nodes"),
},
probe: probeBroker,
marks,
},
binding,
secret,
namedBrokers(settings),
);
let failed = 0;
for (const o of outcomes) {
if (o.result === "unchanged") console.log(`[nodered-mqtt] ${o.what}: already as the mesh says${o.note ? ` — ${o.note}` : ""}`);
else if (o.result === "written") console.log(`[nodered-mqtt] ${o.what}: wrote ${o.fields.join(", ")}${o.note ? ` — ${o.note}` : ""}`);
else {
failed++;
console.error(`[nodered-mqtt] ${o.what}: ${o.problem}`);
}
}
process.exitCode = failed > 0 ? 1 : 0;
-117
View File
@@ -1,117 +0,0 @@
// Ask the broker, before Node-RED is told anything, whether it takes the login and password the
// mesh delivered — and whether that login may subscribe to every topic, as flows expect.
//
// One MQTT 3.1.1 session: CONNECT (clean, a throwaway client id, so no flow's session is taken
// over), read the CONNACK, optionally SUBSCRIBE once and read the SUBACK, DISCONNECT. No dependency:
// the handful of bytes MQTT needs for this are written here.
import { randomBytes } from "node:crypto";
import { connect } from "node:net";
export interface ProbeResult {
/** 0 accepted; 4 bad username or password; 5 not authorised. */
connack: number;
/** The SUBACK return code for the filter asked about: 0–2 granted, 0x80 refused. */
suback?: number;
}
export type Probe = (host: string, port: number, username: string, password: string, subscribe?: string) => Promise<ProbeResult>;
function str(v: string): Buffer {
const b = Buffer.from(v, "utf8");
const len = Buffer.alloc(2);
len.writeUInt16BE(b.length);
return Buffer.concat([len, b]);
}
function packet(type: number, body: Buffer): Buffer {
let remaining = body.length;
const lenBytes: number[] = [];
do {
let byte = remaining % 128;
remaining = Math.floor(remaining / 128);
if (remaining > 0) byte |= 0x80;
lenBytes.push(byte);
} while (remaining > 0);
return Buffer.concat([Buffer.from([type, ...lenBytes]), body]);
}
/** The first complete packet in `buf`: its type byte, its body, and how many bytes it took. */
export function firstPacket(buf: Buffer): { type: number; body: Buffer; used: number } | undefined {
if (buf.length < 2) return undefined;
let length = 0;
let multiplier = 1;
let i = 1;
for (;;) {
if (i >= buf.length) return undefined;
const byte = buf[i++];
length += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) break;
multiplier *= 128;
if (i > 4) throw new Error("malformed MQTT remaining length");
}
if (buf.length < i + length) return undefined;
return { type: buf[0], body: buf.subarray(i, i + length), used: i + length };
}
export const probeBroker: Probe = (host, port, username, password, subscribe) => {
const connectBody = Buffer.concat([
str("MQTT"),
Buffer.from([4, 0xc2, 0, 10]), // level 4 (3.1.1); username + password + clean session; keepalive 10s
str(`mesh-probe-${randomBytes(6).toString("hex")}`),
str(username),
str(password),
]);
return new Promise((resolve, reject) => {
const socket = connect({ host, port });
let buf = Buffer.alloc(0);
const result: ProbeResult = { connack: -1 };
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`no answer from the broker at ${host}:${port} within 10s`));
}, 10_000);
const finish = (): void => {
clearTimeout(timer);
if (result.connack === 0) socket.end(Buffer.from([0xe0, 0]));
else socket.destroy();
resolve(result);
};
socket.on("connect", () => socket.write(packet(0x10, connectBody)));
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
let p;
try {
p = firstPacket(buf);
} catch (err) {
clearTimeout(timer);
socket.destroy();
reject(err);
return;
}
if (!p) return;
buf = buf.subarray(p.used);
const kind = p.type >> 4;
if (kind === 2) {
result.connack = p.body[1] ?? -1;
if (result.connack !== 0 || !subscribe) return finish();
// SUBSCRIBE, packet id 1, one filter at QoS 0.
socket.write(packet(0x82, Buffer.concat([Buffer.from([0, 1]), str(subscribe), Buffer.from([0])])));
} else if (kind === 9) {
result.suback = p.body[2];
return finish();
}
}
});
socket.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
socket.on("close", () => {
if (result.connack === -1) {
clearTimeout(timer);
reject(new Error(`the broker at ${host}:${port} closed the connection without answering`));
}
});
});
};
+1 -6
View File
@@ -1,14 +1,9 @@
{
"name": "@novox/module-nodered",
"version": "0.1.0",
"description": "nodered \u2014 flow-based automation. Its client and tools live here (novox/hq ADR 0039).",
"description": "nodered — flow-based automation. Its client and tools live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts tools/index.ts mqtt/probe.ts mqtt/connection.ts mqtt/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
-124
View File
@@ -1,124 +0,0 @@
// What holds nodered's MQTT step (mqtt/connection.ts): the broker nodes the mesh owns — the one it
// makes, or the ones settings name — are made to use the broker and login the mesh bound, only after
// the broker takes that login; every other field of a node is kept; nothing is deployed when nothing
// differs; a node that is not named is never touched; a loopback broker address is refused.
//
// Node-RED and the broker are fakes answering as the real ones do (admin API v2 of nodered/node-red
// 5.0.7, the build ace runs).
import { test } from "node:test";
import assert from "node:assert/strict";
import { MESH_BROKER_ID, namedBrokers, reconcileBrokers, type Binding, type FlowNode, type Marks, type NodeRed } from "../mqtt/connection.ts";
import type { Probe } from "../mqtt/probe.ts";
const MINTED = "mesh-minted-password";
const binding = (at = "ace.internal"): Binding => ({ provision: "mqtt-topic", from: "ace", at, as: "mesh_ace_nodered", serves: { scheme: "mqtt", port: 1883 } });
/** ace's flows, reduced: its one broker node (dead, zurag.be:1884) and a node that uses it. */
function aceFlows(): FlowNode[] {
return [
{ id: "2b0aece9c5f3b307", type: "mqtt-broker", name: "MQTT Broker", broker: "zurag.be", port: "1884", clientid: "", usetls: false, protocolVersion: "4", keepalive: "60" },
{ id: "fe0cae96f1e3ae4d", type: "mqtt in", topic: "stat/sonoff_office_light_switch/RESULT", broker: "2b0aece9c5f3b307", z: "t" },
{ id: "other-broker", type: "mqtt-broker", name: "someone else's", broker: "test.mosquitto.org", port: "1883" },
];
}
function fakeNodeRed(flows: FlowNode[], creds: Record<string, { user?: string; password?: string }> = {}) {
let rev = "r1";
const deploys: FlowNode[][] = [];
const nodered: NodeRed = {
async flows() {
return { rev, flows: structuredClone(flows) };
},
async credentials(_type, id) {
const c = creds[id] ?? {};
return { user: c.user, has_password: Boolean(c.password) };
},
async deploy(at, next) {
if (at !== rev) throw new Error("Node-RED /flows: 409 version_mismatch");
for (const n of next) {
if (n.credentials) creds[n.id] = { ...(creds[n.id] ?? {}), ...(n.credentials as object) };
}
flows.splice(0, flows.length, ...next.map(({ credentials: _c, ...n }) => n as FlowNode));
deploys.push(next);
rev = `r${deploys.length + 1}`;
},
};
return { nodered, deploys, flows, creds };
}
const marks = (): Marks & { store: Map<string, string> } => {
const store = new Map<string, string>();
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
};
const takes: Probe = async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_nodered" && pass === MINTED ? 0 : 5, suback: 0 });
test("ace: the named broker node is moved to the bound broker and login; the other broker is not touched", async () => {
const f = fakeNodeRed(aceFlows(), { "2b0aece9c5f3b307": { user: "luffy", password: "old" }, "other-broker": { user: "x", password: "y" } });
const m = marks();
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: m }, binding(), `${MINTED}\n`, ["2b0aece9c5f3b307"]);
assert.deepEqual(out, [{ what: "broker 2b0aece9c5f3b307", result: "written", fields: ["broker", "port", "user", "password"] }]);
const node = f.flows.find((n) => n.id === "2b0aece9c5f3b307");
assert.deepEqual(node, { ...aceFlows()[0], broker: "ace.internal", port: "1883", usetls: false });
assert.deepEqual(f.creds["2b0aece9c5f3b307"], { user: "mesh_ace_nodered", password: MINTED });
assert.deepEqual(f.flows.find((n) => n.id === "other-broker"), aceFlows()[2]);
assert.deepEqual(f.creds["other-broker"], { user: "x", password: "y" });
// Only the changed node carried credentials in the deploy.
assert.deepEqual(f.deploys[0].filter((n) => n.credentials).map((n) => n.id), ["2b0aece9c5f3b307"]);
// Again: nothing differs, nothing is deployed.
const again = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: m }, binding(), MINTED, ["2b0aece9c5f3b307"]);
assert.deepEqual(again, [{ what: "broker 2b0aece9c5f3b307", result: "unchanged" }]);
assert.equal(f.deploys.length, 1);
});
test("fresh: with nothing named, the step makes its own broker node", async () => {
const f = fakeNodeRed([]);
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, []);
assert.deepEqual(out, [{ what: `broker ${MESH_BROKER_ID}`, result: "written", fields: ["node"] }]);
assert.equal(f.flows[0].type, "mqtt-broker");
assert.equal(f.flows[0].broker, "ace.internal");
assert.deepEqual(f.creds[MESH_BROKER_ID], { user: "mesh_ace_nodered", password: MINTED });
});
test("a login the broker does not take is never written", async () => {
const f = fakeNodeRed(aceFlows());
const out = await reconcileBrokers({ nodered: f.nodered, probe: async () => ({ connack: 5 }), marks: marks() }, binding(), MINTED, ["2b0aece9c5f3b307"]);
assert.equal(out[0].result, "refused");
assert.equal(f.deploys.length, 0);
});
test("a named node that is not there, or a loopback broker, is refused", async () => {
const f = fakeNodeRed(aceFlows());
const out = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding(), MINTED, ["gone"]);
assert.match((out[0] as { problem: string }).problem, /no such node/);
const lo = await reconcileBrokers({ nodered: f.nodered, probe: takes, marks: marks() }, binding("127.0.0.1"), MINTED, []);
assert.match((lo[0] as { problem: string }).problem, /Node-RED itself/);
assert.equal(f.deploys.length, 0);
});
test("a deploy that raced another is read again once", async () => {
const f = fakeNodeRed(aceFlows());
let first = true;
const racing: NodeRed = {
...f.nodered,
async flows() {
const got = await f.nodered.flows();
if (first) {
first = false;
return { ...got, rev: "stale" };
}
return got;
},
};
const out = await reconcileBrokers({ nodered: racing, probe: takes, marks: marks() }, binding(), MINTED, ["2b0aece9c5f3b307"]);
assert.equal(out[0].result, "written");
assert.equal(f.deploys.length, 1);
});
test("settings name broker nodes under mqtt.brokers", () => {
assert.deepEqual(namedBrokers({ mqtt: { brokers: ["a", "", 3, "b"] } }), ["a", "b"]);
assert.deepEqual(namedBrokers({ endpoints: {} }), []);
assert.deepEqual(namedBrokers(undefined), []);
});
+1 -7
View File
@@ -8,11 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"tools/index.ts",
"mqtt/probe.ts",
"mqtt/connection.ts",
"mqtt/index.ts"
]
"include": ["client.ts", "tools/index.ts"]
}
+1 -1
View File
@@ -81,7 +81,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NZBGET_URL": "http://127.0.0.1:6789",
"MESH_NZBGET_URL": "http://127.0.0.1:${port:6789}",
"MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password",
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/ombi
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/ombi/dist /app/modules/ombi/dist
# 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/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js
# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+58 -10
View File
@@ -28,10 +28,15 @@
"path": "/var/lib/mesh/ombi",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"path": "/services/ombi/config",
"mode": "0700",
"owner": "1000:1000"
},
@@ -39,7 +44,7 @@
"id": "server",
"type": "container",
"name": "ombi",
"image": "lscr.io/linuxserver/ombi@sha256:a6f76ac521ba01eee2e9f0c23a3fed22e56630d97a04d5eeaeaa36c1e681640d",
"image": "lscr.io/linuxserver/ombi@sha256:22d6ebadbaaa728571353e74dc2173719e0fb02d4eaec551a7e9d2ee99ef68ac",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -49,7 +54,7 @@
"3579"
],
"volumes": [
"/services/ombi/config:/config"
"${dir:config}:/config"
]
},
{
@@ -68,24 +73,59 @@
"volumes": [
"/var/lib/mesh/ombi/broker:/run/secrets/broker:ro",
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro",
"/services/ombi/config:/var/lib/ombi/config:ro"
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_OMBI_URL": "http://127.0.0.1:3579",
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json",
"MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config"
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{
"id": "servarr",
"type": "container",
"name": "mesh-ombi-servarr",
"network": "host",
"run-once": true,
"volumes": [
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
"${dir:state}/sonarr-api.json:/run/servarr/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/servarr/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/servarr/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/servarr/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/servarr/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/servarr/lidarr-api.secret:ro"
],
"env": {
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_SERVARR_DIR": "/run/servarr"
},
"args": [
"run",
"/app/modules/ombi/dist/servarr/index.js"
],
"restart-on": [
"bound-sonarr-api",
"secret-sonarr-api",
"bound-radarr-api",
"secret-radarr-api",
"bound-lidarr-api",
"secret-lidarr-api"
],
"artifact": "runtime"
}
],
"requires": [
"route"
"lidarr-api",
"radarr-api",
"route",
"sonarr-api"
],
"contributes": {
"route": {
@@ -94,7 +134,15 @@
}
},
"binds": {
"route": "/var/lib/mesh/ombi/route.json"
"route": "${dir:state}/route.json",
"sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json",
"lidarr-api": "${dir:state}/lidarr-api.json"
},
"secrets": {
"sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret",
"lidarr-api": "${dir:state}/lidarr-api.secret"
},
"build": {
"on": [
+5
View File
@@ -4,6 +4,11 @@
"description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
+59
View File
@@ -0,0 +1,59 @@
// ombi's Servarr step — run once by the host after ombi's server starts, and run again whenever a
// binding or pair credential it reads changes (the container's `restart-on`, novox/hq ADR 0099).
//
// **A step, not a loop**, for the reason route-adapter gives: everything it does is a function of
// files the mesh writes, and the host already knows when they change. It connects to no broker.
//
// Exits non-zero when any app could not be put right — a refused credential, an unreachable app, an
// ombi that cannot reach it — so the node reports the step failed and the host runs it again on the
// next apply. It is declared last in the manifest, so its failing gates nothing else of ombi's
// (novox/hq ADR 0136).
//
// Reads, per app, `<dir>/<provision>.json` (the binding) and `<dir>/<provision>.secret` (the pair
// credential), where <dir> is MESH_SERVARR_DIR. Never prints a key.
import { join } from "node:path";
import { APPS, ombiReady, readBinding, readIfThere, reconcileApp, type Http } from "./settings.js";
const dir = process.env.MESH_SERVARR_DIR ?? "/run/servarr";
const url = process.env.MESH_OMBI_URL ?? "http://127.0.0.1:3579";
const apiKey = (await readIfThere(process.env.MESH_OMBI_API_KEY_FILE))?.trim() ?? process.env.MESH_OMBI_API_KEY ?? "";
const waitSeconds = Number(process.env.MESH_OMBI_WAIT_SECONDS ?? "180");
const http: Http = { fetch: (u, init) => fetch(u, init) };
if (!apiKey) {
console.error("[ombi-servarr] no ombi API key — ombi's own `api-key` secret has not been accepted");
process.exit(1);
}
const ombi = { url, apiKey };
if (!(await ombiReady(http, ombi, waitSeconds * 1000))) {
console.error(`[ombi-servarr] ombi did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
let failed = 0;
for (const spec of APPS) {
const outcome = await reconcileApp(
http,
ombi,
spec,
await readBinding(join(dir, `${spec.provision}.json`)),
await readIfThere(join(dir, `${spec.provision}.secret`)),
);
switch (outcome.result) {
case "unchanged":
console.log(`[ombi-servarr] ${outcome.app}: already as the mesh says; connection tested`);
break;
case "written":
console.log(`[ombi-servarr] ${outcome.app}: wrote ${outcome.fields.join(", ")}; connection tested`);
break;
case "refused":
failed++;
console.error(`[ombi-servarr] ${outcome.app}: ${outcome.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
+304
View File
@@ -0,0 +1,304 @@
// Where ombi reaches Sonarr, Radarr and Lidarr — decided by the mesh, written into ombi by ombi's
// own API.
//
// **Why this exists.** ombi keeps its connection to each Servarr app in its own database
// (OmbiSettings.db), not in a file, so the mesh has nowhere to write `${bound:sonarr-api:at}` for it.
// ombi requires `sonarr-api`, `radarr-api` and `lidarr-api`; the mesh delivers, for each, a binding
// (where the app is: `at`, and what it serves: `port`, `scheme`, `url-base`) and a pair credential
// (the app's API key, accepted by the operator — a Servarr app has exactly one key and the mesh
// cannot mint it). This step reads those files and makes ombi's settings say the same thing.
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path and API key. The
// quality profile, root folder, language profile, tags, "enabled" and every other choice an operator
// made in ombi's settings screen are left exactly as they are: the mesh knows where the app is, not
// what ombi should do with it. Radarr's 4K instance is a different Radarr and is not touched.
//
// **A credential the app refuses is never written.** Until the operator accepts the app's API key
// for this pair, the mesh delivers a value it minted itself, which no Servarr app will ever accept
// (novox/hq ADR 0092). Writing it would replace a working key in ombi with a dead one. So the key is
// tried against the app first; refused, nothing for that app is written and the step fails naming
// the `secret accept` that fixes it.
//
// Pure logic and a small HTTP seam, so it is tested against fake servers (test/servarr.test.ts).
import { readFile } from "node:fs/promises";
/** One Servarr app ombi connects to, and the shape of that connection in ombi's API. */
export interface ServarrApp {
/** The app, as ombi's API names it: /Settings/<app>, /Tester/<app>. */
app: "sonarr" | "radarr" | "lidarr";
/** The provision it is required as — the manifest's `requires`, `binds` and `secrets` key. */
provision: string;
/** The app's own status endpoint, which answers 401 to a wrong key. */
statusPath: string;
/**
* Where the one connection sits in ombi's settings document. Radarr's is `{radarr, radarr4K}`
* (two Radarr instances); only `radarr` is this provision's.
*/
within?: string;
}
export const APPS: readonly ServarrApp[] = [
{ app: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" },
{ app: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status", within: "radarr" },
{ app: "lidarr", provision: "lidarr-api", statusPath: "/api/v1/system/status" },
];
/** The connection fields ombi keeps for an app — the only ones this step ever writes. */
export interface Connection {
ip: string;
port: number;
ssl: boolean;
/** ombi's name for the app's URL base; null when the app is served at the root. */
subDir: string | null;
apiKey: string;
}
/** What the mesh wrote at `binds.<provision>`: the binding document (controller's boundFile). */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export type Wanted = { ok: true; connection: Connection; from: string } | { ok: false; problem: string };
/**
* The connection the mesh says ombi should use, from the binding and the pair credential.
*
* Refused rather than guessed when the binding cannot be dialled from ombi's own container: a
* loopback `at` — what the mesh hands a machine that is not on the private network — is ombi's
* container itself, not the app.
*/
export function wanted(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Wanted {
if (!binding) {
return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` };
}
const at = typeof binding.at === "string" ? binding.at.trim() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!at) {
return { ok: false, problem: `the ${spec.provision} binding names no host (at)` };
}
if (isLoopback(at)) {
return {
ok: false,
problem:
`the ${spec.provision} binding says ${spec.app} is at ${at}, which from ombi's own container is ` +
`ombi itself. The mesh hands loopback to a machine that is not on the private network; put it ` +
`on the private network so ${spec.app} has an address ombi can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(serves.port)})` };
}
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") {
return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}, which ombi cannot dial` };
}
const key = (credential ?? "").trim();
if (!key) {
return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` };
}
return {
ok: true,
from: typeof binding.from === "string" ? binding.from : "",
connection: { ip: at, port, ssl: scheme === "https", subDir: subDirOf(serves["url-base"]), apiKey: key },
};
}
/** ombi's `subDir`: the URL base with its slashes trimmed, null when there is none. */
export function subDirOf(urlBase: unknown): string | null {
const trimmed = typeof urlBase === "string" ? urlBase.trim().replace(/^\/+|\/+$/g, "") : "";
return trimmed === "" ? null : trimmed;
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/** Which connection fields differ between what ombi holds and what the mesh says. Names only. */
export function differing(current: Record<string, unknown> | undefined, want: Connection): (keyof Connection)[] {
const now = current ?? {};
const out: (keyof Connection)[] = [];
if (String(now.ip ?? "") !== want.ip) out.push("ip");
if (Number(now.port ?? 0) !== want.port) out.push("port");
if (Boolean(now.ssl) !== want.ssl) out.push("ssl");
if (subDirOf(now.subDir) !== want.subDir) out.push("subDir");
if (String(now.apiKey ?? "") !== want.apiKey) out.push("apiKey");
return out;
}
/** ombi's settings for the app with the connection laid over them and nothing else changed. */
export function withConnection(current: Record<string, unknown> | undefined, want: Connection): Record<string, unknown> {
return { ...(current ?? {}), ip: want.ip, port: want.port, ssl: want.ssl, subDir: want.subDir, apiKey: want.apiKey };
}
/** The app's base URL as the step dials it — the same host and port ombi will be given. */
export function appUrl(want: Connection): string {
const scheme = want.ssl ? "https" : "http";
const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip;
return `${scheme}://${host}:${want.port}${want.subDir ? `/${want.subDir}` : ""}`;
}
/** How one app came out. */
export type Outcome =
| { app: string; result: "unchanged" }
| { app: string; result: "written"; fields: string[] }
| { app: string; result: "refused"; problem: string };
/** The HTTP the step needs, so a test can stand fakes in for ombi and the apps. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<{
status: number;
text(): Promise<string>;
}>;
}
export interface Ombi {
url: string;
apiKey: string;
}
async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise<unknown> {
const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1${path}`, {
method,
headers: {
ApiKey: ombi.apiKey,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (res.status < 200 || res.status >= 300) {
// The body is ombi's error, never a request echo, so it carries no key.
throw new Error(`ombi ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
}
return text ? (JSON.parse(text) as unknown) : undefined;
}
/**
* Does the app take this key? `true` it does, `false` it refused it (401/403), and a thrown error
* when it could not be asked — unreachable, or answering something that is neither.
*/
export async function appTakes(http: Http, spec: ServarrApp, want: Connection): Promise<boolean> {
const res = await http.fetch(`${appUrl(want)}${spec.statusPath}`, {
method: "GET",
headers: { "X-Api-Key": want.apiKey, Accept: "application/json" },
});
if (res.status === 401 || res.status === 403) return false;
if (res.status >= 200 && res.status < 300) return true;
throw new Error(`${spec.app} answered ${res.status} at ${spec.statusPath}`);
}
/** The remedy for a refused key, in the controller's own words (ADR 0092). */
export function acceptRemedy(spec: ServarrApp, from: string): string {
return (
`${spec.app} refuses the ${spec.provision} credential the mesh delivered, so it was not written ` +
`into ombi. A Servarr app has one API key and the mesh cannot make it: accept ${spec.app}'s own ` +
`key for this pair — \`secret accept <this node> ombi ${spec.provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${spec.app}'s ApiKey>\``
);
}
/**
* Bring ombi's connection to one app in line with the mesh: check the key against the app, compare,
* write only the connection fields when they differ, then have ombi test the connection from its own
* container. Never throws: every failure is an outcome with a reason.
*/
export async function reconcileApp(
http: Http,
ombi: Ombi,
spec: ServarrApp,
binding: Binding | undefined,
credential: string | undefined,
): Promise<Outcome> {
const w = wanted(spec, binding, credential);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) return { app: spec.app, result: "refused", problem: w.problem };
const want = w.connection;
try {
if (!(await appTakes(http, spec, want))) {
return { app: spec.app, result: "refused", problem: acceptRemedy(spec, w.from) };
}
} catch (err) {
return {
app: spec.app,
result: "refused",
problem: `${spec.app} could not be asked whether it takes the key at ${want.ip}:${want.port}: ${message(err)}`,
};
}
try {
const document = (await ombiCall(http, ombi, "GET", `/Settings/${spec.app}`)) as Record<string, unknown> | undefined;
const current = spec.within ? (document?.[spec.within] as Record<string, unknown> | undefined) : document;
const fields = differing(current, want);
if (fields.length > 0) {
const next = withConnection(current, want);
const body = spec.within ? { ...(document ?? {}), [spec.within]: next } : next;
const saved = await ombiCall(http, ombi, "POST", `/Settings/${spec.app}`, body);
if (saved === false) {
return { app: spec.app, result: "refused", problem: `ombi declined to save its ${spec.app} settings` };
}
}
// ombi's own test, from ombi's own container — the path the step's check above did not take.
const tested = (await ombiCall(http, ombi, "POST", `/Tester/${spec.app}`, withConnection(current, want))) as
| { isValid?: boolean; expectedSubDir?: string | null }
| undefined;
if (!tested?.isValid) {
const hint = tested?.expectedSubDir ? ` (ombi expected the base path ${tested.expectedSubDir})` : "";
return {
app: spec.app,
result: "refused",
problem:
`ombi cannot reach ${spec.app} at ${want.ip}:${want.port} from its own container${hint}` +
(fields.length > 0 ? `; its settings were written (${fields.join(", ")})` : ""),
};
}
return fields.length > 0 ? { app: spec.app, result: "written", fields } : { app: spec.app, result: "unchanged" };
} catch (err) {
return { app: spec.app, result: "refused", problem: message(err) };
}
}
/** Wait for ombi to answer, because the step runs right after its container starts. */
export async function ombiReady(http: Http, ombi: Ombi, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1/Status`, { method: "GET" });
if (res.status === 200) return true;
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, pauseMs));
}
}
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A binding file parsed, or undefined when absent or not JSON. */
export async function readBinding(path: string | undefined): Promise<Binding | undefined> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as Binding;
} catch {
return undefined;
}
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+147
View File
@@ -0,0 +1,147 @@
// What holds ombi's Servarr step (servarr/settings.ts): the connection ombi keeps for each app is
// made to say what the mesh bound — host, port, TLS, base path, key — and nothing else it keeps is
// touched; nothing is written when nothing differs; Radarr's 4K instance is left alone; and a key the
// app refuses (the mesh's own minted value, before the operator accepts the app's key) is never
// written, with the `secret accept` that fixes it named.
//
// ombi and the apps are fakes: the routes the step touches, answering as the real ones do (checked
// against lscr.io/linuxserver/ombi 4.53.10 and the catalogue's pinned sonarr/radarr/lidarr).
import { test } from "node:test";
import assert from "node:assert/strict";
import { APPS, differing, reconcileApp, subDirOf, wanted, type Binding, type Http, type ServarrApp } from "../servarr/settings.ts";
const SONARR = APPS.find((a) => a.app === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.app === "radarr") as ServarrApp;
const LIDARR = APPS.find((a) => a.app === "lidarr") as ServarrApp;
const THE_KEY = "the-apps-own-key";
function binding(provision: string, port: number, at = "ace.internal"): Binding {
return { binding: 1, provision, from: "ace", at, as: "mesh_ace_ombi", serves: { scheme: "http", port, "url-base": "" } } as Binding;
}
interface Call {
method: string;
url: string;
body?: unknown;
}
/** ombi's settings store and the apps' key check, behind one fetch. */
function fakes(settings: Record<string, unknown>, opts: { appKey?: string; reachable?: boolean } = {}) {
const calls: Call[] = [];
const appKey = opts.appKey ?? THE_KEY;
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
const body = init?.body ? (JSON.parse(init.body) as unknown) : undefined;
calls.push({ method, url, body });
const reply = (status: number, value?: unknown) => ({
status,
text: async () => (value === undefined ? "" : JSON.stringify(value)),
});
const u = new URL(url);
if (u.pathname.endsWith("/system/status")) {
if (opts.reachable === false) throw new Error("connect ECONNREFUSED");
return init?.headers?.["X-Api-Key"] === appKey ? reply(200, { version: "4" }) : reply(401);
}
if (init?.headers?.ApiKey !== "ombi-key") return reply(401);
const m = u.pathname.match(/^\/api\/v1\/(Settings|Tester)\/(\w+)$/);
if (!m) return reply(404);
const [, kind, app] = m;
if (kind === "Settings" && method === "GET") return reply(200, settings[app]);
if (kind === "Settings" && method === "POST") {
settings[app] = body;
return reply(200, true);
}
const tried = body as { apiKey?: string };
return reply(200, { isValid: tried.apiKey === appKey, expectedSubDir: null });
},
};
return { http, calls, settings };
}
const OMBI = { url: "http://127.0.0.1:3579", apiKey: "ombi-key" };
const operatorSonarr = () => ({
enabled: true, apiKey: "old-key", qualityProfile: "3", seasonFolders: true, rootPath: "10",
qualityProfileAnime: "7", rootPathAnime: "9", languageProfile: 1, ssl: false, subDir: null,
ip: "sonarr", port: 8989, id: 5,
});
test("it writes the connection the mesh bound, and keeps every other setting ombi had", async () => {
const f = fakes({ sonarr: operatorSonarr() });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), `${THE_KEY}\n`);
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["ip", "port", "apiKey"] });
assert.deepEqual(f.settings.sonarr, {
...operatorSonarr(), ip: "ace.internal", port: 20101, apiKey: THE_KEY, ssl: false, subDir: null,
});
// Checked against the app itself, at the bound address, before anything was written.
assert.equal(f.calls[0].url, "http://ace.internal:20101/api/v3/system/status");
});
test("nothing is written when ombi already says what the mesh says", async () => {
const f = fakes({ sonarr: { ...operatorSonarr(), ip: "ace.internal", port: 20101, apiKey: THE_KEY } });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), THE_KEY);
assert.deepEqual(out, { app: "sonarr", result: "unchanged" });
assert.equal(f.calls.filter((c) => c.method === "POST" && c.url.includes("/Settings/")).length, 0);
});
test("a key the app refuses is never written, and the accept that fixes it is named", async () => {
const f = fakes({ sonarr: operatorSonarr() });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), "a-value-the-mesh-minted");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> ombi sonarr-api --provider ace/);
assert.doesNotMatch((out as { problem: string }).problem, /a-value-the-mesh-minted/);
assert.deepEqual(f.settings.sonarr, operatorSonarr(), "ombi's working settings were left alone");
assert.equal(f.calls.some((c) => c.url.includes("/api/v1/")), false, "ombi was not even asked");
});
test("an app it cannot reach is reported, and ombi is left alone", async () => {
const f = fakes({ sonarr: operatorSonarr() }, { reachable: false });
const out = await reconcileApp(f.http, OMBI, SONARR, binding("sonarr-api", 20101), THE_KEY);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /could not be asked.*ECONNREFUSED/);
assert.deepEqual(f.settings.sonarr, operatorSonarr());
});
test("radarr's connection is written inside its combined document, and the 4K instance is untouched", async () => {
const fourK = { enabled: true, apiKey: "4k-key", ip: "radarr4k", port: 7879, defaultQualityProfile: "9", id: 7 };
const f = fakes({ radarr: { radarr: { enabled: true, apiKey: "old", ip: "radarr", port: 7878, defaultRootPath: "/movies", id: 6 }, radarr4K: fourK } });
const out = await reconcileApp(f.http, OMBI, RADARR, binding("radarr-api", 20102), THE_KEY);
assert.equal(out.result, "written");
const doc = f.settings.radarr as { radarr: Record<string, unknown>; radarr4K: unknown };
assert.deepEqual(doc.radarr4K, fourK);
assert.equal(doc.radarr.ip, "ace.internal");
assert.equal(doc.radarr.port, 20102);
assert.equal(doc.radarr.defaultRootPath, "/movies");
});
test("lidarr is checked on its own API version", async () => {
const f = fakes({ lidarr: { enabled: true, apiKey: null, ip: null, port: 0, id: 0 } });
const out = await reconcileApp(f.http, OMBI, LIDARR, binding("lidarr-api", 20103), THE_KEY);
assert.equal(out.result, "written");
assert.equal(f.calls[0].url, "http://ace.internal:20103/api/v1/system/status");
});
test("a loopback binding is refused: from ombi's container it is ombi itself", () => {
const w = wanted(SONARR, binding("sonarr-api", 20101, "127.0.0.1"), THE_KEY);
assert.equal(w.ok, false);
assert.match((w as { problem: string }).problem, /private network/);
});
test("the base path is ombi's subDir, slashes trimmed; empty is none", () => {
assert.equal(subDirOf(""), null);
assert.equal(subDirOf("/sonarr/"), "sonarr");
assert.deepEqual(
differing({ ip: "h", port: 1, ssl: false, subDir: "", apiKey: "k" }, { ip: "h", port: 1, ssl: false, subDir: null, apiKey: "k" }),
[],
);
});
test("an https binding sets ombi's ssl flag", () => {
const b = binding("sonarr-api", 443);
(b.serves as Record<string, unknown>).scheme = "https";
const w = wanted(SONARR, b, THE_KEY);
assert.equal(w.ok && w.connection.ssl, true);
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"]
}
+1 -1
View File
@@ -105,7 +105,7 @@
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
],
"env": {
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable",
"MESH_PROVISION_POSTGRES_PORT": "${seat:mesh-store:5432}",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
"MESH_BROKER_FILE": "/run/secrets/broker",
+1 -1
View File
@@ -82,7 +82,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:8080",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:${port:8080}",
"MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password",
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
+14 -1
View File
@@ -1,6 +1,19 @@
{
"module": "radarr",
"version": "1",
"provides": [
{
"name": "radarr-api",
"scope": "mesh"
}
],
"serves": {
"radarr-api": {
"scheme": "http",
"port": 7878,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -75,7 +88,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RADARR_URL": "http://127.0.0.1:7878",
"MESH_RADARR_URL": "http://127.0.0.1:${port:7878}",
"MESH_RADARR_CONFIG_DIR": "/var/lib/radarr/config"
},
"artifact": "runtime"
+16 -14
View File
@@ -27,13 +27,13 @@
}
},
"receives": {
"redis-cache": "${dir:grants}/mesh.json"
"redis-cache": "/var/lib/redis-module/grants/mesh.json"
},
"grants": {
"redis-cache": "${dir:grants}"
"redis-cache": "/var/lib/redis-module/grants"
},
"secrets": {
"secret": "${dir:state}/default.secret"
"secret": "/var/lib/redis-module/default.secret"
},
"own-secrets": {
"broker": "/var/lib/mesh/redis/broker"
@@ -57,27 +57,29 @@
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"path": "/var/lib/redis-module",
"mode": "0700"
},
{
"id": "grants",
"id": "grants-dir",
"type": "directory",
"path": "/var/lib/redis-module/grants",
"mode": "0700"
},
{
"id": "data",
"type": "directory",
"path": "/services/redis/data",
"mode": "0700",
"owner": "999:1000"
"owner": "999:999"
},
{
"id": "server-conf",
"type": "file",
"path": "${dir:state}/redis.conf",
"path": "/var/lib/redis-module/redis.conf",
"mode": "0600",
"content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n",
"owner": "999:1000"
"owner": "999:999"
},
{
"id": "net",
@@ -88,14 +90,14 @@
"id": "server",
"type": "container",
"name": "redis",
"image": "redis@sha256:520775a41a63e77e06c73e35d2fd9cc15921a609516818796b4ecbb813078bc7",
"image": "redis@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf",
"network": "redis",
"ports": [
"6379"
],
"volumes": [
"${dir:data}:/data",
"${dir:state}/redis.conf:/etc/redis/redis.conf:ro"
"/services/redis/data:/data",
"/var/lib/redis-module/redis.conf:/etc/redis/redis.conf:ro"
],
"args": [
"/etc/redis/redis.conf"
@@ -111,8 +113,8 @@
"network": "redis",
"volumes": [
"/var/lib/mesh/redis/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/redis-module/grants:ro",
"${dir:state}/default.secret:/run/secrets/default:ro"
"/var/lib/redis-module/grants:/var/lib/redis-module/grants:ro",
"/var/lib/redis-module/default.secret:/run/secrets/default:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
+1 -1
View File
@@ -100,7 +100,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SEARXNG_URL": "http://127.0.0.1:8080",
"MESH_SEARXNG_URL": "http://127.0.0.1:${port:8080}",
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
+14 -1
View File
@@ -1,6 +1,19 @@
{
"module": "sonarr",
"version": "1",
"provides": [
{
"name": "sonarr-api",
"scope": "mesh"
}
],
"serves": {
"sonarr-api": {
"scheme": "http",
"port": 8989,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -80,7 +93,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SONARR_URL": "http://127.0.0.1:8989",
"MESH_SONARR_URL": "http://127.0.0.1:${port:8989}",
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
},
"artifact": "runtime"
+21 -41
View File
@@ -10,35 +10,35 @@
"port": 8443,
"protocol": "tcp",
"from": "mesh",
"why": "the controller web UI and API, over its own self-signed tls; named through the proxy as an https route"
"why": "the controller web UI, over its own self-signed tls; reaching it from outside is a route grant later"
},
{
"name": "inform",
"port": 8080,
"protocol": "tcp",
"from": "mesh",
"why": "device inform, how APs and switches check in and are adopted; the controller tells devices this number, so the machine must publish it on the same one"
"why": "device inform \u2014 how APs and switches check in and are adopted"
},
{
"name": "stun",
"port": 3478,
"protocol": "udp",
"from": "mesh",
"why": "STUN for managed devices; the controller tells devices this number, so the machine must publish it on the same one"
"why": "STUN, so managed devices can find the controller through NAT"
},
{
"name": "discovery",
"port": 10001,
"protocol": "udp",
"from": "mesh",
"why": "device discovery broadcasts from unadopted devices and the UniFi apps"
"why": "device discovery \u2014 the controller finds unadopted devices on the network"
},
{
"name": "discovery-l2",
"port": 1900,
"port": 1902,
"protocol": "udp",
"from": "mesh",
"why": "make-controller-discoverable-on-L2 (SSDP); the software listens on 1900, which machines commonly have taken by another SSDP speaker"
"why": "layer-2 (UBNT) discovery broadcasts; published on 1902, the container listens on 1900"
},
{
"name": "portal-tls",
@@ -76,15 +76,10 @@
"path": "/var/lib/mesh/unifi",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "data",
"type": "directory",
"path": "/services/unifi/data",
"mode": "0700",
"owner": "1000:1000"
},
@@ -92,17 +87,17 @@
"id": "server",
"type": "container",
"name": "unifi-controller",
"image": "lscr.io/linuxserver/unifi-controller@sha256:0ae315a3a45635e443899e30e86bd507c2c48922cb27f4bc7241777885f4650e",
"image": "lscr.io/linuxserver/unifi-controller@sha256:fcd5d8b13a77a588c79c1b49e5fc9ad08115aa3bb1a3576c589c64908a68845f",
"ports": [
"8443",
"8080",
"3478/udp",
"10001/udp",
"1900/udp",
"8843",
"8880",
"6789",
"5514/udp"
"8443:8443",
"8080:8080",
"3478:3478/udp",
"10001:10001/udp",
"1902:1900/udp",
"8843:8843",
"8880:8880",
"6789:6789",
"5514:5514/udp"
],
"env": {
"PUID": "1000",
@@ -112,7 +107,7 @@
"MEM_STARTUP": "1024"
},
"volumes": [
"${dir:data}:/config"
"/services/unifi/data:/config"
]
},
{
@@ -120,7 +115,7 @@
"type": "file",
"path": "/var/lib/mesh/unifi/config.json",
"mode": "0600",
"content": "{\n \"site\": \"default\",\n \"password\": \"${secret:controller}\"\n}\n",
"content": "{}\n",
"merge": "json"
},
{
@@ -134,7 +129,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_UNIFI_URL": "https://127.0.0.1:${port:8443}",
"MESH_UNIFI_URL": "https://127.0.0.1:8443",
"MESH_UNIFI_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
@@ -143,23 +138,8 @@
"artifact": "runtime"
}
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "unifi",
"endpoint": "web",
"scheme": "https",
"insecure": true
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"own-secrets": {
"broker": "/var/lib/mesh/unifi/broker",
"controller": "/var/lib/mesh/unifi/controller"
"broker": "/var/lib/mesh/unifi/broker"
},
"build": {
"on": [