Files
plex-docker/Freeloader/plex_relay/README.md
T
benjamin 72f4661bdc Replace patchelf crack with Freeloader LD_PRELOAD approach
- Multi-stage Dockerfile: discover patterns from PMS binary (capstone),
  compile .so with zig (musl), layer onto lscr.io/linuxserver/plex
- Uses LD_PRELOAD instead of patchelf (which corrupts Plex's musl loader)
- Auto-discovery: broad structural patterns with string-anchored fallback
  (//feature) and relationship-based fallback (BITSET_REF within BS_INIT)
- hook.cpp uses __has_include for generated patterns with hardcoded fallbacks
- Custom wrapper.sh (no traffic_logger preload)
- Vendored Freeloader source (github.com/authrequest/Freeloader, AGPL-3.0)
- Removed stale plexmediaserver_crack.so binary
- Supports Plex 1.43.3+ (verified against 1.43.2 and 1.43.3)
2026-08-19 22:33:42 +02:00

179 lines
8.2 KiB
Markdown

<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
# plex_relay
A reverse-engineered, runnable reimplementation of the **Plex Media Server**
`RelayController` — the component that makes a server reachable remotely by
opening a **reverse SSH tunnel out to a Plex-operated relay host**.
Reconstructed from the `Plex Media Server` **1.43.2.10687** binary (Linux
x86-64). Ships **no Plex code**, embeds no keys, and authenticates to nothing on
its own. It is a behavioural model for interoperability research on
infrastructure **you operate yourself**.
---
## What Plex Relay does (reversed)
```
plex.tv ──"startRelay"(host,port)──▶ ServerEventManager_handle_pubsub_event (0xF5BE3C)
│ gate: signed_in && published && relay_enabled
▼
RelayController_connect (0x12307F2)
│ 1 dedup an already-active tunnel for host
│ 2 refresh relay host key (≤24h cache)
│ 3 pin [host]:443 in relayHostKey.txt
│ 4 spawn the ssh reverse tunnel
│ 5 arm the 300s inactive-connection reaper
▼
ssh -p <port> -N -R 0:127.0.0.1:<PMS port>
-o UserKnownHostsFile=<datadir>/relayHostKey.txt
-o LogLevel=VERBOSE -o PreferredAuthentications=password
-o PubkeyAuthentication=no -l <myplex-id> -F /dev/null <relay-host>
password = MyPlex token, delivered via the PLEXTOKEN env var + SSH_ASKPASS
```
The relay binds an ephemeral remote port (`-R 0:…`) and forwards inbound
remote-client traffic back down the tunnel to the local PMS service. The relay
host's SSH key is **pinned**: PMS downloads it at most once per day from
`https://downloads.plex.tv/relay/relay_v1.pub` and writes a per-endpoint
known_hosts line. `relayHostKey.txt` doubles as PMS's cache and the file handed
to `ssh` (the `#` lines are valid known_hosts comments).
---
## Architecture
High cohesion (one reason to change per module) and low coupling (dependencies
point inward to abstractions, never outward to I/O):
```
cli composition root / argument parsing
└─ controller orchestration; depends ONLY on the two protocols below
├─ store HostKeyTrust ── composes ↓↓
│ ├─ keys RelayKeyProvider (HTTPS fetch + TTL cache)
│ └─ cache HostKeyCache (relayHostKey.txt, atomic, 0o600)
└─ tunnel TunnelFactory ── build_ssh_argv (pure) + SSH_ASKPASS + child process
models immutable domain values + parsing (no I/O, thread-safe)
config immutable, validated configuration
errors one rooted exception hierarchy
```
**Dependency inversion.** `RelayController` names what it needs as `Protocol`s —
`HostKeyTrust` (store) and `TunnelFactory`/`Tunnel` (tunnel) — and is handed
concrete adapters by `RelayController.from_config`, the single composition root.
Every external concern (HTTP, filesystem, subprocess, clock) sits behind an
injected seam, so the orchestration is unit-tested with fakes and **no network,
disk, or process is touched** in the suite.
| Module | Responsibility | Depends on |
| --- | --- | --- |
| `errors` | exception taxonomy | — |
| `models` | `HostKey`, `RelayKey`, `parse_relay_pub`, endpoint formatting | `errors` |
| `config` | frozen, validated `RelayConfig` (token redacted from `repr`) | `errors` |
| `keys` | fetch `relay_v1.pub` (HTTPS-only, byte-capped, timed) + TTL cache | `errors`, `models` |
| `cache` | load/save `relayHostKey.txt` atomically at `0o600` | `errors`, `models` |
| `store` | `HostKeyManager`: compose key + cache, pin endpoints | `keys`, `cache`, `models` |
| `tunnel` | `build_ssh_argv` (pure) + askpass + `SubprocessTunnel` | `config`, `errors` |
| `controller` | connect / reap / stop lifecycle, thread-safety | the protocols above |
| `cli` | wire adapters, parse args | everything |
### Binary → code map
| Binary symbol | Address | Code |
| --- | --- | --- |
| `RelayController` ctor (loads cache) | `0x122FDDA` | `HostKeyCache.load` + `HostKeyManager.__init__` |
| `RelayController_connect` | `0x12307F2` | `RelayController.connect` + `store` + `tunnel` |
| stopRelay | `0x123068C` | `RelayController.stop` |
| inactive-connection reaper (300s) | `0x12320EE` | `RelayController.reap_once` / `_reaper_tick` |
| `relayHostKey.txt` path | `0x1231FEE` | `RelayConfig.cache_path` |
| `startRelay` PubSub dispatch | `0xF5BE3C` | `RelayController.start_relay` |
---
## Error model
All failures derive from `RelayError`, so callers catch the subsystem broadly or
a specific mode. Adapter exceptions (`urllib`, `OSError`, `subprocess`) are
caught at the boundary and re-raised as domain errors — they never leak.
- `ConfigError` — invalid configuration (raised eagerly in `RelayConfig`).
- `RelayKeyError` — relay key fetch/parse (non-HTTPS, oversize, transport, bad format).
- `HostKeyCacheError` — unreadable/malformed cache (load auto-rebuilds, as PMS does).
- `TunnelError` — `ssh` could not be launched.
**Contract:** `connect()` *raises* on failure (library callers decide).
`start_relay()` — the plex.tv event entry — is *resilient*: it logs and returns
`False`, mirroring PMS so a bad event can't kill an event loop.
---
## Security
- **Credential never on the command line.** The token is passed to `ssh` only
via the `PLEXTOKEN` env var, read by a generated `SSH_ASKPASS` helper. The
helper is mode `0o700` and removed on stop *and* via a `weakref.finalize`, so a
crash can't leak it. `RelayConfig` excludes the token from `repr`.
- **Fetch hardening.** The relay-key URL is HTTPS-only by default (configurable
URLs are an SSRF surface), the response is byte-capped, and the request is
time-limited.
- **Trust-store integrity.** `relayHostKey.txt` is written atomically at `0o600`;
it pins the host key `ssh` verifies, so it must not be world-writable.
- **No shell.** Processes are spawned from an argv list, never a shell string.
---
## Performance & scale
- **At most one key fetch per TTL**, behind a lock, on a **monotonic** clock
(immune to wall-clock jumps).
- **Disk writes only on change** — re-pinning an unchanged endpoint is a no-op.
- **O(1) liveness** via `Popen.poll()`; the reaper is a single background
`threading.Timer` that sweeps O(n) connections every 300s and reschedules
itself only while connections remain (idle controllers spawn no timers).
- **Immutable domain + value objects** are freely shareable across threads;
mutable state lives behind one `RLock`.
---
## Install & test
```bash
cd plex_relay
python -m pip install -e ".[test]"
python -m pytest -q # 51 tests + 1 POSIX-only; no network, no ssh
```
## Use
```bash
export PLEX_RELAY_TOKEN=... # keep the secret off the cmdline
plex-relay show --host RELAY_HOST --user MY_ID # dry run: resolve key + print argv
plex-relay connect --host RELAY_HOST --user MY_ID --local-port 32400
```
```python
from plex_relay import RelayConfig, RelayController
with RelayController.from_config(RelayConfig(token="…", ssh_user="my-id")) as ctrl:
ctrl.start_relay("relay.example.net", 443) # gated + resilient, like PMS
```
For tests or custom transports, bypass the composition root and inject your own
collaborators: `RelayController(config, hostkeys=…, tunnels=…)`.
---
## Fidelity & limitations
- **Faithful:** ssh argv (order + flags), the `relayHostKey.txt` format, the 24h
key cache, per-endpoint pinning, the dedup / 300s reaper lifecycle, and the
`startRelay` gating.
- **Adapted, with intent:** the known_hosts endpoint is keyed by the actual ssh
port (the binary hardcodes `:443`); the local forward target is configurable
(the binary reads the PMS port from its own config); TTLs use a monotonic clock.
- **POSIX only:** password delivery uses `SSH_ASKPASS`, which Windows OpenSSH
does not honour.
- **Not a turnkey relay swap:** this is only the *server→relay* leg. Plex brokers
both ends, so pointing it at your own relay also needs a relay `sshd` you
control plus client-discovery redirection (see the parent project's notes).