Files
mesh-catalog/modules/tautulli/test/plex.test.ts
T
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

267 lines
12 KiB
TypeScript

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