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