From b068a9d399f7df76bf273f1fe17bac9266e0a7c6 Mon Sep 17 00:00:00 2001 From: jochens Date: Wed, 30 Sep 2026 12:59:12 +0200 Subject: [PATCH] ombi: reach plex through the mesh, in the same step as the Servarr apps ombi reached plex at its public name, typed into its settings screen, so it depended on plex's public route and on nobody moving plex. ombi now requires plex-api, and the run-once step that writes its Servarr connections writes its Plex one too - renamed from `servarr` to `connections`, since it is no longer only that. ombi keeps several Plex servers. The entry this provision names is found by the server's own machineIdentifier (plex answers it at /identity, and ombi stored it when the server was loaded), and only its host, port, TLS, base path and token are written, only when they differ. Another server's entry, the selected libraries, whether Plex is enabled and every other choice are left alone. An ombi with no entry for the server gets one. The token is tried against plex first. Until the operator accepts the server's X-Plex-Token for this pair the mesh delivers a value it minted, which plex refuses (401, or 400 on a network it trusts); refused, nothing is written and the step fails naming the secret accept, so a working token in ombi is never replaced by a dead one. Tests import the compiled step, as keycloak's do: the step imports its sibling with the .js specifier the build needs, which type stripping does not resolve. `npm test` builds first. --- modules/ombi/Dockerfile | 4 +- modules/ombi/connections/index.ts | 64 +++++++++ modules/ombi/module.json | 33 +++-- modules/ombi/package.json | 4 +- modules/ombi/plex/settings.ts | 228 ++++++++++++++++++++++++++++++ modules/ombi/servarr/index.ts | 59 -------- modules/ombi/servarr/settings.ts | 4 +- modules/ombi/test/plex.test.ts | 173 +++++++++++++++++++++++ modules/ombi/tsconfig.json | 2 +- 9 files changed, 492 insertions(+), 79 deletions(-) create mode 100644 modules/ombi/connections/index.ts create mode 100644 modules/ombi/plex/settings.ts delete mode 100644 modules/ombi/servarr/index.ts create mode 100644 modules/ombi/test/plex.test.ts diff --git a/modules/ombi/Dockerfile b/modules/ombi/Dockerfile index a3369f1..d2ccf38 100644 --- a/modules/ombi/Dockerfile +++ b/modules/ombi/Dockerfile @@ -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 servarr/settings.ts plex/settings.ts connections/index.ts \ --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist FROM ${RUNTIME_BASE} @@ -22,6 +22,6 @@ COPY --from=build /app/modules/ombi/dist /app/modules/ombi/dist # provider's provisioner runs its reconcile loop in the same process, with the broker connected — # the convention novox/hq issues 060/061 settled. ENV MESH_TOOL_MODULES=/app/modules/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js -# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr` +# NOT dist/connections/index.js: that is a step the host runs to completion, named by the `connections` # container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the # serving sidecar too, and exit it. diff --git a/modules/ombi/connections/index.ts b/modules/ombi/connections/index.ts new file mode 100644 index 0000000..0d9b645 --- /dev/null +++ b/modules/ombi/connections/index.ts @@ -0,0 +1,64 @@ +// ombi's connections 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). +// It brings ombi's connections to Sonarr, Radarr, Lidarr (servarr/settings.ts) and Plex +// (plex/settings.ts) in line with what the mesh bound. +// +// **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). One app failing does not stop the others being put right. +// +// Reads, per provision, `/.json` (the binding) and `/.secret` (the +// pair credential), where is MESH_CONNECTIONS_DIR. Never prints a key or a token. + +import { join } from "node:path"; + +import { PLEX_PROVISION, reconcilePlex } from "../plex/settings.js"; +import { APPS, ombiReady, readBinding, readIfThere, reconcileApp, type Http, type Outcome } from "../servarr/settings.js"; + +const dir = process.env.MESH_CONNECTIONS_DIR ?? "/run/connections"; +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-connections] 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-connections] ombi did not answer at ${url} within ${waitSeconds}s`); + process.exit(1); +} + +const inputs = async (provision: string) => + [await readBinding(join(dir, `${provision}.json`)), await readIfThere(join(dir, `${provision}.secret`))] as const; + +const outcomes: Outcome[] = []; +for (const spec of APPS) { + outcomes.push(await reconcileApp(http, ombi, spec, ...(await inputs(spec.provision)))); +} +outcomes.push(await reconcilePlex(http, ombi, ...(await inputs(PLEX_PROVISION)))); + +let failed = 0; +for (const outcome of outcomes) { + switch (outcome.result) { + case "unchanged": + console.log(`[ombi-connections] ${outcome.app}: already as the mesh says; connection tested`); + break; + case "written": + console.log(`[ombi-connections] ${outcome.app}: wrote ${outcome.fields.join(", ")}; connection tested`); + break; + case "refused": + failed++; + console.error(`[ombi-connections] ${outcome.app}: ${outcome.problem}`); + break; + } +} +process.exitCode = failed > 0 ? 1 : 0; diff --git a/modules/ombi/module.json b/modules/ombi/module.json index e3d6c3a..d40ba5d 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -87,28 +87,30 @@ "artifact": "runtime" }, { - "id": "servarr", + "id": "connections", "type": "container", - "name": "mesh-ombi-servarr", + "name": "mesh-ombi-connections", "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" + "${dir:state}/sonarr-api.json:/run/connections/sonarr-api.json:ro", + "${dir:state}/sonarr-api.secret:/run/connections/sonarr-api.secret:ro", + "${dir:state}/radarr-api.json:/run/connections/radarr-api.json:ro", + "${dir:state}/radarr-api.secret:/run/connections/radarr-api.secret:ro", + "${dir:state}/lidarr-api.json:/run/connections/lidarr-api.json:ro", + "${dir:state}/lidarr-api.secret:/run/connections/lidarr-api.secret:ro", + "${dir:state}/plex-api.json:/run/connections/plex-api.json:ro", + "${dir:state}/plex-api.secret:/run/connections/plex-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" + "MESH_CONNECTIONS_DIR": "/run/connections" }, "args": [ "run", - "/app/modules/ombi/dist/servarr/index.js" + "/app/modules/ombi/dist/connections/index.js" ], "restart-on": [ "bound-sonarr-api", @@ -116,13 +118,16 @@ "bound-radarr-api", "secret-radarr-api", "bound-lidarr-api", - "secret-lidarr-api" + "secret-lidarr-api", + "bound-plex-api", + "secret-plex-api" ], "artifact": "runtime" } ], "requires": [ "lidarr-api", + "plex-api", "radarr-api", "route", "sonarr-api" @@ -137,12 +142,14 @@ "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" + "lidarr-api": "${dir:state}/lidarr-api.json", + "plex-api": "${dir:state}/plex-api.json" }, "secrets": { "sonarr-api": "${dir:state}/sonarr-api.secret", "radarr-api": "${dir:state}/radarr-api.secret", - "lidarr-api": "${dir:state}/lidarr-api.secret" + "lidarr-api": "${dir:state}/lidarr-api.secret", + "plex-api": "${dir:state}/plex-api.secret" }, "build": { "on": [ diff --git a/modules/ombi/package.json b/modules/ombi/package.json index 297e62b..d8a3c44 100644 --- a/modules/ombi/package.json +++ b/modules/ombi/package.json @@ -5,9 +5,9 @@ "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", + "build": "tsc client.ts index.ts tools/index.ts servarr/settings.ts plex/settings.ts connections/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "typecheck": "tsc -p tsconfig.json", - "test": "node --test --experimental-strip-types 'test/*.test.ts'" + "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { "@novox/mesh-sdk": "^0.1.0" diff --git a/modules/ombi/plex/settings.ts b/modules/ombi/plex/settings.ts new file mode 100644 index 0000000..a4dff9c --- /dev/null +++ b/modules/ombi/plex/settings.ts @@ -0,0 +1,228 @@ +// Where ombi reaches Plex — decided by the mesh, written into ombi by ombi's own API. +// +// **Why this exists.** ombi keeps its Plex servers in its own database (OmbiSettings.db), so the +// mesh has no file to write `${bound:plex-api:at}` into. ombi requires `plex-api`; the mesh delivers +// a binding (where plex is: `at`, and what it serves: `port`, `scheme`) and a pair credential (the +// server owner's X-Plex-Token, accepted by the operator — plex.tv issues it and the mesh cannot +// mint it). This step makes ombi's Plex settings say the same thing, beside its Servarr ones. +// +// **Which entry is plex's.** ombi may list several Plex servers. The one this provision names is +// found by the server's own machineIdentifier, which plex answers at /identity — the same value +// ombi stored when an operator loaded the server in its settings screen. That entry's connection is +// brought in line; an entry for any other server is never touched. When no entry is this server's, +// one is added, named as plex names itself — it is the mesh's, so later runs keep it true. +// +// **Only the connection, and only when it differs.** Host, port, TLS, base path and token. Whether +// Plex is enabled in ombi, watchlist import, the selected libraries, the batch size and everything +// else an operator chose are left exactly as they are. +// +// **A token plex refuses is never written.** Until the operator accepts the server's token for this +// pair, the mesh delivers a value it minted itself, which plex answers with 401 (or 400 on its own +// network). Writing it would replace a working token in ombi with a dead one, so the token is tried +// against plex first; refused, nothing is written and the step fails naming the `secret accept`. + +import { isLoopback, ombiCall, type Binding, type Http, type Ombi, type Outcome } from "../servarr/settings.js"; + +/** The provision ombi requires for Plex — the manifest's `requires`, `binds` and `secrets` key. */ +export const PLEX_PROVISION = "plex-api"; + +/** The connection fields ombi keeps for a Plex server — the only ones this step ever writes. */ +export interface PlexConnection { + ip: string; + port: number; + ssl: boolean; + subDir: string | null; + plexAuthToken: string; +} + +export type PlexWanted = { ok: true; connection: PlexConnection; from: string } | { ok: false; problem: string }; + +/** The connection the mesh says ombi should use, from the binding and the pair credential. */ +export function wantedPlex(binding: Binding | undefined, credential: string | undefined): PlexWanted { + if (!binding) { + return { ok: false, problem: `no binding for ${PLEX_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 ${PLEX_PROVISION} binding names no host (at)` }; + if (isLoopback(at)) { + return { + ok: false, + problem: + `the ${PLEX_PROVISION} binding says plex 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 plex has an address ombi can dial`, + }; + } + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + return { ok: false, problem: `the ${PLEX_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 ${PLEX_PROVISION} binding serves scheme ${scheme}, which ombi cannot dial` }; + } + const token = (credential ?? "").trim(); + if (!token) return { ok: false, problem: `the ${PLEX_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: null, plexAuthToken: token }, + }; +} + +/** plex's base URL as the step dials it — the same host and port ombi will be given. */ +export function plexUrl(want: PlexConnection): string { + const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip; + return `${want.ssl ? "https" : "http"}://${host}:${want.port}`; +} + +/** Which connection fields differ between an entry ombi holds and what the mesh says. Names only. */ +export function differingPlex(current: Record | undefined, want: PlexConnection): (keyof PlexConnection)[] { + const now = current ?? {}; + const out: (keyof PlexConnection)[] = []; + 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"); + const sub = typeof now.subDir === "string" && now.subDir.trim() !== "" ? now.subDir : null; + if (sub !== want.subDir) out.push("subDir"); + if (String(now.plexAuthToken ?? "") !== want.plexAuthToken) out.push("plexAuthToken"); + return out; +} + +async function plexGet(http: Http, want: PlexConnection, path: string, withToken: boolean) { + const headers: Record = { Accept: "application/json" }; + if (withToken) headers["X-Plex-Token"] = want.plexAuthToken; + return http.fetch(`${plexUrl(want)}${path}`, { method: "GET", headers }); +} + +/** + * Does plex take this token? `true` it does; `false` it refused it — 401 or 403, or 400, which is + * what plex answers a token it never issued on a network it trusts. A thrown error when plex could + * not be asked. + */ +export async function plexTakes(http: Http, want: PlexConnection): Promise<{ takes: boolean; friendlyName?: string }> { + const res = await plexGet(http, want, "/", true); + if (res.status === 400 || res.status === 401 || res.status === 403) return { takes: false }; + if (res.status < 200 || res.status >= 300) throw new Error(`plex answered ${res.status} at /`); + let friendlyName: string | undefined; + try { + const body = JSON.parse(await res.text()) as { MediaContainer?: { friendlyName?: unknown } }; + if (typeof body.MediaContainer?.friendlyName === "string") friendlyName = body.MediaContainer.friendlyName; + } catch { + // a name is a nicety for a new entry, not a condition + } + return { takes: true, friendlyName }; +} + +/** The server's own machineIdentifier, which plex answers without a token. */ +export async function plexIdentity(http: Http, want: PlexConnection): Promise { + const res = await plexGet(http, want, "/identity", false); + if (res.status !== 200) throw new Error(`plex answered ${res.status} at /identity`); + const body = JSON.parse(await res.text()) as { MediaContainer?: { machineIdentifier?: unknown } }; + const id = body.MediaContainer?.machineIdentifier; + if (typeof id !== "string" || id === "") throw new Error("plex's /identity names no machineIdentifier"); + return id; +} + +/** The remedy for a refused token, in the controller's own words (ADR 0092). */ +export function plexAcceptRemedy(from: string): string { + return ( + `plex refuses the ${PLEX_PROVISION} credential the mesh delivered, so it was not written into ombi. ` + + `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 ombi ${PLEX_PROVISION} --provider ${from || ""} ` + + `--from \`` + ); +} + +/** The batch size ombi's settings screen fills in for a server it adds ("150 by default"). */ +const EPISODE_BATCH_SIZE = 150; + +/** + * ombi's Plex settings with this server's connection laid over them: every entry naming the + * server's machineIdentifier gets the connection, every other entry is left as it was, and when + * none names it one is added. Returns the document to save and the fields that changed. + */ +export function withPlexServer( + document: Record | undefined, + machineIdentifier: string, + want: PlexConnection, + name: string, +): { next: Record; fields: string[]; added: boolean; entry: Record } { + const doc = document ?? {}; + const servers = Array.isArray(doc.servers) ? (doc.servers as Record[]) : []; + const fields = new Set(); + let entry: Record | undefined; + const next = servers.map((s) => { + if (s?.machineIdentifier !== machineIdentifier) return s; + for (const f of differingPlex(s, want)) fields.add(f); + const laid = { ...s, ip: want.ip, port: want.port, ssl: want.ssl, subDir: want.subDir, plexAuthToken: want.plexAuthToken }; + entry ??= laid; + return laid; + }); + if (entry) return { next: { ...doc, servers: next }, fields: [...fields], added: false, entry }; + const added: Record = { + name, + machineIdentifier, + ip: want.ip, + port: want.port, + ssl: want.ssl, + subDir: want.subDir, + plexAuthToken: want.plexAuthToken, + episodeBatchSize: EPISODE_BATCH_SIZE, + plexSelectedLibraries: [], + }; + return { next: { ...doc, servers: [...next, added] }, fields: ["server"], added: true, entry: added }; +} + +/** + * Bring ombi's connection to plex in line with the mesh: check the token against plex, find the + * server's entry by its machineIdentifier, write only the connection fields when they differ (or add + * the entry), then have ombi test the connection from its own container. Never throws. + */ +export async function reconcilePlex(http: Http, ombi: Ombi, binding: Binding | undefined, credential: string | undefined): Promise { + const app = "plex"; + const w = wantedPlex(binding, credential); + // `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not + // narrow. + if ("problem" in w) return { app, result: "refused", problem: w.problem }; + const want = w.connection; + + let name: string; + let machineIdentifier: string; + try { + const taken = await plexTakes(http, want); + if (!taken.takes) return { app, result: "refused", problem: plexAcceptRemedy(w.from) }; + machineIdentifier = await plexIdentity(http, want); + name = taken.friendlyName || "Plex"; + } catch (err) { + return { app, result: "refused", problem: `plex could not be asked whether it takes the token at ${want.ip}:${want.port}: ${message(err)}` }; + } + + try { + const document = (await ombiCall(http, ombi, "GET", "/Settings/Plex")) as Record | undefined; + const laid = withPlexServer(document, machineIdentifier, want, name); + if (laid.fields.length > 0) { + const saved = await ombiCall(http, ombi, "POST", "/Settings/Plex", laid.next); + if (saved === false) return { app, result: "refused", problem: "ombi declined to save its Plex 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/plex", laid.entry); + if (tested !== true) { + return { + app, + result: "refused", + problem: + `ombi cannot reach plex at ${want.ip}:${want.port} from its own container` + + (laid.fields.length > 0 ? `; its settings were written (${laid.fields.join(", ")})` : ""), + }; + } + return laid.fields.length > 0 ? { app, result: "written", fields: laid.fields } : { app, result: "unchanged" }; + } catch (err) { + return { app, result: "refused", problem: message(err) }; + } +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/modules/ombi/servarr/index.ts b/modules/ombi/servarr/index.ts deleted file mode 100644 index ad705f0..0000000 --- a/modules/ombi/servarr/index.ts +++ /dev/null @@ -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, `/.json` (the binding) and `/.secret` (the pair -// credential), where 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; diff --git a/modules/ombi/servarr/settings.ts b/modules/ombi/servarr/settings.ts index 314dc34..34d8854 100644 --- a/modules/ombi/servarr/settings.ts +++ b/modules/ombi/servarr/settings.ts @@ -115,7 +115,7 @@ export function subDirOf(urlBase: unknown): string | null { return trimmed === "" ? null : trimmed; } -function isLoopback(host: string): boolean { +export function isLoopback(host: string): boolean { const h = host.toLowerCase(); return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h); } @@ -163,7 +163,7 @@ export interface Ombi { apiKey: string; } -async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise { +export async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise { const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1${path}`, { method, headers: { diff --git a/modules/ombi/test/plex.test.ts b/modules/ombi/test/plex.test.ts new file mode 100644 index 0000000..7ed83b0 --- /dev/null +++ b/modules/ombi/test/plex.test.ts @@ -0,0 +1,173 @@ +// What holds ombi's Plex step (plex/settings.ts): the entry for the server plex says it is — found +// by machineIdentifier — is made to say what the mesh bound (host, port, TLS, token) and nothing +// else it keeps is touched; an entry for another server is left alone; an ombi with no entry for it +// gets one; nothing is written when nothing differs; and a token plex refuses (the mesh's own minted +// value, before the operator accepts the server's token) is never written, with the `secret accept` +// that fixes it named. +// +// ombi and plex are fakes answering the routes the step touches as the real ones do (checked against +// lscr.io/linuxserver/ombi 4.53.10 and plexinc/pms-docker 1.43.4: plex answers 401 to an unknown +// token from another network and 400 on one it trusts; ombi's /Tester/plex answers a bare boolean). +// +// Imports the compiled step, as keycloak's tests do: plex/settings.ts imports its sibling with the +// `.js` specifier the build needs, which Node's type stripping does not resolve to a `.ts` file. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { differingPlex, reconcilePlex, wantedPlex, withPlexServer } from "../dist/plex/settings.js"; +import type { Binding, Http } from "../servarr/settings.ts"; + +const TOKEN = "the-servers-own-token"; +const MACHINE = "5c47d9a165d10b622995d55b3ae1f168242f33bd"; +const OMBI = { url: "http://127.0.0.1:3579", apiKey: "ombi-key" }; + +function binding(port = 32400, at = "ace.internal", scheme = "http"): Binding { + return { binding: 1, provision: "plex-api", from: "ace", at, as: "mesh_ace_ombi", serves: { scheme, port } } as Binding; +} + +interface Call { + method: string; + url: string; + body?: unknown; +} + +function fakes(plexSettings: Record, opts: { reachable?: boolean; trusted?: boolean } = {}) { + const calls: Call[] = []; + const store = { plex: plexSettings }; + 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.port === "32400" || u.hostname === "ace.internal") { + if (opts.reachable === false) throw new Error("connect ECONNREFUSED"); + if (u.pathname === "/identity") return reply(200, { MediaContainer: { machineIdentifier: MACHINE } }); + const token = init?.headers?.["X-Plex-Token"]; + if (token !== TOKEN) return reply(opts.trusted ? 400 : 401); + return reply(200, { MediaContainer: { friendlyName: "ace", machineIdentifier: MACHINE } }); + } + if (init?.headers?.ApiKey !== "ombi-key") return reply(401); + if (u.pathname === "/api/v1/Settings/Plex" && method === "GET") return reply(200, store.plex); + if (u.pathname === "/api/v1/Settings/Plex" && method === "POST") { + store.plex = body as Record; + return reply(200, true); + } + if (u.pathname === "/api/v1/Tester/plex") { + const tried = body as { plexAuthToken?: string; ip?: string }; + return reply(200, tried.plexAuthToken === TOKEN && tried.ip === "ace.internal"); + } + return reply(404); + }, + }; + return { http, calls, store }; +} + +// What an operator's ombi holds: plex loaded through its public name, plus a friend's server. +const operatorPlex = () => ({ + enable: true, + enableWatchlistImport: true, + monitorAll: false, + installId: "b358a2a2-2ab0-4025-a3f3-450313c3c418", + servers: [ + { + name: "ace", plexAuthToken: TOKEN, machineIdentifier: MACHINE, episodeBatchSize: 150, + serverHostname: "https://app.plex.tv", plexSelectedLibraries: [{ key: "1", title: "Films", enabled: true }], + ssl: true, subDir: null, ip: "plex.zurag.be", port: 443, id: 1, + }, + { + name: "a friend", plexAuthToken: "their-token", machineIdentifier: "another-server", episodeBatchSize: 150, + plexSelectedLibraries: [], ssl: false, subDir: null, ip: "10.0.0.9", port: 32400, id: 2, + }, + ], + id: 4, +}); + +test("the server's own entry gets the bound connection; its libraries and every other setting stay", async () => { + const f = fakes(operatorPlex()); + const out = await reconcilePlex(f.http, OMBI, binding(), `${TOKEN}\n`); + assert.deepEqual(out, { app: "plex", result: "written", fields: ["ip", "port", "ssl"] }); + const want = operatorPlex(); + Object.assign(want.servers[0], { ip: "ace.internal", port: 32400, ssl: false }); + assert.deepEqual(f.store.plex, want); +}); + +test("another server's entry is never touched", async () => { + const f = fakes(operatorPlex()); + await reconcilePlex(f.http, OMBI, binding(), TOKEN); + const servers = f.store.plex.servers as Record[]; + assert.deepEqual(servers[1], operatorPlex().servers[1]); +}); + +test("nothing is written when ombi already says what the mesh says", async () => { + const doc = operatorPlex(); + Object.assign(doc.servers[0], { ip: "ace.internal", port: 32400, ssl: false }); + const f = fakes(doc); + const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN); + assert.deepEqual(out, { app: "plex", result: "unchanged" }); + assert.equal(f.calls.filter((c) => c.method === "POST" && c.url.includes("/Settings/")).length, 0); +}); + +test("an ombi with no entry for this server gets one, named as plex names itself", async () => { + const fresh = { enable: false, enableWatchlistImport: false, monitorAll: false, installId: "x", servers: null, id: 0 }; + const f = fakes(fresh); + const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN); + assert.deepEqual(out, { app: "plex", result: "written", fields: ["server"] }); + assert.deepEqual(f.store.plex, { + ...fresh, + servers: [{ + name: "ace", machineIdentifier: MACHINE, ip: "ace.internal", port: 32400, ssl: false, subDir: null, + plexAuthToken: TOKEN, episodeBatchSize: 150, plexSelectedLibraries: [], + }], + }); + assert.equal(f.store.plex.enable, false, "whether plex is enabled in ombi is the operator's choice"); +}); + +test("a token plex refuses is never written, and the accept that fixes it is named", async () => { + for (const trusted of [false, true]) { + const f = fakes(operatorPlex(), { trusted }); + const out = await reconcilePlex(f.http, OMBI, binding(), "a-value-the-mesh-minted"); + assert.equal(out.result, "refused"); + const problem = (out as { problem: string }).problem; + assert.match(problem, /secret accept ombi plex-api --provider ace/); + assert.doesNotMatch(problem, /a-value-the-mesh-minted/); + assert.deepEqual(f.store.plex, operatorPlex(), "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("a plex it cannot reach is reported, and ombi is left alone", async () => { + const f = fakes(operatorPlex(), { reachable: false }); + const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN); + assert.equal(out.result, "refused"); + assert.match((out as { problem: string }).problem, /could not be asked.*ECONNREFUSED/); + assert.deepEqual(f.store.plex, operatorPlex()); +}); + +test("a loopback binding is refused: from ombi's container it is ombi itself", () => { + const w = wantedPlex(binding(32400, "127.0.0.1"), TOKEN); + assert.equal(w.ok, false); + assert.match((w as { problem: string }).problem, /private network/); +}); + +test("an https binding sets ombi's ssl flag; an empty subDir is none", () => { + const w = wantedPlex(binding(32400, "ace.internal", "https"), TOKEN); + assert.equal(w.ok && w.connection.ssl, true); + assert.deepEqual( + differingPlex({ ip: "h", port: 1, ssl: false, subDir: "", plexAuthToken: "k" }, { ip: "h", port: 1, ssl: false, subDir: null, plexAuthToken: "k" }), + [], + ); +}); + +test("every entry naming the server is laid over, not only the first", () => { + const doc = { servers: [{ machineIdentifier: MACHINE, ip: "a" }, { machineIdentifier: MACHINE, ip: "b" }] }; + const want = { ip: "ace.internal", port: 32400, ssl: false, subDir: null, plexAuthToken: TOKEN }; + const laid = withPlexServer(doc, MACHINE, want, "ace"); + assert.equal(laid.added, false); + assert.deepEqual((laid.next.servers as { ip: string }[]).map((s) => s.ip), ["ace.internal", "ace.internal"]); +}); diff --git a/modules/ombi/tsconfig.json b/modules/ombi/tsconfig.json index 8d90a9f..d0c9ec8 100644 --- a/modules/ombi/tsconfig.json +++ b/modules/ombi/tsconfig.json @@ -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", "servarr/settings.ts", "plex/settings.ts", "connections/index.ts"] }