Files
felhom.eu/documentation/audits/night-2026-10-28/bench/plex/upgrade_fixtures.diff
T

125 lines
6.1 KiB
Diff

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(),
}