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.
261 lines
10 KiB
Python
261 lines
10 KiB
Python
# 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())
|