Compare 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
32 changed files with 1255 additions and 752 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"
+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"
+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
@@ -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"
+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"