Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991e33f749 | ||
|
|
3e378170df |
File diff suppressed because one or more lines are too long
@@ -120,7 +120,7 @@
|
||||
"port": 7080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here — everything else 301s to https and would loop a proxy"
|
||||
"why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy"
|
||||
},
|
||||
{
|
||||
"name": "web-tls",
|
||||
@@ -315,7 +315,10 @@
|
||||
"${dir:data-data}:/data",
|
||||
"${dir:data-dkim}:/dkim"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified"
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "imap",
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
"/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
|
||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable",
|
||||
"MESH_PROVISION_POSTGRES_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,6 +41,32 @@ function meshConfig(file?: string): Record<string, string> {
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
/**
|
||||
* The API key Tautulli minted for itself, read from its own config.ini (mounted read-only).
|
||||
*
|
||||
* Tautulli owns this key: it writes it on first run and every client of its API — this runtime
|
||||
* included — must present the same one. So the mesh does not mint or hold it; the one place it
|
||||
* lives is the file Tautulli keeps, and a key regenerated in Tautulli's settings is simply read
|
||||
* again on the next start. Undefined when there is no file or no key yet (a fresh install whose
|
||||
* setup wizard has not run).
|
||||
*/
|
||||
export function keyOfTautulli(dir?: string): string | undefined {
|
||||
if (!dir) return undefined;
|
||||
let ini: string;
|
||||
try { ini = readFileSync(`${dir.replace(/\/$/, "")}/config.ini`, "utf8"); }
|
||||
catch { return undefined; }
|
||||
let section = "";
|
||||
for (const raw of ini.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
const header = /^\[(.+)\]$/.exec(line);
|
||||
if (header) { section = header[1]; continue; }
|
||||
if (section !== "General") continue;
|
||||
const kv = /^api_key\s*=\s*"?([^"]*)"?$/.exec(line);
|
||||
if (kv && kv[1]) return kv[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class TautulliClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
@@ -52,15 +78,16 @@ export class TautulliClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. The API key is read from MESH_TAUTULLI_APIKEY
|
||||
* (Tautulli mints it in Settings → Web Interface); the base URL defaults to the local container.
|
||||
* Build from the module's resolved environment. The API key is the one Tautulli minted for
|
||||
* itself (Settings → Web Interface), read from its config.ini under MESH_TAUTULLI_CONFIG_DIR;
|
||||
* MESH_TAUTULLI_APIKEY still wins where it is set. The base URL defaults to the local container.
|
||||
* Throws when no key is configured — the module then contributes nothing rather than failing.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): TautulliClient {
|
||||
const cfg = meshConfig(env.MESH_TAUTULLI_CONFIG_FILE);
|
||||
const url = cfg.url ?? (env.MESH_TAUTULLI_URL ?? `http://127.0.0.1:${env.TAUTULLI_PORT ?? "8181"}`);
|
||||
const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY;
|
||||
if (!apiKey) throw new Error("no Tautulli API key — set MESH_TAUTULLI_APIKEY");
|
||||
const apiKey = cfg.apiKey ?? env.MESH_TAUTULLI_APIKEY ?? keyOfTautulli(env.MESH_TAUTULLI_CONFIG_DIR);
|
||||
if (!apiKey) throw new Error("no Tautulli API key — Tautulli's config.ini has none yet (finish its setup and enable the API)");
|
||||
return new TautulliClient(url, apiKey);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "<its node>"
|
||||
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 <this node> tautulli %s --provider %s --from <file holding the "
|
||||
"server's X-Plex-Token>`" % (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())
|
||||
@@ -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 <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/);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user