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.
267 lines
12 KiB
TypeScript
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/);
|
|
});
|