Files
mesh-catalog/modules/tautulli/module.json
T
jschoubben 991e33f749 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.
2026-09-30 13:09:43 +02:00

178 lines
15 KiB
JSON

{
"module": "tautulli",
"version": "1",
"emits": [
"watch.recorded"
],
"own-secrets": {
"broker": "/var/lib/mesh/tautulli/broker"
},
"capabilities": [
"container-runtime"
],
"listens": [
{
"name": "web",
"port": 8181,
"protocol": "tcp",
"from": "mesh",
"why": "the watch statistics pages and API"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/tautulli",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"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 \"<its node>\"\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 <this node> tautulli %s --provider %s --from <file holding the \"\n \"server's X-Plex-Token>`\" % (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",
"name": "tautulli",
"image": "lscr.io/linuxserver/tautulli@sha256:e35570d636471f8aabfac7044057712679f954844a763ba735baa9659ea142b5",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC"
},
"ports": [
"8181"
],
"volumes": [
"${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"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/tautulli/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-tautulli",
"network": "host",
"volumes": [
"/var/lib/mesh/tautulli/broker:/run/secrets/broker:ro",
"/var/lib/mesh/tautulli/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/tautulli/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_TAUTULLI_URL": "http://127.0.0.1:${port:8181}",
"MESH_TAUTULLI_CONFIG_FILE": "/run/config/config.json",
"MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config"
},
"restart-on": [
"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": {
"route": {
"label": "tautulli",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:state}/route.json",
"plex-api": "${dir:state}/plex-api.json"
},
"secrets": {
"plex-api": "${dir:state}/plex-api.secret"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}