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.
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user