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
mesh-admin 8064e5da8f Merge pull request 'searxng: its settings are a file the mesh writes, not the image's defaults' (#143) from feat/searxng-settings-as-a-file into main 2026-09-29 20:45:13 +00:00
jschoubben 63a255c5cb searxng: bind its route where its state now lives
The route binding still named /var/lib/searxng-module, the directory the
previous commit placed elsewhere — the host would have written it into a
directory nothing declares. Same shape as gitea and nextcloud.
2026-09-29 22:41:56 +02:00
jschoubben 7ad1fbd5c6 searxng: its settings are a file the mesh writes, not the image's defaults
The module ran searxng on the image's built-in settings, which serve html
only — so the module's own search tool (format=json) was refused by the
software it fronts. And there was no way to configure it per machine: the
only file settings reach was the sidecar's.

settings.yml is now the module's one mergeable file (JSON is YAML): generic
defaults in the manifest (json format on, limiter and image proxy off,
valkey wired), and whatever differs per machine — base_url, method,
autocomplete, suspended times — set as the assignment's settings. The
secret key is filled on the machine through ${secret:secret}, so the
secrets-in-environment exception and the env file go. Directories are
placed. Image pinned to 2026.9.20, what ace runs today (the old pin was
older, 2026.9.1).

The sidecar's config.json is no longer mergeable: settings merge into every
mergeable file of a module, and the sidecar would have received searxng's
keys. It only ever read an optional url, which its env already carries.

Verified on ace: the pinned image serves html and json from a read-only,
root-owned 0600 JSON settings.yml.
2026-09-29 22:33:38 +02:00
jschoubben 67f5f4cffd Merge pull request 'ca-trust: a machine trusts the mesh's authority because a module put its root there' (#142) from feat/ca-trust into main 2026-09-29 14:06:36 +00:00
jschoubben 8797335fbc ca-trust: a machine trusts the mesh's authority because a module put its root there
novox/hq ADR 0147, issue 129. Every internal HTTPS name fails verification
on every machine: the certificates are genuine and nothing on a machine has
ever been told what issued them. The proxy's fetch answers for the proxy and
for nothing else — a browser, git over HTTPS and every module calling another
by an internal name read the machine's own trust store.

The module requires internal-acme-ca, fetches the root over the mesh's own
network (no prior trust to have; that is what this establishes), installs it
among the machine's anchors and refreshes the extracted bundles. Being
unassigned stops the unit, and stopping it takes the anchor away and
refreshes them again.

