125 lines
6.1 KiB
Diff
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(),
|
|
}
|