From 991e33f749670345dd16350dca7a5f1465620972 Mon Sep 17 00:00:00 2001 From: jochens Date: Wed, 30 Sep 2026 13:09:43 +0200 Subject: [PATCH] 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. --- modules/tautulli/Dockerfile | 5 +- modules/tautulli/client.ts | 2 +- modules/tautulli/module.json | 60 ++++++- modules/tautulli/package.json | 5 + modules/tautulli/plex/50-mesh-plex | 8 + modules/tautulli/plex/check.ts | 175 +++++++++++++++++++ modules/tautulli/plex/index.ts | 47 +++++ modules/tautulli/plex/mesh-plex.py | 260 ++++++++++++++++++++++++++++ modules/tautulli/test/plex.test.ts | 266 +++++++++++++++++++++++++++++ modules/tautulli/tsconfig.json | 2 +- 10 files changed, 825 insertions(+), 5 deletions(-) create mode 100644 modules/tautulli/plex/50-mesh-plex create mode 100644 modules/tautulli/plex/check.ts create mode 100644 modules/tautulli/plex/index.ts create mode 100644 modules/tautulli/plex/mesh-plex.py create mode 100644 modules/tautulli/test/plex.test.ts diff --git a/modules/tautulli/Dockerfile b/modules/tautulli/Dockerfile index 13f4e9a..edb1fd1 100644 --- a/modules/tautulli/Dockerfile +++ b/modules/tautulli/Dockerfile @@ -13,7 +13,7 @@ ARG RUNTIME_BASE FROM ${BUILD_BASE} AS build WORKDIR /app/modules/tautulli COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ +RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts plex/check.ts plex/index.ts \ --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist FROM ${RUNTIME_BASE} @@ -22,3 +22,6 @@ COPY --from=build /app/modules/tautulli/dist /app/modules/tautulli/dist # provider's provisioner runs its reconcile loop in the same process, with the broker connected — # the convention novox/hq issues 060/061 settled. ENV MESH_TOOL_MODULES=/app/modules/tautulli/dist/index.js,/app/modules/tautulli/dist/tools/index.js +# NOT dist/plex/index.js: that is a step the host runs to completion, named by the `plex` +# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the +# serving sidecar too, and exit it. diff --git a/modules/tautulli/client.ts b/modules/tautulli/client.ts index 5a6c48c..95fb400 100644 --- a/modules/tautulli/client.ts +++ b/modules/tautulli/client.ts @@ -50,7 +50,7 @@ function meshConfig(file?: string): Record { * again on the next start. Undefined when there is no file or no key yet (a fresh install whose * setup wizard has not run). */ -function keyOfTautulli(dir?: string): string | undefined { +export function keyOfTautulli(dir?: string): string | undefined { if (!dir) return undefined; let ini: string; try { ini = readFileSync(`${dir.replace(/\/$/, "")}/config.ini`, "utf8"); } diff --git a/modules/tautulli/module.json b/modules/tautulli/module.json index 715afc0..fe06883 100644 --- a/modules/tautulli/module.json +++ b/modules/tautulli/module.json @@ -38,6 +38,20 @@ "mode": "0700", "owner": "1000:1000" }, + { + "id": "plex-init-code", + "type": "file", + "path": "${dir:state}/mesh-plex.py", + "mode": "0644", + "content": "# Where Tautulli reaches Plex — decided by the mesh, written into Tautulli's config.ini before\n# Tautulli starts.\n#\n# Written by the mesh from the tautulli module's manifest, and run by the linuxserver image's\n# custom-init (50-mesh-plex) each time the `server` container starts, as root, before Tautulli.\n# Editing it here lasts until the next apply.\n#\n# WHY BEFORE START, AND NOT A STEP AFTER IT. Tautulli keeps its Plex connection only in config.ini\n# ([PMS]), reads that file at start, and writes its whole in-memory config back on every shutdown.\n# A step editing the file while Tautulli runs is overwritten the moment the container is recreated;\n# its API has no command that sets the connection, and its settings form needs an admin login. The\n# one moment the file is Tautulli's to read and nobody's to overwrite is here: after the old\n# container stopped (and wrote), before the new one reads. The container restarts on its binding\n# and credential (`restart-on`), so a plex that moves or a token that is accepted lands here.\n#\n# WHAT IT WRITES, AND WHEN. Only [PMS] keys, and only when they differ from what the mesh says:\n# pms_ip, pms_port, pms_ssl, pms_url - from the binding; always, when the binding is usable\n# pms_identifier - plex's own machineIdentifier, when plex answers /identity\n# pms_token - the pair credential, ONLY when plex takes it\n# Every other key and section, comment and ordering stays as it was, byte for byte.\n#\n# A TOKEN PLEX REFUSES IS NEVER WRITTEN. Until the operator accepts the server's X-Plex-Token for\n# this pair, the mesh delivers a value it minted, which plex answers with 401 (400 on a network it\n# trusts). Writing it would replace a working token with a dead one. The address is still written:\n# it is right whatever the token, and Tautulli's existing token keeps working at the new address.\n# The refusal is loud here and in the `plex` step, which fails naming the `secret accept`.\n#\n# Never prints the token. Exits 0 even on a refusal: custom-init ignores the code, and Tautulli\n# starting on what it had is better than not starting. The step after the server is what fails.\n\nimport json\nimport os\nimport re\nimport sys\nimport tempfile\nimport time\nimport urllib.error\nimport urllib.request\n\nBINDING = os.environ.get(\"MESH_PLEX_BINDING\", \"/run/mesh/plex-api.json\")\nSECRET = os.environ.get(\"MESH_PLEX_SECRET\", \"/run/mesh/plex-api.secret\")\nCONFIG = os.environ.get(\"MESH_TAUTULLI_CONFIG\", \"/config/config.ini\")\nWAIT = float(os.environ.get(\"MESH_PLEX_WAIT_SECONDS\", \"60\"))\nPROVISION = \"plex-api\"\n\n\ndef say(message):\n print(\"[mesh-plex] \" + message, flush=True)\n\n\ndef is_loopback(host):\n h = host.lower()\n return h in (\"localhost\", \"::1\", \"[::1]\") or h.startswith(\"127.\")\n\n\ndef wanted_address(binding):\n \"\"\"The [PMS] address keys the binding says, or a reason it cannot say them.\"\"\"\n if not isinstance(binding, dict):\n return None, \"no binding for %s was delivered\" % PROVISION\n at = binding.get(\"at\")\n at = at.strip() if isinstance(at, str) else \"\"\n serves = binding.get(\"serves\") if isinstance(binding.get(\"serves\"), dict) else {}\n try:\n port = int(serves.get(\"port\"))\n except (TypeError, ValueError):\n port = 0\n scheme = serves.get(\"scheme\") or \"http\"\n if not at:\n return None, \"the %s binding names no host (at)\" % PROVISION\n if is_loopback(at):\n return None, (\n \"the %s binding says plex is at %s, which from Tautulli's container is Tautulli itself; \"\n \"the mesh hands loopback to a machine that is not on the private network\" % (PROVISION, at))\n if not 0 < port < 65536:\n return None, \"the %s binding serves no usable port (%r)\" % (PROVISION, serves.get(\"port\"))\n if scheme not in (\"http\", \"https\"):\n return None, \"the %s binding serves scheme %s, which Tautulli cannot dial\" % (PROVISION, scheme)\n host = \"[%s]\" % at if \":\" in at and not at.startswith(\"[\") else at\n url = \"%s://%s:%d\" % (scheme, host, port)\n return {\"pms_ip\": at, \"pms_port\": str(port), \"pms_ssl\": \"1\" if scheme == \"https\" else \"0\", \"pms_url\": url}, None\n\n\ndef plex_get(url, path, token=None):\n \"\"\"(status, parsed JSON or None); raises OSError when plex cannot be asked.\"\"\"\n headers = {\"Accept\": \"application/json\"}\n if token is not None:\n headers[\"X-Plex-Token\"] = token\n request = urllib.request.Request(url + path, headers=headers)\n try:\n with urllib.request.urlopen(request, timeout=10) as response:\n body = response.read()\n try:\n return response.status, json.loads(body)\n except ValueError:\n return response.status, None\n except urllib.error.HTTPError as err:\n return err.code, None\n\n\ndef ask_plex(url, token):\n \"\"\"(takes: True/False/None, machineIdentifier or None). None: plex could not be asked in time.\"\"\"\n deadline = time.monotonic() + WAIT\n while True:\n try:\n status, _ = plex_get(url, \"/\", token)\n if status in (400, 401, 403):\n takes = False\n elif 200 <= status < 300:\n takes = True\n else:\n raise OSError(\"plex answered %d at /\" % status)\n identifier = None\n status, body = plex_get(url, \"/identity\")\n if status == 200 and isinstance(body, dict):\n value = (body.get(\"MediaContainer\") or {}).get(\"machineIdentifier\")\n identifier = value if isinstance(value, str) and value else None\n return takes, identifier\n except OSError as err:\n if time.monotonic() >= deadline:\n say(\"plex could not be asked at %s: %s\" % (url, err))\n return None, None\n time.sleep(3)\n\n\nSECTION = re.compile(r\"^\\s*\\[([^\\]]+)\\]\\s*$\")\nKEY = re.compile(r\"^(\\s*)([A-Za-z0-9_]+)(\\s*=\\s*)(.*?)\\s*$\")\n\n\ndef unquoted(value):\n if len(value) >= 2 and value[0] == value[-1] and value[0] in \"\\\"'\":\n return value[1:-1]\n return value\n\n\ndef plain(value):\n \"\"\"ConfigObj reads a value unquoted unless it holds one of these; none of ours should.\"\"\"\n return not re.search(r\"[#,\\\"'\\r\\n]\", value) and value == value.strip()\n\n\ndef laid_over(text, wanted):\n \"\"\"config.ini's text with [PMS] saying `wanted`, and the names of the keys that changed.\"\"\"\n lines = text.splitlines(True)\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n changed = []\n start = end = None\n for i, line in enumerate(lines):\n m = SECTION.match(line)\n if m:\n if start is not None:\n end = i\n break\n if m.group(1).strip() == \"PMS\":\n start = i\n if start is None:\n if lines and lines[-1].strip():\n lines.append(\"\\n\")\n lines.append(\"[PMS]\\n\")\n start, end = len(lines) - 1, len(lines)\n elif end is None:\n end = len(lines)\n seen = set()\n for i in range(start + 1, end):\n m = KEY.match(lines[i])\n if not m or m.group(2) not in wanted:\n continue\n key = m.group(2)\n seen.add(key)\n if unquoted(m.group(4)) != wanted[key]:\n lines[i] = \"%s%s%s%s\\n\" % (m.group(1), key, m.group(3), wanted[key])\n changed.append(key)\n missing = [k for k in wanted if k not in seen]\n # Insert after the section's last key, not after the blank lines that separate it from the next.\n at = end\n while at > start + 1 and not lines[at - 1].strip():\n at -= 1\n for key in missing:\n lines.insert(at, \"%s = %s\\n\" % (key, wanted[key]))\n at += 1\n changed.append(key)\n return \"\".join(lines), changed\n\n\ndef write_config(text):\n \"\"\"Replace config.ini whole, keeping its owner and mode; a new one takes the directory's owner.\"\"\"\n directory = os.path.dirname(CONFIG) or \".\"\n try:\n st = os.stat(CONFIG)\n uid, gid, mode = st.st_uid, st.st_gid, st.st_mode & 0o7777\n except FileNotFoundError:\n st = os.stat(directory)\n uid, gid, mode = st.st_uid, st.st_gid, 0o644\n fd, tmp = tempfile.mkstemp(prefix=\".config.ini.\", dir=directory)\n try:\n with os.fdopen(fd, \"w\", encoding=\"utf-8\") as f:\n f.write(text)\n os.chmod(tmp, mode)\n try:\n os.chown(tmp, uid, gid)\n except PermissionError:\n pass\n os.replace(tmp, CONFIG)\n except BaseException:\n if os.path.exists(tmp):\n os.unlink(tmp)\n raise\n\n\ndef read(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n return f.read()\n except FileNotFoundError:\n return None\n\n\ndef main():\n raw = read(BINDING)\n try:\n binding = json.loads(raw) if raw is not None else None\n except ValueError:\n binding = None\n address, problem = wanted_address(binding)\n if problem:\n say(\"left Tautulli's Plex connection as it was: \" + problem)\n return 0\n wanted = dict(address)\n token = (read(SECRET) or \"\").strip()\n takes, identifier = ask_plex(address[\"pms_url\"], token) if token else (False, None)\n if identifier:\n wanted[\"pms_identifier\"] = identifier\n frm = binding.get(\"from\") or \"\"\n if not token:\n say(\"no %s credential was delivered; only the address was written\" % PROVISION)\n elif takes and plain(token):\n wanted[\"pms_token\"] = token\n elif takes is False:\n say(\"plex refuses the %s credential the mesh delivered, so it was not written; the address was. \"\n \"plex's token is issued by plex.tv and the mesh cannot make it: accept the server's own token \"\n \"for this pair - `secret accept tautulli %s --provider %s --from `\" % (PROVISION, PROVISION, frm))\n elif takes:\n say(\"the %s credential holds characters config.ini cannot carry unquoted; it was not written\" % PROVISION)\n else:\n say(\"plex could not be asked whether it takes the %s credential; only the address was written\" % PROVISION)\n if not all(plain(v) for v in wanted.values()):\n say(\"the %s binding holds characters config.ini cannot carry unquoted; nothing was written\" % PROVISION)\n return 0\n before = read(CONFIG)\n after, changed = laid_over(before or \"\", wanted)\n if not changed:\n say(\"Tautulli's Plex connection is already as the mesh says (%s)\" % address[\"pms_url\"])\n return 0\n write_config(after)\n say(\"wrote %s into Tautulli's [PMS] (%s)\" % (\", \".join(changed), address[\"pms_url\"]))\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n" + }, + { + "id": "plex-init", + "type": "file", + "path": "${dir:state}/50-mesh-plex", + "mode": "0755", + "content": "#!/bin/bash\n# Run by the linuxserver image's custom-init each time Tautulli's container starts, as root, before\n# Tautulli: puts where the mesh says plex is into Tautulli's config.ini (mesh-plex.py says why).\n#\n# Written by the mesh from the tautulli module's manifest. Editing it here lasts until the next apply.\nPY=/lsiopy/bin/python3\n[ -x \"$PY\" ] || PY=python3\nexec \"$PY\" /run/mesh/mesh-plex.py\n" + }, { "id": "server", "type": "container", @@ -52,7 +66,17 @@ "8181" ], "volumes": [ - "${dir:config}:/config" + "${dir:config}:/config", + "${dir:state}/50-mesh-plex:/custom-cont-init.d/50-mesh-plex:ro", + "${dir:state}/mesh-plex.py:/run/mesh/mesh-plex.py:ro", + "${dir:state}/plex-api.json:/run/mesh/plex-api.json:ro", + "${dir:state}/plex-api.secret:/run/mesh/plex-api.secret:ro" + ], + "restart-on": [ + "plex-init", + "plex-init-code", + "bound-plex-api", + "secret-plex-api" ] }, { @@ -83,9 +107,37 @@ "runtime-config" ], "artifact": "runtime" + }, + { + "id": "plex", + "type": "container", + "name": "mesh-tautulli-plex", + "network": "host", + "run-once": true, + "volumes": [ + "${dir:state}/plex-api.json:/run/plex/plex-api.json:ro", + "${dir:state}/plex-api.secret:/run/plex/plex-api.secret:ro", + "${dir:config}:/var/lib/tautulli/config:ro" + ], + "env": { + "MESH_TAUTULLI_URL": "http://127.0.0.1:${port:8181}", + "MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config", + "MESH_PLEX_DIR": "/run/plex" + }, + "args": [ + "run", + "/app/modules/tautulli/dist/plex/index.js" + ], + "restart-on": [ + "server", + "bound-plex-api", + "secret-plex-api" + ], + "artifact": "runtime" } ], "requires": [ + "plex-api", "route" ], "contributes": { @@ -95,7 +147,11 @@ } }, "binds": { - "route": "${dir:state}/route.json" + "route": "${dir:state}/route.json", + "plex-api": "${dir:state}/plex-api.json" + }, + "secrets": { + "plex-api": "${dir:state}/plex-api.secret" }, "build": { "on": [ diff --git a/modules/tautulli/package.json b/modules/tautulli/package.json index b8e3718..df8b301 100644 --- a/modules/tautulli/package.json +++ b/modules/tautulli/package.json @@ -4,6 +4,11 @@ "description": "tautulli — Plex watch statistics. Its API client, tools and events live here (novox/hq ADR 0039).", "type": "module", "private": true, + "scripts": { + "build": "tsc client.ts index.ts tools/index.ts plex/check.ts plex/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "typecheck": "tsc -p tsconfig.json", + "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" + }, "dependencies": { "@novox/mesh-sdk": "^0.1.0" }, diff --git a/modules/tautulli/plex/50-mesh-plex b/modules/tautulli/plex/50-mesh-plex new file mode 100644 index 0000000..e32537b --- /dev/null +++ b/modules/tautulli/plex/50-mesh-plex @@ -0,0 +1,8 @@ +#!/bin/bash +# Run by the linuxserver image's custom-init each time Tautulli's container starts, as root, before +# Tautulli: puts where the mesh says plex is into Tautulli's config.ini (mesh-plex.py says why). +# +# Written by the mesh from the tautulli module's manifest. Editing it here lasts until the next apply. +PY=/lsiopy/bin/python3 +[ -x "$PY" ] || PY=python3 +exec "$PY" /run/mesh/mesh-plex.py diff --git a/modules/tautulli/plex/check.ts b/modules/tautulli/plex/check.ts new file mode 100644 index 0000000..f83a627 --- /dev/null +++ b/modules/tautulli/plex/check.ts @@ -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; +} + +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 }): Promise<{ + status: number; + text(): Promise; + }>; +} + +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 tautulli ${PROVISION} --provider ${from || ""} ` + + `--from \`` + ); +} + +/** 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/modules/tautulli/plex/index.ts b/modules/tautulli/plex/index.ts new file mode 100644 index 0000000..dfcdca2 --- /dev/null +++ b/modules/tautulli/plex/index.ts @@ -0,0 +1,47 @@ +// tautulli's plex step — run once by the host after Tautulli's server container, and again whenever +// its binding, its pair credential or the server changed (the container's `restart-on`, novox/hq +// ADR 0099). It checks; it writes nothing (plex/check.ts says why, and where the write is). +// +// Exits non-zero when Tautulli does not reach plex as the mesh says, so the node reports the step +// failed. Declared last in the manifest, so its failing gates nothing else of tautulli's (novox/hq +// ADR 0136). Never prints a key or a token. + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { keyOfTautulli } from "../client.js"; +import { check, tautulliReady, PROVISION, type Binding, type Http } from "./check.js"; + +const dir = process.env.MESH_PLEX_DIR ?? "/run/plex"; +const url = process.env.MESH_TAUTULLI_URL ?? "http://127.0.0.1:8181"; +const waitSeconds = Number(process.env.MESH_TAUTULLI_WAIT_SECONDS ?? "180"); + +const http: Http = { fetch: (u, init) => fetch(u, init) }; +const read = (path: string) => readFile(path, "utf8").catch(() => undefined); + +const apiKey = keyOfTautulli(process.env.MESH_TAUTULLI_CONFIG_DIR); +if (!apiKey) { + console.error("[tautulli-plex] Tautulli's config.ini holds no API key yet — it writes one on its first start"); + process.exit(1); +} +const tautulli = { url, apiKey }; + +if (!(await tautulliReady(http, tautulli, waitSeconds * 1000))) { + console.error(`[tautulli-plex] Tautulli did not answer at ${url} within ${waitSeconds}s`); + process.exit(1); +} + +let binding: Binding | undefined; +try { + const raw = await read(join(dir, `${PROVISION}.json`)); + binding = raw === undefined ? undefined : (JSON.parse(raw) as Binding); +} catch { + binding = undefined; +} +const outcome = await check(http, tautulli, binding, await read(join(dir, `${PROVISION}.secret`))); +if (outcome.result === "connected") { + console.log(`[tautulli-plex] Tautulli reaches plex at ${outcome.url} as the mesh says; connected`); +} else { + console.error(`[tautulli-plex] ${outcome.problem}`); + process.exitCode = 1; +} diff --git a/modules/tautulli/plex/mesh-plex.py b/modules/tautulli/plex/mesh-plex.py new file mode 100644 index 0000000..e229ea5 --- /dev/null +++ b/modules/tautulli/plex/mesh-plex.py @@ -0,0 +1,260 @@ +# Where Tautulli reaches Plex — decided by the mesh, written into Tautulli's config.ini before +# Tautulli starts. +# +# Written by the mesh from the tautulli module's manifest, and run by the linuxserver image's +# custom-init (50-mesh-plex) each time the `server` container starts, as root, before Tautulli. +# Editing it here lasts until the next apply. +# +# WHY BEFORE START, AND NOT A STEP AFTER IT. Tautulli keeps its Plex connection only in config.ini +# ([PMS]), reads that file at start, and writes its whole in-memory config back on every shutdown. +# A step editing the file while Tautulli runs is overwritten the moment the container is recreated; +# its API has no command that sets the connection, and its settings form needs an admin login. The +# one moment the file is Tautulli's to read and nobody's to overwrite is here: after the old +# container stopped (and wrote), before the new one reads. The container restarts on its binding +# and credential (`restart-on`), so a plex that moves or a token that is accepted lands here. +# +# WHAT IT WRITES, AND WHEN. Only [PMS] keys, and only when they differ from what the mesh says: +# pms_ip, pms_port, pms_ssl, pms_url - from the binding; always, when the binding is usable +# pms_identifier - plex's own machineIdentifier, when plex answers /identity +# pms_token - the pair credential, ONLY when plex takes it +# Every other key and section, comment and ordering stays as it was, byte for byte. +# +# A TOKEN PLEX REFUSES IS NEVER WRITTEN. Until the operator accepts the server's X-Plex-Token for +# this pair, the mesh delivers a value it minted, which plex answers with 401 (400 on a network it +# trusts). Writing it would replace a working token with a dead one. The address is still written: +# it is right whatever the token, and Tautulli's existing token keeps working at the new address. +# The refusal is loud here and in the `plex` step, which fails naming the `secret accept`. +# +# Never prints the token. Exits 0 even on a refusal: custom-init ignores the code, and Tautulli +# starting on what it had is better than not starting. The step after the server is what fails. + +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.request + +BINDING = os.environ.get("MESH_PLEX_BINDING", "/run/mesh/plex-api.json") +SECRET = os.environ.get("MESH_PLEX_SECRET", "/run/mesh/plex-api.secret") +CONFIG = os.environ.get("MESH_TAUTULLI_CONFIG", "/config/config.ini") +WAIT = float(os.environ.get("MESH_PLEX_WAIT_SECONDS", "60")) +PROVISION = "plex-api" + + +def say(message): + print("[mesh-plex] " + message, flush=True) + + +def is_loopback(host): + h = host.lower() + return h in ("localhost", "::1", "[::1]") or h.startswith("127.") + + +def wanted_address(binding): + """The [PMS] address keys the binding says, or a reason it cannot say them.""" + if not isinstance(binding, dict): + return None, "no binding for %s was delivered" % PROVISION + at = binding.get("at") + at = at.strip() if isinstance(at, str) else "" + serves = binding.get("serves") if isinstance(binding.get("serves"), dict) else {} + try: + port = int(serves.get("port")) + except (TypeError, ValueError): + port = 0 + scheme = serves.get("scheme") or "http" + if not at: + return None, "the %s binding names no host (at)" % PROVISION + if is_loopback(at): + return None, ( + "the %s binding says plex is at %s, which from Tautulli's container is Tautulli itself; " + "the mesh hands loopback to a machine that is not on the private network" % (PROVISION, at)) + if not 0 < port < 65536: + return None, "the %s binding serves no usable port (%r)" % (PROVISION, serves.get("port")) + if scheme not in ("http", "https"): + return None, "the %s binding serves scheme %s, which Tautulli cannot dial" % (PROVISION, scheme) + host = "[%s]" % at if ":" in at and not at.startswith("[") else at + url = "%s://%s:%d" % (scheme, host, port) + return {"pms_ip": at, "pms_port": str(port), "pms_ssl": "1" if scheme == "https" else "0", "pms_url": url}, None + + +def plex_get(url, path, token=None): + """(status, parsed JSON or None); raises OSError when plex cannot be asked.""" + headers = {"Accept": "application/json"} + if token is not None: + headers["X-Plex-Token"] = token + request = urllib.request.Request(url + path, headers=headers) + try: + with urllib.request.urlopen(request, timeout=10) as response: + body = response.read() + try: + return response.status, json.loads(body) + except ValueError: + return response.status, None + except urllib.error.HTTPError as err: + return err.code, None + + +def ask_plex(url, token): + """(takes: True/False/None, machineIdentifier or None). None: plex could not be asked in time.""" + deadline = time.monotonic() + WAIT + while True: + try: + status, _ = plex_get(url, "/", token) + if status in (400, 401, 403): + takes = False + elif 200 <= status < 300: + takes = True + else: + raise OSError("plex answered %d at /" % status) + identifier = None + status, body = plex_get(url, "/identity") + if status == 200 and isinstance(body, dict): + value = (body.get("MediaContainer") or {}).get("machineIdentifier") + identifier = value if isinstance(value, str) and value else None + return takes, identifier + except OSError as err: + if time.monotonic() >= deadline: + say("plex could not be asked at %s: %s" % (url, err)) + return None, None + time.sleep(3) + + +SECTION = re.compile(r"^\s*\[([^\]]+)\]\s*$") +KEY = re.compile(r"^(\s*)([A-Za-z0-9_]+)(\s*=\s*)(.*?)\s*$") + + +def unquoted(value): + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + return value + + +def plain(value): + """ConfigObj reads a value unquoted unless it holds one of these; none of ours should.""" + return not re.search(r"[#,\"'\r\n]", value) and value == value.strip() + + +def laid_over(text, wanted): + """config.ini's text with [PMS] saying `wanted`, and the names of the keys that changed.""" + lines = text.splitlines(True) + if lines and not lines[-1].endswith("\n"): + lines[-1] += "\n" + changed = [] + start = end = None + for i, line in enumerate(lines): + m = SECTION.match(line) + if m: + if start is not None: + end = i + break + if m.group(1).strip() == "PMS": + start = i + if start is None: + if lines and lines[-1].strip(): + lines.append("\n") + lines.append("[PMS]\n") + start, end = len(lines) - 1, len(lines) + elif end is None: + end = len(lines) + seen = set() + for i in range(start + 1, end): + m = KEY.match(lines[i]) + if not m or m.group(2) not in wanted: + continue + key = m.group(2) + seen.add(key) + if unquoted(m.group(4)) != wanted[key]: + lines[i] = "%s%s%s%s\n" % (m.group(1), key, m.group(3), wanted[key]) + changed.append(key) + missing = [k for k in wanted if k not in seen] + # Insert after the section's last key, not after the blank lines that separate it from the next. + at = end + while at > start + 1 and not lines[at - 1].strip(): + at -= 1 + for key in missing: + lines.insert(at, "%s = %s\n" % (key, wanted[key])) + at += 1 + changed.append(key) + return "".join(lines), changed + + +def write_config(text): + """Replace config.ini whole, keeping its owner and mode; a new one takes the directory's owner.""" + directory = os.path.dirname(CONFIG) or "." + try: + st = os.stat(CONFIG) + uid, gid, mode = st.st_uid, st.st_gid, st.st_mode & 0o7777 + except FileNotFoundError: + st = os.stat(directory) + uid, gid, mode = st.st_uid, st.st_gid, 0o644 + fd, tmp = tempfile.mkstemp(prefix=".config.ini.", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + os.chmod(tmp, mode) + try: + os.chown(tmp, uid, gid) + except PermissionError: + pass + os.replace(tmp, CONFIG) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def read(path): + try: + with open(path, encoding="utf-8") as f: + return f.read() + except FileNotFoundError: + return None + + +def main(): + raw = read(BINDING) + try: + binding = json.loads(raw) if raw is not None else None + except ValueError: + binding = None + address, problem = wanted_address(binding) + if problem: + say("left Tautulli's Plex connection as it was: " + problem) + return 0 + wanted = dict(address) + token = (read(SECRET) or "").strip() + takes, identifier = ask_plex(address["pms_url"], token) if token else (False, None) + if identifier: + wanted["pms_identifier"] = identifier + frm = binding.get("from") or "" + if not token: + say("no %s credential was delivered; only the address was written" % PROVISION) + elif takes and plain(token): + wanted["pms_token"] = token + elif takes is False: + say("plex refuses the %s credential the mesh delivered, so it was not written; the address was. " + "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 tautulli %s --provider %s --from `" % (PROVISION, PROVISION, frm)) + elif takes: + say("the %s credential holds characters config.ini cannot carry unquoted; it was not written" % PROVISION) + else: + say("plex could not be asked whether it takes the %s credential; only the address was written" % PROVISION) + if not all(plain(v) for v in wanted.values()): + say("the %s binding holds characters config.ini cannot carry unquoted; nothing was written" % PROVISION) + return 0 + before = read(CONFIG) + after, changed = laid_over(before or "", wanted) + if not changed: + say("Tautulli's Plex connection is already as the mesh says (%s)" % address["pms_url"]) + return 0 + write_config(after) + say("wrote %s into Tautulli's [PMS] (%s)" % (", ".join(changed), address["pms_url"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/modules/tautulli/test/plex.test.ts b/modules/tautulli/test/plex.test.ts new file mode 100644 index 0000000..05db591 --- /dev/null +++ b/modules/tautulli/test/plex.test.ts @@ -0,0 +1,266 @@ +// 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/); +}); diff --git a/modules/tautulli/tsconfig.json b/modules/tautulli/tsconfig.json index 3677859..88684f1 100644 --- a/modules/tautulli/tsconfig.json +++ b/modules/tautulli/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "index.ts", "tools/index.ts"] + "include": ["client.ts", "index.ts", "tools/index.ts", "plex/check.ts", "plex/index.ts"] }