Arch's layout is named out loud: a machine that keeps anchors elsewhere fails
visibly rather than writing a file nothing reads.
2026-09-29 15:07:40 +02:00
mesh-admin 53dc108603 Merge pull request 'Remove the network-checker module: it does not do what was decided' (#141) from chore/remove-the-network-checker-module into main 2026-09-29 12:43:34 +00:00
jschoubben ebf5ba2d4c Remove the network-checker module: it does not do what was decided
What was in the catalogue was the first thing I built, not the thing ADR 0146
describes. It dialled raw ports on machine addresses from one hosting form and
emitted nothing, so findings would have sat in a file on the machine — the exact
thing issue 145 is about. It was never registered, never assigned, and never ran.

0146 says names per hosting form, fetched over TLS with the certificate verified,
and machines discovered over the bus. That shares nothing with this but the word
checker, so it goes rather than being bent into shape. Recorded as work to be
analysed and built deliberately.

Connectivity is checked by hand in the meantime, against the services the mesh
already runs.
2026-09-29 14:43:25 +02:00
mesh-admin bbac08a7d2 Merge pull request 'A network-checker module: dial what the mesh claims, from where the callers are' (#140) from feat/a-network-checker-module into main 2026-09-29 11:44:37 +00:00
31 changed files with 1319 additions and 480 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"
+56
View File
@@ -0,0 +1,56 @@
{
"module": "ca-trust",
"version": "1",
"slug": "catrust",
"capabilities": [
"service-manager"
],
"requires": [
"internal-acme-ca"
],
"seats": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"claims": [
{
"name": "the-mesh-trust-anchor",
"scope": "node"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "anchor",
"type": "file",
"path": "${dir:state}/anchor",
"mode": "0755",
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
},
{
"id": "unit",
"type": "file",
"path": "/etc/systemd/system/mesh-ca-trust.service",
"mode": "0644",
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
},
{
"id": "trust",
"type": "service",
"unit": "mesh-ca-trust.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"anchor",
"unit"
]
}
]
}
+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"
-84
View File
@@ -1,84 +0,0 @@
// Dial everything the mesh claims is reachable, and say what was found (novox/hq ADR 0145).
//
// Runs on a cadence, from this machine, in this module's own container — the same position every other
// module on the machine calls from. That is the whole point: a check run by the host or by the control
// plane reaches these addresses by a path no ordinary caller uses, and would have passed throughout the
// outage that produced this module (novox/hq 04-ISSUES/145).
//
// It reports and does nothing else. A checker that repaired things would be a second control plane.
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
import { dial, tally, targetsFor, type Counts, type Result, type Roster } from "../reach.js";
/** Where the mesh renders this machine's view of the others, and where the counts are kept between runs. */
const rosterFile = process.env.MESH_NETWORK_CHECKER_ROSTER ?? "/run/config/roster.json";
const stateDir = process.env.MESH_NETWORK_CHECKER_STATE ?? "/run/state";
const probePort = Number(process.env.MESH_NETWORK_CHECKER_PORT ?? "9876");
const publicPort = process.env.MESH_NETWORK_CHECKER_PUBLIC_PORT
? Number(process.env.MESH_NETWORK_CHECKER_PUBLIC_PORT)
: undefined;
const timeoutMs = Number(process.env.MESH_NETWORK_CHECKER_TIMEOUT_MS ?? "4000");
const threshold = Number(process.env.MESH_NETWORK_CHECKER_THRESHOLD ?? "2");
/** read is a JSON file or a stated failure — never a silent default, which is how a checker comes to
* report that everything is fine because it read nothing. */
function read<T>(path: string, whenMissing: T | null): T {
try {
return JSON.parse(readFileSync(path, "utf8")) as T;
} catch (err) {
if (whenMissing !== null) return whenMissing;
console.error(`network-checker: cannot read ${path}: ${(err as Error).message}`);
process.exit(1);
}
}
function writeAtomically(path: string, body: string): void {
mkdirSync(dirname(path), { recursive: true });
const temp = `${path}.writing`;
writeFileSync(temp, body);
renameSync(temp, path);
}
async function main(): Promise<void> {
const roster = read<Roster>(rosterFile, null);
if (!roster.machines?.length) {
console.error("network-checker: the roster names no machines; nothing to check");
process.exit(1);
}
const targets = targetsFor(roster, probePort, publicPort);
// In parallel, because a machine that is away should not delay the rest: a run that takes
// machines × timeout would outlast its own cadence on a mesh of any size.
const results: Result[] = await Promise.all(targets.map((t) => dial(t, timeoutMs)));
const countsFile = join(stateDir, "consecutive.json");
const { counts, broken } = tally(results, read<Counts>(countsFile, {}), threshold);
writeAtomically(countsFile, JSON.stringify(counts, null, 1));
// Written whole, every run: a reader asking "what does this machine reach" gets an answer about now
// rather than the last time something changed.
writeAtomically(join(stateDir, "reach.json"), JSON.stringify({
node: roster.node,
at: new Date().toISOString(),
checked: results.length,
broken: broken.length,
results,
}, null, 1));
for (const b of broken) {
console.error(
`network-checker: ${roster.node} cannot reach ${b.machine} (${b.claim}) at ${b.at}:${b.port} — ` +
`${b.failed} failed${b.detail ? `: ${b.detail}` : ""}, ${b.consecutive} run(s) running`);
}
if (broken.length === 0) {
console.log(`network-checker: ${roster.node} reaches all ${results.length} checked path(s)`);
}
// A broken path is not this process failing. It did its job; exiting non-zero would make the mesh
// read the checker as the fault, and a scheduled step that fails is retried rather than believed.
process.exit(0);
}
void main();
-61
View File
@@ -1,61 +0,0 @@
{
"module": "network-checker",
"version": "1",
"slug": "netcheck",
"listens": [
{
"name": "probe",
"port": 9876,
"protocol": "tcp",
"from": "mesh",
"why": "what the other machines' checkers dial. Deliberately this module's own endpoint and nothing else's: it is admitted by exactly the rule that governs every internally-exposed service, so it fails when that rule is wrong. A probe on a port that is never closed — ssh, say — would have passed throughout the outage this module exists to catch (novox/hq ADR 0145)"
}
],
"facts": {
"roster": {
"path": "/var/lib/network-checker/roster.json",
"template": "{\n \"generated\": \"by the mesh — do not edit; replaced whenever a machine joins or leaves\",\n \"node\": \"{{.Node}}\",\n \"machines\": [{{range $i, $m := .Machines}}{{if $i}},{{end}}\n { \"name\": \"{{$m.Name}}\", \"fqdn\": \"{{$m.FQDN}}\", \"address\": \"{{$m.Address}}\" }{{end}}\n ]\n}\n"
}
},
"build": {
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": ["probe/index.js", "check/index.js"]
}
]
},
"resources": [
{
"id": "state",
"type": "directory",
"path": "/var/lib/network-checker",
"mode": "0700"
},
{
"id": "probe",
"type": "container",
"name": "mesh-network-checker-probe",
"args": ["run", "/app/modules/network-checker/dist/probe/index.js"],
"ports": ["9876"],
"state": "running",
"env": { "MESH_NETWORK_CHECKER_PORT": "9876" }
},
{
"id": "check",
"type": "container",
"name": "mesh-network-checker-check",
"network": "host",
"schedule": "*/5 * * * *",
"args": ["run", "/app/modules/network-checker/dist/check/index.js"],
"volumes": ["/var/lib/network-checker:/run/state"],
"env": {
"MESH_NETWORK_CHECKER_ROSTER": "/run/state/roster.json",
"MESH_NETWORK_CHECKER_STATE": "/run/state",
"MESH_NETWORK_CHECKER_PORT": "${port:9876}"
}
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"name": "@novox/module-network-checker",
"version": "0.1.0",
"description": "network-checker — dials what the mesh claims is reachable, from where the callers are, and says what it found.",
"type": "module",
"private": true,
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
-31
View File
@@ -1,31 +0,0 @@
// The endpoint the other machines' checkers dial (novox/hq ADR 0145).
//
// **This module's own endpoint is the instrument.** It is declared reachable over the private network
// like any other service, so it is admitted by exactly the rule that governs every internally-exposed
// service and it fails when that rule is wrong. A probe on a port that is never closed — ssh, say —
// would have passed throughout the outage this module exists to catch.
//
// It accepts a connection and closes it. Answering anything would make this a protocol, and then the
// question would be whether the protocol worked rather than whether the path did.
import { createServer } from "node:net";
const port = Number(process.env.MESH_NETWORK_CHECKER_PORT ?? "9876");
const server = createServer((socket) => {
// Written before closing so a person dialling it by hand sees something, and so a half-open
// connection is not mistaken for a working path by a client that only checks the handshake.
socket.end("mesh network-checker\n");
});
server.on("error", (err: Error) => {
// Said and fatal: a probe that cannot listen must not look like a probe that nothing dialled.
console.error(`network-checker: cannot serve the probe on ${port}: ${err.message}`);
process.exit(1);
});
server.listen(port, () => console.log(`network-checker: probe listening on ${port}`));
for (const signal of ["SIGTERM", "SIGINT"] as const) {
process.on(signal, () => server.close(() => process.exit(0)));
}
-155
View File
@@ -1,155 +0,0 @@
// What the mesh claims is reachable, and how to find out (novox/hq ADR 0145).
//
// The mesh asserts three things are callable (ADR 0144): what runs on the same machine, another
// machine's service exposed to the private network, and another machine's service exposed publicly.
// This decides what to dial for each and reads the answers. It opens connections and nothing more —
// the module that owns a service is the one that knows whether it is working.
//
// **The target is this module's own endpoint, and that is deliberate.** The obvious thing to dial is a
// service every machine has, and the services every machine has are the ones never closed — ssh above
// all. Dialling one of those would have passed throughout the outage this exists to catch, because what
// broke was a service exposed to the private network and ssh is admitted unconditionally. A probe on a
// port that cannot fail measures nothing.
import { connect } from "node:net";
import { lookup } from "node:dns";
/** One machine as the mesh's roster describes it. */
export interface Machine {
name: string;
fqdn: string;
address: string;
/** The name this machine is reached by from outside, where it has one. Absent for most machines, and
* a machine with no public face has no public claim to check. */
public?: string;
}
/** The roster the mesh renders for this module: who this machine is, and who the others are. */
export interface Roster {
node: string;
machines: Machine[];
}
/** Which of the mesh's three claims a check is about, so a failure says which one broke. */
export type Claim = "this machine" | "the private network" | "the public network";
/** One thing to dial. */
export interface Target {
claim: Claim;
machine: string;
/** What to dial — a name where the point is that names resolve, an address where it is not. */
at: string;
port: number;
/** Whether `at` is a name that must resolve first, so a resolution failure is reported as one. */
byName: boolean;
}
/** What one dial found. */
export interface Result extends Target {
ok: boolean;
/** Which step failed, so a reader is sent to the right place: the resolver, or the filter. */
failed?: "resolution" | "connection";
detail?: string;
ms: number;
}
/**
* targetsFor is everything this machine should be able to reach, from the roster it was given.
*
* Its own machine first, because that is the case that distinguishes a caller on the machine from a
* caller in one of its containers — the one that broke. Then every other machine over the private
* network. The public claim is only checked where a public address is known for a machine, because a
* machine with no public face has nothing to fail.
*/
export function targetsFor(roster: Roster, probePort: number, publicPort?: number): Target[] {
const out: Target[] = [];
for (const m of roster.machines) {
const own = m.name === roster.node;
out.push({
claim: own ? "this machine" : "the private network",
machine: m.name,
at: m.address,
port: probePort,
byName: false,
});
// And by name, because a name that does not resolve and a port that does not answer are different
// faults with different owners.
out.push({
claim: own ? "this machine" : "the private network",
machine: m.name,
at: m.fqdn,
port: probePort,
byName: true,
});
}
if (publicPort !== undefined) {
for (const m of roster.machines) {
if (!m.public) continue;
out.push({
claim: "the public network",
machine: m.name,
at: m.public,
port: publicPort,
byName: true,
});
}
}
return out;
}
/** dial opens a connection and closes it. Whether the port accepts is the whole of what is asked. */
export function dial(target: Target, timeoutMs: number): Promise<Result> {
const began = Date.now();
const done = (ok: boolean, failed?: Result["failed"], detail?: string): Result => ({
...target, ok, failed, detail, ms: Date.now() - began,
});
return new Promise<Result>((resolve) => {
const open = () => {
const socket = connect({ host: target.at, port: target.port });
const finish = (r: Result) => { socket.destroy(); resolve(r); };
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(done(true)));
socket.once("timeout", () => finish(done(false, "connection", "timed out")));
socket.once("error", (err: Error) => finish(done(false, "connection", err.message)));
};
if (!target.byName) { open(); return; }
// Resolved first and reported separately: a checker that says "unreachable" for a name the
// resolver never answered sends a reader to the filter, which is not where the fault is.
lookup(target.at, (err) => {
if (err) { resolve(done(false, "resolution", err.message)); return; }
open();
});
});
}
/** A path's running count of consecutive failures, keyed so it survives between runs. */
export type Counts = Record<string, number>;
/** keyOf names one path, stably, so a count follows it across runs. */
export function keyOf(t: Target): string {
return `${t.claim}|${t.machine}|${t.at}|${t.port}`;
}
/**
* tally folds this run's results into the counts carried from the last one.
*
* **One failure is not a fault.** A machine rebooting is ordinary, and a checker that cries at the
* first missed dial trains a reader to ignore it — which is worse than not checking (ADR 0145). A path
* is broken once it has failed on consecutive runs, and the count travels with the result so a reader
* can tell "briefly away" from "never worked".
*/
export function tally(results: Result[], before: Counts, threshold: number): {
counts: Counts; broken: Array<Result & { consecutive: number }>;
} {
const counts: Counts = {};
const broken: Array<Result & { consecutive: number }> = [];
for (const r of results) {
const key = keyOf(r);
const n = r.ok ? 0 : (before[key] ?? 0) + 1;
if (n > 0) counts[key] = n;
if (n >= threshold) broken.push({ ...r, consecutive: n });
}
return { counts, broken };
}
@@ -1,72 +0,0 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { keyOf, tally, targetsFor, type Result, type Roster } from "../reach.js";
const roster: Roster = {
node: "here",
machines: [
{ name: "here", fqdn: "here.internal", address: "10.0.0.1" },
{ name: "there", fqdn: "there.internal", address: "10.0.0.2", public: "there.example.test" },
],
};
test("its own machine is checked, which is the case that distinguishes a caller on it from one in a container", () => {
const own = targetsFor(roster, 9876).filter((t) => t.claim === "this machine");
assert.equal(own.length, 2, "its own machine by address and by name");
assert.ok(own.some((t) => t.at === "10.0.0.1" && !t.byName));
assert.ok(own.some((t) => t.at === "here.internal" && t.byName));
});
test("every other machine is checked over the private network", () => {
const other = targetsFor(roster, 9876).filter((t) => t.claim === "the private network");
assert.deepEqual(other.map((t) => t.machine), ["there", "there"]);
});
test("the public claim is only checked where a machine has a public name", () => {
const pub = targetsFor(roster, 9876, 443).filter((t) => t.claim === "the public network");
assert.equal(pub.length, 1, "only the machine with a public name");
assert.equal(pub[0]!.at, "there.example.test");
assert.equal(pub[0]!.port, 443);
});
test("no public claim is made when no public port was given", () => {
assert.equal(targetsFor(roster, 9876).filter((t) => t.claim === "the public network").length, 0);
});
const failed = (at: string): Result => ({
claim: "this machine", machine: "here", at, port: 9876, byName: false,
ok: false, failed: "connection", ms: 1,
});
const passed = (at: string): Result => ({
claim: "this machine", machine: "here", at, port: 9876, byName: false, ok: true, ms: 1,
});
test("one failure is not a fault — a machine rebooting is ordinary", () => {
const { counts, broken } = tally([failed("10.0.0.1")], {}, 2);
assert.equal(broken.length, 0, "one missed dial says nothing");
assert.equal(counts[keyOf(failed("10.0.0.1"))], 1, "and is remembered");
});
test("a path that keeps failing is broken, and the count travels with it", () => {
const first = tally([failed("10.0.0.1")], {}, 2);
const second = tally([failed("10.0.0.1")], first.counts, 2);
assert.equal(second.broken.length, 1);
assert.equal(second.broken[0]!.consecutive, 2, "so a reader can tell briefly away from never worked");
});
test("a path that recovers stops being counted", () => {
const first = tally([failed("10.0.0.1")], {}, 2);
const second = tally([passed("10.0.0.1")], first.counts, 2);
assert.equal(second.broken.length, 0);
assert.deepEqual(second.counts, {}, "nothing carried forward for a path that works");
});
test("a count follows one path and not another", () => {
const a = failed("10.0.0.1");
const b = failed("10.0.0.2");
const first = tally([a, b], {}, 2);
const second = tally([a], first.counts, 2);
assert.equal(second.broken.length, 1, "only the path dialled this run is judged");
assert.equal(second.broken[0]!.at, "10.0.0.1");
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["reach.ts", "probe/index.ts", "check/index.ts", "test/*.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"
+22 -21
View File
@@ -5,7 +5,7 @@
"container-runtime"
],
"own-secrets": {
"secret": "/var/lib/searxng-module/secret.secret",
"secret": "/var/lib/mesh/searxng/secret",
"broker": "/var/lib/mesh/searxng/broker"
},
"listens": [
@@ -27,22 +27,14 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/searxng-module",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "valkey-data",
"type": "directory",
"path": "/var/lib/searxng-module/valkey-data",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/searxng-module/server.env",
"mode": "0600",
"content": "SEARXNG_SECRET=${secret:secret}\nSEARXNG_VALKEY_URL=valkey://valkey:6379/0\n"
},
{
"id": "net",
"type": "network",
@@ -63,30 +55,39 @@
"warning"
],
"volumes": [
"/var/lib/searxng-module/valkey-data:/data"
"${dir:valkey-data}:/data"
]
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.yml",
"mode": "0600",
"merge": "json",
"content": "{\n \"use_default_settings\": true,\n \"server\": {\n \"secret_key\": \"${secret:secret}\",\n \"base_url\": false,\n \"limiter\": false,\n \"image_proxy\": false,\n \"public_instance\": false\n },\n \"search\": {\n \"formats\": [\"html\", \"json\"]\n },\n \"valkey\": {\n \"url\": \"valkey://valkey:6379/0\"\n }\n}\n"
},
{
"id": "server",
"type": "container",
"name": "searxng",
"image": "searxng/searxng@sha256:c7cc75852051bf6254afda6ed1b920dd1677d8efe4ab141bf558f02e582f4371",
"image": "searxng/searxng@sha256:cd8812607ab73730a0b1a0dc4990223fe1b9e383f6f35947114d0bef7f8bb441",
"network": "searxng",
"env-file": [
"/var/lib/searxng-module/server.env"
],
"ports": [
"8080"
],
"secrets-in-environment": "SEARXNG_SECRET is env-only, but settings.yml carries server.secret_key; convertible by mounting a generated settings.yml, not yet done"
"volumes": [
"${dir:state}/settings.yml:/etc/searxng/settings.yml:ro"
],
"restart-on": [
"settings"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/searxng/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
"content": "{}\n"
},
{
"id": "runtime",
@@ -99,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": [
@@ -118,7 +119,7 @@
}
},
"binds": {
"route": "/var/lib/searxng-module/route.json"
"route": "${dir:state}/route.json"
},
"build": {
"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"