Compare commits

..
Author SHA1 Message Date
jschoubben 991e33f749 tautulli: reach plex through the mesh, written before Tautulli starts
Tautulli reached plex at 172.18.0.1, the gateway of a HAL network that goes
away with HAL, and the plan was to retype it by hand in the window. Tautulli
now requires plex-api, and where plex is comes from the binding.

Tautulli keeps the connection only in config.ini, reads it at start and
writes its whole config back on every shutdown; its API cannot set it and
its settings form needs an admin login. So a step after start would be
overwritten the moment the container is recreated. The write is made where
nothing can overwrite it: the linuxserver image's custom-init runs
plex/mesh-plex.py as root before Tautulli starts, and the server restarts on
its binding and credential, so a moved plex or an accepted token lands.

It writes only [PMS] keys, only when they differ, every other line byte for
byte: pms_ip, pms_port, pms_ssl and pms_url from the binding; pms_identifier
from plex's /identity; pms_token only when plex takes it. A minted value -
before the operator accepts the server's X-Plex-Token for this pair - is
never written, while the address still is, so Tautulli's own working token
keeps working at plex's new address.

A failure in custom-init is a log line nobody reads, so a run-once `plex`
step, declared last so it gates nothing (ADR 0136), checks what the mesh can
report: plex takes the credential (else it names the secret accept), Tautulli
holds the bound URL, and Tautulli says it is connected. It writes nothing.

The script is kept as plex/mesh-plex.py and plex/50-mesh-plex; module.json
carries copies, and a test fails when they differ. Tests run the script with
python3 against a fake plex (skipped where there is none) and the step
against fakes; `npm test` builds first.
2026-09-30 13:09:43 +02:00
jschoubben 3e378170df tautulli: its config is placed, it runs the build in use, and its runtime finds its own key
The module stated /services/tautulli/config and /var/lib/mesh/tautulli/route.json,
novox's layout, which no definition may carry (ADR 0112). Tautulli's config dir is
now a placed directory (${dir:config}) and the route binds into the placed state
(${dir:state}), as searxng and mosquitto do.

The image was pinned to v2.18.1-ls242; ace runs ls244 (2026-09-11), and Tautulli
migrates its own database schema, so the pin moves to the digest ace runs.

The runtime called http://127.0.0.1:8181, which is the software's port, not the
machine port the mesh assigns; it now asks with ${port:8181}, as gitea does.

The runtime mounted Tautulli's config dir read-only but never read it: its API key
could only come from MESH_TAUTULLI_APIKEY or the settings-merged config.json, i.e.
a secret in settings. Tautulli mints and owns that key in its config.ini, so the
runtime now reads it from there. Nothing for the mesh to mint or accept.

