# 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())