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.
This commit is contained in:
2026-09-30 13:09:43 +02:00
parent 3e378170df
commit 991e33f749
10 changed files with 825 additions and 5 deletions
+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);
}