Verified: catalogue tests with MESH_CATALOGUE pointing here (not skipped); the
pinned image started in a throwaway container on a dir owned 1001:2000 with
PUID/PGID 1000 answers /status 200 and re-owns /config to 1000:1000 on start;
client.ts, run under node, read the key from that instance's config.ini and got
success from get_activity and get_history; without a config.ini it throws, which
the tools and events entrypoints already treat as "not configured".
2026-09-29 23:41:47 +02:00
33 changed files with 902 additions and 1255 deletions
+1 -4
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 apikey.ts client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,6 +22,3 @@ 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
@@ -1,37 +0,0 @@
// 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;
}
+3 -9
View File
@@ -5,8 +5,6 @@
import { readFileSync } from "node:fs";
import { apiKeyFromConfigDir } from "./apikey.js";
export interface WantedSubtitle {
kind: "episode" | "movie";
title: string; // series + episode, or movie title
@@ -49,7 +47,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() || undefined; }
try { return readFileSync(file, "utf8").trim(); }
catch { return undefined; }
}
@@ -68,13 +66,9 @@ 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 ??
apiKeyFromConfigDir(env.MESH_BAZARR_CONFIG_DIR) ??
readSecret(env.MESH_BAZARR_API_KEY_FILE) ??
env.MESH_BAZARR_API_KEY;
const apiKey = cfg.apiKey ?? 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 — bazarr's config/config.yaml under MESH_BAZARR_CONFIG_DIR has none");
if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY");
return new BazarrClient(url, apiKey);
}
+13 -53
View File
@@ -8,7 +8,8 @@
"subtitle.downloaded"
],
"own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker"
"broker": "/var/lib/mesh/bazarr/broker",
"api-key": "/var/lib/mesh/bazarr/api-key"
},
"listens": [
{
@@ -44,15 +45,10 @@
"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"
},
@@ -60,7 +56,7 @@
"id": "server",
"type": "container",
"name": "bazarr",
"image": "lscr.io/linuxserver/bazarr@sha256:d24bd0048c759a468970989e9df11a6b96a7628d556d00f923e60a35ba59237b",
"image": "lscr.io/linuxserver/bazarr@sha256:3a820372f19fcb2981ea19fe4b5382934d67414afaba974bce831ddda0a64a02",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -70,7 +66,7 @@
"6767"
],
"volumes": [
"${dir:config}:/config",
"/services/bazarr/config:/config",
"/services/media/movies:/movies",
"/services/media/series:/series",
"/services/media/anime:/anime",
@@ -80,7 +76,7 @@
{
"id": "runtime-config",
"type": "file",
"path": "${dir:state}/config.json",
"path": "/var/lib/mesh/bazarr/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
@@ -92,12 +88,14 @@
"network": "host",
"volumes": [
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/bazarr/config: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"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
},
@@ -105,42 +103,10 @@
"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": [
"radarr-api",
"route",
"sonarr-api"
"route"
],
"contributes": {
"route": {
@@ -149,13 +115,7 @@
}
},
"binds": {
"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"
"route": "/var/lib/mesh/bazarr/route.json"
},
"build": {
"on": [
-5
View File
@@ -4,11 +4,6 @@
"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
@@ -1,63 +0,0 @@
// 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
@@ -1,290 +0,0 @@
// 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
@@ -1,156 +0,0 @@
// 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": ["apikey.ts", "client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"]
"include": ["client.ts", "index.ts", "tools/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:${port:8787}",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
},
"artifact": "runtime"
+1 -14
View File
@@ -1,19 +1,6 @@
{
"module": "lidarr",
"version": "1",
"provides": [
{
"name": "lidarr-api",
"scope": "mesh"
}
],
"serves": {
"lidarr-api": {
"scheme": "http",
"port": 8686,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -88,7 +75,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LIDARR_URL": "http://127.0.0.1:${port:8686}",
"MESH_LIDARR_URL": "http://127.0.0.1:8686",
"MESH_LIDARR_CONFIG_DIR": "/var/lib/lidarr/config"
},
"artifact": "runtime"
+1 -1
View File
@@ -81,7 +81,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_NZBGET_URL": "http://127.0.0.1:${port:6789}",
"MESH_NZBGET_URL": "http://127.0.0.1:6789",
"MESH_NZBGET_PASSWORD_FILE": "/run/secrets/password",
"MESH_NZBGET_CONFIG_FILE": "/run/config/config.json",
"MESH_NZBGET_CONFIG_DIR": "/var/lib/nzbget/config"
+1 -4
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 servarr/settings.ts servarr/index.ts \
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,6 +22,3 @@ 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.
+10 -58
View File
@@ -28,15 +28,10 @@
"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"
},
@@ -44,7 +39,7 @@
"id": "server",
"type": "container",
"name": "ombi",
"image": "lscr.io/linuxserver/ombi@sha256:22d6ebadbaaa728571353e74dc2173719e0fb02d4eaec551a7e9d2ee99ef68ac",
"image": "lscr.io/linuxserver/ombi@sha256:a6f76ac521ba01eee2e9f0c23a3fed22e56630d97a04d5eeaeaa36c1e681640d",
"env": {
"PUID": "1000",
"PGID": "1000",
@@ -54,7 +49,7 @@
"3579"
],
"volumes": [
"${dir:config}:/config"
"/services/ombi/config:/config"
]
},
{
@@ -73,59 +68,24 @@
"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"
"/var/lib/mesh/ombi/config.json:/run/config/config.json:ro",
"/services/ombi/config:/var/lib/ombi/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
"MESH_OMBI_URL": "http://127.0.0.1:3579",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json"
"MESH_OMBI_CONFIG_FILE": "/run/config/config.json",
"MESH_OMBI_CONFIG_DIR": "/var/lib/ombi/config"
},
"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": [
"lidarr-api",
"radarr-api",
"route",
"sonarr-api"
"route"
],
"contributes": {
"route": {
@@ -134,15 +94,7 @@
}
},
"binds": {
"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"
"route": "/var/lib/mesh/ombi/route.json"
},
"build": {
"on": [
-5
View File
@@ -4,11 +4,6 @@
"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
@@ -1,59 +0,0 @@
// 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
@@ -1,304 +0,0 @@
// 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
@@ -1,147 +0,0 @@
// 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", "servarr/settings.ts", "servarr/index.ts"]
"include": ["client.ts", "index.ts", "tools/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:${port:8080}",
"MESH_QBITTORRENT_URL": "http://127.0.0.1:8080",
"MESH_QBITTORRENT_PASSWORD_FILE": "/run/secrets/password",
"MESH_QBITTORRENT_CONFIG_FILE": "/run/config/config.json",
"MESH_QBITTORRENT_CONFIG_DIR": "/var/lib/qbittorrent/config"
+1 -14
View File
@@ -1,19 +1,6 @@
{
"module": "radarr",
"version": "1",
"provides": [
{
"name": "radarr-api",
"scope": "mesh"
}
],
"serves": {
"radarr-api": {
"scheme": "http",
"port": 7878,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -88,7 +75,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RADARR_URL": "http://127.0.0.1:${port:7878}",
"MESH_RADARR_URL": "http://127.0.0.1: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:${port:8080}",
"MESH_SEARXNG_URL": "http://127.0.0.1:8080",
"MESH_SEARXNG_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
+1 -14
View File
@@ -1,19 +1,6 @@
{
"module": "sonarr",
"version": "1",
"provides": [
{
"name": "sonarr-api",
"scope": "mesh"
}
],
"serves": {
"sonarr-api": {
"scheme": "http",
"port": 8989,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
@@ -93,7 +80,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_SONARR_URL": "http://127.0.0.1:${port:8989}",
"MESH_SONARR_URL": "http://127.0.0.1:8989",
"MESH_SONARR_CONFIG_DIR": "/var/lib/sonarr/config"
},
"artifact": "runtime"
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/tautulli
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 plex/check.ts plex/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
@@ -22,3 +22,6 @@ COPY --from=build /app/modules/tautulli/dist /app/modules/tautulli/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/tautulli/dist/index.js,/app/modules/tautulli/dist/tools/index.js
# NOT dist/plex/index.js: that is a step the host runs to completion, named by the `plex`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
+31 -4
View File
@@ -41,6 +41,32 @@ function meshConfig(file?: string): Record<string, string> {
catch { return {}; }
}
/**
* The API key Tautulli minted for itself, read from its own config.ini (mounted read-only).
*
* Tautulli owns this key: it writes it on first run and every client of its API — this runtime
* included — must present the same one. So the mesh does not mint or hold it; the one place it
* lives is the file Tautulli keeps, and a key regenerated in Tautulli's settings is simply read
* again on the next start. Undefined when there is no file or no key yet (a fresh install whose
* setup wizard has not run).
*/
export function keyOfTautulli(dir?: string): string | undefined {
if (!dir) return undefined;
let ini: string;
try { ini = readFileSync(`${dir.replace(/\/$/, "")}/config.ini`, "utf8"); }
catch { return undefined; }
let section = "";
for (const raw of ini.split(/\r?\n/)) {
const line = raw.trim();
const header = /^\[(.+)\]$/.exec(line);
if (header) { section = header[1]; continue; }
if (section !== "General") continue;
const kv = /^api_key\s*=\s*"?([^"]*)"?$/.exec(line);
if (kv && kv[1]) return kv[1];
}
return undefined;
}
export class TautulliClient {
readonly baseUrl: string;
@@ -52,15 +78,16 @@ export class TautulliClient {
}
/**
* Build from the module's resolved environment. The API key is read from MESH_TAUTULLI_APIKEY
* (Tautulli mints it in Settings → Web Interface); the base URL defaults to the local container.
* Build from the module's resolved environment. The API key is the one Tautulli minted for
* itself (Settings → Web Interface), read from its config.ini under MESH_TAUTULLI_CONFIG_DIR;
* MESH_TAUTULLI_APIKEY still wins where it is set. The base URL defaults to the local container.
* Throws when no key is configured — the module then contributes nothing rather than failing.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient {
const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE);
const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`);
const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY;
if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY");
const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY ?? keyOfTautulli(env.MESH_TAUTULLI_CONFIG_DIR);
if (!apiKey) throw new Error("no Tautulli API key — Tautulli's config.ini has none yet (finish its setup and enable the API)");
return new TautulliClient(url, apiKey);
}
File diff suppressed because one or more lines are too long
+5
View File
@@ -4,6 +4,11 @@
"description": "tautulli — Plex watch statistics. 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 plex/check.ts plex/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Run by the linuxserver image's custom-init each time Tautulli's container starts, as root, before
# Tautulli: puts where the mesh says plex is into Tautulli's config.ini (mesh-plex.py says why).
#
# Written by the mesh from the tautulli module's manifest. Editing it here lasts until the next apply.
PY=/lsiopy/bin/python3
[ -x "$PY" ] || PY=python3
exec "$PY" /run/mesh/mesh-plex.py
+175
View File
@@ -0,0 +1,175 @@
// Whether Tautulli reaches Plex as the mesh says — the half of tautulli's plex-api consumer the
// mesh can see fail.
//
// **The write is not here.** Tautulli keeps its Plex connection only in config.ini, rewrites that
// file from memory on every shutdown, and has no API command that sets it; so the write happens in
// the server container itself, before Tautulli starts (plex/mesh-plex.py, run by the image's
// custom-init). A failure there is a line in Tautulli's log that nobody reads. This step runs after
// the server, as a run-once container declared last, and fails the node's report when:
//
// - the pair credential is one plex refuses — the mesh's own minted value, before the operator
// accepts the server's X-Plex-Token for this pair — naming the `secret accept` that fixes it;
// - Tautulli is not pointed where the binding says (the start-time write did not land);
// - Tautulli is pointed there and still not connected to plex (its own connection state).
//
// It writes nothing, to Tautulli or to plex. Pure logic and a small HTTP seam, tested against fakes
// (test/plex.test.ts).
/** What the mesh wrote at `binds.plex-api`: the binding document (controller's boundFile). */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export const PROVISION = "plex-api";
export interface Wanted {
url: string;
token: string;
from: string;
}
/** The HTTP the step needs, so a test can stand fakes in for Tautulli and plex. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string> }): Promise<{
status: number;
text(): Promise<string>;
}>;
}
export type Outcome = { result: "connected"; url: string } | { result: "refused"; problem: string };
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/** The URL Tautulli should hold as pms_url — the same one mesh-plex.py writes. */
export function wanted(binding: Binding | undefined, credential: string | undefined): Wanted | { problem: string } {
if (!binding) return { problem: `no binding for ${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);
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (!at) return { problem: `the ${PROVISION} binding names no host (at)` };
if (isLoopback(at)) {
return {
problem:
`the ${PROVISION} binding says plex is at ${at}, which from Tautulli's container is Tautulli itself; ` +
`the mesh hands loopback to a machine that is not on the private network`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { problem: `the ${PROVISION} binding serves no usable port (${String(serves.port)})` };
}
if (scheme !== "http" && scheme !== "https") return { problem: `the ${PROVISION} binding serves scheme ${scheme}, which Tautulli cannot dial` };
const token = (credential ?? "").trim();
if (!token) return { problem: `the ${PROVISION} credential is empty or was not delivered` };
const host = at.includes(":") && !at.startsWith("[") ? `[${at}]` : at;
return { url: `${scheme}://${host}:${port}`, token, from: typeof binding.from === "string" ? binding.from : "" };
}
/** The remedy for a refused token, in the controller's own words (ADR 0092). */
export function acceptRemedy(from: string): string {
return (
`plex refuses the ${PROVISION} credential the mesh delivered, so Tautulli was not given it. plex's ` +
`token is issued by plex.tv and the mesh cannot make it: accept the server's own token for this pair — ` +
`\`secret accept <this node> tautulli ${PROVISION} --provider ${from || "<its node>"} ` +
`--from <file holding the server's X-Plex-Token>\``
);
}
/** Does plex take the token? 401/403, or 400 on a network plex trusts, is a refusal. */
export async function plexTakes(http: Http, want: Wanted): Promise<boolean> {
const res = await http.fetch(`${want.url}/`, { method: "GET", headers: { "X-Plex-Token": want.token, Accept: "application/json" } });
if (res.status === 400 || res.status === 401 || res.status === 403) return false;
if (res.status >= 200 && res.status < 300) return true;
throw new Error(`plex answered ${res.status} at /`);
}
export interface Tautulli {
url: string;
apiKey: string;
}
/** One Tautulli API command's `data`, or a thrown error. The error never carries the key. */
export async function tautulliCmd(http: Http, t: Tautulli, cmd: string): Promise<any> {
const q = new URLSearchParams({ apikey: t.apiKey, cmd });
const res = await http.fetch(`${t.url.replace(/\/$/, "")}/api/v2?${q.toString()}`, { method: "GET" });
const text = await res.text();
if (res.status !== 200) throw new Error(`Tautulli ${cmd} answered ${res.status}`);
const body = (JSON.parse(text) as { response?: { result?: string; message?: string; data?: unknown } }).response ?? {};
if (body.result !== "success") throw new Error(`Tautulli ${cmd}: ${body.message ?? "error"}`);
return body.data;
}
/** Wait for Tautulli to answer, because the step runs right after its container starts. */
export async function tautulliReady(http: Http, t: Tautulli, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const res = await http.fetch(`${t.url.replace(/\/$/, "")}/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));
}
}
/**
* Check Tautulli against the mesh: the token plex takes, the URL Tautulli holds, and Tautulli's own
* connection state, polled for `connectMs` because Tautulli connects to plex a moment after start.
* Never throws.
*/
export async function check(
http: Http,
t: Tautulli,
binding: Binding | undefined,
credential: string | undefined,
connectMs = 60_000,
pauseMs = 3000,
): Promise<Outcome> {
const w = wanted(binding, credential);
if ("problem" in w) return { result: "refused", problem: w.problem };
try {
if (!(await plexTakes(http, w))) return { result: "refused", problem: acceptRemedy(w.from) };
} catch (err) {
return { result: "refused", problem: `plex could not be asked whether it takes the token at ${w.url}: ${message(err)}` };
}
try {
const info = (await tautulliCmd(http, t, "get_server_info")) as { pms_url?: unknown } | undefined;
const holds = typeof info?.pms_url === "string" ? info.pms_url : "";
if (holds.replace(/\/$/, "") !== w.url) {
return {
result: "refused",
problem:
`Tautulli reaches plex at ${holds || "nothing"}, not ${w.url} as the mesh says. Its container writes ` +
`this into config.ini as it starts (custom-init 50-mesh-plex); its log says why it did not`,
};
}
const until = Date.now() + connectMs;
for (;;) {
// server_status answers {connected}, not wrapped in `data` on every version: accept both.
const status = (await tautulliCmd(http, t, "server_status")) as { connected?: unknown } | undefined;
if (status?.connected === true) return { result: "connected", url: w.url };
if (Date.now() >= until) {
return {
result: "refused",
problem: `Tautulli holds ${w.url} and is not connected to plex there — its log says why (a token plex no longer takes, or plex down)`,
};
}
await new Promise((r) => setTimeout(r, pauseMs));
}
} catch (err) {
return { result: "refused", problem: message(err) };
}
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+47
View File
@@ -0,0 +1,47 @@
// tautulli's plex step — run once by the host after Tautulli's server container, and again whenever
// its binding, its pair credential or the server changed (the container's `restart-on`, novox/hq
// ADR 0099). It checks; it writes nothing (plex/check.ts says why, and where the write is).
//
// Exits non-zero when Tautulli does not reach plex as the mesh says, so the node reports the step
// failed. Declared last in the manifest, so its failing gates nothing else of tautulli's (novox/hq
// ADR 0136). Never prints a key or a token.
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { keyOfTautulli } from "../client.js";
import { check, tautulliReady, PROVISION, type Binding, type Http } from "./check.js";
const dir = process.env.MESH_PLEX_DIR ?? "/run/plex";
const url = process.env.MESH_TAUTULLI_URL ?? "http://127.0.0.1:8181";
const waitSeconds = Number(process.env.MESH_TAUTULLI_WAIT_SECONDS ?? "180");
const http: Http = { fetch: (u, init) => fetch(u, init) };
const read = (path: string) => readFile(path, "utf8").catch(() => undefined);
const apiKey = keyOfTautulli(process.env.MESH_TAUTULLI_CONFIG_DIR);
if (!apiKey) {
console.error("[tautulli-plex] Tautulli's config.ini holds no API key yet — it writes one on its first start");
process.exit(1);
}
const tautulli = { url, apiKey };
if (!(await tautulliReady(http, tautulli, waitSeconds * 1000))) {
console.error(`[tautulli-plex] Tautulli did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
let binding: Binding | undefined;
try {
const raw = await read(join(dir, `${PROVISION}.json`));
binding = raw === undefined ? undefined : (JSON.parse(raw) as Binding);
} catch {
binding = undefined;
}
const outcome = await check(http, tautulli, binding, await read(join(dir, `${PROVISION}.secret`)));
if (outcome.result === "connected") {
console.log(`[tautulli-plex] Tautulli reaches plex at ${outcome.url} as the mesh says; connected`);
} else {
console.error(`[tautulli-plex] ${outcome.problem}`);
process.exitCode = 1;
}
+260
View File
@@ -0,0 +1,260 @@
# Where Tautulli reaches Plex — decided by the mesh, written into Tautulli's config.ini before
# Tautulli starts.
#
# Written by the mesh from the tautulli module's manifest, and run by the linuxserver image's
# custom-init (50-mesh-plex) each time the `server` container starts, as root, before Tautulli.
# Editing it here lasts until the next apply.
#
# WHY BEFORE START, AND NOT A STEP AFTER IT. Tautulli keeps its Plex connection only in config.ini
# ([PMS]), reads that file at start, and writes its whole in-memory config back on every shutdown.
# A step editing the file while Tautulli runs is overwritten the moment the container is recreated;
# its API has no command that sets the connection, and its settings form needs an admin login. The
# one moment the file is Tautulli's to read and nobody's to overwrite is here: after the old
# container stopped (and wrote), before the new one reads. The container restarts on its binding
# and credential (`restart-on`), so a plex that moves or a token that is accepted lands here.
#
# WHAT IT WRITES, AND WHEN. Only [PMS] keys, and only when they differ from what the mesh says:
# pms_ip, pms_port, pms_ssl, pms_url - from the binding; always, when the binding is usable
# pms_identifier - plex's own machineIdentifier, when plex answers /identity
# pms_token - the pair credential, ONLY when plex takes it
# Every other key and section, comment and ordering stays as it was, byte for byte.
#
# A TOKEN PLEX REFUSES IS NEVER WRITTEN. Until the operator accepts the server's X-Plex-Token for
# this pair, the mesh delivers a value it minted, which plex answers with 401 (400 on a network it
# trusts). Writing it would replace a working token with a dead one. The address is still written:
# it is right whatever the token, and Tautulli's existing token keeps working at the new address.
# The refusal is loud here and in the `plex` step, which fails naming the `secret accept`.
#
# Never prints the token. Exits 0 even on a refusal: custom-init ignores the code, and Tautulli
# starting on what it had is better than not starting. The step after the server is what fails.
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.request
BINDING = os.environ.get("MESH_PLEX_BINDING", "/run/mesh/plex-api.json")
SECRET = os.environ.get("MESH_PLEX_SECRET", "/run/mesh/plex-api.secret")
CONFIG = os.environ.get("MESH_TAUTULLI_CONFIG", "/config/config.ini")
WAIT = float(os.environ.get("MESH_PLEX_WAIT_SECONDS", "60"))
PROVISION = "plex-api"
def say(message):
print("[mesh-plex] " + message, flush=True)
def is_loopback(host):
h = host.lower()
return h in ("localhost", "::1", "[::1]") or h.startswith("127.")
def wanted_address(binding):
"""The [PMS] address keys the binding says, or a reason it cannot say them."""
if not isinstance(binding, dict):
return None, "no binding for %s was delivered" % PROVISION
at = binding.get("at")
at = at.strip() if isinstance(at, str) else ""
serves = binding.get("serves") if isinstance(binding.get("serves"), dict) else {}
try:
port = int(serves.get("port"))
except (TypeError, ValueError):
port = 0
scheme = serves.get("scheme") or "http"
if not at:
return None, "the %s binding names no host (at)" % PROVISION
if is_loopback(at):
return None, (
"the %s binding says plex is at %s, which from Tautulli's container is Tautulli itself; "
"the mesh hands loopback to a machine that is not on the private network" % (PROVISION, at))
if not 0 < port < 65536:
return None, "the %s binding serves no usable port (%r)" % (PROVISION, serves.get("port"))
if scheme not in ("http", "https"):
return None, "the %s binding serves scheme %s, which Tautulli cannot dial" % (PROVISION, scheme)
host = "[%s]" % at if ":" in at and not at.startswith("[") else at
url = "%s://%s:%d" % (scheme, host, port)
return {"pms_ip": at, "pms_port": str(port), "pms_ssl": "1" if scheme == "https" else "0", "pms_url": url}, None
def plex_get(url, path, token=None):
"""(status, parsed JSON or None); raises OSError when plex cannot be asked."""
headers = {"Accept": "application/json"}
if token is not None:
headers["X-Plex-Token"] = token
request = urllib.request.Request(url + path, headers=headers)
try:
with urllib.request.urlopen(request, timeout=10) as response:
body = response.read()
try:
return response.status, json.loads(body)
except ValueError:
return response.status, None
except urllib.error.HTTPError as err:
return err.code, None
def ask_plex(url, token):
"""(takes: True/False/None, machineIdentifier or None). None: plex could not be asked in time."""
deadline = time.monotonic() + WAIT
while True:
try:
status, _ = plex_get(url, "/", token)
if status in (400, 401, 403):
takes = False
elif 200 <= status < 300:
takes = True
else:
raise OSError("plex answered %d at /" % status)
identifier = None
status, body = plex_get(url, "/identity")
if status == 200 and isinstance(body, dict):
value = (body.get("MediaContainer") or {}).get("machineIdentifier")
identifier = value if isinstance(value, str) and value else None
return takes, identifier
except OSError as err:
if time.monotonic() >= deadline:
say("plex could not be asked at %s: %s" % (url, err))
return None, None
time.sleep(3)
SECTION = re.compile(r"^\s*\[([^\]]+)\]\s*$")
KEY = re.compile(r"^(\s*)([A-Za-z0-9_]+)(\s*=\s*)(.*?)\s*$")
def unquoted(value):
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
return value[1:-1]
return value
def plain(value):
"""ConfigObj reads a value unquoted unless it holds one of these; none of ours should."""
return not re.search(r"[#,\"'\r\n]", value) and value == value.strip()
def laid_over(text, wanted):
"""config.ini's text with [PMS] saying `wanted`, and the names of the keys that changed."""
lines = text.splitlines(True)
if lines and not lines[-1].endswith("\n"):
lines[-1] += "\n"
changed = []
start = end = None
for i, line in enumerate(lines):
m = SECTION.match(line)
if m:
if start is not None:
end = i
break
if m.group(1).strip() == "PMS":
start = i
if start is None:
if lines and lines[-1].strip():
lines.append("\n")
lines.append("[PMS]\n")
start, end = len(lines) - 1, len(lines)
elif end is None:
end = len(lines)
seen = set()
for i in range(start + 1, end):
m = KEY.match(lines[i])
if not m or m.group(2) not in wanted:
continue
key = m.group(2)
seen.add(key)
if unquoted(m.group(4)) != wanted[key]:
lines[i] = "%s%s%s%s\n" % (m.group(1), key, m.group(3), wanted[key])
changed.append(key)
missing = [k for k in wanted if k not in seen]
# Insert after the section's last key, not after the blank lines that separate it from the next.
at = end
while at > start + 1 and not lines[at - 1].strip():
at -= 1
for key in missing:
lines.insert(at, "%s = %s\n" % (key, wanted[key]))
at += 1
changed.append(key)
return "".join(lines), changed
def write_config(text):
"""Replace config.ini whole, keeping its owner and mode; a new one takes the directory's owner."""
directory = os.path.dirname(CONFIG) or "."
try:
st = os.stat(CONFIG)
uid, gid, mode = st.st_uid, st.st_gid, st.st_mode & 0o7777
except FileNotFoundError:
st = os.stat(directory)
uid, gid, mode = st.st_uid, st.st_gid, 0o644
fd, tmp = tempfile.mkstemp(prefix=".config.ini.", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.chmod(tmp, mode)
try:
os.chown(tmp, uid, gid)
except PermissionError:
pass
os.replace(tmp, CONFIG)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def read(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return None
def main():
raw = read(BINDING)
try:
binding = json.loads(raw) if raw is not None else None
except ValueError:
binding = None
address, problem = wanted_address(binding)
if problem:
say("left Tautulli's Plex connection as it was: " + problem)
return 0
wanted = dict(address)
token = (read(SECRET) or "").strip()
takes, identifier = ask_plex(address["pms_url"], token) if token else (False, None)
if identifier:
wanted["pms_identifier"] = identifier
frm = binding.get("from") or "<its node>"
if not token:
say("no %s credential was delivered; only the address was written" % PROVISION)
elif takes and plain(token):
wanted["pms_token"] = token
elif takes is False:
say("plex refuses the %s credential the mesh delivered, so it was not written; the address was. "
"plex's token is issued by plex.tv and the mesh cannot make it: accept the server's own token "
"for this pair - `secret accept <this node> tautulli %s --provider %s --from <file holding the "
"server's X-Plex-Token>`" % (PROVISION, PROVISION, frm))
elif takes:
say("the %s credential holds characters config.ini cannot carry unquoted; it was not written" % PROVISION)
else:
say("plex could not be asked whether it takes the %s credential; only the address was written" % PROVISION)
if not all(plain(v) for v in wanted.values()):
say("the %s binding holds characters config.ini cannot carry unquoted; nothing was written" % PROVISION)
return 0
before = read(CONFIG)
after, changed = laid_over(before or "", wanted)
if not changed:
say("Tautulli's Plex connection is already as the mesh says (%s)" % address["pms_url"])
return 0
write_config(after)
say("wrote %s into Tautulli's [PMS] (%s)" % (", ".join(changed), address["pms_url"]))
return 0
if __name__ == "__main__":
sys.exit(main())
+266
View File
@@ -0,0 +1,266 @@
// What holds tautulli's plex-api consumer — both halves.
//
// The write (plex/mesh-plex.py, run in the server container before Tautulli starts): [PMS] is made
// to say what the mesh bound and every other line of config.ini stays byte for byte; nothing is
// written when nothing differs; a token plex refuses is never written, while the address still is;
// a config.ini that does not exist yet is started with [PMS] alone. Run with the machine's python3
// against a fake plex; skipped where there is no python3.
//
// The check (plex/check.ts, the step declared last): a refused token fails naming the `secret
// accept`; a Tautulli pointed elsewhere, or not connected, fails; one pointed where the binding says
// and connected passes.
//
// And the manifest carries exactly the files in plex/ — they are the source, module.json the copy.
//
// Fakes answer as the real ones do (checked against lscr.io/linuxserver/tautulli 2.18.1-ls244 and
// plexinc/pms-docker 1.43.4: plex answers 401 to an unknown token from another network, 400 on one
// it trusts). Imports the compiled step, as keycloak's tests do.
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFile, spawnSync } from "node:child_process";
import { createServer, type Server } from "node:http";
import { mkdtempSync, readFileSync, statSync, writeFileSync, existsSync } from "node:fs";
import { networkInterfaces, tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { check, type Binding, type Http } from "../dist/plex/check.js";
const here = fileURLToPath(new URL("..", import.meta.url));
const TOKEN = "the-servers-own-token";
const MACHINE = "5c47d9a165d10b622995d55b3ae1f168242f33bd";
// ---- the manifest carries the files ------------------------------------------------------------
test("module.json carries plex/mesh-plex.py and plex/50-mesh-plex exactly", () => {
const m = JSON.parse(readFileSync(join(here, "module.json"), "utf8")) as { resources: { id: string; content?: string }[] };
const byId = (id: string) => m.resources.find((r) => r.id === id)?.content;
assert.equal(byId("plex-init-code"), readFileSync(join(here, "plex/mesh-plex.py"), "utf8"));
assert.equal(byId("plex-init"), readFileSync(join(here, "plex/50-mesh-plex"), "utf8"));
// Nothing in them the mesh would read as a placeholder.
assert.doesNotMatch(byId("plex-init-code") ?? "", /\$\{/);
assert.doesNotMatch(byId("plex-init") ?? "", /\$\{/);
});
// ---- the write: mesh-plex.py -------------------------------------------------------------------
const python = spawnSync("python3", ["--version"]).status === 0 ? "python3" : undefined;
/** An address of this machine that is not loopback, which the script refuses as plex's. */
function outwardAddress(): string | undefined {
for (const list of Object.values(networkInterfaces())) {
for (const a of list ?? []) if (a.family === "IPv4" && !a.internal) return a.address;
}
return undefined;
}
const outward = outwardAddress();
function fakePlex(opts: { trusted?: boolean } = {}): Promise<{ server: Server; port: number }> {
const server = createServer((req, res) => {
if (req.url === "/identity") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ MediaContainer: { machineIdentifier: MACHINE } }));
return;
}
if (req.headers["x-plex-token"] !== TOKEN) {
res.writeHead(opts.trusted ? 400 : 401);
res.end();
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ MediaContainer: { friendlyName: "ace" } }));
});
return new Promise((resolve) => server.listen(0, "0.0.0.0", () => resolve({ server, port: (server.address() as { port: number }).port })));
}
// An operator's config.ini, shaped as Tautulli writes it: plex at HAL's network gateway.
const OPERATOR_INI = [
"[General]",
"first_run_complete = 1",
"api_key = 0123456789abcdef0123456789abcdef",
"",
"[PMS]",
"pms_identifier = " + MACHINE,
"pms_ip = 172.18.0.1",
"pms_is_remote = 0",
"pms_name = ace",
"pms_port = 32400",
'pms_token = "' + TOKEN + '"',
"pms_ssl = 0",
"pms_url = http://172.18.0.1:32400",
"pms_url_manual = 0",
"",
"[Monitoring]",
"monitor_pms_updates = 0",
"",
].join("\n");
function runScript(dir: string, at: string, port: number, credential: string, wait = "5") {
writeFileSync(join(dir, "plex-api.json"), JSON.stringify({ binding: 1, provision: "plex-api", from: "ace", at, serves: { scheme: "http", port } }));
writeFileSync(join(dir, "plex-api.secret"), credential + "\n");
// Asynchronously: the fake plex answers from this same process, so a blocking spawn would starve it.
return new Promise<{ status: number; out: string }>((resolve) => {
execFile(python as string, [join(here, "plex/mesh-plex.py")], {
env: {
...process.env,
MESH_PLEX_BINDING: join(dir, "plex-api.json"),
MESH_PLEX_SECRET: join(dir, "plex-api.secret"),
MESH_TAUTULLI_CONFIG: join(dir, "config.ini"),
MESH_PLEX_WAIT_SECONDS: wait,
},
encoding: "utf8",
}, (err, stdout, stderr) => resolve({ status: err ? Number((err as { code?: unknown }).code ?? 1) : 0, out: `${stdout}${stderr}` }));
});
}
const skip = !python ? "no python3 here" : !outward ? "no non-loopback address to serve a fake plex on" : false;
test("the write: [PMS] says what the mesh bound, and every other line stays", { skip }, async () => {
const { server, port } = await fakePlex();
try {
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
writeFileSync(join(dir, "config.ini"), OPERATOR_INI);
const r = await runScript(dir, outward as string, port, TOKEN);
assert.equal(r.status, 0);
// The token was already the server's (quoted, as ConfigObj may write it): not rewritten.
assert.match(r.out, /wrote pms_ip, pms_port, pms_url into Tautulli's \[PMS\]/);
const url = `http://${outward}:${port}`;
const expected = OPERATOR_INI
.replace("pms_ip = 172.18.0.1", `pms_ip = ${outward}`)
.replace("pms_port = 32400", `pms_port = ${port}`)
.replace("pms_url = http://172.18.0.1:32400", `pms_url = ${url}`);
assert.equal(readFileSync(join(dir, "config.ini"), "utf8"), expected);
assert.doesNotMatch(r.out, new RegExp(TOKEN));
// Again: nothing differs, nothing is written.
const before = statSync(join(dir, "config.ini")).mtimeMs;
const again = await runScript(dir, outward as string, port, TOKEN);
assert.match(again.out, /already as the mesh says/);
assert.equal(statSync(join(dir, "config.ini")).mtimeMs, before);
} finally {
server.close();
}
});
test("the write: a token plex refuses is never written; the address still is", { skip }, async () => {
for (const trusted of [false, true]) {
const { server, port } = await fakePlex({ trusted });
try {
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
writeFileSync(join(dir, "config.ini"), OPERATOR_INI);
const r = await runScript(dir, outward as string, port, "a-value-the-mesh-minted");
assert.equal(r.status, 0, "custom-init ignores the code; Tautulli starts on what it had");
assert.match(r.out, /secret accept <this node> tautulli plex-api --provider ace/);
assert.doesNotMatch(r.out, /a-value-the-mesh-minted/);
const ini = readFileSync(join(dir, "config.ini"), "utf8");
assert.match(ini, new RegExp(`pms_token = "${TOKEN}"`), "the working token stays");
assert.match(ini, new RegExp(`pms_ip = ${outward!.replace(/\./g, "\\.")}\n`));
} finally {
server.close();
}
}
});
test("the write: a Tautulli with no config.ini yet is started with [PMS] alone", { skip }, async () => {
const { server, port } = await fakePlex();
try {
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
await runScript(dir, outward as string, port, TOKEN);
assert.equal(
readFileSync(join(dir, "config.ini"), "utf8"),
`[PMS]\npms_ip = ${outward}\npms_port = ${port}\npms_ssl = 0\npms_url = http://${outward}:${port}\n` +
`pms_identifier = ${MACHINE}\npms_token = ${TOKEN}\n`,
);
} finally {
server.close();
}
});
test("the write: a plex that cannot be asked gets its address written and no token", { skip }, async () => {
const { server, port } = await fakePlex();
await new Promise((r) => server.close(r)); // nothing listens there now
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
writeFileSync(join(dir, "config.ini"), OPERATOR_INI);
const r = await runScript(dir, outward as string, port, TOKEN, "0");
assert.match(r.out, /could not be asked/);
const ini = readFileSync(join(dir, "config.ini"), "utf8");
assert.match(ini, new RegExp(`pms_url = http://${outward!.replace(/\./g, "\\.")}:${port}\n`));
assert.match(ini, new RegExp(`pms_token = "${TOKEN}"`), "left exactly as it was");
});
test("the write: a loopback binding writes nothing", { skip: !python ? "no python3 here" : false }, async () => {
const dir = mkdtempSync(join(tmpdir(), "mesh-plex-"));
writeFileSync(join(dir, "config.ini"), OPERATOR_INI);
const r = await runScript(dir, "127.0.0.1", 32400, TOKEN, "0");
assert.match(r.out, /private network/);
assert.equal(readFileSync(join(dir, "config.ini"), "utf8"), OPERATOR_INI);
assert.equal(existsSync(join(dir, "config.ini")), true);
});
// ---- the check: plex/check.ts ------------------------------------------------------------------
function binding(at = "ace.internal", port = 32400): Binding {
return { provision: "plex-api", from: "ace", at, serves: { scheme: "http", port } };
}
function fakes(opts: { holds?: string; connected?: boolean; trusted?: boolean } = {}) {
const calls: string[] = [];
const http: Http = {
async fetch(url, init) {
calls.push(url);
const reply = (status: number, value?: unknown) => ({ status, text: async () => (value === undefined ? "" : JSON.stringify(value)) });
const u = new URL(url);
if (u.hostname === "ace.internal") {
return init?.headers?.["X-Plex-Token"] === TOKEN ? reply(200, {}) : reply(opts.trusted ? 400 : 401);
}
if (u.searchParams.get("apikey") !== "tautulli-key") return reply(401);
const cmd = u.searchParams.get("cmd");
if (cmd === "get_server_info") {
return reply(200, { response: { result: "success", data: { pms_url: opts.holds ?? "http://ace.internal:32400", pms_ip: "ace.internal" } } });
}
if (cmd === "server_status") {
return reply(200, { response: { result: "success", data: { result: "success", connected: opts.connected ?? true } } });
}
return reply(404);
},
};
return { http, calls };
}
const TAUTULLI = { url: "http://127.0.0.1:8181", apiKey: "tautulli-key" };
test("the check: pointed where the binding says and connected passes", async () => {
const f = fakes();
assert.deepEqual(await check(f.http, TAUTULLI, binding(), TOKEN, 0, 0), { result: "connected", url: "http://ace.internal:32400" });
});
test("the check: a token plex refuses fails naming the accept, and never prints it", async () => {
for (const trusted of [false, true]) {
const f = fakes({ trusted });
const out = await check(f.http, TAUTULLI, binding(), "a-value-the-mesh-minted", 0, 0);
assert.equal(out.result, "refused");
const problem = (out as { problem: string }).problem;
assert.match(problem, /secret accept <this node> tautulli plex-api --provider ace/);
assert.doesNotMatch(problem, /a-value-the-mesh-minted/);
assert.equal(f.calls.some((c) => c.includes("/api/v2")), false, "Tautulli was not even asked");
}
});
test("the check: a Tautulli pointed elsewhere fails, naming where it points", async () => {
const out = await check(fakes({ holds: "http://172.18.0.1:32400" }).http, TAUTULLI, binding(), TOKEN, 0, 0);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /at http:\/\/172\.18\.0\.1:32400, not http:\/\/ace\.internal:32400/);
});
test("the check: pointed right but not connected fails", async () => {
const out = await check(fakes({ connected: false }).http, TAUTULLI, binding(), TOKEN, 0, 0);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /not connected/);
});
test("the check: a loopback binding is refused", async () => {
const out = await check(fakes().http, TAUTULLI, binding("127.0.0.1"), TOKEN, 0, 0);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /private network/);
});
+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", "plex/check.ts", "plex/index.ts"]
}