// 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 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 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/); });