diff --git a/scripts/upgrade_fixtures.py b/scripts/upgrade_fixtures.py index 062a803..f88caa6 100644 --- a/scripts/upgrade_fixtures.py +++ b/scripts/upgrade_fixtures.py @@ -852,6 +852,113 @@ class NZBGet: return True +class Plex: + """Plex's own HTTP API makes a library section and the same API proves it survived (felhom.eu R-1238). + + NO plex.tv ACCOUNT. An UNCLAIMED Plex answers a request from a private address with no token (felhom.eu + `architecture/13-threat-model.md`, the R-1045 row) - the harness calls from the bench host at the container's own + IP, so the caller is the docker bridge gateway (172.x.0.1), a private address. Measured on the bench 2026-10-28 + night (felhom.eu audits/night-2026-10-28/bench/plex/): POST /library/sections answered 201 with the new section; + GET read it back before and after 1.41.4.9463 -> 1.43.5.11029, with PLEX_CLAIM absent AND with a junk PLEX_CLAIM + (the image's first-run claim exchange fails and the server stays unclaimed). A CLAIMED server would answer 401 to + this route - seed() then records why and returns None, it never reads that as "no data". + + The server says it is ready only when /identity carries no `startState`: the healthcheck (curl -f /identity) is + healthy while `startState` is `startingPlugins`, and a POST then answers 400 "the server is still starting up" + (measured, the first spike attempt). JSON only with `Accept: application/json` - XML otherwise. + + What is proven: the section (its name, uuid and folder) lives in Plex's own database in the config volume. No media + is scanned; the scanner and the metadata agents are NOT measured, and no record claims they are.""" + port = 32400 + container = "plex" + J = ("-H", "Accept: application/json") + + def _base(self, ipfn): + ip = ipfn(self.container) + return f"http://{ip}:{self.port}" if ip else "" + + def _ready(self, base, say, tries=90, delay=4): + """(True, identity) once /identity answers 200 JSON with no startState; (False, last answer) otherwise.""" + last = "" + for _ in range(tries): + rc, code, out = _curl(base + "/identity", *self.J, timeout=15) + last = f"rc={rc} http={code} {out[:160]}" + if rc == 0 and code == "200": + try: + mc = json.loads(out).get("MediaContainer") or {} + except ValueError: + mc = {} + if "claimed" in mc and "startState" not in mc: + return True, mc + time.sleep(delay) + say(f" plex: never finished starting ({last})") + return False, None + + def _sections(self, base): + rc, code, out = _curl(base + "/library/sections", *self.J, timeout=30) + if rc != 0 or code != "200": + return None, f"rc={rc} http={code} {out[:160]}" + try: + return (json.loads(out).get("MediaContainer") or {}).get("Directory") or [], out + except ValueError: + return None, f"non-JSON http={code} {out[:160]}" + + def seed(self, ipfn, say): + base = self._base(ipfn) + if not base: + say(" plex: no container IP") + return None + ok, ident = self._ready(base, say) + if not ok: + return None + if ident.get("claimed") is not False: + self.tried = "the unclaimed private-address route: the server reports claimed=%r" % ident.get("claimed") + say(f" plex: {self.tried} - this route needs an UNCLAIMED server") + return None + name = "felhom" + secrets.token_hex(4) + q = ("name=%s&type=movie&agent=tv.plex.agents.movie&scanner=Plex%%20Movie&language=en-US&location=%%2Fmedia" % name) + rc, code, out = _curl(base + "/library/sections?" + q, *self.J, method="POST", timeout=60) + say(f" plex: POST /library/sections {name} http={code} (server {ident.get('version')}, claimed=false)") + if rc != 0 or code not in ("200", "201"): + self.tried = f"POST /library/sections answered rc={rc} http={code}: {out[:160]}" + say(f" plex: the app refused the section ({out[:160]})") + return None + try: + made = [d for d in (json.loads(out).get("MediaContainer") or {}).get("Directory") or [] if d.get("title") == name] + except ValueError: + made = [] + if not made or not made[0].get("uuid"): + say(f" plex: the create answer does not carry the section ({out[:160]})") + return None + say(f" plex: seeded library section {name} key={made[0].get('key')} uuid={made[0]['uuid']}") + return {"name": name, "uuid": made[0]["uuid"]} + + def verify(self, ipfn, seeded, say): + base = self._base(ipfn) + if not base: + return False + ok, ident = self._ready(base, say) + if not ok: + return False + secs, out = self._sections(base) + if secs is None: + say(f" plex: the app would not list its sections ({out})") + return False + # negative control first: a name nobody created must be absent + ghost = "felhom" + secrets.token_hex(4) + if any(d.get("title") == ghost for d in secs) or ghost in out: + say(" plex: READBACK IS UNUSABLE - a section nobody created is present") + return False + hit = [d for d in secs if d.get("title") == seeded["name"]] + if not hit: + say(f" plex: the seeded section {seeded['name']} is GONE ({len(secs)} section(s) listed)") + return False + same = hit[0].get("uuid") == seeded["uuid"] + locs = [l.get("path") for l in hit[0].get("Location") or []] + say(f" plex: section {seeded['name']} read back (server {ident.get('version')}), same uuid={same}, folders={locs}") + return same and "/media" in locs + + FIXTURES = { "privatebin": PrivateBin(), "docmost": Docmost(), @@ -865,4 +972,5 @@ FIXTURES = { "transmission": Transmission(), "sabnzbd": SABnzbd(), "nzbget": NZBGet(), + "plex": Plex(), }