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)
This commit is contained in:
1 parent
4399a8288d
commit
72f4661bdc
72 files changed
+77927
-17
No files matched your search
@@ -0,0 +1,3 @@
|
||||
Freeloader/.git
|
||||
Freeloader/.gitignore
|
||||
.git
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+81
-17
@@ -1,21 +1,85 @@
|
||||
FROM lscr.io/linuxserver/plex:latest
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Multi-stage build with automatic signature discovery:
|
||||
# 1. base — PMS base image (source of the binary to analyze)
|
||||
# 2. discover — auto-discover hook patterns from the PMS binary
|
||||
# 3. builder — cross-compile the .so with zig (musl) + generated patterns
|
||||
# 4. runtime — layer onto lscr.io/linuxserver/plex with LD_PRELOAD wrapper
|
||||
#
|
||||
# Based on https://github.com/authrequest/Freeloader (AGPL-3.0-or-later).
|
||||
|
||||
# Install patchelf
|
||||
RUN apt-get update && \
|
||||
apt-get install -y patchelf && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
ARG PLEX_BASE_IMAGE=lscr.io/linuxserver/plex:latest
|
||||
|
||||
# COPY plexmediaserver_crack.so /config/plexmediaserver_crack.so
|
||||
# RUN chmod 644 /config/plexmediaserver_crack.so
|
||||
# ── Stage 1: base (PMS image, used as source for discovery) ──────────────
|
||||
FROM ${PLEX_BASE_IMAGE} AS base
|
||||
|
||||
# Download the Plex crack
|
||||
RUN wget -O /tmp/plexmediaserver_crack.so \
|
||||
https://gitgud.io/yuv420p10le/plexmediaserver_crack/-/raw/master/binaries/plexmediaserver_crack.so && \
|
||||
cp /tmp/plexmediaserver_crack.so /config/plexmediaserver_crack.so && \
|
||||
chmod 644 /config/plexmediaserver_crack.so
|
||||
# ── Stage 2: discover hook patterns from the PMS binary ─────────────────
|
||||
# Auto-discovers byte patterns by analyzing the PMS binary. If a pattern
|
||||
# can't be found, the build fails here — before compiling or shipping.
|
||||
FROM debian:bookworm-slim AS discover
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip \
|
||||
&& pip3 install --break-system-packages capstone \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY scripts/discover_patterns.py /tmp/discover_patterns.py
|
||||
COPY --from=base /usr/lib/plexmediaserver/ /tmp/plex/
|
||||
RUN python3 /tmp/discover_patterns.py "/tmp/plex/Plex Media Server" \
|
||||
-o /tmp/patterns_generated.h \
|
||||
&& cat /tmp/patterns_generated.h
|
||||
|
||||
# Apply the crack using patchelf
|
||||
RUN PLEX_DIR=/usr/lib/plexmediaserver && \
|
||||
ln -sf /config/plexmediaserver_crack.so $PLEX_DIR/lib/plexmediaserver_crack.so && \
|
||||
patchelf --remove-needed plexmediaserver_crack.so $PLEX_DIR/lib/libsoci_core.so || true && \
|
||||
patchelf --add-needed plexmediaserver_crack.so $PLEX_DIR/lib/libsoci_core.so
|
||||
# ── Stage 3: build the musl .so ───────────────────────────────────────────
|
||||
FROM debian:bookworm-slim AS builder
|
||||
|
||||
ARG ZIG_VERSION=0.13.0
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
RUN mkdir -p /src/toolchain \
|
||||
&& curl -fsSL \
|
||||
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" \
|
||||
-o /tmp/zig.tar.xz \
|
||||
&& tar -C /src/toolchain --strip-components=1 -xf /tmp/zig.tar.xz \
|
||||
&& rm /tmp/zig.tar.xz
|
||||
ENV PATH="/src/toolchain:${PATH}"
|
||||
|
||||
COPY Freeloader/build.sh ./
|
||||
COPY Freeloader/src ./src
|
||||
COPY Freeloader/third_party ./third_party
|
||||
COPY --from=discover /tmp/patterns_generated.h ./src/patterns_generated.h
|
||||
RUN bash build.sh
|
||||
|
||||
# ── Stage 4: runtime -- patch lscr.io/linuxserver/plex ────────────────────
|
||||
FROM ${PLEX_BASE_IMAGE} AS runtime
|
||||
|
||||
ARG PLEX_BASE_IMAGE
|
||||
ARG PATCH_VERSION=dev
|
||||
LABEL org.opencontainers.image.title="plexmediaserver-crack (linuxserver)" \
|
||||
org.opencontainers.image.source="https://github.com/authrequest/Freeloader" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
|
||||
plex_patch.base="${PLEX_BASE_IMAGE}" \
|
||||
plex_patch.version="${PATCH_VERSION}"
|
||||
|
||||
RUN set -eux; \
|
||||
PMS="/usr/lib/plexmediaserver/Plex Media Server"; \
|
||||
PMS_LIB="/usr/lib/plexmediaserver/lib"; \
|
||||
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
|
||||
[ -x "${PMS}" ] || { echo "patcher: missing ${PMS} in ${PLEX_BASE_IMAGE}"; exit 1; }; \
|
||||
[ -d "${PMS_LIB}" ] || { echo "patcher: missing ${PMS_LIB}/ in ${PLEX_BASE_IMAGE}"; exit 1; }; \
|
||||
[ -f "${RUN_SCRIPT}" ] || { echo "patcher: missing ${RUN_SCRIPT} in ${PLEX_BASE_IMAGE}"; exit 1; }
|
||||
|
||||
COPY --from=builder /src/build/plexmediaserver_crack.so \
|
||||
/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
|
||||
RUN chmod 0644 /usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
|
||||
COPY wrapper.sh /usr/lib/plexmediaserver/plex-crack-wrapper.sh
|
||||
RUN chmod 0755 /usr/lib/plexmediaserver/plex-crack-wrapper.sh
|
||||
|
||||
RUN set -eux; \
|
||||
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
|
||||
cp "${RUN_SCRIPT}" "${RUN_SCRIPT}.orig"; \
|
||||
printf '#!/usr/bin/with-contenv bash\nexec s6-setuidgid abc /usr/lib/plexmediaserver/plex-crack-wrapper.sh\n' \
|
||||
> "${RUN_SCRIPT}"; \
|
||||
chmod 0755 "${RUN_SCRIPT}"
|
||||
@@ -0,0 +1,76 @@
|
||||
# .dockerignore — keep the Docker build context small.
|
||||
#
|
||||
# Both docker/Dockerfile.* only need: build.sh, src/, third_party/.
|
||||
# Everything else is excluded so the daemon doesn't ship big/unrelated
|
||||
# files into the buildkit context.
|
||||
|
||||
# VCS / secrets
|
||||
.git
|
||||
.gitattributes
|
||||
.mcp.json
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Build outputs and toolchain
|
||||
build/
|
||||
Build/
|
||||
build-*/
|
||||
dist/
|
||||
toolchain/
|
||||
compile_commands.json
|
||||
|
||||
# Compiled artifacts
|
||||
*.o
|
||||
*.obj
|
||||
*.lo
|
||||
*.a
|
||||
*.so
|
||||
*.so.*
|
||||
*.dll
|
||||
*.dylib
|
||||
*.exe
|
||||
*.out
|
||||
|
||||
# Plex binaries / IDA DBs
|
||||
libsoci_core.so
|
||||
Plex_Media_Server
|
||||
*.i64
|
||||
*.idb
|
||||
*.id0
|
||||
*.id1
|
||||
*.id2
|
||||
*.nam
|
||||
*.til
|
||||
|
||||
# Subprojects not part of the build
|
||||
windows/
|
||||
plex_relay/
|
||||
experimental/
|
||||
scripts/
|
||||
docs/
|
||||
|
||||
# Docs and meta
|
||||
AGENTS.md
|
||||
README.md
|
||||
LICENSE
|
||||
.gitattributes
|
||||
|
||||
# Python cruft
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
.cache/
|
||||
*.tmp
|
||||
*.log
|
||||
*.bak
|
||||
*.swp
|
||||
@@ -0,0 +1,9 @@
|
||||
# Linux-targeted project: keep everything LF in the repo and on checkout.
|
||||
# The /bin/sh + python launchers MUST stay LF or they break on the Plex host.
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.cpp text eol=lf
|
||||
*.hpp text eol=lf
|
||||
*.c text eol=lf
|
||||
*.h text eol=lf
|
||||
@@ -0,0 +1,87 @@
|
||||
# ─── Secrets / local machine config (NEVER COMMIT) ───────────────────────
|
||||
# .mcp.json here held a plaintext SSH password — keep all local creds out.
|
||||
.mcp.json
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
*_rsa
|
||||
*_rsa.pub
|
||||
id_ed25519*
|
||||
id_rsa*
|
||||
|
||||
# ─── Copyrighted Plex binaries (do NOT redistribute) ─────────────────────
|
||||
/Plex_Media_Server
|
||||
/Plex Media Server
|
||||
pms.bin
|
||||
libsoci_core.so
|
||||
|
||||
# ─── IDA Pro databases (large; derived from the copyrighted binary) ──────
|
||||
*.i64
|
||||
*.idb
|
||||
*.id0
|
||||
*.id1
|
||||
*.id2
|
||||
*.nam
|
||||
*.til
|
||||
|
||||
# ─── Compiled output (from github/gitignore: C++) ────────────────────────
|
||||
*.o
|
||||
*.obj
|
||||
*.lo
|
||||
*.slo
|
||||
*.gch
|
||||
*.pch
|
||||
*.d
|
||||
*.a
|
||||
*.la
|
||||
*.lai
|
||||
*.lib
|
||||
*.so
|
||||
*.so.*
|
||||
*.dylib
|
||||
*.dll
|
||||
*.out
|
||||
*.app
|
||||
*.exe
|
||||
|
||||
# ─── Build dirs / generated ───────────────────────────────────────────────
|
||||
build/
|
||||
Build/
|
||||
build-*/
|
||||
dist/
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
cmake_install.cmake
|
||||
install_manifest.txt
|
||||
compile_commands.json
|
||||
|
||||
# ─── Toolchain auto-downloaded by build.sh ────────────────────────────────
|
||||
toolchain/
|
||||
|
||||
# ─── Python (plex_relay / scripts/plex-tailnet) ───────────────────────────
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
venv/
|
||||
.tox/
|
||||
|
||||
# ─── Temp / logs ──────────────────────────────────────────────────────────
|
||||
*.tmp
|
||||
*.log
|
||||
*.bak
|
||||
*.swp
|
||||
*~
|
||||
.cache/
|
||||
|
||||
# ─── OS / editor cruft ────────────────────────────────────────────────────
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,144 @@
|
||||
# AGENTS.md - Plex_Patch
|
||||
|
||||
## Project overview
|
||||
|
||||
Reverse-engineering notes and a runtime patch for **Plex Media Server** on
|
||||
**Linux x86-64** (and **ARM64/aarch64**). It hooks Plex's
|
||||
`FeatureManager` and forces every feature bit on, unlocking gated features.
|
||||
Educational / RE use on software you run yourself; it ships no Plex code and
|
||||
bypasses no account/server authentication.
|
||||
|
||||
## Target & runtime reality (important)
|
||||
|
||||
- **Target:** the main `Plex Media Server` executable (PIE). The feature machinery
|
||||
moved here from `libsoci_core.so` on post-2024/08/13 builds; `libsoci_core.so`
|
||||
is no longer the target.
|
||||
- **Plex runs against its OWN bundled musl libc + libgcompat**
|
||||
(`/usr/lib/plexmediaserver/lib/`), NOT the host glibc. This drives the build and
|
||||
injection choices below.
|
||||
- **ARM64:** confirmed working on aarch64-linux-musl. The ARM64 binary has the
|
||||
same musl constraint; zig cross-compilation targets `aarch64-linux-musl`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### x86-64
|
||||
|
||||
- **Language:** C++20, plus the vendored Zydis C amalgamation for instruction
|
||||
decoding.
|
||||
- **Module discovery:** `dl_iterate_phdr` used in early versions; current code
|
||||
uses `/proc/self/maps` (the constructor runs before the main PIE is loaded by
|
||||
musl, so `dl_iterate_phdr` returns 0 callbacks).
|
||||
- **Hook:** `sig_scan()` finds target functions by byte-pattern signature;
|
||||
`create_hook()` installs a 14-byte `jmp [rip+0x06]` trampoline (Zydis decodes
|
||||
the prologue so relocated bytes stay valid).
|
||||
- **Feature unlock effect:** after Plex applies its MyPlex feature list, the
|
||||
hook forces all 14 `g_feature_bitset_slots` qwords on (`std::bitset<896>`),
|
||||
so every feature (including Plex Pass, code 92, slot 11) reads as enabled.
|
||||
- **Webhook unlock:** an additional hook targets `sub_122B2F2` (the generic
|
||||
preference getter at `0x122B2F2`). When the key is `"WebHooksEnabled"`, it
|
||||
returns `true` regardless of the actual persisted value, enabling Plex's
|
||||
built-in webhook dispatch (play/pause/stop/rate events). This is a separate
|
||||
mechanism from the feature bitset — `WebHooksEnabled` is a plain boolean
|
||||
preference, not a feature bit. Both dispatch functions (`sub_125A6D4` @
|
||||
`0x125A6D4` and `sub_125B766` @ `0x125B766`) check only this preference
|
||||
with no secondary feature-bit gate.
|
||||
|
||||
### ARM64 / aarch64
|
||||
|
||||
- **Language:** C++20. Zydis is NOT used on ARM64 (fixed 4-byte instructions,
|
||||
no length decoding needed). `Zydis.h` is conditionally excluded via
|
||||
`#ifndef __aarch64__`.
|
||||
- **Module discovery:** same `/proc/self/maps` parser (`get_dottext_info()`)
|
||||
works identically on ARM64.
|
||||
- **Hook:** `create_hook_arm64()` installs a 16-byte
|
||||
`LDR X17, [PC, #8]; BR X17; <8-byte target>` trampoline. No Zydis needed.
|
||||
The trampoline copies 4 original instructions (16 bytes) and appends the
|
||||
same LDR+BR jump-back sequence.
|
||||
- **Feature unlock effect:** same bitset-force approach — hook the
|
||||
FeatureManager constructor via ARM64 signature, then force all 14 uint64_t
|
||||
slots to `UINT64_MAX`.
|
||||
- **Webhook unlock:** currently targets preference init functions (SSO-check
|
||||
prologue patterns like `ldrb w?, [x?, #0x17]`). The ARM64 callbacks are
|
||||
generic feature-return-true for now; WebHooksEnabled-specific key matching
|
||||
(like the x86-64 sub_122B2F2 hook) is pending identification of the exact
|
||||
ARM64 preference getter function.
|
||||
- **Analysis tooling:** `get_arm64_sigs.py` uses Capstone to extract ARM64
|
||||
function signatures with ADRP page counting; `check_webhooks.py` dumps
|
||||
ADRP+ADD string loads inside candidate functions; `disasm_final.py` /
|
||||
`disasm_key_areas.py` are earlier (broken) Capstone analysis scripts.
|
||||
|
||||
### ARM64 function signatures (from get_arm64_sigs.py)
|
||||
|
||||
| VAddr | Signature | Notes |
|
||||
|-------|-----------|-------|
|
||||
| `0x10dd904` | `FD 7B BA A9 FC 6F 01 A9 FA 67 02 A9 F8 5F 03 A9 F6 57 04 A9 F4 4F 05 A9 FD 03 00 91 FF 43 09 D1` | Preference init function — 6 stp pairs (save 12 regs), sub sp,#0x250. The function at this address loads "WebHooksEnabled" string via ADRP+ADD at 0x10e0eac. |
|
||||
| `0x658120` | `FD 7B BD A9 F5 0B 00 F9 F4 4F 02 A9 FD 03 00 91 F3 03 01 AA F4 03 00 AA 61 00 80 52 E0 03 13 AA` | FeatureManager class function — saves 2 regs, calls with x0/x1, loads FeatureManager strings from page 0x1B7000. |
|
||||
| `0x658070` | `FD 7B 03 A9 F4 4F 04 A9 FD C3 00 91` | FeatureManager constructor-like — stp x29,x30,[sp,#-0x30]! ; stp x20,x19,[sp,#0x10] ; mov x29,sp. Used as catch-all FeatureManager hook target. |
|
||||
| `0xeba0b4` | `FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 08 5C 40 39 09 04 40 F9 F3 03 00 AA 0A 1D 00 13 5F 01 00 71` | Feature-check function — stp x29,x30,[sp,#-0x10]! ; str x19,[sp,#8] ; mov x29,sp ; ldrb w8,[x0,#0x17] (SSO check). 3 ADRP refs to page 0x1BD000 (hasPlexPass strings). |
|
||||
| `0x5e4188` | `FD 7B 01 A9 FD 43 00 91 A8 65 00 B0 08 85 42 F9 49 66 00 F0 ...` | hasPlexPass checking function — 3 ADRP refs to page 0x1BD000. |
|
||||
| `0x5e42c0` | `FD 7B BE A9 F4 4F 01 A9 FD 03 00 91 74 66 00 90 88 82 46 39 ...` | hasPlexPass checking function — 3 ADRP refs to page 0x1BD000. |
|
||||
| `0x5e4368` | `FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 73 66 00 90 68 A2 47 39 ...` | hasPlexPass checking function — 3 ADRP refs to page 0x1BD000. |
|
||||
|
||||
### ARM64 binary layout (from `_Plex Media Server`, text section at file offset 0x5d35bc)
|
||||
|
||||
| Page | Contains | Notable Strings |
|
||||
|------|----------|-----------------|
|
||||
| `0x1B7000` | FeatureManager strings, preference init strings | `"FeatureManager"`, `"FeatureManager: Using cached data"` (@ 0x1B72AB), `"hasPlexPass"` (@ 0x1B749B) |
|
||||
| `0x1BD000` | Feature checking strings | `"playing"`, `"paused"`, `"buffering"`, `"media_css_min_assets_cache_ms"` |
|
||||
| `0x363000` | Webhook/web-related strings | `"WebHooksEnabled"` (@ 0x363871) |
|
||||
|
||||
### ARM64 text section
|
||||
|
||||
- Text section: vaddr=0x5e35bc, file_offset=0x5d35bc, size=0xc05964 (~12MB)
|
||||
- Built for aarch64 Linux (little-endian), PIE position-independent
|
||||
- All functions use ARM64 standard prologue: `stp x29, x30, [sp, #-N]!`
|
||||
- String references via ADRP+ADD pairs (PC-relative page + 12-bit offset)
|
||||
- No fixed function addresses when PIE-loaded; all discovery via sig_scan
|
||||
|
||||
## Build & inject (details in README.md / docs/BUILD.md)
|
||||
|
||||
- **Build with musl** via `zig` (`-target x86_64-linux-musl`): `bash build.sh`.
|
||||
For ARM64: `bash build.sh --arm64`.
|
||||
A glibc build cannot relocate glibc-only symbols (`__isoc23_strtol`,
|
||||
`arc4random`, `*_chk`, `_dl_find_object`) in Plex's musl runtime → exit 127.
|
||||
- **Inject with `LD_PRELOAD`** via `scripts/plex-crack-wrapper.sh` + a systemd
|
||||
|
||||
## Key files
|
||||
|
||||
- `src/hook.cpp` / `src/hook.hpp` — hook engine, feature logic, feature-UUID catalog
|
||||
- `src/main.cpp` — library constructor (`unsetenv("LD_PRELOAD")` then `hook()`)
|
||||
- `build.sh` — musl build via zig (auto-downloaded) with an ABI sanity gate
|
||||
- `scripts/plex-crack-wrapper.sh` — `LD_PRELOAD` launcher scoped to the PMS process
|
||||
- `scripts/readbitset.py` — live feature-bitset verifier
|
||||
- `third_party/zydis/` — vendored Zydis disassembler (MIT)
|
||||
- `get_arm64_sigs.py` — Capstone-based ARM64 function signature extractor with ADRP page counting
|
||||
- `check_webhooks.py` — dump ADRP+ADD string loads within candidate ARM64 functions
|
||||
- `experimental/debug_hook.c` — standalone alternate hook (legacy `is_feature_available` signature)
|
||||
|
||||
RE artifacts (the `Plex Media Server` binary, `libsoci_core.so`, and `*.i64` IDA
|
||||
databases) are git-ignored and not redistributed.
|
||||
|
||||
## Signature patterns
|
||||
|
||||
Hex bytes with `?` wildcards; spaces ignored (`?` = one-byte wildcard). Patterns
|
||||
are version-specific — re-verify after PMS updates.
|
||||
|
||||
### x86-64 signatures
|
||||
|
||||
| Target | Address | Signature | Notes |
|
||||
|--------|---------|-----------|-------|
|
||||
| `bitset_init` (modern path constructor) | dynamic | `55 48 89 E5 41 57 41 56 41 55 41 54 53 48 81 EC ? ? 00 00 49 89 FE 48 8D 9D ? ? ? ? 48 89 DF E8 ? ? ? ? 48 8B 1B 48 85 DB` | `FeatureManager_apply_feature_list_xml` — post-2024/08/13 |
|
||||
| `sub_122B2F2` (preference getter) | `0x122B2F2` | `48 89 F3 4C 89 F7 0F B6 46 17 48 89 F1 84 C0` | `mov rbx, rsi; mov r14, rdi; movzx eax,[rsi+0x17]` — std::string SSO check prologue |
|
||||
| `is_user_feature_set` (legacy) | dynamic | `55 48 89 E5 48 8B 07 48 85 C0 74 09` | Pre-2024/08/13 fallback |
|
||||
| `is_feature_available` (legacy) | dynamic | `E8 ? ? ? ? 86 43` (call rel32 + `test al, byte ptr [rbx+3]`) | Rel32 followed, pre-2024/08/13 fallback |
|
||||
| `map_find` (legacy) | dynamic | `55 48 89 E5 41 57 41 56 53 48 83 EC ? 49 89 F7 4C 8D 77` | Pre-2024/08/13 fallback |
|
||||
|
||||
### ARM64 signatures (built with `bash build.sh --arm64`)
|
||||
|
||||
| Target | Signature | Notes |
|
||||
|--------|-----------|-------|
|
||||
| `FeatureManager_init` (bitset constructor) | `FD 7B 03 A9 F4 4F 04 A9 FD C3 00 91` | stp x29,x30,[sp,#-0x30]! ; stp x20,x19,[sp,#0x10] ; mov x29,sp. Catches FeatureManager init to force bits on. |
|
||||
| `FeatureManager_class_method` (alternative) | `FD 7B BD A9 F5 0B 00 F9 F4 4F 02 A9 FD 03 00 91 F3 03 01 AA F4 03 00 AA 61 00 80 52 E0 03 13 AA` | Saves 1 reg pair + str ; handles x0/x1 args. |
|
||||
| `feature_check_sso` (feature checker) | `FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 08 5C 40 39` | stp x29,x30,[sp,#-0x10]! ; str x19,[sp,#8] ; mov x29,sp ; ldrb w8,[x0,#0x17]. Hooked to always return true. |
|
||||
| `pref_init` (preference init) | `FD 7B BA A9 FC 6F 01 A9 FA 67 02 A9 F8 5F 03 A9 F6 57 04 A9 F4 4F 05 A9 FD 03 00 91 FF 43 09 D1` | 6 stp pairs, sub sp,#0x250. Loads "WebHooksEnabled" string. WebHooksEnabled-specific hook pending. |
|
||||
| `pref_getter_sso` (generic getter, placeholder) | `FD 7B ?? A9 ?? ?? ?? A9 FD 03 00 91 ?? 5C 40 39 ??` | Broad pattern: prologue + SSO check on any x-reg. May match the preference getter. |
|
||||
@@ -0,0 +1,365 @@
|
||||
# FEEDBACK.md — load this at the start of every session
|
||||
|
||||
> **Read this file at the start of every session on this project.**
|
||||
> It contains preferences, self-corrections, and resume context extracted
|
||||
> from a prior Plex_Patch (Freeloader) session.
|
||||
>
|
||||
> **Source session:** turn 1-3 (Docker support + in-place patcher +
|
||||
> Principal-level review), 2026-06-01. Reviewed by the user.
|
||||
>
|
||||
> **If the file gets long, trim it.** Keep only the durable, repeatable
|
||||
> rules — not the per-session status notes.
|
||||
|
||||
---
|
||||
|
||||
## 1 · User preferences (extracted from how they actually work)
|
||||
|
||||
- **Hands-off, trusting style.** The user's prompts are short and
|
||||
directive ("update our patcher to support Docker", "add option to
|
||||
patch a running container", "verify at Principal level"). They
|
||||
expect me to figure out the *how* once they give the *what*.
|
||||
- **One-shot prompts, not iterative.** Each user turn is a complete
|
||||
scope expansion. They do not iterate on micro-decisions.
|
||||
- **Trusts my decisions.** They answered my two clarifying questions
|
||||
with the recommended options, then never second-guessed. Don't
|
||||
re-ask what I can decide.
|
||||
- **Values quality over speed.** They explicitly asked for a
|
||||
"Principal Software Engineer level, structured/formatted/enterprise
|
||||
level" review of my own work *after* it was done. Match that
|
||||
quality bar from the start.
|
||||
- **Wants resumability.** They asked "What did we do so far?" mid-
|
||||
session. They value the ability to context-switch.
|
||||
- **Wants the safety valve.** "Continue if you have next steps, or
|
||||
stop and ask for clarification if you are unsure how to proceed."
|
||||
This is a good model — finish the work but stop when genuinely
|
||||
blocked.
|
||||
- **Wants me to do the prep.** "Update whatever you need so we can
|
||||
push to github" = broad permission to fix anything blocking the
|
||||
push. Do the audit, do the fixes, then commit and push.
|
||||
- **Likes tabular structured output.** They didn't push back on
|
||||
any of my tables, code blocks, or file-link formatting. Keep
|
||||
using `| col | col |` tables and `file:///` links for file refs.
|
||||
|
||||
## 2 · Commit style for this repo (Freeloader / Plex_Patch)
|
||||
|
||||
Style observed in `git log` — match it:
|
||||
|
||||
```
|
||||
Add top-level Windows patching doc index
|
||||
Add Windows x64 godmode DLL + injector
|
||||
Add relay reimplementation + remote-access tooling; label Remote Watch Pass
|
||||
Add GNU AGPL-3.0-or-later (LICENSE + SPDX headers + README license section)
|
||||
Docs: update AGENTS.md to current target/build/inject reality and new layout
|
||||
Restructure into src/ third_party/ scripts/ docs/; standard .gitignore; drop stale duplicates and orphaned CM...
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Subject starts with capital verb: `Add …`, `Docs: …`, `Restructure …`,
|
||||
`Initial commit: …`. No `feat:` / `fix:` / `chore:` conventional-commits
|
||||
prefixes.
|
||||
- Subject ≤ 72 chars, concise.
|
||||
- Body (when present) explains *what* + *why* and any non-obvious
|
||||
decisions. Multiple paragraphs separated by blank lines are fine.
|
||||
- One atomic commit per feature, not per file.
|
||||
|
||||
## 3 · Things I should do differently next time (self-corrections)
|
||||
|
||||
These are the actual mistakes I made this session:
|
||||
|
||||
1. **Don't claim behavior I haven't verified.** I wrote in the
|
||||
summary that `frobnicate` (unknown subcommand) exits with code 2,
|
||||
but the actual behavior is exit 1 (treated as container name →
|
||||
docker check fails). Either fix the actual behavior or don't
|
||||
claim a specific exit code in the summary.
|
||||
|
||||
2. **Don't fire background agents I won't engage with.** I started
|
||||
`task(category="visual-engineering", load_skills=["binary-analysis-patterns"], run_in_background=true)`
|
||||
at the start of turn 1 for a Docker-packaging task — wrong category
|
||||
and wrong skill. The agent was never used. Either use the
|
||||
background agent or don't start it. Background agents are
|
||||
expensive; do not start speculatively.
|
||||
|
||||
3. **Don't re-read files I just wrote.** I read
|
||||
`docker/plex-docker-patch.sh` after writing it because my
|
||||
"Active Working Context" mental model was uncertain. Trust the
|
||||
context I have. Re-read only if I've genuinely lost track (e.g.,
|
||||
after a compaction or long turn gap).
|
||||
|
||||
4. **Keep summaries concise and resume-actionable.** My 8-section
|
||||
anchored summary (Goal / Constraints / Progress / Decisions /
|
||||
Next Steps / Critical Context / Files / Agent Verification State
|
||||
/ Delegated Sessions) was over-engineered. The user wanted
|
||||
"what did we do so far", not a meta-analysis of my own process.
|
||||
Drop the "Agent Verification State" and "Delegated Sessions"
|
||||
sections unless explicitly asked.
|
||||
|
||||
5. **Don't include "Active Working Context" sections in summaries.**
|
||||
They are a mental model, not the actual file content. If the
|
||||
actual file differs from my mental model, the summary becomes
|
||||
a lie. Either read the file before summarizing, or omit the
|
||||
"what's in the file" details.
|
||||
|
||||
6. **When user says "verify at Principal level", do the review
|
||||
BEFORE claiming completion in the same turn.** I finished the
|
||||
implementation, then ran the review in a later turn. Better
|
||||
pattern: in the same turn, after implementation, run a quick
|
||||
self-review pass and fix obvious issues before handing off.
|
||||
Saves a turn.
|
||||
|
||||
7. **When in-place rewrites are large (e.g., 337 → 552 lines),
|
||||
state the scope at the top of the summary.** "Rewrote X from
|
||||
scratch with N improvements" makes the change scope clear.
|
||||
|
||||
8. **When the user says "update whatever you need so we can push
|
||||
to github", do the pre-push audit as a checklist:**
|
||||
- No secrets in any new file (`git grep -nE 'password|api[_-]?key|token|secret|bearer'`)
|
||||
- No active git hooks (`.git/hooks/` all `.sample`?)
|
||||
- `.gitignore` covers all sensitive patterns?
|
||||
- Git identity set?
|
||||
- `core.fileMode` and `core.autocrlf` consistent with repo?
|
||||
- All changes intentional (`git status` matches plan)?
|
||||
Then commit + push. Don't ask for re-confirmation.
|
||||
|
||||
9. **Use `bash -n` + smoke tests in PARALLEL, not sequentially.**
|
||||
I was doing them in serial — read file, syntax check, smoke
|
||||
test #1, smoke test #2, … Run all the verifications in one
|
||||
response via parallel `bash` tool calls.
|
||||
|
||||
10. **PowerShell on Windows — common gotchas to remember:**
|
||||
- `head` is not a valid command. Use `Get-Content -TotalCount N`
|
||||
or `Select-Object -First N`. Or just don't truncate in the
|
||||
bash command.
|
||||
- `$?` is a **boolean** (success/failure), not an exit code.
|
||||
Use `$LASTEXITCODE` for the numeric exit code.
|
||||
- `ls -la` is not valid. Use `Get-ChildItem -LiteralPath X`.
|
||||
- Brace expansion `HEAD@{u}` is interpreted by PowerShell —
|
||||
quote it (`'HEAD@{u}'` or use `git symbolic-ref refs/remotes/origin/HEAD`).
|
||||
- `bash -n C:\path\file` fails on Windows paths. Use a
|
||||
relative path with the `workdir` parameter.
|
||||
|
||||
## 4 · Patterns that worked (keep doing these)
|
||||
|
||||
- **Arg-parser with single `case` loop, mutual-exclusion checks
|
||||
at the end.** Cleaner than scattered checks per-flag.
|
||||
- **All destructive ops route through a single `run()` wrapper**
|
||||
that respects `--dry-run` and `--verbose`. Single place to see
|
||||
side effects. The pattern from `plex-docker-patch.sh` is good —
|
||||
reuse it for future scripts.
|
||||
- **Container shell commands use `docker exec sh -c '...' _ "${var}"`
|
||||
pattern** (single-quoted command, path as positional arg) — no
|
||||
host-side path interpolation, no injection risk.
|
||||
- **Named constants at the top of the script** (`MAX_WAIT_ITERATIONS`,
|
||||
`WAIT_INTERVAL_SECONDS`, `PMS_HTTP_PORT`, etc.) — easier to tune,
|
||||
easier to read.
|
||||
- **Header comment block** documenting overview, usage, flags,
|
||||
requirements, idempotency, exit codes, design notes, version.
|
||||
This is the "enterprise README inline" pattern.
|
||||
- **Idempotency statement in the header.** "install: safe to
|
||||
re-run. The .orig is preserved across re-installs, the .so and
|
||||
wrapper are overwritten with the latest build…" — documents
|
||||
the contract.
|
||||
- **Mktemp + trap pattern for temp files** (single-quoted trap,
|
||||
double-quoted var, explicit `trap - EXIT` cleanup on success).
|
||||
- **Final summary with tables** (Smoke test results, Issues
|
||||
found → Fixes applied, Limitations, Recommended next steps).
|
||||
The user read and engaged with this format.
|
||||
- **Pre-existing failures explicitly noted.** "Done. Note: N
|
||||
pre-existing errors unrelated to my changes." — distinguishes
|
||||
my work from prior state.
|
||||
|
||||
## 5 · Project context (resume faster next time)
|
||||
|
||||
- **Project:** Plex_Patch (fork: **Freeloader**).
|
||||
- **Remote:** `https://github.com/authrequest/Freeloader.git`.
|
||||
- **Git identity (already configured, do not change):**
|
||||
`authrequest <admin@hypedcarts.com>`.
|
||||
- **`core.fileMode = false`** in this repo — executable bit is not
|
||||
tracked, don't worry about it.
|
||||
- **`core.autocrlf = true`** — Windows line endings are normal.
|
||||
- **Target:** Plex Media Server on Linux x86_64.
|
||||
- **Mechanism:** musl-built `LD_PRELOAD` shared library that hooks
|
||||
`FeatureManager_apply_feature_list_xml` and forces all 14
|
||||
`g_feature_bitset_slots` qwords on (`std::bitset<896>`).
|
||||
- **Build:** zig 0.13.0 cross-compile to `x86_64-linux-musl`.
|
||||
`bash build.sh` from the project root. Build artifact:
|
||||
`build/plexmediaserver_crack.so`.
|
||||
- **Injection (native):** `LD_PRELOAD` via `scripts/plex-crack-wrapper.sh`
|
||||
+ systemd drop-in. **Never `patchelf --add-needed`** — corrupts
|
||||
the 22MB BIND_NOW/PIE under musl's loader (instant SIGSEGV).
|
||||
- **Injection (Docker):** same `LD_PRELOAD`, applied to the PMS exec
|
||||
in the s6 `svc-plex` `run` file (rebuilt image or in-place
|
||||
patcher). The .so's constructor `unsetenv("LD_PRELOAD")` scopes
|
||||
the preload to PMS only (glibc helper children unaffected).
|
||||
- **Languages:** C++20, bash, Python (plex_relay / plex-tailnet).
|
||||
- **Vendored:** Zydis disassembler in `third_party/zydis/` (MIT).
|
||||
- **Layout:**
|
||||
- `src/` — `main.cpp` (constructor), `hook.cpp` / `hook.hpp`
|
||||
(signature scan, Zydis-disassembled trampoline)
|
||||
- `build.sh` — musl build + ABI sanity gate
|
||||
- `scripts/` — `plex-crack-wrapper.sh` (native systemd),
|
||||
`readbitset.py` (live verifier), `plex-tailnet/`
|
||||
- `docker/` — `Dockerfile.plexinc`, `Dockerfile.linuxserver`,
|
||||
`wrapper.sh`, `plex-docker-patch.sh`, `README.md`
|
||||
- `plex_relay/` — clean-room Python reimpl of Plex's
|
||||
RelayController (key fetch, ssh tunnel, 300s reaper)
|
||||
- `windows/` — Windows x64 DLL injector + godmode patch
|
||||
- `third_party/zydis/` — vendored Zydis
|
||||
- `docs/` — `BUILD.md`, `DOCKER.md`, `WINDOWS.md`
|
||||
- `AGENTS.md` — architecture / RE notes
|
||||
- `experimental/debug_hook.c` — legacy alternate hook
|
||||
- `LICENSE` — AGPL-3.0-or-later
|
||||
- **Git-ignored:** `Plex Media Server` binary, `libsoci_core.so`,
|
||||
`*.i64` / `*.idb` IDA DBs, `build/`, `toolchain/`, `.mcp.json`,
|
||||
`.env*`, `*.pem`, `*.key`, `id_*` SSH keys.
|
||||
- **Docker images patched (turn 1):**
|
||||
- `plexinc/pms-docker` — official
|
||||
- `lscr.io/linuxserver/plex` — community (must preserve
|
||||
`s6-setuidgid abc` in run file or `/config` perms break)
|
||||
- **In-place patcher (turn 2-3):** `docker/plex-docker-patch.sh`
|
||||
v1.0.0 with install/uninstall/status subcommands + full flag
|
||||
surface. See the file's header for the design notes.
|
||||
- **Limitations:** x86_64 only (no arm64 PMS Docker image today).
|
||||
LSIO requires preserving `s6-setuidgid abc`. Plex bundles its
|
||||
own musl libc + libgcompat — glibc `.so` cannot be loaded.
|
||||
|
||||
## 6 · Templates (re-use these)
|
||||
|
||||
### Pre-push audit checklist
|
||||
```
|
||||
[ ] git status — all changes intentional, no unexpected files
|
||||
[ ] git diff --stat — sizes look right
|
||||
[ ] git grep -nE 'password|api[_-]?key|token|secret|bearer' -- <paths>
|
||||
[ ] .git/hooks/ — all *.sample, no active hooks
|
||||
[ ] git config --get user.{name,email} — set
|
||||
[ ] git remote -v — right remote
|
||||
[ ] core.fileMode / core.autocrlf — match repo
|
||||
[ ] bash -n <shell scripts> — passes
|
||||
[ ] commit message — matches repo style (capital verb, no conventional prefix)
|
||||
```
|
||||
|
||||
### Bash script header template (from plex-docker-patch.sh)
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# <path>
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# <one-line purpose>
|
||||
#
|
||||
# ── Overview ──────────────────────────────────────────────────────
|
||||
# <how it works>
|
||||
#
|
||||
# ── Usage ─────────────────────────────────────────────────────────
|
||||
# <script> [flags] <subcommand> [args]
|
||||
#
|
||||
# Subcommands:
|
||||
# <subcmd> [args] <purpose>
|
||||
#
|
||||
# Flags:
|
||||
# --flag <purpose>
|
||||
#
|
||||
# ── Requirements ──────────────────────────────────────────────────
|
||||
# - <req 1>
|
||||
#
|
||||
# ── Idempotency ──────────────────────────────────────────────────
|
||||
# <what's safe to re-run, what's not>
|
||||
#
|
||||
# ── Exit codes ───────────────────────────────────────────────────
|
||||
# 0 success
|
||||
# 1 runtime error
|
||||
# 2 usage error
|
||||
#
|
||||
# ── Design notes ─────────────────────────────────────────────────
|
||||
# - <key design decision 1>
|
||||
# - <key design decision 2>
|
||||
#
|
||||
# ── Version ──────────────────────────────────────────────────────
|
||||
SCRIPT_VERSION='X.Y.Z'
|
||||
|
||||
set -euo pipefail
|
||||
[[ -n "${DEBUG:-}" ]] && set -x
|
||||
```
|
||||
|
||||
### Arg-parser template
|
||||
```bash
|
||||
parse_args() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
subcmd1|subcmd2)
|
||||
if [ -n "${SUBCOMMAND}" ]; then
|
||||
die "subcommand already specified: ${SUBCOMMAND}"
|
||||
fi
|
||||
SUBCOMMAND="$1"; shift
|
||||
;;
|
||||
help|-h|--help) print_usage; exit 0 ;;
|
||||
--flag) [ "$#" -ge 2 ] || die "--flag requires an argument"
|
||||
VAR="$2"; shift 2 ;;
|
||||
--flag=*) VAR="${1#--flag=}"; shift ;;
|
||||
--bool-flag) BOOL=true; shift ;;
|
||||
--) shift; break ;;
|
||||
-*) die "unknown flag: $1 (try --help)" ;;
|
||||
*)
|
||||
if [ -z "${POSITIONAL}" ]; then
|
||||
POSITIONAL="$1"
|
||||
else
|
||||
die "unexpected positional: $1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Defaults.
|
||||
SUBCOMMAND="${SUBCOMMAND:-default}"
|
||||
POSITIONAL="${POSITIONAL:-default-positional}"
|
||||
|
||||
# Mutual-exclusion.
|
||||
if [ "${FLAG_A}" = "true" ] && [ "${FLAG_B}" = "true" ]; then
|
||||
die "--flag-a and --flag-b are mutually exclusive"
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
### `run()` wrapper template (for --dry-run / --verbose)
|
||||
```bash
|
||||
run() {
|
||||
if [ "${DRY_RUN}" = "true" ]; then
|
||||
local arg
|
||||
printf '[dry-run]'
|
||||
for arg in "$@"; do printf ' %s' "${arg}"; done
|
||||
printf '\n' >&2
|
||||
else
|
||||
[ "${VERBOSE}" = "true" ] && printf '+ %s\n' "$*" >&2
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7 · Anti-patterns I should remember to avoid
|
||||
|
||||
- **String interpolation in `docker exec sh -c "..."` patterns.**
|
||||
Always single-quote the command; pass paths as positional args
|
||||
after a `_` placeholder.
|
||||
- **`ps -ef | grep X | grep -v grep` for PID lookup.** Can match
|
||||
the scanning command itself. Use `/proc/*/comm` scan instead.
|
||||
- **`docker ps -a | grep -qx NAME` for container existence.** Use
|
||||
`docker inspect NAME` (canonical, race-free).
|
||||
- **`mktemp -t prefix` for portability.** BSD vs GNU semantics
|
||||
differ. Use plain `mktemp` (no template).
|
||||
- **Unquoted `trap 'rm -f $var' EXIT`.** Empty `$var` → `rm -f`
|
||||
runs in CWD. Always single-quote the trap, double-quote the var.
|
||||
- **Heredoc-over-`docker exec` for writing files.** Quoting hell,
|
||||
injection risk. Build the file locally with `printf`, then
|
||||
`docker cp`.
|
||||
- **Bash arrays in `for X in ${arr}` (unquoted).** Always
|
||||
`for X in "${arr[@]}"`.
|
||||
- **Deleting failing tests to "pass".** Detect the real bug.
|
||||
- **`as any` / `@ts-ignore` / empty `catch {}`.** Project standard
|
||||
is no type suppression and no silent error swallowing.
|
||||
- **Amending a commit that was rejected by hooks.** Always create
|
||||
a new commit; never `--amend` a failed commit.
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2026-06-01 (turn 1-3 review session on Plex_Patch / Freeloader).
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Plex_Patch
|
||||
|
||||
Reverse-engineering notes and tooling for **Plex Media Server** on **Linux
|
||||
x86-64** — covering both *feature unlocking* and *remote access*.
|
||||
|
||||
> ⚠️ **Disclaimer** — For educational and reverse-engineering purposes, on
|
||||
> software you legally run yourself. Nothing here bypasses account or server
|
||||
> authentication, and **no Plex code** is included or redistributed. If you rely
|
||||
> on Plex, buy a Plex Pass — it funds the developers. Use at your own risk; no
|
||||
> warranty.
|
||||
|
||||
## What's here
|
||||
|
||||
| # | Component | Path | Summary |
|
||||
|---|-----------|------|---------|
|
||||
| 1 | **Feature-unlock patch** | `src/`, `build.sh` | `LD_PRELOAD` shared library: forces every `FeatureManager` bit on **and** adds webhook CRUD via socket interception |
|
||||
| 2 | **Relay RE + model** | `plex_relay/` | Reverse-engineered, runnable reimplementation of Plex's `RelayController` |
|
||||
| 3 | **Remote access (no patch)** | `scripts/plex-tailnet/` | Reach your server over Tailscale/Headscale instead of Plex Relay |
|
||||
| 4 | **Docker support** | `docker/`, [`docs/DOCKER.md`](docs/DOCKER.md) | Patched `plexinc/pms-docker` / `lscr.io/linuxserver/plex` images (multi-stage build) **and** in-place patcher for a running container (`plex-docker-patch.sh`) |
|
||||
|
||||
Each subsystem has its own README; this page is the map.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Feature-unlock patch
|
||||
|
||||
Plex's feature gates read a single in-memory table, `g_feature_bitset_slots`
|
||||
(14 × `uint64`), populated from the MyPlex feature list. A feature with internal
|
||||
code `C` is "available" iff `slots[C >> 3] & (1 << (C & 7))`. The patch (`src/`)
|
||||
is a small shared library whose constructor finds
|
||||
`FeatureManager_apply_feature_list_xml`, installs a trampoline, and forces all 14
|
||||
slots to `0xFF…FF` after Plex applies its feature list — so every feature
|
||||
(including Plex Pass, code 92) reads as enabled.
|
||||
|
||||
Two non-obvious requirements make or break this on a real install:
|
||||
|
||||
1. **Build against musl, not glibc.** Plex bundles its own musl libc + libgcompat
|
||||
(`/usr/lib/plexmediaserver/lib/`). A glibc-built `.so` fails to relocate
|
||||
glibc-only symbols and Plex exits 127. The build uses `zig` to target
|
||||
`x86_64-linux-musl`.
|
||||
2. **Inject with `LD_PRELOAD`, never `patchelf`.** `patchelf --add-needed`
|
||||
corrupts the PIE under musl's loader (instant SIGSEGV). A tiny launcher sets
|
||||
`LD_PRELOAD` only for the Plex `exec`, and the library `unsetenv`s it so
|
||||
Plex's glibc helper children are unaffected.
|
||||
|
||||
```bash
|
||||
bash build.sh # -> build/plexmediaserver_crack.so (musl); prints install steps
|
||||
```
|
||||
|
||||
**Webhook socket interceptor.** The same `.so` also hooks POSIX socket functions
|
||||
(`read`, `recvfrom`, `sendmsg`) to intercept `/api/v2/user/webhooks` HTTP requests
|
||||
made by the Plex Web client to the local server. Instead of returning PMS's native
|
||||
404 (the endpoint only exists on plex.tv, not locally), the hook serves a complete
|
||||
webhook CRUD API backed by a JSON file at `/var/lib/plexmediaserver/webhooks.json`:
|
||||
|
||||
- `GET /api/v2/user/webhooks` — list all webhooks
|
||||
- `POST /api/v2/user/webhooks` — add webhook(s) from `urls[]=` form body
|
||||
- `PUT /api/v2/user/webhooks/:id` — update a webhook
|
||||
- `DELETE /api/v2/user/webhooks/:id` — delete a webhook
|
||||
- `OPTIONS` — CORS preflight
|
||||
|
||||
After every mutating operation, the hook calls into Plex's in-process
|
||||
`WebhookManager` to refresh the dispatch vector, so changes take effect without
|
||||
a server restart. The webhook file path can be overridden with the
|
||||
`PLEX_WEBHOOKS_FILE` environment variable. The Plex Web bundle also needs a
|
||||
one-time static patch so its JavaScript talks to `window.location.origin`
|
||||
instead of the Plex cloud API — see [`AGENTS.md`](AGENTS.md) for details.
|
||||
|
||||
Full build / install / uninstall guide: **[`docs/BUILD.md`](docs/BUILD.md)**.
|
||||
|
||||
## 2 · Plex Relay — `plex_relay/`
|
||||
|
||||
A study of how Plex makes a server reachable when no direct connection exists: it
|
||||
opens a **reverse SSH tunnel to a Plex-operated relay host**. `plex_relay/` is a
|
||||
clean-room, dependency-free Python reimplementation of the `RelayController`
|
||||
translation unit (key fetch + 24h cache, `relayHostKey.txt` pinning, the ssh
|
||||
tunnel, the 300s reaper), with a typed error model, injected I/O seams, and a
|
||||
full test suite. See **[`plex_relay/README.md`](plex_relay/README.md)**.
|
||||
|
||||
## 3 · Remote access without patching — `scripts/plex-tailnet/`
|
||||
|
||||
The pragmatic alternative to both Plex Relay and patching: put the server and its
|
||||
viewers on a **Tailscale/Headscale mesh VPN** and let Plex publish the tailnet
|
||||
address. Includes an idempotent setup script (security questionnaire, firewall
|
||||
lockdown, health check), an optional self-hosted Headscale installer, and a
|
||||
shared shell library. See **[`scripts/plex-tailnet/README.md`](scripts/plex-tailnet/README.md)**.
|
||||
|
||||
## 4 · Docker support — `docker/`
|
||||
|
||||
Same `LD_PRELOAD`-on-the-PMS-exec patch, packaged for the two popular Plex
|
||||
container images. Two flows are supported:
|
||||
|
||||
- **Rebuild a patched image** — multi-stage Dockerfiles (`Dockerfile.plexinc`,
|
||||
`Dockerfile.linuxserver`) build the musl `.so` with `zig`, layer it onto
|
||||
the upstream image, and replace the s6 `svc-plex` `run` file. Best for
|
||||
repeat deploys and CI/CD.
|
||||
- **Patch a running container in place** — `plex-docker-patch.sh` modifies
|
||||
the live container's filesystem (`.so`, wrapper, s6 `run` file) and
|
||||
restarts it. No image rebuild, original image untouched, fully
|
||||
revertible. Best for one-off patching of a container you don't want
|
||||
to touch.
|
||||
|
||||
The wrapper sets `LD_PRELOAD` *last* and the `.so`'s constructor `unsetenv`s
|
||||
it, so glibc helper children (Tuner, Script Host, transcoders) are unaffected.
|
||||
See **[`docker/README.md`](docker/README.md)** and the full guide
|
||||
**[`docs/DOCKER.md`](docs/DOCKER.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | What |
|
||||
|------|------|
|
||||
| `src/hook.cpp` · `hook.hpp` | hooking engine: `dl_iterate_phdr` discovery, signature scan, trampoline, feature logic, feature-UUID catalog |
|
||||
| `src/main.cpp` | library constructor (`unsetenv` + `hook()`) |
|
||||
| `src/webhook_handler.cpp` · `webhook_handler.hpp` | socket-level HTTP interceptor: hooks `read`/`recvfrom`/`sendmsg` to intercept `/api/v2/user/webhooks` and serve local CRUD from a JSON file |
|
||||
| `build.sh` | musl build via `zig` (auto-downloaded) with an ABI sanity gate |
|
||||
| `scripts/plex-crack-wrapper.sh` | systemd `ExecStart` launcher scoping `LD_PRELOAD` to the Plex process |
|
||||
| `scripts/readbitset.py` | verifier: dumps the live feature bitset from a running PMS |
|
||||
| `scripts/plex-tailnet/` | Tailscale/Headscale remote-access setup (see its README) |
|
||||
| `plex_relay/` | Python reimplementation of Plex's `RelayController` (see its README) |
|
||||
| `windows/` | Windows x64 DLL injector + godmode patch (see its README) |
|
||||
| `docker/` | patched `plexinc/pms-docker` + `lscr.io/linuxserver/plex` images + in-place patcher for running containers (see its README) |
|
||||
| `third_party/zydis/` | vendored [Zydis](https://github.com/zyantific/zydis) disassembler (MIT) |
|
||||
| `docs/BUILD.md` | native Linux build / install / uninstall guide |
|
||||
| `docs/DOCKER.md` | Docker build / run / verify / uninstall / troubleshooting guide |
|
||||
| `docs/WINDOWS.md` | top-level Windows x64 patching/build index |
|
||||
| `experimental/debug_hook.c` | standalone alternate hook (legacy signature) |
|
||||
| `AGENTS.md` | architecture / RE notes |
|
||||
|
||||
## Not in this repo (by design)
|
||||
|
||||
The copyrighted Plex binaries (`Plex Media Server`, `libsoci_core.so`), the IDA
|
||||
Pro databases (`*.i64`, `*.id0`, …), the auto-downloaded `toolchain/`, and any
|
||||
local machine config (`.mcp.json`, keys, `.env`) are intentionally
|
||||
**git-ignored** — they are large, sensitive, or not ours to distribute. Point
|
||||
your own analysis tools at your own Plex install.
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL-3.0-or-later](LICENSE) © the Plex_Patch authors. Each source file
|
||||
carries an `SPDX-License-Identifier: AGPL-3.0-or-later` tag.
|
||||
|
||||
The vendored Zydis disassembler in `third_party/zydis/` is **MIT**-licensed (see
|
||||
`third_party/zydis/README.md`); its terms are preserved and unaffected.
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/bin/bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Build plexmediaserver_crack.so for Plex Media Server on Linux.
|
||||
#
|
||||
# IMPORTANT: Plex ships and runs against its OWN bundled musl libc + libgcompat
|
||||
# (see /usr/lib/plexmediaserver/lib/{libc.so,ld-musl-x86_64.so.1,libgcompat.so.0}).
|
||||
# A glibc-built .so will NOT load into Plex -- the dynamic loader fails to
|
||||
# relocate glibc-only symbols (__isoc23_strtol, arc4random, *_chk, _dl_find_object)
|
||||
# and Plex exits 127. We therefore cross-compile against musl with zig, which
|
||||
# bundles musl for clean cross-compilation.
|
||||
#
|
||||
# IMPORTANT: The .so must NOT statically link libc++ or libc++abi. Plex's
|
||||
# runtime uses GCC's libstdc++ for C++ exception handling. If our .so defines
|
||||
# __cxa_throw/__cxa_begin_catch/__gxx_personality_v0 (from libc++abi), they
|
||||
# override libstdc++'s versions via LD_PRELOAD, breaking boost::filesystem
|
||||
# exception handling and crashing Plex. We use -nostdlib++ and strip all C++
|
||||
# runtime usage from the source to avoid this entirely.
|
||||
#
|
||||
# Injection is done with LD_PRELOAD (NOT patchelf): patchelf rewrites the 22MB
|
||||
# BIND_NOW/PIE binary's program headers in a way musl's loader cannot tolerate,
|
||||
# which corrupts the executable (instant SIGSEGV on start). See install notes
|
||||
# printed at the end.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
# Parse command-line arguments.
|
||||
ARM64=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-a|--arm64) ARM64=true; shift ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [-a|--arm64]"
|
||||
echo " -a, --arm64 Build for aarch64-linux-musl (ARM64, e.g. UDM Pro)"
|
||||
echo " (default: x86_64-linux-musl)"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ZIG_VERSION="0.13.0"
|
||||
if [ "$ARM64" = true ]; then
|
||||
TARGET="aarch64-linux-musl"
|
||||
OUT="build/plexmediaserver_crack_arm64.so"
|
||||
echo "=== Building for ARM64 (aarch64-linux-musl) ==="
|
||||
else
|
||||
TARGET="x86_64-linux-musl"
|
||||
OUT="build/plexmediaserver_crack.so"
|
||||
echo "=== Building for x86_64 (x86_64-linux-musl) ==="
|
||||
fi
|
||||
|
||||
echo "=== Plex Media Server Crack - Linux (musl) Build ==="
|
||||
|
||||
# Resolve a zig toolchain: $ZIG override, then PATH, then a local download.
|
||||
if [ -n "${ZIG:-}" ] && [ -x "${ZIG}" ]; then
|
||||
:
|
||||
elif command -v zig &> /dev/null; then
|
||||
ZIG="$(command -v zig)"
|
||||
else
|
||||
ZIG_DIR="toolchain/zig-linux-x86_64-${ZIG_VERSION}"
|
||||
if [ ! -x "${ZIG_DIR}/zig" ]; then
|
||||
echo "zig not found; downloading ${ZIG_VERSION}..."
|
||||
mkdir -p toolchain
|
||||
curl -fL --connect-timeout 20 \
|
||||
-o "toolchain/zig.tar.xz" \
|
||||
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz"
|
||||
tar -C toolchain -xf "toolchain/zig.tar.xz"
|
||||
fi
|
||||
ZIG="${ZIG_DIR}/zig"
|
||||
fi
|
||||
echo "Using zig: ${ZIG} ($("${ZIG}" version))"
|
||||
|
||||
# Required sources.
|
||||
REQUIRED_FILES="src/hook.cpp src/hook.hpp src/main.cpp src/webhook_handler.hpp src/webhook_handler.cpp src/traffic_logger.hpp src/traffic_logger.cpp"
|
||||
if [ "$ARM64" = false ]; then
|
||||
REQUIRED_FILES="$REQUIRED_FILES third_party/zydis/Zydis.c third_party/zydis/Zydis.h"
|
||||
fi
|
||||
for f in $REQUIRED_FILES; do
|
||||
[ -f "$f" ] || { echo "ERROR: missing $f"; exit 1; }
|
||||
done
|
||||
|
||||
# ARM64 doesn't use Zydis (fixed 4-byte instructions, no decoding needed).
|
||||
if [ "$ARM64" = true ]; then
|
||||
CFLAGS=(-target "${TARGET}" -O2 -fPIC -I src)
|
||||
CXXFLAGS=(-target "${TARGET}" -std=c++20 -O2 -fPIC -fno-exceptions -fno-rtti -nostdlib++ -I src)
|
||||
else
|
||||
CFLAGS=(-target "${TARGET}" -O2 -fPIC -I src -I third_party/zydis)
|
||||
CXXFLAGS=(-target "${TARGET}" -std=c++20 -O2 -fPIC -fno-exceptions -fno-rtti -nostdlib++ -I src -I third_party/zydis)
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
if [ "$ARM64" = false ]; then
|
||||
echo "=== compiling Zydis.c (C) ==="
|
||||
"${ZIG}" cc "${CFLAGS[@]}" -c third_party/zydis/Zydis.c -o build/Zydis.o
|
||||
fi
|
||||
echo "=== compiling hook.cpp (C++) ==="
|
||||
"${ZIG}" c++ "${CXXFLAGS[@]}" -c src/hook.cpp -o build/hook.o
|
||||
echo "=== compiling main.cpp (C++) ==="
|
||||
"${ZIG}" c++ "${CXXFLAGS[@]}" -c src/main.cpp -o build/main.o
|
||||
echo "=== compiling webhook_handler.cpp (C++) ==="
|
||||
"${ZIG}" c++ "${CXXFLAGS[@]}" -c src/webhook_handler.cpp -o build/webhook_handler.o
|
||||
echo "=== linking ${OUT} ==="
|
||||
if [ "$ARM64" = true ]; then
|
||||
"${ZIG}" c++ -target "${TARGET}" -nostdlib++ -shared -o "${OUT}" build/main.o build/hook.o build/webhook_handler.o
|
||||
else
|
||||
"${ZIG}" c++ -target "${TARGET}" -nostdlib++ -shared -o "${OUT}" build/main.o build/hook.o build/webhook_handler.o build/Zydis.o
|
||||
fi
|
||||
rm -f build/Zydis.o build/hook.o build/main.o build/webhook_handler.o
|
||||
|
||||
# ── Traffic logger (socket-hooking LD_PRELOAD library) ──────────────────────
|
||||
TRF_OUT="build/plexmediaserver_traffic_logger.so"
|
||||
echo "=== compiling traffic_logger.cpp (C++) ==="
|
||||
"${ZIG}" c++ "${CXXFLAGS[@]}" -c src/traffic_logger.cpp -o build/traffic_logger.o
|
||||
echo "=== linking ${TRF_OUT} ==="
|
||||
"${ZIG}" c++ -target "${TARGET}" -nostdlib++ -shared -o "${TRF_OUT}" build/traffic_logger.o
|
||||
rm -f build/traffic_logger.o
|
||||
echo "=== $("${ZIG}" size "${TRF_OUT}" 2>/dev/null || stat -c %s "${TRF_OUT}") ==="
|
||||
|
||||
for lib in "${OUT}" "${TRF_OUT}"; do
|
||||
if [ -f "${lib}" ]; then
|
||||
echo ""
|
||||
echo "=== ABI sanity check: ${lib} ==="
|
||||
if readelf --dyn-syms "${lib}" | grep -E "UND .*(__isoc23_|_chk$|arc4random|_dl_find_object)" ; then
|
||||
echo "ERROR: glibc-only symbols present in ${lib} -- this will not load into Plex."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: only musl libc symbols are referenced."
|
||||
echo "NEEDED: $(readelf -d "${lib}" | awk '/NEEDED/{print $5}' | tr -d '[]' | tr '\n' ' ')"
|
||||
echo "=== $(stat -c %s "${lib}") bytes ==="
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ARM64" = true ]; then
|
||||
cat <<'EOF'
|
||||
|
||||
Install (ARM64 / UDM Pro -- via SSH):
|
||||
|
||||
1. Copy the artifacts to the UDM Pro:
|
||||
scp build/plexmediaserver_crack_arm64.so \
|
||||
root@udmpro:/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
|
||||
scp scripts/plex-crack-wrapper.sh \
|
||||
root@udmpro:/usr/local/bin/plex-crack-wrapper.sh
|
||||
|
||||
2. SSH into UDM Pro and set permissions:
|
||||
ssh root@udmpro
|
||||
chmod 755 /usr/local/bin/plex-crack-wrapper.sh
|
||||
mkdir -p /etc/systemd/system/plexmediaserver.service.d
|
||||
printf '[Service]\nExecStart=\nExecStart=/usr/local/bin/plex-crack-wrapper.sh\n' \
|
||||
> /etc/systemd/system/plexmediaserver.service.d/override.conf
|
||||
systemctl daemon-reload
|
||||
systemctl restart plexmediaserver
|
||||
|
||||
EOF
|
||||
else
|
||||
cat <<'EOF'
|
||||
|
||||
Install (proper method -- LD_PRELOAD, no patchelf):
|
||||
|
||||
1. Copy the artifacts to the Plex host:
|
||||
build/plexmediaserver_crack.so -> /usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
|
||||
scripts/plex-crack-wrapper.sh -> /usr/local/bin/ (chmod 755)
|
||||
|
||||
2. Add a systemd drop-in that swaps ExecStart for the wrapper (the wrapper sets
|
||||
LD_PRELOAD *after* /bin/sh starts, so only the musl Plex process is preloaded
|
||||
and glibc helper children are unaffected):
|
||||
|
||||
mkdir -p /etc/systemd/system/plexmediaserver.service.d
|
||||
printf '[Service]\nExecStart=\nExecStart=/usr/local/bin/plex-crack-wrapper.sh\n' \
|
||||
> /etc/systemd/system/plexmediaserver.service.d/override.conf
|
||||
|
||||
3. Apply:
|
||||
systemctl daemon-reload
|
||||
systemctl restart plexmediaserver
|
||||
|
||||
To uninstall: remove the drop-in (and rm the .so), then daemon-reload + restart.
|
||||
Do NOT use `patchelf --add-needed` on the Plex binary -- it corrupts it under
|
||||
musl's loader. If a previous attempt did, restore with:
|
||||
apt-get install --reinstall plexmediaserver # or dpkg -i the matching .deb
|
||||
EOF
|
||||
fi
|
||||
@@ -0,0 +1,116 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Patch lscr.io/linuxserver/plex with the feature-unlock shared library.
|
||||
#
|
||||
# docker build -f docker/Dockerfile.linuxserver -t plex-crack:lsio .
|
||||
# docker run -d --name plex --network=host \
|
||||
# -e PUID=$(id -u) -e PGID=$(id -g) \
|
||||
# -v /srv/plex/config:/config -v /srv/plex/data:/data \
|
||||
# plex-crack:lsio
|
||||
#
|
||||
# Two stages (mirrors Dockerfile.plexinc):
|
||||
# 1. builder -- zig cross-compile plexmediaserver_crack.so (musl)
|
||||
# 2. runtime -- layer onto lscr.io/linuxserver/plex + override the s6 plex
|
||||
# service so PMS is exec'd (as user 'abc' via s6-setuidgid,
|
||||
# preserving LSIO's permission model) with LD_PRELOAD.
|
||||
#
|
||||
# Why s6-setuidgid is preserved: LSIO's run file drops to the 'abc' user
|
||||
# (uid 911) before exec'ing the PMS binary. If we don't keep that, PMS
|
||||
# would run as root, which (a) breaks LSIO's permission model, (b) prevents
|
||||
# writes to /config and /data which are chowned to abc on first start,
|
||||
# (c) makes the container harder to use (root-owned media library).
|
||||
|
||||
# ── Build args (global -- visible to every FROM) ──────────────────────────
|
||||
ARG PLEX_BASE_IMAGE=lscr.io/linuxserver/plex:latest
|
||||
|
||||
# ── Stage 1: build the musl .so (identical to plexinc) ────────────────────
|
||||
FROM debian:bookworm-slim AS builder
|
||||
|
||||
ARG ZIG_VERSION=0.13.0
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
RUN mkdir -p /src/toolchain \
|
||||
&& curl -fsSL \
|
||||
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" \
|
||||
-o /tmp/zig.tar.xz \
|
||||
&& tar -C /src/toolchain --strip-components=1 -xf /tmp/zig.tar.xz \
|
||||
&& rm /tmp/zig.tar.xz
|
||||
ENV PATH="/src/toolchain:${PATH}"
|
||||
|
||||
COPY build.sh ./
|
||||
COPY src ./src
|
||||
COPY third_party ./third_party
|
||||
RUN bash build.sh
|
||||
|
||||
# ── Stage 2: runtime -- patch lscr.io/linuxserver/plex ────────────────────
|
||||
FROM ${PLEX_BASE_IMAGE} AS runtime
|
||||
|
||||
ARG PLEX_BASE_IMAGE
|
||||
ARG PATCH_VERSION=dev
|
||||
LABEL org.opencontainers.image.title="plexmediaserver-crack (linuxserver)" \
|
||||
org.opencontainers.image.source="https://github.com/authrequest/Freeloader" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
|
||||
plex_patch.base="${PLEX_BASE_IMAGE}" \
|
||||
plex_patch.version="${PATCH_VERSION}"
|
||||
|
||||
# Sanity: refuse to build on an unfamiliar upstream layout.
|
||||
RUN set -eux; \
|
||||
PMS="/usr/lib/plexmediaserver/Plex Media Server"; \
|
||||
PMS_LIB="/usr/lib/plexmediaserver/lib"; \
|
||||
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
|
||||
[ -x "${PMS}" ] || { echo "patcher: missing ${PMS} in ${PLEX_BASE_IMAGE}"; exit 1; }; \
|
||||
[ -d "${PMS_LIB}" ] || { echo "patcher: missing ${PMS_LIB}/ in ${PLEX_BASE_IMAGE}"; exit 1; }; \
|
||||
[ -f "${RUN_SCRIPT}" ] || { echo "patcher: missing ${RUN_SCRIPT} in ${PLEX_BASE_IMAGE}"; exit 1; }
|
||||
|
||||
COPY --from=builder /src/build/plexmediaserver_crack.so \
|
||||
/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
|
||||
COPY --from=builder /src/build/plexmediaserver_traffic_logger.so \
|
||||
/usr/lib/plexmediaserver/lib/plexmediaserver_traffic_logger.so
|
||||
COPY docker/wrapper.sh /usr/lib/plexmediaserver/plex-crack-wrapper.sh
|
||||
RUN chmod 0755 /usr/lib/plexmediaserver/plex-crack-wrapper.sh
|
||||
|
||||
# Override the s6 plex service run file. We preserve LSIO's s6-setuidgid
|
||||
# abc so PMS still runs as user 'abc' (uid 911) -- otherwise /config and
|
||||
# /data would be created as root and break the LSIO permission model.
|
||||
RUN set -eux; \
|
||||
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
|
||||
cp "${RUN_SCRIPT}" "${RUN_SCRIPT}.orig"; \
|
||||
printf '#!/usr/bin/with-contenv bash\nexec s6-setuidgid abc /usr/lib/plexmediaserver/plex-crack-wrapper.sh\n' \
|
||||
> "${RUN_SCRIPT}"; \
|
||||
chmod 0755 "${RUN_SCRIPT}"
|
||||
|
||||
# ── Patch the PMS binary in-place to disable transcode session limits ──────
|
||||
# Patches 6 conditional-jump instructions so the limit-checking code always
|
||||
# takes the normal (no-error) path. See AGENTS.md for full RE notes.
|
||||
RUN set -eux; \
|
||||
PMS="/usr/lib/plexmediaserver/Plex Media Server"; \
|
||||
\
|
||||
# Read N bytes at a file offset as hex string (no separator). \
|
||||
rd() { dd if="${PMS}" bs=1 skip="$1" count="$2" 2>/dev/null | od -A n -t x1 | tr -d ' \n'; }; \
|
||||
\
|
||||
# sanity: verify expected bytes at each patch site (guards against version drift) \
|
||||
echo "=== verifying patch-site bytes ==="; \
|
||||
[ "$(rd 18624607 2)" = "7e0c" ] || { echo "sanity FAIL at 0x11C305F"; exit 1; }; \
|
||||
[ "$(rd 18624781 2)" = "7e08" ] || { echo "sanity FAIL at 0x11C310D"; exit 1; }; \
|
||||
[ "$(rd 18624793 2)" = "7e08" ] || { echo "sanity FAIL at 0x11C3119"; exit 1; }; \
|
||||
[ "$(rd 18624674 6)" = "0f8fc2f6ffff" ] || { echo "sanity FAIL at 0x11C30A2"; exit 1; }; \
|
||||
[ "$(rd 18624682 6)" = "0f8f1ef7ffff" ] || { echo "sanity FAIL at 0x11C30AA"; exit 1; }; \
|
||||
[ "$(rd 18624630 6)" = "0f8f9f000000" ] || { echo "sanity FAIL at 0x11C3076"; exit 1; }; \
|
||||
echo "sanity: expected bytes match, applying patches"; \
|
||||
\
|
||||
# apply patches \
|
||||
# jle -> jmp (2-byte: \353=0xEB \14=0x0C \10=0x08) \
|
||||
printf '\353\014' | dd of="${PMS}" bs=1 seek=18624607 conv=notrunc 2>/dev/null; \
|
||||
printf '\353\010' | dd of="${PMS}" bs=1 seek=18624781 conv=notrunc 2>/dev/null; \
|
||||
printf '\353\010' | dd of="${PMS}" bs=1 seek=18624793 conv=notrunc 2>/dev/null; \
|
||||
# jg -> NOP (6-byte: \220=0x90) \
|
||||
printf '\220\220\220\220\220\220' | dd of="${PMS}" bs=1 seek=18624674 conv=notrunc 2>/dev/null; \
|
||||
printf '\220\220\220\220\220\220' | dd of="${PMS}" bs=1 seek=18624682 conv=notrunc 2>/dev/null; \
|
||||
printf '\220\220\220\220\220\220' | dd of="${PMS}" bs=1 seek=18624630 conv=notrunc 2>/dev/null; \
|
||||
\
|
||||
echo "binary patch applied: transcode session limits disabled"
|
||||
@@ -0,0 +1,91 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Patch plexinc/pms-docker with the feature-unlock shared library.
|
||||
#
|
||||
# docker build -f docker/Dockerfile.plexinc -t plex-crack:plexinc .
|
||||
# docker run -d --name plex --network=host \
|
||||
# -v /srv/plex/config:/config -v /srv/plex/data:/data \
|
||||
# plex-crack:plexinc
|
||||
#
|
||||
# Two stages:
|
||||
# 1. builder -- zig 0.13.0 cross-compile plexmediaserver_crack.so (musl)
|
||||
# 2. runtime -- layer it onto plexinc/pms-docker + override the s6 plex
|
||||
# service so PMS is exec'd with LD_PRELOAD=...crack.so.
|
||||
# The .so's constructor (src/main.cpp) calls unsetenv, so
|
||||
# PMS's glibc helper children (Tuner, Script Host) are
|
||||
# unaffected.
|
||||
#
|
||||
# Patch invariants are enforced at build time (RUN sanity): the upstream
|
||||
# layout must match what the wrapper assumes. If plexinc/pms-docker
|
||||
# restructures, the build fails here rather than the container failing
|
||||
# mysteriously at runtime.
|
||||
|
||||
# ── Build args (global -- visible to every FROM) ──────────────────────────
|
||||
ARG PLEX_BASE_IMAGE=plexinc/pms-docker:latest
|
||||
|
||||
# ── Stage 1: build the musl .so ────────────────────────────────────────────
|
||||
FROM debian:bookworm-slim AS builder
|
||||
|
||||
ARG ZIG_VERSION=0.13.0
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Toolchain layer (cached across source-only changes).
|
||||
RUN mkdir -p /src/toolchain \
|
||||
&& curl -fsSL \
|
||||
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" \
|
||||
-o /tmp/zig.tar.xz \
|
||||
&& tar -C /src/toolchain --strip-components=1 -xf /tmp/zig.tar.xz \
|
||||
&& rm /tmp/zig.tar.xz
|
||||
ENV PATH="/src/toolchain:${PATH}"
|
||||
|
||||
# Build sources. The .dockerignore at the repo root whitelists these.
|
||||
COPY build.sh ./
|
||||
COPY src ./src
|
||||
COPY third_party ./third_party
|
||||
RUN bash build.sh
|
||||
# build.sh writes /src/build/plexmediaserver_crack.so (musl).
|
||||
|
||||
# ── Stage 2: runtime -- patch plexinc/pms-docker ──────────────────────────
|
||||
FROM ${PLEX_BASE_IMAGE} AS runtime
|
||||
|
||||
ARG PLEX_BASE_IMAGE
|
||||
ARG PATCH_VERSION=dev
|
||||
LABEL org.opencontainers.image.title="plexmediaserver-crack (plexinc)" \
|
||||
org.opencontainers.image.source="https://github.com/authrequest/Freeloader" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
|
||||
plex_patch.base="${PLEX_BASE_IMAGE}" \
|
||||
plex_patch.version="${PATCH_VERSION}"
|
||||
|
||||
# Sanity: refuse to build on an unfamiliar upstream layout.
|
||||
RUN set -eux; \
|
||||
PMS="/usr/lib/plexmediaserver/Plex Media Server"; \
|
||||
PMS_LIB="/usr/lib/plexmediaserver/lib"; \
|
||||
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
|
||||
[ -x "${PMS}" ] || { echo "patcher: missing ${PMS} in ${PLEX_BASE_IMAGE}"; exit 1; }; \
|
||||
[ -d "${PMS_LIB}" ] || { echo "patcher: missing ${PMS_LIB}/ in ${PLEX_BASE_IMAGE}"; exit 1; }; \
|
||||
[ -f "${RUN_SCRIPT}" ] || { echo "patcher: missing ${RUN_SCRIPT} in ${PLEX_BASE_IMAGE}"; exit 1; }
|
||||
|
||||
# Drop the .so and the in-container launcher.
|
||||
COPY --from=builder /src/build/plexmediaserver_crack.so \
|
||||
/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
|
||||
COPY --from=builder /src/build/plexmediaserver_traffic_logger.so \
|
||||
/usr/lib/plexmediaserver/lib/plexmediaserver_traffic_logger.so
|
||||
COPY docker/wrapper.sh /usr/lib/plexmediaserver/plex-crack-wrapper.sh
|
||||
RUN chmod 0755 /usr/lib/plexmediaserver/plex-crack-wrapper.sh
|
||||
|
||||
# Override the s6 plex service run file. The original is kept as .orig for
|
||||
# forensics / downgrade (rebuild against the unpatched image to revert).
|
||||
# plexinc's upstream service already runs as the 'plex' user, so we do not
|
||||
# add s6-setuidgid here -- LSIO is the image that needs it (see its Dockerfile).
|
||||
RUN set -eux; \
|
||||
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
|
||||
cp "${RUN_SCRIPT}" "${RUN_SCRIPT}.orig"; \
|
||||
printf '#!/usr/bin/with-contenv bash\nexec /usr/lib/plexmediaserver/plex-crack-wrapper.sh\n' \
|
||||
> "${RUN_SCRIPT}"; \
|
||||
chmod 0755 "${RUN_SCRIPT}"
|
||||
@@ -0,0 +1,113 @@
|
||||
# Docker support for plexmediaserver_crack
|
||||
|
||||
Patches Plex Media Server running in Docker — both the official
|
||||
[`plexinc/pms-docker`](https://hub.docker.com/r/plexinc/pms-docker) image
|
||||
and the community [`lscr.io/linuxserver/plex`](https://hub.docker.com/r/linuxserver/plex)
|
||||
image — using the same `LD_PRELOAD`-on-the-PMS-exec mechanism as the native
|
||||
systemd install. See the top-level [README](../README.md) and the full guide
|
||||
[`docs/DOCKER.md`](../docs/DOCKER.md) for the why and the troubleshooting.
|
||||
|
||||
## Build
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
# Official image (plexinc/pms-docker)
|
||||
docker build -f docker/Dockerfile.plexinc -t plex-crack:plexinc .
|
||||
|
||||
# Community image (lscr.io/linuxserver/plex)
|
||||
docker build -f docker/Dockerfile.linuxserver -t plex-crack:lsio .
|
||||
```
|
||||
|
||||
The first build downloads `zig 0.13.0` and the chosen Plex base image.
|
||||
Subsequent builds reuse cached layers until `src/`, `third_party/`, or
|
||||
`build.sh` change. Pin the base with `--build-arg PLEX_BASE_IMAGE=...` if
|
||||
you need reproducibility across PMS updates.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# plexinc (no PUID/PGID; the image runs PMS as the upstream 'plex' user)
|
||||
docker run -d --name plex --network=host \
|
||||
-v /srv/plex/config:/config \
|
||||
-v /srv/plex/data:/data \
|
||||
plex-crack:plexinc
|
||||
|
||||
# linuxserver (honor PUID/PGID so /config and /data chown correctly on first start)
|
||||
docker run -d --name plex --network=host \
|
||||
-e PUID=$(id -u) -e PGID=$(id -g) \
|
||||
-e TZ=America/Los_Angeles \
|
||||
-v /srv/plex/config:/config \
|
||||
-v /srv/plex/data:/data \
|
||||
plex-crack:lsio
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:32400/identity # -> 200
|
||||
```
|
||||
|
||||
## Patch in place (no rebuild)
|
||||
|
||||
If you already have a Plex container running and don't want to rebuild
|
||||
the image or recreate the container, `plex-docker-patch.sh` applies the
|
||||
same patch to a live container — no `docker build` needed, original
|
||||
image untouched, original container + its volumes preserved. Revertible
|
||||
via `uninstall` (a `.orig` copy of the s6 `run` file is kept).
|
||||
|
||||
```bash
|
||||
# Default container name: "plex"
|
||||
./docker/plex-docker-patch.sh install
|
||||
|
||||
# Custom container name
|
||||
./docker/plex-docker-patch.sh install my-plex
|
||||
|
||||
# Revert (restores the s6 run file from its .orig)
|
||||
./docker/plex-docker-patch.sh uninstall my-plex
|
||||
|
||||
# Status
|
||||
./docker/plex-docker-patch.sh status my-plex
|
||||
```
|
||||
|
||||
The script auto-detects the base image (plexinc vs LSIO) by reading the
|
||||
s6 `run` file content inside the container, so the same `.so` and
|
||||
`wrapper.sh` are used in both cases. Zig must be available on the host
|
||||
(the script invokes `build.sh`); a pre-existing
|
||||
`build/plexmediaserver_crack.so` is reused.
|
||||
|
||||
### When to use which
|
||||
|
||||
- **Dockerfile build** (the `docker build` flow above) — best for
|
||||
repeat deploys, multi-host, CI/CD, immutable images. You commit a
|
||||
patched image and ship it.
|
||||
- **`plex-docker-patch.sh`** — best for one-off patching of a running
|
||||
container you don't want to touch. Modifies the live container's
|
||||
filesystem; fully revertible via `uninstall`.
|
||||
|
||||
## What's where
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Dockerfile.plexinc` | Multi-stage build → patched `plexinc/pms-docker` |
|
||||
| `Dockerfile.linuxserver` | Multi-stage build → patched `lscr.io/linuxserver/plex` |
|
||||
| `wrapper.sh` | In-container launcher (env → `LD_PRELOAD` last → `exec` PMS) |
|
||||
| `plex-docker-patch.sh` | In-place patcher for a running container (`install` / `uninstall` / `status`) |
|
||||
|
||||
For docker-compose, signature drift, verification with
|
||||
`scripts/readbitset.py`, uninstall, and troubleshooting, see
|
||||
[`../docs/DOCKER.md`](../docs/DOCKER.md).
|
||||
|
||||
### Quick troubleshooting (in-place patcher)
|
||||
|
||||
| Symptom | Likely cause | First check |
|
||||
|---|---|---|
|
||||
| `install` says `ERROR: docker not on PATH` | docker CLI not installed or user not in `docker` group | `docker version` (must run as you) |
|
||||
| `install` says `could not find s6 svc-plex run file` | upstream image changed its s6 layout | `docker exec <name> ls -la /etc/s6-overlay/s6-rc.d/svc-plex/ /etc/services.d/plex/` — open an issue with output |
|
||||
| `install` succeeds but `.so is NOT in /proc/$PID/maps` | PMS exited 127 (loader failure) | `docker logs <name> \| tail -50` — usually a glibc `.so` got in (rebuild with `build.sh`) |
|
||||
| `status` shows `PATCH IS NOT ACTIVE` after `install` | run file wasn't rewritten (e.g., readonly layer) or container wasn't restarted | `docker exec <name> cat /etc/s6-overlay/s6-rc.d/svc-plex/run` — should print `exec .../plex-crack-wrapper.sh` |
|
||||
| `uninstall` says `no run.orig found` | `.orig` was deleted, or the run file was never backed up (e.g., you ran an older version of the script) | restore manually: `docker cp <upstream-image>:/etc/s6-overlay/s6-rc.d/svc-plex/run <name>:/etc/s6-overlay/s6-rc.d/svc-plex/run` |
|
||||
|
||||
For deeper diagnostics (PMS exit 127, glibc vs musl ABI, LSIO `/config`
|
||||
ownership, signature drift on PMS updates), see
|
||||
[`../docs/DOCKER.md`](../docs/DOCKER.md#troubleshooting).
|
||||
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env bash
|
||||
# docker/plex-docker-patch.sh
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# Patch a running Plex Media Server container in place.
|
||||
#
|
||||
# ── Overview ──────────────────────────────────────────────────────────────
|
||||
# Same LD_PRELOAD mechanism as the Dockerfile-based approach: the s6
|
||||
# `svc-plex` `run` file is rewritten to exec the in-container wrapper,
|
||||
# which sets LD_PRELOAD last and execs the PMS binary. The .so's
|
||||
# constructor (src/main.cpp) calls unsetenv, so glibc helper children
|
||||
# (Tuner, Script Host, transcoders) are unaffected.
|
||||
#
|
||||
# No `docker build`, no container recreate, original image untouched.
|
||||
# Revertible via `uninstall` (a `.orig` copy of the s6 `run` file is kept).
|
||||
#
|
||||
# ── Usage ──────────────────────────────────────────────────────────────────
|
||||
# plex-docker-patch.sh [flags] <subcommand> [container-name]
|
||||
#
|
||||
# Subcommands:
|
||||
# install [name] Apply the patch in place (default if omitted)
|
||||
# uninstall [name] Restore the s6 run file from its .orig
|
||||
# status [name] Report whether the patch is active
|
||||
# help Show usage
|
||||
#
|
||||
# Flags (can appear before or after the subcommand):
|
||||
# --name <name> Container name (alternative to positional)
|
||||
# --no-build Use existing build/plexmediaserver_crack.so; do not invoke build.sh
|
||||
# --force-rebuild Delete build/plexmediaserver_crack.so and rebuild from scratch
|
||||
# --dry-run Print the actions that would be taken without executing them
|
||||
# --verbose, -v Trace every docker/build command to stderr before execution
|
||||
# --quiet, -q Suppress non-essential output (only the final report)
|
||||
# --version Print the script version and exit
|
||||
#
|
||||
# Container name defaults to "plex". Both plexinc/pms-docker and
|
||||
# lscr.io/linuxserver/plex are auto-detected by reading the s6 run file:
|
||||
# LSIO uses `s6-setuidgid abc`; plexinc does not.
|
||||
#
|
||||
# The .so is built locally via the project's build.sh, so a zig-capable
|
||||
# toolchain is required on the host (or an existing build/plexmediaserver_crack.so
|
||||
# is reused). See docs/DOCKER.md for the full guide.
|
||||
#
|
||||
# ── Requirements ──────────────────────────────────────────────────────────
|
||||
# - docker on PATH and accessible to the current user
|
||||
# - bash 4+ (or bash 3.2+ on macOS; arrays + $'...' are used)
|
||||
# - curl + xz-utils (for build.sh) if no prebuilt .so
|
||||
#
|
||||
# ── Idempotency ────────────────────────────────────────────────────────────
|
||||
# install: safe to re-run. The .orig is preserved across re-installs, the
|
||||
# .so and wrapper are overwritten with the latest build, the
|
||||
# run file is rewritten, the container is restarted.
|
||||
# uninstall: safe to re-run. The run file is re-copied from .orig each time.
|
||||
# status: read-only, always safe.
|
||||
#
|
||||
# ── Exit codes ─────────────────────────────────────────────────────────────
|
||||
# 0 success
|
||||
# 1 runtime error (docker missing, container not found, install failed)
|
||||
# 2 usage error (unknown subcommand or flag)
|
||||
#
|
||||
# ── Design notes ──────────────────────────────────────────────────────────
|
||||
# - `set -euo pipefail` for fail-fast. All transient failures must surface
|
||||
# as non-zero exits so callers can detect them.
|
||||
# - `docker exec sh -c '...' _ "${var}"` is the only safe pattern for
|
||||
# passing host-side paths into the container's shell: single-quote the
|
||||
# command so the host bash does NOT interpolate, then pass the path as
|
||||
# a positional arg. Without this, a path containing `;` or `$()` would
|
||||
# be a command-injection vector. (Currently safe because all paths come
|
||||
# from a hardcoded candidate list, but the safe pattern is enforced
|
||||
# throughout for future-proofing.)
|
||||
# - PMS PID lookup scans /proc/*/comm (not ps -ef | grep), which avoids
|
||||
# the "Plex" pattern matching the scanning command itself, and works
|
||||
# regardless of which process-listing tools the container provides.
|
||||
# - All destructive operations route through `run`, which respects
|
||||
# --dry-run and --verbose. This is the single place to look to see what
|
||||
# side effects the script has.
|
||||
# - Logging: stdout is for human-readable status and the final report;
|
||||
# stderr is for warnings, errors, --verbose traces, and --dry-run plans.
|
||||
# This makes the script CI-friendly (pipe stdout, capture stderr).
|
||||
# - `DEBUG=1` env var enables `set -x` for full command tracing.
|
||||
#
|
||||
# ── Version ────────────────────────────────────────────────────────────────
|
||||
SCRIPT_VERSION='1.0.0'
|
||||
|
||||
set -euo pipefail
|
||||
[[ -n "${DEBUG:-}" ]] && set -x
|
||||
|
||||
# ── Paths / constants ─────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# In-container paths (must match what the Dockerfiles copy to).
|
||||
SO_PATH_INSIDE="/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so"
|
||||
WRAPPER_PATH_INSIDE="/usr/lib/plexmediaserver/plex-crack-wrapper.sh"
|
||||
PMS_COMM_NAME="Plex Media Server" # prctl(PR_SET_NAME) sets this
|
||||
|
||||
# s6-overlay v3 / v2 candidate paths, in preference order. If the upstream
|
||||
# image's s6 layout changes, this is the single place to update.
|
||||
S6_RUN_CANDIDATES=(
|
||||
"/etc/s6-overlay/s6-rc.d/svc-plex/run"
|
||||
"/etc/services.d/plex/run"
|
||||
)
|
||||
|
||||
# Operational defaults.
|
||||
DEFAULT_CONTAINER="plex"
|
||||
MAX_WAIT_ITERATIONS=30
|
||||
WAIT_INTERVAL_SECONDS=2
|
||||
PMS_HTTP_PORT=32400
|
||||
|
||||
# ── Arg-parser state ──────────────────────────────────────────────────────
|
||||
SUBCOMMAND=""
|
||||
CONTAINER_NAME=""
|
||||
DRY_RUN=false
|
||||
VERBOSE=false
|
||||
QUIET=false
|
||||
SKIP_BUILD=false
|
||||
FORCE_REBUILD=false
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────
|
||||
# stdout -> human-readable status, success messages, final report
|
||||
# stderr -> warnings, errors, --verbose traces, --dry-run plans
|
||||
log() { [ "${QUIET}" != "true" ] && printf '%s\n' "$*"; }
|
||||
warn() { printf 'WARNING: %s\n' "$*" >&2; }
|
||||
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
trace() { [ "${VERBOSE}" = "true" ] && printf '+ %s\n' "$*" >&2 || true; }
|
||||
|
||||
# ── Command runner: respects --dry-run and --verbose ──────────────────────
|
||||
# All destructive operations go through this wrapper. In dry-run mode
|
||||
# the command is echoed to stderr and skipped. In verbose mode the
|
||||
# command is echoed to stderr before execution. Stdout is captured by
|
||||
# the caller as usual.
|
||||
run() {
|
||||
if [ "${DRY_RUN}" = "true" ]; then
|
||||
printf '[dry-run]'
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
printf ' %s' "${arg}"
|
||||
done
|
||||
printf '\n' >&2
|
||||
else
|
||||
trace "$*"
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Usage ─────────────────────────────────────────────────────────────────
|
||||
print_usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [flags] <subcommand> [container-name]
|
||||
|
||||
Subcommands:
|
||||
install [name] Apply the patch in place (default)
|
||||
uninstall [name] Revert the s6 run file from its .orig
|
||||
status [name] Report whether the patch is active
|
||||
help Show this message
|
||||
|
||||
Flags (can appear before or after the subcommand):
|
||||
--name <name> Container name (alternative to positional)
|
||||
--no-build Use existing build/plexmediaserver_crack.so; do not invoke build.sh
|
||||
--force-rebuild Delete build/plexmediaserver_crack.so and rebuild from scratch
|
||||
--dry-run Print the actions that would be taken without executing them
|
||||
--verbose, -v Trace every docker/build command to stderr
|
||||
--quiet, -q Suppress non-essential output (only the final report)
|
||||
--version Print the script version and exit
|
||||
|
||||
Container name defaults to "${DEFAULT_CONTAINER}".
|
||||
|
||||
Examples:
|
||||
$(basename "$0") install # default container
|
||||
$(basename "$0") install my-plex # custom name
|
||||
$(basename "$0") --name my-plex install # flag form
|
||||
$(basename "$0") install --dry-run # preview only
|
||||
$(basename "$0") --verbose status my-plex # trace every docker call
|
||||
$(basename "$0") uninstall my-plex
|
||||
|
||||
The script auto-detects the base image (plexinc vs linuxserver) by
|
||||
reading the s6 svc-plex run file inside the container. The same .so and
|
||||
docker/wrapper.sh are used in both cases; only the run file content
|
||||
differs (LSIO preserves s6-setuidgid abc).
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Arg parsing ───────────────────────────────────────────────────────────
|
||||
parse_args() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
install|uninstall|status)
|
||||
if [ -n "${SUBCOMMAND}" ]; then
|
||||
die "subcommand already specified: ${SUBCOMMAND}"
|
||||
fi
|
||||
SUBCOMMAND="$1"
|
||||
shift
|
||||
;;
|
||||
help|-h|--help) print_usage; exit 0 ;;
|
||||
--name) [ "$#" -ge 2 ] || die "--name requires an argument"
|
||||
CONTAINER_NAME="$2"; shift 2 ;;
|
||||
--name=*) CONTAINER_NAME="${1#--name=}"; shift ;;
|
||||
--no-build) SKIP_BUILD=true; shift ;;
|
||||
--force-rebuild) FORCE_REBUILD=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--verbose|-v) VERBOSE=true; shift ;;
|
||||
--quiet|-q) QUIET=true; shift ;;
|
||||
--version) printf '%s\n' "${SCRIPT_VERSION}"; exit 0 ;;
|
||||
--) shift; break ;;
|
||||
-*) die "unknown flag: $1 (try --help)" ;;
|
||||
*)
|
||||
if [ -z "${CONTAINER_NAME}" ]; then
|
||||
CONTAINER_NAME="$1"
|
||||
else
|
||||
die "unexpected positional argument: $1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SUBCOMMAND="${SUBCOMMAND:-install}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-${DEFAULT_CONTAINER}}"
|
||||
|
||||
# Mutual-exclusion checks.
|
||||
if [ "${SKIP_BUILD}" = "true" ] && [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
die "--no-build and --force-rebuild are mutually exclusive"
|
||||
fi
|
||||
if [ "${QUIET}" = "true" ] && [ "${VERBOSE}" = "true" ]; then
|
||||
die "--quiet and --verbose are mutually exclusive"
|
||||
fi
|
||||
|
||||
# Make state available to subcommand functions.
|
||||
export SUBCOMMAND CONTAINER_NAME DRY_RUN VERBOSE QUIET SKIP_BUILD FORCE_REBUILD
|
||||
}
|
||||
|
||||
# ── Pre-flight checks ────────────────────────────────────────────────────
|
||||
require_docker() {
|
||||
command -v docker >/dev/null 2>&1 || die "docker not on PATH"
|
||||
}
|
||||
|
||||
require_container_exists() {
|
||||
require_docker
|
||||
if ! docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
|
||||
die "container '${CONTAINER_NAME}' not found. Start one with: docker run -d --name ${CONTAINER_NAME} ..."
|
||||
fi
|
||||
}
|
||||
|
||||
require_container_running() {
|
||||
require_container_exists
|
||||
local state
|
||||
state="$(docker inspect --format '{{.State.Running}}' "${CONTAINER_NAME}" 2>/dev/null || echo unknown)"
|
||||
if [ "${state}" != "true" ]; then
|
||||
die "container '${CONTAINER_NAME}' is not running (state: ${state}). Start it with: docker start ${CONTAINER_NAME}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Detection ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Echoes the s6 svc-plex run file path on stdout, or returns 1.
|
||||
# Two candidates are tried in preference order: s6-overlay v3 path, then
|
||||
# the legacy v2 path.
|
||||
detect_run_script() {
|
||||
local p
|
||||
for p in "${S6_RUN_CANDIDATES[@]}"; do
|
||||
if run docker exec -u root "${CONTAINER_NAME}" test -f "${p}" 2>/dev/null; then
|
||||
printf '%s\n' "${p}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Echoes one of: plexinc, linuxserver, unknown
|
||||
# Detection: LSIO's run file content includes `s6-setuidgid abc`; plexinc's
|
||||
# does not. If neither marker is found, return `unknown` (the install
|
||||
# flow will refuse to proceed).
|
||||
detect_base_image() {
|
||||
local run_script="$1"
|
||||
local content
|
||||
content="$(run docker exec -u root "${CONTAINER_NAME}" cat "${run_script}" 2>/dev/null || true)"
|
||||
if printf '%s' "${content}" | grep -q 's6-setuidgid abc'; then
|
||||
printf 'linuxserver\n'
|
||||
elif printf '%s' "${content}" | grep -qE 'Plex Media Server|start\.sh|with-contenv'; then
|
||||
printf 'plexinc\n'
|
||||
else
|
||||
printf 'unknown\n'
|
||||
fi
|
||||
}
|
||||
|
||||
# Echoes the PMS PID on stdout, or empty.
|
||||
# Strategy: scan /proc/*/comm for the PMS comm name (set via
|
||||
# prctl(PR_SET_NAME)). This avoids the "ps -ef | grep | grep -v grep"
|
||||
# pattern, which has a tendency to match its own command line, and works
|
||||
# regardless of which process-listing tools are in the container.
|
||||
find_pms_pid() {
|
||||
run docker exec "${CONTAINER_NAME}" sh -c '
|
||||
for d in /proc/[0-9]*; do
|
||||
[ -r "$d/comm" ] || continue
|
||||
if [ "$(cat "$d/comm" 2>/dev/null)" = "$1" ]; then
|
||||
basename "$d"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
exit 1
|
||||
' _ "${PMS_COMM_NAME}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Build ─────────────────────────────────────────────────────────────────
|
||||
build_so() {
|
||||
local so_path="${PROJECT_ROOT}/build/plexmediaserver_crack.so"
|
||||
|
||||
if [ "${FORCE_REBUILD}" = "true" ] && [ -f "${so_path}" ]; then
|
||||
log "[*] --force-rebuild: removing existing .so"
|
||||
run rm -f "${so_path}"
|
||||
fi
|
||||
|
||||
if [ "${SKIP_BUILD}" = "true" ]; then
|
||||
if [ ! -f "${so_path}" ]; then
|
||||
die "--no-build specified but ${so_path} does not exist. Build it first with: bash build.sh"
|
||||
fi
|
||||
log "[*] --no-build: reusing existing .so (build.sh not invoked)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "${so_path}" ]; then
|
||||
log "[*] Reusing existing .so (rm it or pass --force-rebuild to rebuild)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "[*] Building .so via build.sh (this may take 1-2 min on first run)..."
|
||||
( cd "${PROJECT_ROOT}" && run bash build.sh )
|
||||
}
|
||||
|
||||
# ── Filesystem ops ────────────────────────────────────────────────────────
|
||||
# Build the new s6 run file content locally and docker cp it in. We do
|
||||
# this locally (rather than heredoc-over-docker-exec) to avoid quoting
|
||||
# hell and command-injection risk.
|
||||
write_new_run_file() {
|
||||
local base="$1" out="$2"
|
||||
{
|
||||
printf '#!/usr/bin/with-contenv bash\n'
|
||||
if [ "${base}" = "linuxserver" ]; then
|
||||
printf 'exec s6-setuidgid abc %s\n' "${WRAPPER_PATH_INSIDE}"
|
||||
else
|
||||
printf 'exec %s\n' "${WRAPPER_PATH_INSIDE}"
|
||||
fi
|
||||
} > "${out}"
|
||||
chmod 0755 "${out}"
|
||||
}
|
||||
|
||||
# Back up the s6 run file to .orig. Idempotent: if .orig exists, leave it.
|
||||
# Uses the safe `sh -c '...' _ "${path}"` pattern: single-quoted command
|
||||
# + positional arg, so the host bash never interpolates the path.
|
||||
backup_run_file() {
|
||||
local run_script="$1"
|
||||
if run docker exec -u root "${CONTAINER_NAME}" test -f "${run_script}.orig" 2>/dev/null; then
|
||||
log "[*] ${run_script}.orig already present, leaving it"
|
||||
return 0
|
||||
fi
|
||||
run docker exec -u root "${CONTAINER_NAME}" \
|
||||
sh -c 'cp "$1" "$1".orig' _ "${run_script}"
|
||||
log "[*] Backed up ${run_script} -> ${run_script}.orig"
|
||||
}
|
||||
|
||||
# Copy the .so and wrapper into the container and chmod the wrapper.
|
||||
copy_artifacts() {
|
||||
local so_host="${PROJECT_ROOT}/build/plexmediaserver_crack.so"
|
||||
local wrapper_host="${SCRIPT_DIR}/wrapper.sh"
|
||||
|
||||
if [ ! -f "${so_host}" ]; then
|
||||
die "${so_host} does not exist. Build it first with: bash build.sh"
|
||||
fi
|
||||
if [ ! -f "${wrapper_host}" ]; then
|
||||
die "${wrapper_host} does not exist. Re-clone the project or restore docker/wrapper.sh."
|
||||
fi
|
||||
|
||||
log "[*] Copying .so and wrapper into the container..."
|
||||
run docker cp "${so_host}" "${CONTAINER_NAME}:${SO_PATH_INSIDE}"
|
||||
run docker cp "${wrapper_host}" "${CONTAINER_NAME}:${WRAPPER_PATH_INSIDE}"
|
||||
run docker exec -u root "${CONTAINER_NAME}" chmod 0755 "${WRAPPER_PATH_INSIDE}"
|
||||
}
|
||||
|
||||
# ── Verification ───────────────────────────────────────────────────────────
|
||||
# Polls PMS /identity until it returns 200 or the timeout expires.
|
||||
wait_for_pms_ready() {
|
||||
log "[*] Waiting for PMS /identity (up to $((MAX_WAIT_ITERATIONS * WAIT_INTERVAL_SECONDS))s)..."
|
||||
local i code
|
||||
for i in $(seq 1 "${MAX_WAIT_ITERATIONS}"); do
|
||||
code="$(curl -fsS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PMS_HTTP_PORT}/identity" 2>/dev/null || echo 000)"
|
||||
if [ "${code}" = "200" ]; then
|
||||
log "[*] PMS is up (HTTP 200)."
|
||||
return 0
|
||||
fi
|
||||
sleep "${WAIT_INTERVAL_SECONDS}"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Confirms the .so is mapped into the PMS process.
|
||||
verify_so_mapped() {
|
||||
local pms_pid="$1"
|
||||
if [ -z "${pms_pid}" ]; then
|
||||
warn "could not find PMS pid inside container"
|
||||
return 1
|
||||
fi
|
||||
if run docker exec "${CONTAINER_NAME}" grep -F plexmediaserver_crack.so "/proc/${pms_pid}/maps" >/dev/null 2>&1; then
|
||||
log "[*] OK: plexmediaserver_crack.so is mapped into PMS (pid ${pms_pid})"
|
||||
return 0
|
||||
fi
|
||||
warn "PMS running (pid ${pms_pid}) but .so is NOT in /proc/${pms_pid}/maps"
|
||||
warn " This usually means PMS exited 127 (loader failure). Check: docker logs ${CONTAINER_NAME} | tail -50"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Subcommands ──────────────────────────────────────────────────────────
|
||||
do_install() {
|
||||
require_container_running
|
||||
log "[*] Container: ${CONTAINER_NAME}"
|
||||
|
||||
local run_script
|
||||
if ! run_script="$(detect_run_script)"; then
|
||||
die "could not find s6 svc-plex run file in container. Tried: ${S6_RUN_CANDIDATES[*]}"
|
||||
fi
|
||||
log "[*] s6 run file: ${run_script}"
|
||||
|
||||
local base
|
||||
base="$(detect_base_image "${run_script}")"
|
||||
case "${base}" in
|
||||
linuxserver) log "[*] Detected base: lscr.io/linuxserver/plex (s6-setuidgid abc will be preserved)" ;;
|
||||
plexinc) log "[*] Detected base: plexinc/pms-docker" ;;
|
||||
*)
|
||||
die "could not detect base image from s6 run file content. Please file an issue with the file contents."
|
||||
;;
|
||||
esac
|
||||
|
||||
build_so
|
||||
copy_artifacts
|
||||
backup_run_file "${run_script}"
|
||||
|
||||
# Write the new run file locally and copy it in. The mktemp + trap
|
||||
# pattern ensures we never leak the temp file, even on error paths.
|
||||
local tmp_run
|
||||
tmp_run="$(mktemp)"
|
||||
trap 'rm -f "${tmp_run}"' EXIT
|
||||
write_new_run_file "${base}" "${tmp_run}"
|
||||
run docker cp "${tmp_run}" "${CONTAINER_NAME}:${run_script}"
|
||||
rm -f "${tmp_run}"
|
||||
trap - EXIT
|
||||
|
||||
log "[*] Restarting ${CONTAINER_NAME} (s6 will exec the new run file)..."
|
||||
run docker restart "${CONTAINER_NAME}" >/dev/null
|
||||
|
||||
if ! wait_for_pms_ready; then
|
||||
warn "PMS did not return 200 within $((MAX_WAIT_ITERATIONS * WAIT_INTERVAL_SECONDS))s. Check: docker logs ${CONTAINER_NAME} | tail -50"
|
||||
fi
|
||||
|
||||
local pms_pid
|
||||
pms_pid="$(find_pms_pid)"
|
||||
verify_so_mapped "${pms_pid}"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Patch applied. For full verification (all 14 feature bits ON):
|
||||
docker cp ${PROJECT_ROOT}/scripts/readbitset.py ${CONTAINER_NAME}:/tmp/readbitset.py
|
||||
docker exec -u root ${CONTAINER_NAME} python3 /tmp/readbitset.py ${pms_pid:-<PMS_PID>}
|
||||
|
||||
To revert: $(basename "$0") uninstall ${CONTAINER_NAME}
|
||||
To re-check: $(basename "$0") status ${CONTAINER_NAME}
|
||||
EOF
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
require_container_running
|
||||
|
||||
local run_script
|
||||
if ! run_script="$(detect_run_script)"; then
|
||||
die "could not find s6 svc-plex run file. Tried: ${S6_RUN_CANDIDATES[*]}"
|
||||
fi
|
||||
|
||||
if ! run docker exec -u root "${CONTAINER_NAME}" test -f "${run_script}.orig" 2>/dev/null; then
|
||||
die "no ${run_script}.orig found -- the patch may not be installed, or the .orig was deleted. Manual restore: docker cp <upstream-image>:${run_script} ${CONTAINER_NAME}:${run_script}"
|
||||
fi
|
||||
|
||||
log "[*] Restoring ${run_script} from .orig..."
|
||||
run docker exec -u root "${CONTAINER_NAME}" \
|
||||
sh -c 'cp "$1" "$2"' _ "${run_script}.orig" "${run_script}"
|
||||
run docker exec -u root "${CONTAINER_NAME}" chmod 0755 "${run_script}"
|
||||
|
||||
log "[*] Restarting ${CONTAINER_NAME}..."
|
||||
run docker restart "${CONTAINER_NAME}" >/dev/null
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Patch removed. PMS is back to upstream behavior. Optional cleanup:
|
||||
docker exec -u root ${CONTAINER_NAME} rm -f ${SO_PATH_INSIDE} ${WRAPPER_PATH_INSIDE}
|
||||
docker exec -u root ${CONTAINER_NAME} rm -f ${run_script}.orig
|
||||
EOF
|
||||
}
|
||||
|
||||
do_status() {
|
||||
require_container_exists
|
||||
|
||||
local run_script
|
||||
if ! run_script="$(detect_run_script)"; then
|
||||
warn "s6 svc-plex run file not found (tried: ${S6_RUN_CANDIDATES[*]})"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local run_content has_orig has_so has_wrapper pms_pid
|
||||
run_content="$(run docker exec -u root "${CONTAINER_NAME}" cat "${run_script}" 2>/dev/null || true)"
|
||||
if run docker exec -u root "${CONTAINER_NAME}" test -f "${run_script}.orig" 2>/dev/null; then
|
||||
has_orig="yes"
|
||||
else
|
||||
has_orig="no"
|
||||
fi
|
||||
if run docker exec -u root "${CONTAINER_NAME}" test -f "${SO_PATH_INSIDE}" 2>/dev/null; then
|
||||
has_so="yes"
|
||||
else
|
||||
has_so="no"
|
||||
fi
|
||||
if run docker exec -u root "${CONTAINER_NAME}" test -x "${WRAPPER_PATH_INSIDE}" 2>/dev/null; then
|
||||
has_wrapper="yes"
|
||||
else
|
||||
has_wrapper="no"
|
||||
fi
|
||||
pms_pid="$(find_pms_pid)"
|
||||
|
||||
log "Container: ${CONTAINER_NAME}"
|
||||
log " s6 run file: ${run_script}"
|
||||
log " .orig present: ${has_orig}"
|
||||
log " .so present: ${has_so} (${SO_PATH_INSIDE})"
|
||||
log " wrapper: ${has_wrapper} (${WRAPPER_PATH_INSIDE})"
|
||||
log " PMS pid: ${pms_pid:-<not running>}"
|
||||
log " run file content:"
|
||||
printf '%s\n' "${run_content}" | sed 's/^/ /'
|
||||
log ""
|
||||
|
||||
if printf '%s' "${run_content}" | grep -q 'plex-crack-wrapper.sh'; then
|
||||
log "[*] PATCH IS ACTIVE (s6 run file points to the wrapper)."
|
||||
if [ -n "${pms_pid}" ] && run docker exec "${CONTAINER_NAME}" grep -F plexmediaserver_crack.so "/proc/${pms_pid}/maps" >/dev/null 2>&1; then
|
||||
log "[*] .so is mapped into PMS (pid ${pms_pid})."
|
||||
else
|
||||
warn "PMS running (pid ${pms_pid:-?}) but .so is NOT in its maps; check docker logs."
|
||||
fi
|
||||
else
|
||||
log "[*] PATCH IS NOT ACTIVE (s6 run file does not point to the wrapper)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────
|
||||
parse_args "$@"
|
||||
case "${SUBCOMMAND}" in
|
||||
install) do_install ;;
|
||||
uninstall) do_uninstall ;;
|
||||
status) do_status ;;
|
||||
esac
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# In-container launcher for Plex Media Server. Used by the patched
|
||||
# plexinc/pms-docker and lscr.io/linuxserver/plex images; both Dockerfiles
|
||||
# replace the upstream s6 service run file so it execs this script.
|
||||
#
|
||||
# Mirrors scripts/plex-crack-wrapper.sh from the native systemd install, with
|
||||
# one key invariant:
|
||||
#
|
||||
# LD_PRELOAD is exported *last*, immediately before `exec`, so the glibc
|
||||
# shell helpers spawned for the PLEX_MEDIA_SERVER_INFO_* assignments
|
||||
# (grep/awk/uname/tr) are NOT preloaded. The .so's constructor
|
||||
# (src/main.cpp) calls unsetenv("LD_PRELOAD") when it loads into the
|
||||
# (musl) PMS process, so PMS's glibc helper children (Tuner Service,
|
||||
# Script Host, transcoders) are unaffected as well.
|
||||
#
|
||||
# `exec` is mandatory so s6-supervise sees PMS as the supervised process
|
||||
# (no fork). "$@" preserves any args the upstream invocation might add.
|
||||
|
||||
set -eu
|
||||
|
||||
# 1. Platform strings PMS echoes in /identity. Mirrors upstream start.sh.
|
||||
export PLEX_MEDIA_SERVER_INFO_VENDOR="$(grep ^NAME= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
|
||||
export PLEX_MEDIA_SERVER_INFO_MODEL="$(uname -m)"
|
||||
export PLEX_MEDIA_SERVER_INFO_PLATFORM_VERSION="$(grep ^VERSION= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
|
||||
|
||||
# 2. Set LD_PRELOAD only now. The .so paths match where the Dockerfiles
|
||||
# copy them. Do NOT change this without updating both Dockerfiles.
|
||||
PRELOADS=""
|
||||
PRELOADS="${PRELOADS}:/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so"
|
||||
PRELOADS="${PRELOADS}:/usr/lib/plexmediaserver/lib/plexmediaserver_traffic_logger.so"
|
||||
export LD_PRELOAD="${PRELOADS#:}"
|
||||
|
||||
# 3. Hand off to PMS.
|
||||
exec "/usr/lib/plexmediaserver/Plex Media Server" "$@"
|
||||
@@ -0,0 +1,107 @@
|
||||
# Building & installing plexmediaserver_crack (Linux)
|
||||
|
||||
## How this works (read this first)
|
||||
|
||||
Plex Media Server ships and runs against its **own bundled musl libc + libgcompat**
|
||||
(`/usr/lib/plexmediaserver/lib/{libc.so,ld-musl-x86_64.so.1,libgcompat.so.0}`),
|
||||
**not** the host's glibc. Two consequences drive everything below:
|
||||
|
||||
- **Build target must be musl.** A glibc-built `.so` fails to load into Plex: the
|
||||
loader can't relocate glibc-only symbols (`__isoc23_strtol`, `arc4random`,
|
||||
`*_chk`, `_dl_find_object`) and PMS exits 127. We cross-compile with **zig**
|
||||
(`-target x86_64-linux-musl`), which bundles musl + libc++ and statically links
|
||||
the C++ runtime, leaving only musl libc references that Plex's `libc.so` satisfies.
|
||||
- **Inject with `LD_PRELOAD`, never `patchelf`.** `patchelf --add-needed` rewrites
|
||||
the 22 MB BIND_NOW/PIE binary's program headers in a way musl's loader cannot
|
||||
tolerate, which **corrupts the executable** (instant SIGSEGV on start). `LD_PRELOAD`
|
||||
touches nothing on disk and is trivially reversible.
|
||||
|
||||
The crack hooks `FeatureManager_apply_feature_list_xml` and forces all 14 entries
|
||||
of `g_feature_bitset_slots` on (the "Godmode" approach), enabling every feature
|
||||
including Plex Pass (feature code 92, slot 11).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A Linux environment (native or WSL). `build.sh` will download a pinned `zig`
|
||||
toolchain on first run, so you only need `curl` + `tar`/`xz` available (or `zig`
|
||||
already on `PATH`, or pass `ZIG=/path/to/zig`). No cmake/g++/glibc toolchain needed.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
bash build.sh
|
||||
```
|
||||
|
||||
Output: `build/plexmediaserver_crack.so` (a musl shared object). The script runs an ABI
|
||||
sanity check and refuses to emit a `.so` that references any glibc-only symbol.
|
||||
It prints the install steps below on success.
|
||||
|
||||
## Install (native systemd installs)
|
||||
|
||||
The launcher `plex-crack-wrapper.sh` sets `LD_PRELOAD` **after** `/bin/sh` is
|
||||
already running and just before it `exec`s Plex, so only the (musl) Plex process
|
||||
is preloaded. The crack's constructor also calls `unsetenv("LD_PRELOAD")`, so the
|
||||
glibc `/bin/sh` helper children Plex spawns (Tuner Service, Script Host) are
|
||||
unaffected.
|
||||
|
||||
```bash
|
||||
# 1. Place the artifacts on the Plex host:
|
||||
install -o plex -g plex -m644 build/plexmediaserver_crack.so /usr/lib/plexmediaserver/lib/
|
||||
install -m755 scripts/plex-crack-wrapper.sh /usr/local/bin/
|
||||
|
||||
# 2. Drop-in that swaps ExecStart for the wrapper:
|
||||
mkdir -p /etc/systemd/system/plexmediaserver.service.d
|
||||
printf '[Service]\nExecStart=\nExecStart=/usr/local/bin/plex-crack-wrapper.sh\n' \
|
||||
> /etc/systemd/system/plexmediaserver.service.d/override.conf
|
||||
|
||||
# 3. Apply:
|
||||
systemctl daemon-reload
|
||||
systemctl restart plexmediaserver
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
systemctl is-active plexmediaserver # -> active
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:32400/identity # -> 200
|
||||
# Confirm the .so is mapped into the process:
|
||||
PID=$(systemctl show -p MainPID --value plexmediaserver)
|
||||
grep -F plexmediaserver_crack.so /proc/$PID/maps
|
||||
```
|
||||
|
||||
`scripts/readbitset.py <pid>` (root) dumps the live feature bitset; all 14 slots should
|
||||
read `0xffffffffffffffff` when the hook is active.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
rm -f /etc/systemd/system/plexmediaserver.service.d/override.conf
|
||||
rm -f /usr/lib/plexmediaserver/lib/plexmediaserver_crack.so /usr/local/bin/plex-crack-wrapper.sh
|
||||
systemctl daemon-reload && systemctl restart plexmediaserver
|
||||
```
|
||||
|
||||
### Recovering a binary already corrupted by patchelf
|
||||
|
||||
If a prior attempt ran `patchelf` against the Plex binary and PMS now SIGSEGVs on
|
||||
start, restore the pristine executable:
|
||||
|
||||
```bash
|
||||
apt-get install --reinstall -y plexmediaserver
|
||||
# If the repo isn't configured, fetch the exact installed version and dpkg -i it:
|
||||
V=$(dpkg-query -W -f='${Version}' plexmediaserver)
|
||||
curl -fL -o /tmp/pms.deb "https://downloads.plex.tv/plex-media-server-new/$V/debian/plexmediaserver_${V}_amd64.deb"
|
||||
dpkg -i /tmp/pms.deb
|
||||
systemctl reset-failed plexmediaserver && systemctl restart plexmediaserver
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- x86-64 Linux only.
|
||||
- "Godmode": enables ALL features regardless of GUID. The full feature UUID
|
||||
catalog is in `src/hook.cpp` (`kFeatureGuidCatalog`) for reference.
|
||||
- Signature patterns in `hook()` are version-specific; you may need to re-verify
|
||||
them after a PMS update.
|
||||
- For Docker (`plexinc/pms-docker`, `lscr.io/linuxserver/plex`), see
|
||||
[`DOCKER.md`](DOCKER.md) — same `LD_PRELOAD`-on-the-PMS-exec principle,
|
||||
but the s6 service plumbing is different per image.
|
||||
- For intro/credit detection: Settings -> Library -> Marker source -> "local detection only".
|
||||
@@ -0,0 +1,351 @@
|
||||
# Building & running the Plex patcher in Docker (Linux x86-64)
|
||||
|
||||
The same `LD_PRELOAD`-on-the-PMS-exec trick the native systemd install uses
|
||||
([`BUILD.md`](BUILD.md)) translates to Docker: build a patched Plex image,
|
||||
run it as you would the upstream image, and the (musl) PMS process is
|
||||
preloaded with the crack without affecting the (glibc) s6 init, helper
|
||||
children, or transcoders.
|
||||
|
||||
## How this works (read this first)
|
||||
|
||||
Three constraints are identical to the native install:
|
||||
|
||||
1. **Build target is musl** — Plex bundles and runs against its own musl
|
||||
libc. A glibc `.so` won't load. We cross-compile with `zig` in a
|
||||
multi-stage build (`debian:bookworm-slim` builder, Plex base image
|
||||
runtime).
|
||||
2. **Inject with `LD_PRELOAD`** — `patchelf` corrupts the PIE under musl's
|
||||
loader. The patch is at-rest: drop a `.so` next to PMS in the image and
|
||||
have the s6 service exec PMS through a thin wrapper that exports
|
||||
`LD_PRELOAD` only at exec time.
|
||||
3. **Scope `LD_PRELOAD` to the PMS exec** — the wrapper sets `LD_PRELOAD`
|
||||
*after* any glibc child work and only for the final `exec`. The `.so`'s
|
||||
constructor (see [`../src/main.cpp`](../src/main.cpp)) calls
|
||||
`unsetenv("LD_PRELOAD")`, so PMS's glibc helper children (Tuner, Script
|
||||
Host, transcoders) are unaffected.
|
||||
|
||||
The only difference between the two Dockerfiles is how the s6 service for
|
||||
Plex is replaced — the `.so` and `wrapper.sh` are identical. In both cases
|
||||
the upstream run file is backed up as `run.orig` for forensics.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine 20.10+ with BuildKit enabled (`DOCKER_BUILDKIT=1` or
|
||||
BuildKit as the default builder in recent Docker). Both Dockerfiles use
|
||||
`# syntax=docker/dockerfile:1.7`.
|
||||
- An x86_64 Linux host (or any host with `docker buildx` configured for
|
||||
`linux/amd64`).
|
||||
- A place to keep PMS state on the host. We recommend `/srv/plex/config`
|
||||
and `/srv/plex/data`; the Dockerfiles don't bake any of this in.
|
||||
|
||||
## Build
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
# Official (plexinc/pms-docker)
|
||||
docker build -f docker/Dockerfile.plexinc -t plex-crack:plexinc .
|
||||
|
||||
# Community (lscr.io/linuxserver/plex)
|
||||
docker build -f docker/Dockerfile.linuxserver -t plex-crack:lsio .
|
||||
```
|
||||
|
||||
The first build downloads `zig 0.13.0` and the chosen Plex base image.
|
||||
Subsequent builds reuse cached layers until `src/`, `third_party/`, or
|
||||
`build.sh` change.
|
||||
|
||||
### Pinning the base image
|
||||
|
||||
Both Dockerfiles accept a build-arg to pin the upstream Plex image:
|
||||
|
||||
```bash
|
||||
docker build -f docker/Dockerfile.plexinc \
|
||||
--build-arg PLEX_BASE_IMAGE=plexinc/pms-docker:1.42.1.1007-7e0e6c83c \
|
||||
-t plex-crack:plexinc-1.42 .
|
||||
```
|
||||
|
||||
The sanity `RUN` in stage 2 verifies the upstream layout before the patch
|
||||
layers are added — if plexinc or LSIO moves the PMS binary, the `lib/`
|
||||
directory, or the s6 service file, the build fails *here* with a clear
|
||||
message rather than the container failing mysteriously at runtime.
|
||||
|
||||
You can also stamp the patched image with a version:
|
||||
|
||||
```bash
|
||||
docker build -f docker/Dockerfile.plexinc \
|
||||
--build-arg PATCH_VERSION=v1.2.3 \
|
||||
-t plex-crack:plexinc-v1.2.3 .
|
||||
```
|
||||
|
||||
`PATCH_VERSION` ends up in the `plex_patch.version` OCI label and is
|
||||
visible via `docker inspect`.
|
||||
|
||||
## Run
|
||||
|
||||
### docker run
|
||||
|
||||
```bash
|
||||
# Official (plexinc/pms-docker). No special env; plex runs as the upstream
|
||||
# 'plex' user.
|
||||
docker run -d --name plex --network=host \
|
||||
-v /srv/plex/config:/config \
|
||||
-v /srv/plex/data:/data \
|
||||
plex-crack:plexinc
|
||||
|
||||
# Community (linuxserver). Honor the LSIO PUID/PGID convention so /config
|
||||
# and /data are chowned correctly on first start.
|
||||
docker run -d --name plex --network=host \
|
||||
-e PUID=$(id -u) -e PGID=$(id -g) \
|
||||
-e TZ=America/Los_Angeles \
|
||||
-v /srv/plex/config:/config \
|
||||
-v /srv/plex/data:/data \
|
||||
plex-crack:lsio
|
||||
```
|
||||
|
||||
`--network=host` is what Plex expects for direct LAN access; bridge
|
||||
networking works too if you publish `32400/tcp` (and `3005/tcp`, `8324/tcp`,
|
||||
`32469/udp` for Bonjour/avahi, etc.) — see the Plex docs.
|
||||
|
||||
### docker compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
plex:
|
||||
image: plex-crack:lsio
|
||||
container_name: plex
|
||||
network_mode: host
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=America/Los_Angeles
|
||||
volumes:
|
||||
- /srv/plex/config:/config
|
||||
- /srv/plex/data:/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
For `plex-crack:plexinc`, drop the `PUID`/`PGID` lines.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# 1. PMS is up.
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:32400/identity # -> 200
|
||||
|
||||
# 2. The .so is mapped into the PMS process. s6 runs PMS as a child, so
|
||||
# find it first.
|
||||
docker exec plex ps -ef | grep 'Plex Media Server' | grep -v grep
|
||||
# Take the PID (first column) -- call it $PMS_PID.
|
||||
docker exec plex grep -F plexmediaserver_crack.so /proc/$PMS_PID/maps
|
||||
# -> should list the .so with r-xp perms.
|
||||
|
||||
# 3. Dump the live feature bitset. python3 must be available inside the
|
||||
# container (the upstream images include it; if not, `docker exec -u root
|
||||
# plex apk add python3` on alpine or `apt-get install -y python3` on
|
||||
# debian/ubuntu).
|
||||
docker cp scripts/readbitset.py plex:/tmp/readbitset.py
|
||||
docker exec -u root plex python3 /tmp/readbitset.py $PMS_PID
|
||||
# -> all 14 slots should read 0xffffffffffffffff.
|
||||
```
|
||||
|
||||
Step 3 is the strongest check: if any slot is `0x0…0`, the hook didn't
|
||||
fire and the signature patterns in `src/hook.cpp` need to be re-verified
|
||||
against the new PMS binary.
|
||||
|
||||
## Alternative: patch a running container in place
|
||||
|
||||
The `docker build` flow above builds a new patched image and starts a
|
||||
new container. If you already have a Plex container running — and you
|
||||
want to apply the same patch **without** rebuilding, recreating, or
|
||||
pulling a new image — `docker/plex-docker-patch.sh` does it in place.
|
||||
The container's filesystem is modified directly; the upstream image is
|
||||
left untouched. A `.orig` copy of the s6 `run` file is kept for
|
||||
`uninstall`.
|
||||
|
||||
```bash
|
||||
# Apply (default container name: "plex").
|
||||
./docker/plex-docker-patch.sh install
|
||||
|
||||
# Or with a custom container name.
|
||||
./docker/plex-docker-patch.sh install media-plex
|
||||
|
||||
# Verify.
|
||||
./docker/plex-docker-patch.sh status media-plex
|
||||
|
||||
# Revert (restores s6 run file from .orig).
|
||||
./docker/plex-docker-patch.sh uninstall media-plex
|
||||
```
|
||||
|
||||
Requirements on the host: `docker` on `PATH`, `bash 4+`, and either
|
||||
`zig` available (so `build.sh` can cross-compile) or an existing
|
||||
`build/plexmediaserver_crack.so` (the script reuses it if present).
|
||||
|
||||
### What it does, step by step
|
||||
|
||||
1. Verifies the container exists and is running.
|
||||
2. Detects the s6 service `run` file path
|
||||
(`/etc/s6-overlay/s6-rc.d/svc-plex/run` for v3,
|
||||
`/etc/services.d/plex/run` for v2) and reads it to auto-detect the
|
||||
base image (plexinc vs linuxserver — the latter uses
|
||||
`s6-setuidgid abc`).
|
||||
3. Invokes `build.sh` (skipped if `build/plexmediaserver_crack.so` is
|
||||
already present) to produce the musl `.so`.
|
||||
4. `docker cp`s the `.so` and `wrapper.sh` into the container.
|
||||
5. Backs up the s6 `run` file to `.orig` (preserved across re-installs).
|
||||
6. Writes a new `run` file that execs the wrapper (with or without
|
||||
`s6-setuidgid abc`, depending on the base image).
|
||||
7. `docker restart`s the container.
|
||||
8. Waits for `http://127.0.0.1:32400/identity` to return 200.
|
||||
9. Confirms `plexmediaserver_crack.so` is mapped into the PMS process
|
||||
(`/proc/$PID/maps`).
|
||||
10. Prints the `readbitset.py` command for full feature-bitset
|
||||
verification.
|
||||
|
||||
### When to use which
|
||||
|
||||
- **Dockerfile build** — best for repeat deploys, multi-host, CI/CD,
|
||||
immutable images. You commit a patched image and ship it; running
|
||||
instances are disposable.
|
||||
- **In-place patch** — best for one-off patching of an existing
|
||||
container you don't want to touch (complex `docker run` invocation,
|
||||
custom network, volume layout, or a single-node homelab). Modifies
|
||||
the live container's filesystem; fully revertible via `uninstall`.
|
||||
|
||||
Both use the same `docker/wrapper.sh` and the same
|
||||
`LD_PRELOAD`-on-the-PMS-exec mechanism — only the injection plumbing
|
||||
differs.
|
||||
|
||||
### Limitations
|
||||
|
||||
- x86_64 Linux hosts only (the `.so` is `x86_64-linux-musl`).
|
||||
- The host must be able to `docker exec -u root` into the container.
|
||||
On rootless Docker setups this should still work since the
|
||||
container's `root` is mapped to the host's user.
|
||||
- If the upstream image's s6 layout changes (plexinc or LSIO move the
|
||||
service), the script's auto-detection will fail with a clear error.
|
||||
The Dockerfile flow would fail its sanity `RUN` at build time with
|
||||
the same kind of error.
|
||||
- The patch is **per-container**, not per-image. Re-creating the
|
||||
container (e.g., `docker rm` + `docker run` of the upstream image)
|
||||
reverts the patch; you have to re-run `plex-docker-patch.sh install`.
|
||||
Use the Dockerfile flow for image-baked persistence.
|
||||
|
||||
## Uninstall
|
||||
|
||||
Two ways to remove the patch, depending on which flow you used.
|
||||
|
||||
### Dockerfile-built image
|
||||
|
||||
The patch lives entirely in the image — there is no host state to undo
|
||||
beyond the host's PMS data volumes, which the patch never touches.
|
||||
|
||||
```bash
|
||||
# Stop and remove the container.
|
||||
docker rm -f plex
|
||||
|
||||
# Remove the image.
|
||||
docker rmi plex-crack:plexinc # or plex-crack:lsio
|
||||
```
|
||||
|
||||
To revert to the unmodified upstream image:
|
||||
|
||||
```bash
|
||||
docker pull plexinc/pms-docker:latest
|
||||
docker run -d --name plex --network=host \
|
||||
-v /srv/plex/config:/config \
|
||||
-v /srv/plex/data:/data \
|
||||
plexinc/pms-docker:latest
|
||||
```
|
||||
|
||||
The original s6 service `run` file is preserved in the patched image as
|
||||
`/etc/s6-overlay/s6-rc.d/svc-plex/run.orig` — it can be recovered by
|
||||
rebuilding the patched image without the patch layer (just use the
|
||||
upstream image directly).
|
||||
|
||||
### In-place patch
|
||||
|
||||
```bash
|
||||
# Restore the s6 run file from its .orig and restart the container.
|
||||
./docker/plex-docker-patch.sh uninstall plex
|
||||
|
||||
# Optional cleanup of the .so, wrapper, and .orig backup:
|
||||
docker exec -u root plex rm -f \
|
||||
/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so \
|
||||
/usr/lib/plexmediaserver/plex-crack-wrapper.sh \
|
||||
/etc/s6-overlay/s6-rc.d/svc-plex/run.orig
|
||||
```
|
||||
|
||||
To verify the patch is gone:
|
||||
|
||||
```bash
|
||||
./docker/plex-docker-patch.sh status plex
|
||||
# -> "PATCH IS NOT ACTIVE (s6 run file does not point to the wrapper)."
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `error while loading shared libraries: ...musl...` on PMS start
|
||||
|
||||
A glibc `.so` accidentally got into the image. The build's ABI sanity gate
|
||||
(`grep -E "UND .*(__isoc23_|_chk$|arc4random|_dl_find_object)"` in
|
||||
`build.sh`) should have caught this — if you bypassed `build.sh` and copied
|
||||
in a pre-built artifact, rebuild via the Dockerfile so the gate runs.
|
||||
|
||||
### `patchelf: ...` warnings during build
|
||||
|
||||
We don't use `patchelf`. If you see this, you're running a non-canonical
|
||||
build flow — see [`BUILD.md`](BUILD.md) for why `patchelf` corrupts the
|
||||
PIE under musl's loader.
|
||||
|
||||
### `Plex Media Server` exits 127 immediately
|
||||
|
||||
The s6 service exec'd PMS but PMS can't find a library. Usually this is
|
||||
either the `.so` ABI mismatch (rebuild) or the wrapper is in the wrong
|
||||
location. Check:
|
||||
|
||||
```bash
|
||||
docker exec plex ls -l /usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
|
||||
docker exec plex ls -l /usr/lib/plexmediaserver/plex-crack-wrapper.sh
|
||||
docker exec plex cat /etc/s6-overlay/s6-rc.d/svc-plex/run
|
||||
```
|
||||
|
||||
The last command should print the wrapper path (with or without
|
||||
`s6-setuidgid abc` depending on which image you built).
|
||||
|
||||
### Feature bitset is not all 1s after `readbitset.py`
|
||||
|
||||
The hook didn't fire. Likely cause: PMS has a version the signature
|
||||
patterns in `src/hook.cpp` don't match. Re-verify the signature patterns
|
||||
against the new PMS binary; the project's RE notes are in
|
||||
[`../AGENTS.md`](../AGENTS.md). After fixing the patterns, rebuild with
|
||||
`--no-cache` so the builder stage re-runs:
|
||||
|
||||
```bash
|
||||
docker build --no-cache -f docker/Dockerfile.plexinc -t plex-crack:plexinc .
|
||||
```
|
||||
|
||||
### Container restarts in a loop, s6 logs `script /etc/s6-overlay/s6-rc.d/svc-plex/run exited 1`
|
||||
|
||||
The wrapper is missing or not executable, or the `.so` failed its ABI
|
||||
gate. Check `docker logs plex` and the in-container paths listed above.
|
||||
|
||||
### LSIO: /config or /data ends up owned by root
|
||||
|
||||
PUID/PGID weren't set, or were set to 0. LSIO's perms init runs once and
|
||||
chowns to the configured UID/GID; if PMS is later started as root (which
|
||||
it would be if the s6-setuidgid was dropped from the run file), the
|
||||
subsequent writes will be root-owned. The Dockerfile preserves
|
||||
`s6-setuidgid abc` exactly so this shouldn't happen with a clean build —
|
||||
rebuild without modifying the `cat > "${RUN_SCRIPT}"` block.
|
||||
|
||||
## Notes
|
||||
|
||||
- x86_64 Linux only — the build emits `x86_64-linux-musl` and the base
|
||||
images are amd64. There is no arm64/v3 PMS Docker image today.
|
||||
- Both Dockerfiles emit OCI labels: `plex_patch.base` (the upstream image
|
||||
reference) and `plex_patch.version` (a build-time stamp, default `dev`).
|
||||
- For intro/credit detection: Settings → Library → Marker source → "local
|
||||
detection only".
|
||||
- The patch has no effect on PMS's network behavior, media transcoding,
|
||||
or library scanning — it only forces the in-memory feature bitset on
|
||||
after PMS applies its MyPlex feature list.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Windows patching guide
|
||||
|
||||
Top-level index for the Windows x64 Plex Media Server work in this repo.
|
||||
|
||||
## What exists
|
||||
|
||||
The Windows implementation lives in [`../windows/`](../windows/) and contains:
|
||||
|
||||
- `plex_patch.dll` — injected payload that forces the feature bitset on
|
||||
- `plex_inject.exe` — injector that attaches to or launches `Plex Media Server.exe`
|
||||
- a small engine split into:
|
||||
- `pe_image.h` — PE parsing, section bounds, RVA resolution, version guard
|
||||
- `sig_scan.h` — byte-pattern scanning helpers
|
||||
- `trampoline.h` — Zydis-based x64 inline hook engine
|
||||
- `feature_patch.h` — bitset forcing, populator hook, guard thread
|
||||
|
||||
## Current target
|
||||
|
||||
Pinned to:
|
||||
|
||||
- **Plex Media Server** `1.43.2.10687-563d026ea`
|
||||
- **Platform**: Windows x64
|
||||
|
||||
The patch uses a **version guard** and **bounds-checked RVA resolution** before
|
||||
it touches the target process.
|
||||
|
||||
## Build
|
||||
|
||||
From `windows/`:
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
```
|
||||
|
||||
This produces:
|
||||
|
||||
- `build\plex_patch.dll`
|
||||
- `build\plex_inject.exe`
|
||||
|
||||
The build reuses the repo's vendored Zydis and Zig toolchain conventions.
|
||||
|
||||
## Run
|
||||
|
||||
Attach to a running PMS:
|
||||
|
||||
```bat
|
||||
build\plex_inject.exe
|
||||
```
|
||||
|
||||
Or launch PMS through the injector:
|
||||
|
||||
```bat
|
||||
build\plex_inject.exe --launch "C:\Program Files\Plex\Plex Media Server\Plex Media Server.exe"
|
||||
```
|
||||
|
||||
## Where to read next
|
||||
|
||||
- Detailed Windows architecture and usage: [`../windows/README.md`](../windows/README.md)
|
||||
- Linux build/install flow: [`BUILD.md`](BUILD.md)
|
||||
- Repo overview: [`../README.md`](../README.md)
|
||||
|
||||
## Notes
|
||||
|
||||
- `windows/build/` artifacts are intentionally git-ignored.
|
||||
- The original incompatible `.i64` was left untouched; the working Windows IDB
|
||||
was rebuilt in a writable analysis directory during RE.
|
||||
@@ -0,0 +1,82 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/mman.h>
|
||||
|
||||
static uint64_t hook_fn(uintptr_t user, const char** feature)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int get_dottext_info(uintptr_t* start_out, uintptr_t* end_out)
|
||||
{
|
||||
FILE* file = fopen("/proc/self/maps", "r");
|
||||
if (!file) return 0;
|
||||
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof(line), file))
|
||||
{
|
||||
if (strstr(line, "Plex Media Server") && strstr(line, "r-xp"))
|
||||
{
|
||||
char* endptr;
|
||||
*start_out = strtoull(line, &endptr, 16);
|
||||
if (*endptr != '-') continue;
|
||||
*end_out = strtoull(endptr + 1, NULL, 16);
|
||||
fclose(file);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
fclose(file);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static uintptr_t sig_scan(uintptr_t start, uintptr_t end, const uint8_t* pattern, const uint8_t* mask, int pat_len)
|
||||
{
|
||||
for (uintptr_t i = start; i <= end - (uintptr_t)pat_len; i++)
|
||||
{
|
||||
int match = 1;
|
||||
for (int x = 0; x < pat_len; x++)
|
||||
{
|
||||
if (!mask[x]) continue;
|
||||
if (*(uint8_t*)(i + x) != pattern[x]) { match = 0; break; }
|
||||
}
|
||||
if (match) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void do_hook(uintptr_t target)
|
||||
{
|
||||
if (!target) return;
|
||||
|
||||
uint8_t shellcode[] = {
|
||||
0x48, 0xB8,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x50,
|
||||
0xC3
|
||||
};
|
||||
*(uint64_t*)(&shellcode[2]) = (uint64_t)&hook_fn;
|
||||
|
||||
long page_size = 4096;
|
||||
uintptr_t page_start = target & ~(page_size - 1);
|
||||
mprotect((void*)page_start, page_size, PROT_READ | PROT_WRITE | PROT_EXEC);
|
||||
memcpy((void*)target, shellcode, sizeof(shellcode));
|
||||
mprotect((void*)page_start, page_size, PROT_READ | PROT_EXEC);
|
||||
}
|
||||
|
||||
void __attribute__((constructor)) init_so(void)
|
||||
{
|
||||
uintptr_t start, end;
|
||||
if (!get_dottext_info(&start, &end)) return;
|
||||
|
||||
uint8_t pat[] = { 0xE8, 0x00, 0x00, 0x00, 0x00, 0x86, 0x43 };
|
||||
uint8_t mask[] = { 1, 0, 0, 0, 0, 1, 1 };
|
||||
uintptr_t found = sig_scan(start, end, pat, mask, 7);
|
||||
if (found) {
|
||||
int32_t rel = *(int32_t*)(found + 1);
|
||||
uintptr_t call_target = found + 5 + rel;
|
||||
do_hook(call_target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
@@ -0,0 +1,178 @@
|
||||
<!-- 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).
|
||||
@@ -0,0 +1,5 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
@@ -0,0 +1,39 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "plex-relay"
|
||||
version = "0.2.0"
|
||||
description = "Reverse-engineered reimplementation of the Plex Media Server RelayController (educational / RE use)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
authors = [{ name = "Plex_Patch RE notes" }]
|
||||
dependencies = [] # stdlib only
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=7"]
|
||||
|
||||
[project.scripts]
|
||||
plex-relay = "plex_relay.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
plex_relay = ["py.typed"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_unused_ignores = true
|
||||
warn_redundant_casts = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
@@ -0,0 +1,46 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""plex_relay -- a reverse-engineered reimplementation of the Plex Media Server
|
||||
``RelayController`` (Linux x86-64, build 1.43.2.10687).
|
||||
|
||||
Layering (high cohesion, dependency inversion top-to-bottom):
|
||||
|
||||
cli composition root / argument parsing
|
||||
controller orchestration; depends only on the protocols below
|
||||
store HostKeyTrust: composes the key provider + the disk cache
|
||||
keys relay key acquisition (HTTPS fetch + TTL cache)
|
||||
cache relayHostKey.txt persistence (atomic, 0o600)
|
||||
tunnel ssh argv (pure) + SSH_ASKPASS + child-process tunnel
|
||||
models immutable domain values + parsing (no I/O)
|
||||
config immutable, validated configuration
|
||||
errors one rooted exception hierarchy
|
||||
|
||||
Binary provenance of the key symbols is documented in each module and in
|
||||
``README.md``. Ships no Plex code; authenticates to nothing on its own.
|
||||
"""
|
||||
from .config import RelayConfig
|
||||
from .controller import RelayController
|
||||
from .errors import (
|
||||
ConfigError,
|
||||
HostKeyCacheError,
|
||||
RelayError,
|
||||
RelayKeyError,
|
||||
TunnelError,
|
||||
)
|
||||
from .models import HostKey, RelayKey, known_hosts_endpoint, parse_relay_pub
|
||||
from .tunnel import build_ssh_argv
|
||||
|
||||
__all__ = [
|
||||
"RelayConfig",
|
||||
"RelayController",
|
||||
"HostKey",
|
||||
"RelayKey",
|
||||
"known_hosts_endpoint",
|
||||
"parse_relay_pub",
|
||||
"build_ssh_argv",
|
||||
"RelayError",
|
||||
"ConfigError",
|
||||
"RelayKeyError",
|
||||
"HostKeyCacheError",
|
||||
"TunnelError",
|
||||
]
|
||||
__version__ = "0.2.0"
|
||||
@@ -0,0 +1,97 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""On-disk known_hosts cache (``relayHostKey.txt``) -- file I/O only.
|
||||
|
||||
This module owns the file format and the filesystem; it knows nothing about
|
||||
HTTP, TTLs, or ssh. The file doubles as PMS's cache and the OpenSSH
|
||||
``UserKnownHostsFile`` (``#`` lines are valid known_hosts comments).
|
||||
|
||||
Writes are atomic (write-temp-then-rename) and the file is mode ``0o600`` --
|
||||
it pins the keys ssh will trust, so it must not be world-writable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from .errors import HostKeyCacheError
|
||||
from .models import HostKey
|
||||
|
||||
log = logging.getLogger("plex_relay.cache")
|
||||
|
||||
|
||||
def parse_known_hosts(lines: Iterable[str]) -> dict[str, HostKey]:
|
||||
"""Parse ``# <endpoint>`` + ``<host> <keytype> <keydata>`` line pairs.
|
||||
|
||||
:raises HostKeyCacheError: on any structural violation (mirrors the binary,
|
||||
which discards and rebuilds a malformed file).
|
||||
"""
|
||||
items = [ln.rstrip("\n") for ln in lines if ln.strip()]
|
||||
entries: dict[str, HostKey] = {}
|
||||
i = 0
|
||||
while i < len(items):
|
||||
comment = items[i]
|
||||
if not comment.startswith("#"):
|
||||
raise HostKeyCacheError(f"expected '# <endpoint>' marker, got {comment!r}")
|
||||
if i + 1 >= len(items):
|
||||
raise HostKeyCacheError("comment marker without a following data line")
|
||||
tokens = items[i + 1].split()
|
||||
if len(tokens) != 3:
|
||||
raise HostKeyCacheError(f"data line part count incorrect: {items[i + 1]!r}")
|
||||
endpoint = comment[1:].strip() or tokens[0]
|
||||
host, keytype, keydata = tokens
|
||||
entries[endpoint] = HostKey(host, keytype, keydata)
|
||||
i += 2
|
||||
return entries
|
||||
|
||||
|
||||
class HostKeyCache:
|
||||
"""Load/store relay host keys from a single known_hosts-format file."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = Path(path)
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._path
|
||||
|
||||
def load(self) -> dict[str, HostKey]:
|
||||
"""Return cached entries; a malformed file is dropped and rebuilt empty."""
|
||||
if not self._path.exists():
|
||||
return {}
|
||||
try:
|
||||
text = self._path.read_text("utf-8", "replace")
|
||||
except OSError as exc:
|
||||
raise HostKeyCacheError(f"cannot read {self._path}: {exc}") from exc
|
||||
try:
|
||||
entries = parse_known_hosts(text.splitlines())
|
||||
except HostKeyCacheError as exc:
|
||||
log.warning("host key file malformed, rebuilding: %s", exc)
|
||||
self.save({})
|
||||
return {}
|
||||
log.info("read %d cached host key entries", len(entries))
|
||||
return entries
|
||||
|
||||
def save(self, entries: Mapping[str, HostKey]) -> None:
|
||||
"""Atomically write ``entries`` to the cache file with mode 0o600."""
|
||||
try:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
blocks = "".join(entries[k].cache_block() for k in sorted(entries))
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=self._path.parent, prefix=self._path.name + ".", suffix=".tmp"
|
||||
)
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
os.write(fd, blocks.encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
||||
os.replace(tmp, self._path)
|
||||
except OSError as exc:
|
||||
raise HostKeyCacheError(f"cannot write {self._path}: {exc}") from exc
|
||||
|
||||
|
||||
__all__ = ["HostKeyCache", "parse_known_hosts"]
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Command-line composition root.
|
||||
|
||||
plex-relay show --host H [--port 443] ... # resolve key + print ssh argv (no spawn)
|
||||
plex-relay connect --host H [--port 443] ... # establish and hold the tunnel
|
||||
|
||||
The token is read from ``$PLEX_RELAY_TOKEN`` by default so it never appears in
|
||||
the process list; ``--token`` overrides. ``show`` is a safe dry run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DEFAULT_LOCAL_HOST, DEFAULT_LOCAL_PORT, DEFAULT_RELAY_KEY_URL, RelayConfig
|
||||
from .errors import RelayError
|
||||
from .keys import HttpsRelayKeyFetcher, RelayKeyProvider
|
||||
from .models import known_hosts_endpoint
|
||||
from .tunnel import build_ssh_argv
|
||||
|
||||
log = logging.getLogger("plex_relay.cli")
|
||||
|
||||
|
||||
def _add_common(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument("--host", required=True, help="relay host to dial")
|
||||
p.add_argument("--port", type=int, default=443, help="relay SSH port (default 443)")
|
||||
p.add_argument("--user", required=True, help="relay SSH login (PMS: MyPlex identity)")
|
||||
p.add_argument("--token", default=os.environ.get("PLEX_RELAY_TOKEN", ""),
|
||||
help="relay password (default: $PLEX_RELAY_TOKEN)")
|
||||
p.add_argument("--local-host", default=DEFAULT_LOCAL_HOST)
|
||||
p.add_argument("--local-port", type=int, default=DEFAULT_LOCAL_PORT)
|
||||
p.add_argument("--data-dir", type=Path, default=Path.home() / ".plex_relay")
|
||||
p.add_argument("--key-url", default=DEFAULT_RELAY_KEY_URL)
|
||||
p.add_argument("--insecure-key-url", action="store_true",
|
||||
help="permit a non-HTTPS relay key URL (e.g. file:// for testing)")
|
||||
p.add_argument("--ssh", default="ssh", help="ssh binary")
|
||||
|
||||
|
||||
def _config(args: argparse.Namespace) -> RelayConfig:
|
||||
return RelayConfig(
|
||||
token=args.token or "dry-run",
|
||||
ssh_user=args.user,
|
||||
local_host=args.local_host,
|
||||
local_port=args.local_port,
|
||||
relay_key_url=args.key_url,
|
||||
data_dir=args.data_dir,
|
||||
ssh_binary=args.ssh,
|
||||
allow_insecure_key_url=args.insecure_key_url,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_show(args: argparse.Namespace) -> int:
|
||||
cfg = _config(args)
|
||||
provider = RelayKeyProvider(
|
||||
cfg.relay_key_url,
|
||||
cfg.key_ttl_seconds,
|
||||
fetcher=HttpsRelayKeyFetcher(
|
||||
timeout=cfg.connect_timeout_seconds, allow_insecure=cfg.allow_insecure_key_url
|
||||
),
|
||||
)
|
||||
endpoint = known_hosts_endpoint(args.host, args.port)
|
||||
try:
|
||||
key = provider.get()
|
||||
print(f"relay key : {key.keytype} {key.keydata[:24]}... (from {cfg.relay_key_url})")
|
||||
print(f"known_hosts : {endpoint} {key.keytype} {key.keydata[:24]}...")
|
||||
except RelayError as exc:
|
||||
print(f"relay key : <unavailable: {exc}>")
|
||||
argv = build_ssh_argv(cfg, args.host, args.port, cfg.cache_path)
|
||||
print("ssh argv :\n " + " ".join(argv))
|
||||
print("env : PLEXTOKEN=*** SSH_ASKPASS=<helper> SSH_ASKPASS_REQUIRE=force")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_connect(args: argparse.Namespace) -> int:
|
||||
if not args.token:
|
||||
print("error: no token (set $PLEX_RELAY_TOKEN or --token)", file=sys.stderr)
|
||||
return 2
|
||||
from .controller import RelayController
|
||||
|
||||
cfg = _config(args)
|
||||
stop = threading.Event()
|
||||
with RelayController.from_config(cfg) as ctrl:
|
||||
if not ctrl.start_relay(args.host, args.port):
|
||||
print("error: relay did not start (gating, already active, or failure)", file=sys.stderr)
|
||||
return 1
|
||||
print(f"relay up: {args.host}:{args.port} -> {cfg.local_host}:{cfg.local_port} (Ctrl-C to stop)")
|
||||
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||
while not stop.is_set():
|
||||
stop.wait(2.0)
|
||||
if not ctrl.active_hosts:
|
||||
print("relay connection ended", file=sys.stderr)
|
||||
return 1
|
||||
print("relay stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="plex-relay", description=__doc__)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
for name, func, help_ in (("show", _cmd_show, "resolve key + print ssh argv (no spawn)"),
|
||||
("connect", _cmd_connect, "establish and hold the relay tunnel")):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
_add_common(sp)
|
||||
sp.set_defaults(func=func)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
try:
|
||||
return int(args.func(args))
|
||||
except RelayError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Immutable, validated runtime configuration.
|
||||
|
||||
Frozen so it can be shared freely across threads and never mutated behind a
|
||||
collaborator's back. The relay credential is excluded from ``repr`` so it cannot
|
||||
leak into logs or tracebacks.
|
||||
|
||||
Defaults mirror constants observed in ``RelayController_connect`` (``0x12307F2``):
|
||||
relay key URL, the 24h key TTL (``86400000000`` us) and the 300s reaper
|
||||
(``300000000`` us). The reverse-forward target (``0:127.0.0.1:%d``) is the PMS
|
||||
service port, which the binary reads from its own config -- hence configurable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import ConfigError
|
||||
|
||||
DEFAULT_RELAY_KEY_URL = "https://downloads.plex.tv/relay/relay_v1.pub"
|
||||
DEFAULT_KEY_TTL_SECONDS = 86_400.0
|
||||
DEFAULT_REAP_INTERVAL_SECONDS = 300.0
|
||||
DEFAULT_LOCAL_HOST = "127.0.0.1"
|
||||
DEFAULT_LOCAL_PORT = 32400
|
||||
CACHE_FILENAME = "relayHostKey.txt"
|
||||
|
||||
|
||||
def _default_data_dir() -> Path:
|
||||
return Path.home() / ".plex_relay"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RelayConfig:
|
||||
"""Everything :class:`~plex_relay.controller.RelayController` needs to run."""
|
||||
|
||||
token: str = field(repr=False) # SSH password (PLEXTOKEN); never logged
|
||||
ssh_user: str
|
||||
|
||||
local_host: str = DEFAULT_LOCAL_HOST
|
||||
local_port: int = DEFAULT_LOCAL_PORT
|
||||
|
||||
relay_key_url: str = DEFAULT_RELAY_KEY_URL
|
||||
key_ttl_seconds: float = DEFAULT_KEY_TTL_SECONDS
|
||||
reap_interval_seconds: float = DEFAULT_REAP_INTERVAL_SECONDS
|
||||
|
||||
data_dir: Path = field(default_factory=_default_data_dir)
|
||||
ssh_binary: str = "ssh"
|
||||
ssh_interface: str = "tailscale0"
|
||||
|
||||
connect_timeout_seconds: float = 15.0
|
||||
stop_timeout_seconds: float = 5.0
|
||||
allow_insecure_key_url: bool = False
|
||||
|
||||
# startRelay gating, mirroring ServerEventManager_handle_pubsub_event:
|
||||
# signin_state == 4 && PublishServerOnPlexOnlineKey && RelayEnabled
|
||||
signed_in: bool = True
|
||||
published: bool = True
|
||||
relay_enabled: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Frozen dataclass: normalise/validate via object.__setattr__.
|
||||
object.__setattr__(self, "data_dir", Path(self.data_dir))
|
||||
if not self.token:
|
||||
raise ConfigError("token is required (relay SSH password)")
|
||||
if not self.ssh_user:
|
||||
raise ConfigError("ssh_user is required (relay SSH login)")
|
||||
if not 0 < self.local_port < 65536:
|
||||
raise ConfigError(f"local_port out of range: {self.local_port}")
|
||||
for name in ("key_ttl_seconds", "reap_interval_seconds",
|
||||
"connect_timeout_seconds", "stop_timeout_seconds"):
|
||||
if getattr(self, name) <= 0:
|
||||
raise ConfigError(f"{name} must be positive")
|
||||
|
||||
@property
|
||||
def cache_path(self) -> Path:
|
||||
"""``<data_dir>/relayHostKey.txt`` (the binary's ``sub_1231FEE``)."""
|
||||
return self.data_dir / CACHE_FILENAME
|
||||
|
||||
def gating_ok(self) -> bool:
|
||||
"""True iff all three startRelay preconditions hold."""
|
||||
return self.signed_in and self.published and self.relay_enabled
|
||||
|
||||
|
||||
__all__ = ["RelayConfig", "DEFAULT_RELAY_KEY_URL", "DEFAULT_LOCAL_HOST",
|
||||
"DEFAULT_LOCAL_PORT", "CACHE_FILENAME"]
|
||||
@@ -0,0 +1,162 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Relay controller -- the orchestration layer.
|
||||
|
||||
Depends only on abstractions (:class:`HostKeyTrust`, :class:`TunnelFactory`),
|
||||
so the network, disk, and process concerns are all injected and substitutable.
|
||||
:meth:`RelayController.from_config` is the composition root that wires the
|
||||
default adapters together.
|
||||
|
||||
Concurrency: a single ``RLock`` (the binary's ``recursive_mutex``) guards the
|
||||
connection table and the reaper, which runs on a background ``threading.Timer``
|
||||
and reschedules itself only while connections remain.
|
||||
|
||||
Error contract: :meth:`connect` raises :class:`RelayError` on failure (callers
|
||||
decide). :meth:`start_relay` -- the plex.tv event entry point -- is resilient:
|
||||
it logs and returns ``False`` rather than letting an exception escape an event
|
||||
loop, matching PMS's ``ServerEventManager`` behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from .cache import HostKeyCache
|
||||
from .config import RelayConfig
|
||||
from .errors import RelayError
|
||||
from .keys import RelayKeyProvider
|
||||
from .store import HostKeyManager, HostKeyTrust
|
||||
from .tunnel import SubprocessTunnelFactory, Tunnel, TunnelFactory
|
||||
|
||||
log = logging.getLogger("plex_relay.controller")
|
||||
|
||||
|
||||
class RelayController:
|
||||
"""Manages relay tunnels for one server: connect, reap, stop."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: RelayConfig,
|
||||
hostkeys: HostKeyTrust,
|
||||
tunnels: TunnelFactory,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._hostkeys = hostkeys
|
||||
self._tunnels = tunnels
|
||||
self._lock = threading.RLock()
|
||||
self._connections: dict[str, Tunnel] = {}
|
||||
self._reaper: threading.Timer | None = None
|
||||
self._closed = False
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: RelayConfig) -> "RelayController":
|
||||
"""Composition root: wire the default network/disk/process adapters."""
|
||||
provider = RelayKeyProvider(
|
||||
config.relay_key_url,
|
||||
config.key_ttl_seconds,
|
||||
fetcher=_default_fetcher(config),
|
||||
)
|
||||
manager = HostKeyManager(provider, HostKeyCache(config.cache_path))
|
||||
return cls(config, manager, SubprocessTunnelFactory(config))
|
||||
|
||||
# -- entry points ------------------------------------------------------
|
||||
|
||||
def start_relay(self, host: str, port: int = 443) -> bool:
|
||||
"""plex.tv ``startRelay`` handler: gated and resilient."""
|
||||
if not self._config.gating_ok():
|
||||
log.info(
|
||||
"startRelay ignored (signed_in=%s published=%s relay_enabled=%s)",
|
||||
self._config.signed_in, self._config.published, self._config.relay_enabled,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
return self.connect(host, port)
|
||||
except RelayError as exc:
|
||||
log.error("relay to %s failed: %s", host, exc)
|
||||
return False
|
||||
|
||||
def connect(self, host: str, port: int = 443) -> bool:
|
||||
"""Establish a tunnel to ``host``. Returns False if already active.
|
||||
|
||||
:raises RelayError: if the key cannot be obtained or ssh cannot launch.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RelayError("controller is closed")
|
||||
existing = self._connections.get(host)
|
||||
if existing is not None and existing.is_alive():
|
||||
log.info("already have an active relay connection to %s", host)
|
||||
return False
|
||||
self._hostkeys.ensure_trusted(host, port)
|
||||
tunnel = self._tunnels(host, port, self._hostkeys.known_hosts_path)
|
||||
tunnel.start()
|
||||
self._connections[host] = tunnel
|
||||
self._arm_reaper()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Cancel the reaper and tear down every tunnel. Idempotent."""
|
||||
with self._lock:
|
||||
self._closed = True
|
||||
self._cancel_reaper()
|
||||
connections, self._connections = self._connections, {}
|
||||
for tunnel in connections.values():
|
||||
tunnel.stop(self._config.stop_timeout_seconds)
|
||||
|
||||
# -- reaper ------------------------------------------------------------
|
||||
|
||||
def reap_once(self) -> list[str]:
|
||||
"""Drop finished tunnels; return the hosts removed."""
|
||||
with self._lock:
|
||||
dead = [h for h, t in self._connections.items() if not t.is_alive()]
|
||||
stopped = [self._connections.pop(h) for h in dead]
|
||||
for tunnel in stopped:
|
||||
log.info("cleaning up inactive relay connection to %s", tunnel.host)
|
||||
tunnel.stop(self._config.stop_timeout_seconds)
|
||||
return dead
|
||||
|
||||
def _arm_reaper(self) -> None:
|
||||
if self._reaper is None and self._connections and not self._closed:
|
||||
self._schedule_reaper()
|
||||
|
||||
def _schedule_reaper(self) -> None:
|
||||
timer = threading.Timer(self._config.reap_interval_seconds, self._reaper_tick)
|
||||
timer.daemon = True
|
||||
self._reaper = timer
|
||||
timer.start()
|
||||
|
||||
def _reaper_tick(self) -> None:
|
||||
self.reap_once()
|
||||
with self._lock:
|
||||
self._reaper = None
|
||||
if self._connections and not self._closed:
|
||||
self._schedule_reaper()
|
||||
|
||||
def _cancel_reaper(self) -> None:
|
||||
if self._reaper is not None:
|
||||
self._reaper.cancel()
|
||||
self._reaper = None
|
||||
|
||||
# -- introspection -----------------------------------------------------
|
||||
|
||||
@property
|
||||
def active_hosts(self) -> list[str]:
|
||||
with self._lock:
|
||||
return sorted(h for h, t in self._connections.items() if t.is_alive())
|
||||
|
||||
def __enter__(self) -> "RelayController":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.stop()
|
||||
|
||||
|
||||
def _default_fetcher(config: RelayConfig):
|
||||
from .keys import HttpsRelayKeyFetcher
|
||||
|
||||
return HttpsRelayKeyFetcher(
|
||||
timeout=config.connect_timeout_seconds,
|
||||
allow_insecure=config.allow_insecure_key_url,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RelayController"]
|
||||
@@ -0,0 +1,38 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Exception hierarchy for :mod:`plex_relay`.
|
||||
|
||||
A single rooted hierarchy lets callers catch the whole subsystem
|
||||
(``except RelayError``) or a specific failure mode, and keeps adapter-specific
|
||||
exceptions (``urllib``, ``OSError``, ``subprocess``) from leaking across module
|
||||
boundaries.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class RelayError(Exception):
|
||||
"""Base class for every error raised by this package."""
|
||||
|
||||
|
||||
class ConfigError(RelayError):
|
||||
"""Invalid configuration (bad port, empty credential, ...)."""
|
||||
|
||||
|
||||
class RelayKeyError(RelayError):
|
||||
"""The relay host key could not be fetched or parsed."""
|
||||
|
||||
|
||||
class HostKeyCacheError(RelayError):
|
||||
"""The on-disk known_hosts cache is unreadable or malformed."""
|
||||
|
||||
|
||||
class TunnelError(RelayError):
|
||||
"""The ssh relay tunnel could not be launched."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RelayError",
|
||||
"ConfigError",
|
||||
"RelayKeyError",
|
||||
"HostKeyCacheError",
|
||||
"TunnelError",
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Relay host-key acquisition: fetch ``relay_v1.pub`` and cache it with a TTL.
|
||||
|
||||
Separated from on-disk caching (:mod:`plex_relay.cache`) so the network policy
|
||||
(HTTPS-only, byte-capped, time-limited) and the freshness policy (TTL) live in
|
||||
one cohesive place and can be swapped wholesale in tests via the injected
|
||||
``fetcher``/``clock`` seams.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from .errors import RelayKeyError
|
||||
from .models import RelayKey, parse_relay_pub
|
||||
|
||||
log = logging.getLogger("plex_relay.keys")
|
||||
|
||||
#: Fetches the raw body of a relay-key URL. The seam that tests stub.
|
||||
Fetcher = Callable[[str], str]
|
||||
#: Monotonic time source (seconds). Monotonic so TTLs survive wall-clock jumps.
|
||||
Clock = Callable[[], float]
|
||||
|
||||
_MAX_KEY_BYTES = 64 * 1024 # a host key is a few hundred bytes; cap to bound I/O
|
||||
|
||||
|
||||
class _Opener(Protocol):
|
||||
def __call__(self, url: str, timeout: float): ... # pragma: no cover
|
||||
|
||||
|
||||
class HttpsRelayKeyFetcher:
|
||||
"""Default fetcher: HTTPS-only, time-limited, response-size-capped.
|
||||
|
||||
Hardened against the obvious abuse of a configurable URL: non-HTTPS schemes
|
||||
are refused unless explicitly allowed, the read is bounded, and every
|
||||
transport failure is normalised to :class:`RelayKeyError`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
timeout: float = 15.0,
|
||||
max_bytes: int = _MAX_KEY_BYTES,
|
||||
allow_insecure: bool = False,
|
||||
opener: _Opener = urllib.request.urlopen,
|
||||
) -> None:
|
||||
self._timeout = timeout
|
||||
self._max_bytes = max_bytes
|
||||
self._allow_insecure = allow_insecure
|
||||
self._opener = opener
|
||||
|
||||
def __call__(self, url: str) -> str:
|
||||
scheme = urllib.parse.urlsplit(url).scheme.lower()
|
||||
if scheme != "https" and not (self._allow_insecure and scheme in ("http", "file")):
|
||||
raise RelayKeyError(f"refusing non-HTTPS relay key URL: {url!r}")
|
||||
try:
|
||||
with self._opener(url, timeout=self._timeout) as resp:
|
||||
data = resp.read(self._max_bytes + 1)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise RelayKeyError(f"failed to fetch relay key from {url!r}: {exc}") from exc
|
||||
if len(data) > self._max_bytes:
|
||||
raise RelayKeyError(f"relay key response exceeds {self._max_bytes} bytes")
|
||||
return data.decode("utf-8", "replace")
|
||||
|
||||
|
||||
class RelayKeyProvider:
|
||||
"""Provides the relay :class:`RelayKey`, refreshing past a TTL.
|
||||
|
||||
Thread-safe: concurrent ``get()`` calls serialise on a lock so the key is
|
||||
fetched at most once per TTL window even under contention.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
ttl_seconds: float,
|
||||
fetcher: Fetcher | None = None,
|
||||
clock: Clock = time.monotonic,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._ttl = ttl_seconds
|
||||
self._fetch = fetcher or HttpsRelayKeyFetcher()
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._key: RelayKey | None = None
|
||||
self._fetched_at = 0.0
|
||||
|
||||
def get(self, *, force: bool = False) -> RelayKey:
|
||||
"""Return the relay key, fetching only when stale or ``force``."""
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
if not force and self._key is not None and (now - self._fetched_at) < self._ttl:
|
||||
log.debug("relay key reused (age %.0fs)", now - self._fetched_at)
|
||||
return self._key
|
||||
key = parse_relay_pub(self._fetch(self._url))
|
||||
self._key, self._fetched_at = key, now
|
||||
log.info("relay key refreshed from %s", self._url)
|
||||
return key
|
||||
|
||||
|
||||
__all__ = ["Fetcher", "Clock", "HttpsRelayKeyFetcher", "RelayKeyProvider"]
|
||||
@@ -0,0 +1,110 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Pure domain model: value objects and parsing, no I/O.
|
||||
|
||||
Everything here is deterministic and side-effect free, so it is trivially
|
||||
testable and safe to share across threads (all types are immutable).
|
||||
|
||||
Provenance: in ``Plex Media Server`` 1.43.2.10687, ``RelayController_connect``
|
||||
(``0x12307F2``) downloads ``relay_v1.pub``, splits it into exactly three
|
||||
whitespace tokens (rejecting otherwise -- the "part count incorrect" log), and
|
||||
writes a per-endpoint OpenSSH known_hosts line ``[host]:443 <keytype> <keydata>``
|
||||
into ``relayHostKey.txt``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .errors import RelayKeyError
|
||||
|
||||
# Key types OpenSSH recognises. Used to disambiguate a public-key line
|
||||
# ("keytype keydata comment") from a known_hosts line ("host keytype keydata").
|
||||
_KEY_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com",
|
||||
"ssh-rsa-cert-v01@openssh.com",
|
||||
"ssh-ed25519-cert-v01@openssh.com",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def known_hosts_endpoint(host: str, port: int = 443) -> str:
|
||||
"""Return the OpenSSH known_hosts host field for ``host:port``.
|
||||
|
||||
OpenSSH uses bracket notation for any non-default port. The binary hardcodes
|
||||
``[host]:443``; keying by the actual port keeps the entry valid for relays on
|
||||
other ports while remaining identical at 443.
|
||||
"""
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise RelayKeyError("empty relay host")
|
||||
return host if port == 22 else f"[{host}]:{port}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RelayKey:
|
||||
"""An SSH host key: an algorithm and its base64-encoded blob."""
|
||||
|
||||
keytype: str
|
||||
keydata: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HostKey:
|
||||
"""A single OpenSSH known_hosts entry for a relay endpoint."""
|
||||
|
||||
endpoint: str
|
||||
keytype: str
|
||||
keydata: str
|
||||
|
||||
@classmethod
|
||||
def for_endpoint(cls, endpoint: str, key: RelayKey) -> "HostKey":
|
||||
return cls(endpoint=endpoint, keytype=key.keytype, keydata=key.keydata)
|
||||
|
||||
@property
|
||||
def key(self) -> RelayKey:
|
||||
return RelayKey(self.keytype, self.keydata)
|
||||
|
||||
def known_hosts_line(self) -> str:
|
||||
"""The line OpenSSH consumes: ``<endpoint> <keytype> <keydata>``."""
|
||||
return f"{self.endpoint} {self.keytype} {self.keydata}"
|
||||
|
||||
def cache_block(self) -> str:
|
||||
"""PMS ``relayHostKey.txt`` representation: ``# <endpoint>`` + data line."""
|
||||
return f"# {self.endpoint}\n{self.known_hosts_line()}\n"
|
||||
|
||||
|
||||
def parse_relay_pub(body: str) -> RelayKey:
|
||||
"""Parse a ``relay_v1.pub`` payload into a :class:`RelayKey`.
|
||||
|
||||
Accepts both the OpenSSH public-key form (``keytype keydata comment``) and
|
||||
the known_hosts form (``host keytype keydata``), disambiguated by which token
|
||||
is a recognised key type.
|
||||
|
||||
:raises RelayKeyError: if no usable key line is present (mirrors the binary's
|
||||
"part count incorrect" rejection).
|
||||
"""
|
||||
for raw in body.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
tokens = line.split()
|
||||
if len(tokens) < 3:
|
||||
raise RelayKeyError(
|
||||
f"relay key: part count incorrect ({len(tokens)} tokens)"
|
||||
)
|
||||
if tokens[0] in _KEY_TYPES: # keytype keydata comment
|
||||
return RelayKey(tokens[0], tokens[1])
|
||||
if tokens[1] in _KEY_TYPES: # host keytype keydata
|
||||
return RelayKey(tokens[1], tokens[2])
|
||||
raise RelayKeyError("relay key: no recognised key type in payload")
|
||||
raise RelayKeyError("relay key: payload contained no key line")
|
||||
|
||||
|
||||
__all__ = ["RelayKey", "HostKey", "known_hosts_endpoint", "parse_relay_pub"]
|
||||
Whitespace-only changes.
@@ -0,0 +1,65 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Host-key trust management -- composes the key provider and the disk cache.
|
||||
|
||||
This is the seam the controller depends on (the :class:`HostKeyTrust` protocol):
|
||||
"make sure ssh will trust this relay endpoint, and tell me which file to hand
|
||||
it." It owns no I/O of its own; it wires together :class:`RelayKeyProvider`
|
||||
(network + TTL) and :class:`HostKeyCache` (disk), keeping each collaborator
|
||||
single-purpose and independently testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from .cache import HostKeyCache
|
||||
from .keys import RelayKeyProvider
|
||||
from .models import HostKey, known_hosts_endpoint
|
||||
|
||||
log = logging.getLogger("plex_relay.store")
|
||||
|
||||
|
||||
class HostKeyTrust(Protocol):
|
||||
"""What the controller requires to make a relay endpoint trusted by ssh."""
|
||||
|
||||
def ensure_trusted(self, host: str, port: int = 443, *, force: bool = False) -> HostKey: ...
|
||||
|
||||
@property
|
||||
def known_hosts_path(self) -> Path: ...
|
||||
|
||||
|
||||
class HostKeyManager:
|
||||
"""Default :class:`HostKeyTrust`: refresh key, pin endpoint, persist on change."""
|
||||
|
||||
def __init__(self, provider: RelayKeyProvider, cache: HostKeyCache) -> None:
|
||||
self._provider = provider
|
||||
self._cache = cache
|
||||
self._lock = threading.Lock()
|
||||
self._entries: dict[str, HostKey] = cache.load()
|
||||
|
||||
@property
|
||||
def known_hosts_path(self) -> Path:
|
||||
return self._cache.path
|
||||
|
||||
@property
|
||||
def entries(self) -> dict[str, HostKey]:
|
||||
with self._lock:
|
||||
return dict(self._entries)
|
||||
|
||||
def ensure_trusted(self, host: str, port: int = 443, *, force: bool = False) -> HostKey:
|
||||
"""Ensure ``relayHostKey.txt`` trusts ``host:port``; persist iff changed."""
|
||||
key = self._provider.get(force=force)
|
||||
endpoint = known_hosts_endpoint(host, port)
|
||||
entry = HostKey.for_endpoint(endpoint, key)
|
||||
with self._lock:
|
||||
if self._entries.get(endpoint) == entry:
|
||||
return entry
|
||||
self._entries[endpoint] = entry
|
||||
self._cache.save(self._entries)
|
||||
log.info("pinned relay host key for %s", endpoint)
|
||||
return entry
|
||||
|
||||
|
||||
__all__ = ["HostKeyTrust", "HostKeyManager"]
|
||||
@@ -0,0 +1,207 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""The relay tunnel adapter: build the ssh argv and run it as a child process.
|
||||
|
||||
``build_ssh_argv`` is a pure function (the byte-for-byte reproduction of the
|
||||
argv ``RelayController_connect`` assembles), kept separate from process control
|
||||
so it is testable without spawning anything.
|
||||
|
||||
Security: the relay credential is delivered to ssh via the ``PLEXTOKEN``
|
||||
environment variable read by a generated ``SSH_ASKPASS`` helper -- it never
|
||||
appears on a command line or in the argv list. The helper is mode ``0o700`` and
|
||||
is removed on stop *and* via a finaliser, so a crash cannot leak it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import weakref
|
||||
from pathlib import Path
|
||||
from typing import Callable, Mapping, Protocol
|
||||
|
||||
from .config import RelayConfig
|
||||
from .errors import TunnelError
|
||||
|
||||
log = logging.getLogger("plex_relay.tunnel")
|
||||
|
||||
_ASKPASS_SCRIPT = "#!/bin/sh\nprintf '%s' \"$PLEXTOKEN\"\n"
|
||||
|
||||
|
||||
class ProcessHandle(Protocol):
|
||||
"""The slice of ``subprocess.Popen`` the tunnel relies on."""
|
||||
|
||||
def poll(self) -> int | None: ...
|
||||
def terminate(self) -> None: ...
|
||||
def kill(self) -> None: ...
|
||||
def wait(self, timeout: float | None = ...) -> int: ...
|
||||
|
||||
|
||||
#: Launches a child process from an argv + environment. The seam tests stub.
|
||||
Spawner = Callable[[list[str], Mapping[str, str]], ProcessHandle]
|
||||
|
||||
|
||||
def build_ssh_argv(
|
||||
config: RelayConfig, host: str, port: int, known_hosts_path: Path
|
||||
) -> list[str]:
|
||||
"""Assemble the ssh reverse-tunnel argv, exactly as the binary does.
|
||||
|
||||
The known_hosts path uses POSIX separators (identical on Linux; OpenSSH
|
||||
accepts forward slashes everywhere).
|
||||
"""
|
||||
return [
|
||||
config.ssh_binary,
|
||||
"-p", str(port),
|
||||
"-N",
|
||||
"-R", f"0:{config.local_host}:{config.local_port}",
|
||||
"-o", f"UserKnownHostsFile={Path(known_hosts_path).as_posix()}",
|
||||
"-o", "LogLevel=VERBOSE",
|
||||
"-o", "PreferredAuthentications=password",
|
||||
"-o", "PubkeyAuthentication=no",
|
||||
"-l", config.ssh_user,
|
||||
"-F", "/dev/null",
|
||||
host,
|
||||
]
|
||||
|
||||
|
||||
class _Askpass:
|
||||
"""A short-lived, self-cleaning SSH_ASKPASS helper script."""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
fd, name = tempfile.mkstemp(prefix="plex_relay_askpass_", suffix=".sh")
|
||||
try:
|
||||
os.write(fd, _ASKPASS_SCRIPT.encode("ascii"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
self.path = Path(name)
|
||||
self.path.chmod(stat.S_IRWXU) # 0o700
|
||||
self._token = token
|
||||
self._finalizer = weakref.finalize(self, _unlink, self.path)
|
||||
|
||||
def env(self, base: Mapping[str, str]) -> dict[str, str]:
|
||||
env = dict(base)
|
||||
env.update(
|
||||
PLEXTOKEN=self._token,
|
||||
SSH_ASKPASS=str(self.path),
|
||||
SSH_ASKPASS_REQUIRE="force",
|
||||
DISPLAY=base.get("DISPLAY", ":0"),
|
||||
)
|
||||
return env
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._finalizer()
|
||||
|
||||
|
||||
def _unlink(path: Path) -> None:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _default_spawner(argv: list[str], env: Mapping[str, str]) -> ProcessHandle:
|
||||
# Detach from the controlling tty so ssh uses SSH_ASKPASS for the password.
|
||||
return subprocess.Popen( # noqa: S603 - argv is fully built; no shell
|
||||
argv,
|
||||
env=dict(env),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
class Tunnel(Protocol):
|
||||
"""Lifecycle of a single relay tunnel."""
|
||||
|
||||
@property
|
||||
def host(self) -> str: ...
|
||||
def start(self) -> None: ...
|
||||
def is_alive(self) -> bool: ...
|
||||
def stop(self, timeout: float | None = ...) -> None: ...
|
||||
|
||||
|
||||
class TunnelFactory(Protocol):
|
||||
def __call__(self, host: str, port: int, known_hosts_path: Path) -> Tunnel: ...
|
||||
|
||||
|
||||
class SubprocessTunnel:
|
||||
"""A relay tunnel backed by a child ``ssh`` process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: RelayConfig,
|
||||
host: str,
|
||||
port: int,
|
||||
known_hosts_path: Path,
|
||||
spawner: Spawner,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._known_hosts = Path(known_hosts_path)
|
||||
self._spawn = spawner
|
||||
self._proc: ProcessHandle | None = None
|
||||
self._askpass: _Askpass | None = None
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def argv(self) -> list[str]:
|
||||
return build_ssh_argv(self._config, self._host, self._port, self._known_hosts)
|
||||
|
||||
def start(self) -> None:
|
||||
if self.is_alive():
|
||||
return
|
||||
askpass = _Askpass(self._config.token)
|
||||
try:
|
||||
log.info("starting relay tunnel to %s:%d", self._host, self._port)
|
||||
self._proc = self._spawn(self.argv, askpass.env(os.environ))
|
||||
except OSError as exc:
|
||||
askpass.cleanup()
|
||||
raise TunnelError(f"failed to launch ssh for {self._host}: {exc}") from exc
|
||||
self._askpass = askpass
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def stop(self, timeout: float | None = 5.0) -> None:
|
||||
proc, self._proc = self._proc, None
|
||||
if proc is not None:
|
||||
log.info("stopping relay tunnel to %s", self._host)
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=timeout)
|
||||
except Exception: # noqa: BLE001 - escalate to kill on any wait failure
|
||||
try:
|
||||
proc.kill()
|
||||
except OSError:
|
||||
pass
|
||||
if self._askpass is not None:
|
||||
self._askpass.cleanup()
|
||||
self._askpass = None
|
||||
|
||||
|
||||
class SubprocessTunnelFactory:
|
||||
"""Default :class:`TunnelFactory` producing :class:`SubprocessTunnel`."""
|
||||
|
||||
def __init__(self, config: RelayConfig, spawner: Spawner | None = None) -> None:
|
||||
self._config = config
|
||||
self._spawner = spawner or _default_spawner
|
||||
|
||||
def __call__(self, host: str, port: int, known_hosts_path: Path) -> SubprocessTunnel:
|
||||
return SubprocessTunnel(self._config, host, port, known_hosts_path, self._spawner)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProcessHandle",
|
||||
"Spawner",
|
||||
"Tunnel",
|
||||
"TunnelFactory",
|
||||
"SubprocessTunnel",
|
||||
"SubprocessTunnelFactory",
|
||||
"build_ssh_argv",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from plex_relay.cache import HostKeyCache, parse_known_hosts
|
||||
from plex_relay.errors import HostKeyCacheError
|
||||
from plex_relay.models import HostKey
|
||||
|
||||
|
||||
def _entries():
|
||||
return {
|
||||
"[1.2.3.4]:443": HostKey("[1.2.3.4]:443", "ssh-ed25519", "AAAAfirst"),
|
||||
"[5.6.7.8]:443": HostKey("[5.6.7.8]:443", "ssh-ed25519", "AAAAsecond"),
|
||||
}
|
||||
|
||||
|
||||
def test_save_load_roundtrip(tmp_path):
|
||||
cache = HostKeyCache(tmp_path / "relayHostKey.txt")
|
||||
cache.save(_entries())
|
||||
loaded = HostKeyCache(tmp_path / "relayHostKey.txt").load()
|
||||
assert loaded == _entries()
|
||||
|
||||
|
||||
def test_save_is_sorted_and_blocked(tmp_path):
|
||||
p = tmp_path / "relayHostKey.txt"
|
||||
HostKeyCache(p).save(_entries())
|
||||
text = p.read_text()
|
||||
assert text.index("[1.2.3.4]") < text.index("[5.6.7.8]")
|
||||
assert "# [1.2.3.4]:443\n[1.2.3.4]:443 ssh-ed25519 AAAAfirst\n" in text
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix", reason="POSIX file modes only")
|
||||
def test_save_is_0600(tmp_path):
|
||||
p = tmp_path / "relayHostKey.txt"
|
||||
HostKeyCache(p).save(_entries())
|
||||
assert stat.S_IMODE(os.stat(p).st_mode) == 0o600
|
||||
|
||||
|
||||
def test_missing_file_loads_empty(tmp_path):
|
||||
assert HostKeyCache(tmp_path / "nope.txt").load() == {}
|
||||
|
||||
|
||||
def test_malformed_file_is_rebuilt_empty(tmp_path):
|
||||
p = tmp_path / "relayHostKey.txt"
|
||||
p.write_text("not a comment\ngarbage line\n")
|
||||
assert HostKeyCache(p).load() == {}
|
||||
assert p.read_text() == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lines", [
|
||||
["data without marker"],
|
||||
["# marker only"],
|
||||
["# marker", "too many tokens here now"],
|
||||
])
|
||||
def test_parse_rejects_malformed(lines):
|
||||
with pytest.raises(HostKeyCacheError):
|
||||
parse_known_hosts(lines)
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from plex_relay.config import RelayConfig
|
||||
from plex_relay.errors import ConfigError
|
||||
|
||||
|
||||
def test_valid_config_and_cache_path(tmp_path):
|
||||
cfg = RelayConfig(token="t", ssh_user="u", data_dir=tmp_path)
|
||||
assert cfg.cache_path == tmp_path / "relayHostKey.txt"
|
||||
assert cfg.gating_ok() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kwargs", [
|
||||
{"token": "", "ssh_user": "u"},
|
||||
{"token": "t", "ssh_user": ""},
|
||||
{"token": "t", "ssh_user": "u", "local_port": 0},
|
||||
{"token": "t", "ssh_user": "u", "local_port": 70000},
|
||||
{"token": "t", "ssh_user": "u", "key_ttl_seconds": 0},
|
||||
{"token": "t", "ssh_user": "u", "reap_interval_seconds": -1},
|
||||
])
|
||||
def test_invalid_config_raises(kwargs):
|
||||
with pytest.raises(ConfigError):
|
||||
RelayConfig(**kwargs)
|
||||
|
||||
|
||||
def test_gating_requires_all_three():
|
||||
base = dict(token="t", ssh_user="u")
|
||||
assert RelayConfig(**base, relay_enabled=False).gating_ok() is False
|
||||
assert RelayConfig(**base, published=False).gating_ok() is False
|
||||
assert RelayConfig(**base, signed_in=False).gating_ok() is False
|
||||
|
||||
|
||||
def test_token_is_not_in_repr():
|
||||
cfg = RelayConfig(token="SUPERSECRET", ssh_user="u")
|
||||
assert "SUPERSECRET" not in repr(cfg)
|
||||
|
||||
|
||||
def test_config_is_frozen():
|
||||
cfg = RelayConfig(token="t", ssh_user="u")
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
cfg.local_port = 1 # type: ignore[misc]
|
||||
@@ -0,0 +1,157 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plex_relay.config import RelayConfig
|
||||
from plex_relay.controller import RelayController
|
||||
from plex_relay.errors import RelayError, TunnelError
|
||||
from plex_relay.models import HostKey, RelayKey, known_hosts_endpoint
|
||||
|
||||
|
||||
class FakeTunnel:
|
||||
def __init__(self, host, fail=False):
|
||||
self._host = host
|
||||
self._fail = fail
|
||||
self.alive = False
|
||||
self.stopped = False
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
return self._host
|
||||
|
||||
def start(self):
|
||||
if self._fail:
|
||||
raise TunnelError("spawn failed")
|
||||
self.alive = True
|
||||
|
||||
def is_alive(self):
|
||||
return self.alive
|
||||
|
||||
def stop(self, timeout=None):
|
||||
self.stopped = True
|
||||
self.alive = False
|
||||
|
||||
|
||||
class FakeFactory:
|
||||
def __init__(self):
|
||||
self.fail = False
|
||||
self.created = []
|
||||
|
||||
def __call__(self, host, port, known_hosts_path):
|
||||
t = FakeTunnel(host, fail=self.fail)
|
||||
self.created.append(t)
|
||||
return t
|
||||
|
||||
|
||||
class FakeTrust:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def ensure_trusted(self, host, port=443, *, force=False):
|
||||
self.calls.append((host, port))
|
||||
return HostKey.for_endpoint(known_hosts_endpoint(host, port), RelayKey("ssh-ed25519", "k"))
|
||||
|
||||
@property
|
||||
def known_hosts_path(self):
|
||||
return Path("/k")
|
||||
|
||||
|
||||
def build(**cfgkw):
|
||||
cfg = RelayConfig(token="t", ssh_user="u", reap_interval_seconds=999.0, **cfgkw)
|
||||
trust, factory = FakeTrust(), FakeFactory()
|
||||
return RelayController(cfg, trust, factory), trust, factory
|
||||
|
||||
|
||||
def test_connect_tracks_and_trusts():
|
||||
ctrl, trust, factory = build()
|
||||
try:
|
||||
assert ctrl.connect("relay.example", 443) is True
|
||||
assert ctrl.active_hosts == ["relay.example"]
|
||||
assert trust.calls == [("relay.example", 443)]
|
||||
assert len(factory.created) == 1
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
|
||||
def test_connect_dedup_when_alive():
|
||||
ctrl, _, factory = build()
|
||||
try:
|
||||
assert ctrl.connect("h") is True
|
||||
assert ctrl.connect("h") is False
|
||||
assert len(factory.created) == 1
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
|
||||
def test_reconnect_after_death():
|
||||
ctrl, _, factory = build()
|
||||
try:
|
||||
ctrl.connect("h")
|
||||
factory.created[0].alive = False # tunnel died
|
||||
assert ctrl.connect("h") is True
|
||||
assert len(factory.created) == 2
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
|
||||
def test_connect_raises_on_tunnel_failure():
|
||||
ctrl, _, factory = build()
|
||||
factory.fail = True
|
||||
with pytest.raises(RelayError):
|
||||
ctrl.connect("h")
|
||||
assert ctrl.active_hosts == []
|
||||
ctrl.stop()
|
||||
|
||||
|
||||
def test_start_relay_blocked_by_gating():
|
||||
for kw in ({"relay_enabled": False}, {"published": False}, {"signed_in": False}):
|
||||
ctrl, trust, _ = build(**kw)
|
||||
assert ctrl.start_relay("h") is False
|
||||
assert trust.calls == []
|
||||
ctrl.stop()
|
||||
|
||||
|
||||
def test_start_relay_is_resilient_to_failure():
|
||||
ctrl, _, factory = build()
|
||||
factory.fail = True
|
||||
assert ctrl.start_relay("h") is False # logs + swallows, does not raise
|
||||
ctrl.stop()
|
||||
|
||||
|
||||
def test_start_relay_ok():
|
||||
ctrl, _, _ = build()
|
||||
try:
|
||||
assert ctrl.start_relay("h", 443) is True
|
||||
assert ctrl.active_hosts == ["h"]
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
|
||||
def test_reaper_removes_dead():
|
||||
ctrl, _, factory = build()
|
||||
try:
|
||||
ctrl.connect("a")
|
||||
ctrl.connect("b")
|
||||
factory.created[0].alive = False
|
||||
assert ctrl.reap_once() == ["a"]
|
||||
assert ctrl.active_hosts == ["b"]
|
||||
assert factory.created[0].stopped is True
|
||||
finally:
|
||||
ctrl.stop()
|
||||
|
||||
|
||||
def test_stop_terminates_all_and_closes():
|
||||
ctrl, _, factory = build()
|
||||
ctrl.connect("a")
|
||||
ctrl.connect("b")
|
||||
ctrl.stop()
|
||||
assert ctrl.active_hosts == []
|
||||
assert all(t.stopped for t in factory.created)
|
||||
with pytest.raises(RelayError):
|
||||
ctrl.connect("c")
|
||||
|
||||
|
||||
def test_from_config_builds_controller(tmp_path):
|
||||
cfg = RelayConfig(token="t", ssh_user="u", data_dir=tmp_path)
|
||||
assert isinstance(RelayController.from_config(cfg), RelayController)
|
||||
@@ -0,0 +1,70 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import pytest
|
||||
|
||||
from plex_relay.errors import RelayKeyError
|
||||
from plex_relay.keys import HttpsRelayKeyFetcher, RelayKeyProvider
|
||||
from plex_relay.models import RelayKey
|
||||
|
||||
PUB = "ssh-ed25519 AAAAkeydata comment"
|
||||
KEY = RelayKey("ssh-ed25519", "AAAAkeydata")
|
||||
|
||||
|
||||
def test_provider_respects_ttl_and_force():
|
||||
calls = []
|
||||
now = [1000.0]
|
||||
provider = RelayKeyProvider(
|
||||
"https://x/relay_v1.pub", 86_400.0,
|
||||
fetcher=lambda url: (calls.append(url), PUB)[1],
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
assert provider.get() == KEY
|
||||
now[0] += 3600 # within TTL -> reuse
|
||||
provider.get()
|
||||
assert len(calls) == 1
|
||||
now[0] += 86_400 # past TTL -> refetch
|
||||
provider.get()
|
||||
assert len(calls) == 2
|
||||
provider.get(force=True) # force -> refetch
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
# --- HttpsRelayKeyFetcher ---------------------------------------------------
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, data: bytes):
|
||||
self._data = data
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return self._data[:n] if n >= 0 else self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def test_fetcher_rejects_non_https():
|
||||
f = HttpsRelayKeyFetcher(opener=lambda *a, **k: _FakeResp(b""))
|
||||
with pytest.raises(RelayKeyError):
|
||||
f("http://insecure/relay_v1.pub")
|
||||
|
||||
|
||||
def test_fetcher_allows_insecure_when_opted_in():
|
||||
f = HttpsRelayKeyFetcher(allow_insecure=True, opener=lambda *a, **k: _FakeResp(PUB.encode()))
|
||||
assert f("file:///tmp/relay_v1.pub") == PUB
|
||||
|
||||
|
||||
def test_fetcher_caps_response_size():
|
||||
big = b"x" * 100
|
||||
f = HttpsRelayKeyFetcher(max_bytes=10, opener=lambda *a, **k: _FakeResp(big))
|
||||
with pytest.raises(RelayKeyError, match="exceeds"):
|
||||
f("https://x/relay_v1.pub")
|
||||
|
||||
|
||||
def test_fetcher_wraps_transport_errors():
|
||||
def boom(*a, **k):
|
||||
raise OSError("connection refused")
|
||||
f = HttpsRelayKeyFetcher(opener=boom)
|
||||
with pytest.raises(RelayKeyError, match="failed to fetch"):
|
||||
f("https://x/relay_v1.pub")
|
||||
@@ -0,0 +1,45 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import pytest
|
||||
|
||||
from plex_relay.errors import RelayKeyError
|
||||
from plex_relay.models import HostKey, RelayKey, known_hosts_endpoint, parse_relay_pub
|
||||
|
||||
PUB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc relay@plex" # keytype keydata comment
|
||||
KH = "* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc" # host keytype keydata
|
||||
EXPECT = RelayKey("ssh-ed25519", "AAAAC3NzaC1lZDI1NTE5AAAAIabc")
|
||||
|
||||
|
||||
def test_parse_pubkey_form():
|
||||
assert parse_relay_pub(PUB) == EXPECT
|
||||
|
||||
|
||||
def test_parse_known_hosts_form():
|
||||
assert parse_relay_pub(KH) == EXPECT
|
||||
|
||||
|
||||
def test_parse_skips_comments_and_blanks():
|
||||
assert parse_relay_pub(f"# header\n\n{PUB}\n") == EXPECT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["ssh-ed25519 onlytwo", "aaa bbb ccc", "", "# only comment\n"])
|
||||
def test_parse_rejects_bad_payloads(bad):
|
||||
with pytest.raises(RelayKeyError):
|
||||
parse_relay_pub(bad)
|
||||
|
||||
|
||||
def test_endpoint_bracket_notation():
|
||||
assert known_hosts_endpoint("1.2.3.4", 443) == "[1.2.3.4]:443"
|
||||
assert known_hosts_endpoint("relay.example", 2222) == "[relay.example]:2222"
|
||||
assert known_hosts_endpoint("relay.example", 22) == "relay.example"
|
||||
|
||||
|
||||
def test_endpoint_rejects_empty_host():
|
||||
with pytest.raises(RelayKeyError):
|
||||
known_hosts_endpoint(" ", 443)
|
||||
|
||||
|
||||
def test_hostkey_serialization():
|
||||
hk = HostKey.for_endpoint("[1.2.3.4]:443", EXPECT)
|
||||
assert hk.known_hosts_line() == "[1.2.3.4]:443 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc"
|
||||
assert hk.cache_block() == f"# [1.2.3.4]:443\n{hk.known_hosts_line()}\n"
|
||||
assert hk.key == EXPECT
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
from pathlib import Path
|
||||
|
||||
from plex_relay.keys import RelayKeyProvider
|
||||
from plex_relay.store import HostKeyManager
|
||||
|
||||
|
||||
class FakeCache:
|
||||
def __init__(self):
|
||||
self.entries = {}
|
||||
self.saves = 0
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return Path("/tmp/relayHostKey.txt")
|
||||
|
||||
def load(self):
|
||||
return dict(self.entries)
|
||||
|
||||
def save(self, entries):
|
||||
self.saves += 1
|
||||
self.entries = dict(entries)
|
||||
|
||||
|
||||
def _provider(text_box):
|
||||
return RelayKeyProvider("https://x/relay_v1.pub", 86_400.0,
|
||||
fetcher=lambda u: text_box[0], clock=lambda: 0.0)
|
||||
|
||||
|
||||
def test_ensure_trusted_pins_and_persists():
|
||||
cache = FakeCache()
|
||||
mgr = HostKeyManager(_provider(["ssh-ed25519 AAAAfirst c"]), cache)
|
||||
entry = mgr.ensure_trusted("1.2.3.4", 443)
|
||||
assert entry.known_hosts_line() == "[1.2.3.4]:443 ssh-ed25519 AAAAfirst"
|
||||
assert cache.saves == 1
|
||||
assert "[1.2.3.4]:443" in cache.entries
|
||||
|
||||
|
||||
def test_ensure_trusted_is_idempotent():
|
||||
cache = FakeCache()
|
||||
mgr = HostKeyManager(_provider(["ssh-ed25519 AAAAfirst c"]), cache)
|
||||
mgr.ensure_trusted("1.2.3.4", 443)
|
||||
mgr.ensure_trusted("1.2.3.4", 443) # unchanged -> no extra write
|
||||
assert cache.saves == 1
|
||||
|
||||
|
||||
def test_ensure_trusted_rewrites_on_key_change():
|
||||
cache = FakeCache()
|
||||
box = ["ssh-ed25519 AAAAfirst c"]
|
||||
mgr = HostKeyManager(_provider(box), cache)
|
||||
mgr.ensure_trusted("h", 443)
|
||||
box[0] = "ssh-ed25519 AAAAsecond c"
|
||||
entry = mgr.ensure_trusted("h", 443, force=True)
|
||||
assert entry.keydata == "AAAAsecond"
|
||||
assert cache.saves == 2
|
||||
|
||||
|
||||
def test_known_hosts_path_is_cache_path():
|
||||
cache = FakeCache()
|
||||
mgr = HostKeyManager(_provider(["ssh-ed25519 k c"]), cache)
|
||||
assert mgr.known_hosts_path == cache.path
|
||||
@@ -0,0 +1,91 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plex_relay.config import RelayConfig
|
||||
from plex_relay.errors import TunnelError
|
||||
from plex_relay.tunnel import SubprocessTunnel, SubprocessTunnelFactory, build_ssh_argv
|
||||
|
||||
|
||||
def cfg(**kw):
|
||||
base = dict(token="secret", ssh_user="machineid", local_host="127.0.0.1", local_port=32400)
|
||||
base.update(kw)
|
||||
return RelayConfig(**base)
|
||||
|
||||
|
||||
def test_argv_matches_binary_layout():
|
||||
argv = build_ssh_argv(cfg(), "relay.example", 443, Path("/data/relayHostKey.txt"))
|
||||
assert argv == [
|
||||
"ssh", "-p", "443", "-N", "-R", "0:127.0.0.1:32400",
|
||||
"-o", "UserKnownHostsFile=/data/relayHostKey.txt",
|
||||
"-o", "LogLevel=VERBOSE",
|
||||
"-o", "PreferredAuthentications=password",
|
||||
"-o", "PubkeyAuthentication=no",
|
||||
"-l", "machineid", "-F", "/dev/null", "relay.example",
|
||||
]
|
||||
|
||||
|
||||
def test_argv_honours_port_and_target():
|
||||
argv = build_ssh_argv(cfg(local_host="10.0.0.5", local_port=32500), "h", 2222, Path("/k"))
|
||||
assert "0:10.0.0.5:32500" in argv
|
||||
assert argv[argv.index("-p") + 1] == "2222"
|
||||
|
||||
|
||||
class FakeProc:
|
||||
def __init__(self):
|
||||
self.alive = True
|
||||
self.terminated = False
|
||||
|
||||
def poll(self):
|
||||
return None if self.alive else 0
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
self.alive = False
|
||||
|
||||
def kill(self):
|
||||
self.alive = False
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.alive = False
|
||||
return 0
|
||||
|
||||
|
||||
def test_tunnel_start_sets_secret_env_and_cleans_askpass():
|
||||
captured = {}
|
||||
|
||||
def spawner(argv, env):
|
||||
captured["argv"] = argv
|
||||
captured["env"] = dict(env)
|
||||
return FakeProc()
|
||||
|
||||
t = SubprocessTunnel(cfg(), "relay.example", 443, Path("/k"), spawner)
|
||||
t.start()
|
||||
assert t.is_alive()
|
||||
assert captured["env"]["PLEXTOKEN"] == "secret"
|
||||
assert "SSH_ASKPASS" in captured["env"]
|
||||
askpass = Path(captured["env"]["SSH_ASKPASS"])
|
||||
assert askpass.exists()
|
||||
assert "secret" not in captured["argv"] # never on the command line
|
||||
t.stop()
|
||||
assert not t.is_alive()
|
||||
assert not askpass.exists() # helper removed on stop
|
||||
|
||||
|
||||
def test_tunnel_start_wraps_spawn_failure():
|
||||
def boom(argv, env):
|
||||
raise OSError("ssh not found")
|
||||
t = SubprocessTunnel(cfg(), "h", 443, Path("/k"), boom)
|
||||
with pytest.raises(TunnelError):
|
||||
t.start()
|
||||
assert not t.is_alive()
|
||||
|
||||
|
||||
def test_factory_builds_tunnel():
|
||||
factory = SubprocessTunnelFactory(cfg(), spawner=lambda a, e: FakeProc())
|
||||
t = factory("h", 443, Path("/k"))
|
||||
assert t.host == "h"
|
||||
t.start()
|
||||
assert t.is_alive()
|
||||
t.stop()
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Launcher for plexmediaserver that LD_PRELOADs the (musl-built) crack into the
|
||||
# Plex Media Server process only. LD_PRELOAD must NOT be set via the unit's
|
||||
# Environment= because ExecStart is run by /bin/sh (glibc) first, and a musl .so
|
||||
# cannot load into a glibc process. Setting it here, after this shell is already
|
||||
# running, means only the final `exec` of the (musl) Plex binary is preloaded.
|
||||
#
|
||||
# The three PLEX_MEDIA_SERVER_INFO_* exports mirror the stock unit's ExecStart.
|
||||
export PLEX_MEDIA_SERVER_INFO_VENDOR="$(grep ^NAME= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
|
||||
export PLEX_MEDIA_SERVER_INFO_MODEL="$(uname -m)"
|
||||
export PLEX_MEDIA_SERVER_INFO_PLATFORM_VERSION="$(grep ^VERSION= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
|
||||
export LD_PRELOAD="/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so"
|
||||
exec "/usr/lib/plexmediaserver/Plex Media Server"
|
||||
@@ -0,0 +1,172 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
# Plex over Tailscale / Headscale (no code patching)
|
||||
|
||||
Make a local Plex server reachable by remote users through a **mesh VPN** instead
|
||||
of Plex Relay, router port-forwarding, or a binary patch. Every device that joins
|
||||
your tailnet reaches the Plex host's private tailnet IP directly; WireGuard
|
||||
encrypts the transport end-to-end.
|
||||
|
||||
```
|
||||
Remote client (Tailscale) ─────WireGuard────▶ Plex host (Tailscale) 100.x.y.z:32400
|
||||
│ ▲
|
||||
└── learns the server from plex.tv, which now publishes
|
||||
the custom URL https://100.x.y.z:32400
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout & design
|
||||
|
||||
```
|
||||
plex-tailnet/
|
||||
├── plex-tailscale-setup.sh # run on the Plex host: VPN + Plex config + healthcheck
|
||||
├── headscale-server-setup.sh # run on a VPS (optional): self-hosted control plane
|
||||
├── lib/
|
||||
│ ├── common.sh # shared shell helpers (sourced, never executed)
|
||||
│ └── plex_prefs.py # Preferences.xml read/merge (XML lives here, not in bash)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Principles applied:
|
||||
|
||||
- **Low coupling / high cohesion.** Generic concerns (colour logging, dry-run
|
||||
execution, prompts, root/command guards, secret redaction) live once in
|
||||
`lib/common.sh`; each script keeps only its own orchestration. All XML editing
|
||||
is isolated in `lib/plex_prefs.py` — cohesive, reviewable, and testable on its
|
||||
own (`plex_prefs.py merge|get`), so the shell never hand-parses XML.
|
||||
- **Fail fast, located.** `set -euo pipefail` plus an `ERR` trap that reports the
|
||||
failing line; tolerated failures are explicitly guarded (`|| true` / `|| warn`).
|
||||
- **Idempotent & reversible.** Re-runs merge (never duplicate) settings; every
|
||||
`Preferences.xml` change is preceded by a timestamped backup and ownership is
|
||||
restored afterwards. `--dry-run` previews every action and changes nothing.
|
||||
- **Secret hygiene.** Auth keys are redacted in logs **and** never routed through
|
||||
the dry-run echo. The `headscale` pre-auth key is printed once, labelled as a
|
||||
secret.
|
||||
|
||||
| Component | Runs on | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `plex-tailscale-setup.sh` | Plex host (Linux/systemd) | install/join Tailscale, security questionnaire, edit `Preferences.xml`, firewall, healthcheck |
|
||||
| `headscale-server-setup.sh` | public VPS *(optional)* | install + configure Headscale, create user, mint pre-auth key |
|
||||
| `lib/common.sh` | sourced | logging, `run` (dry-run), prompts, guards, redaction |
|
||||
| `lib/plex_prefs.py` | invoked | merge/read `Preferences.xml` attributes |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### A) Tailscale's control plane (simplest; free for up to 3 users)
|
||||
|
||||
```bash
|
||||
sudo ./plex-tailscale-setup.sh # interactive login (prints a URL)
|
||||
sudo ./plex-tailscale-setup.sh --authkey tskey-auth-xxxxx # unattended
|
||||
```
|
||||
|
||||
Each remote user installs Tailscale (https://tailscale.com/download), joins the
|
||||
same tailnet, opens Plex — done.
|
||||
|
||||
### B) Your own Headscale (no user limits, full control)
|
||||
|
||||
On a public VPS with a DNS name and ports 80/443 open:
|
||||
|
||||
```bash
|
||||
sudo ./headscale-server-setup.sh --domain hs.example.com --user plex # prints a key
|
||||
```
|
||||
|
||||
On the Plex host and every client:
|
||||
|
||||
```bash
|
||||
sudo tailscale up --login-server https://hs.example.com --authkey <preauth-key>
|
||||
# Plex host can do VPN + Plex config in one go:
|
||||
sudo ./plex-tailscale-setup.sh --login-server https://hs.example.com --authkey <preauth-key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What the Plex script changes
|
||||
|
||||
`Preferences.xml` is edited **while Plex is stopped** (Plex overwrites it on
|
||||
shutdown), via `lib/plex_prefs.py`, after a backup, with ownership restored:
|
||||
|
||||
| Attribute | Change | Why |
|
||||
| --- | --- | --- |
|
||||
| `customConnections` | append `https://<tailscale-ip>:32400` | plex.tv publishes the tailnet address for discovery |
|
||||
| `LanNetworksBandwidth` | append `100.64.0.0/10`, `fd7a:115c:a1e0::/48` | treat the tailnet as **LAN**: full quality, no remote throttle |
|
||||
| `secureConnections` | your choice (default Preferred) | clean connect over the already-encrypted tunnel |
|
||||
| `RelayEnabled` | `0` (if you disable Relay) | stop bouncing through Plex's relay once on the tailnet |
|
||||
|
||||
### Security questionnaire (interactive)
|
||||
|
||||
On a terminal the script asks three questions (each has a safe default; pass the
|
||||
flag — or `--yes` — to skip the prompt):
|
||||
|
||||
| Prompt | Flag(s) | Default | Effect |
|
||||
| --- | --- | --- | --- |
|
||||
| Secure connections mode | `--secure required\|preferred\|disabled\|keep` | preferred | `secureConnections` |
|
||||
| Disable Plex Relay? | `--disable-relay` / `--keep-relay` | disable | `RelayEnabled=0` |
|
||||
| Firewall lockdown of `32400/tcp` | `--firewall none\|tailnet\|lan` | none | see below |
|
||||
|
||||
**Firewall lockdown** restricts Plex's port to the VPN (`tailnet`) or VPN + RFC1918
|
||||
LAN (`lan`). It only ever touches `32400/tcp` (SSH stays open), acts only on an
|
||||
**already-active** ufw/firewalld (never enables a firewall — that risks an SSH
|
||||
lockout), and otherwise prints an equivalent `nftables` snippet.
|
||||
|
||||
### Health check
|
||||
|
||||
Runs after install, and standalone with `sudo ./plex-tailscale-setup.sh
|
||||
--healthcheck` (**no changes, no root**). PASS/WARN/FAIL for: Tailscale backend +
|
||||
tailnet IP, the Plex service, Plex's local API, Plex reachable at its tailnet IP,
|
||||
the `customConnections` / LAN-networks / Relay values Plex actually persisted, and
|
||||
the firewall posture.
|
||||
|
||||
Other flags: `--prefs PATH` (quote it), `--service`, `--port`, `--url-scheme`,
|
||||
`--ts-iface`, `--skip-tailscale`, `--skip-plex`, `--dry-run`.
|
||||
|
||||
**Prerequisites:** Plex installed, **claimed**, owner signed in; Linux + systemd;
|
||||
`python3` + `python3-defusedxml` + `curl`; run as root (except `--healthcheck`).
|
||||
|
||||
---
|
||||
|
||||
## Letting other people in
|
||||
|
||||
1. They install Tailscale and join your tailnet/Headscale (a per-user reusable
|
||||
pre-auth key from `headscale preauthkeys create` is the easy path).
|
||||
2. In Plex, **Settings → Users & Sharing**, share the libraries with their Plex
|
||||
account.
|
||||
3. They sign into Plex; the server shows up over the tailnet.
|
||||
|
||||
### Lock guests to the Plex port with ACLs (recommended)
|
||||
|
||||
Headscale (`/etc/headscale/acl.hujson`, referenced by `policy.path`):
|
||||
|
||||
```hujson
|
||||
{
|
||||
"groups": { "group:plexusers": ["alice@", "bob@"] },
|
||||
"hosts": { "plexserver": "100.64.0.5/32" },
|
||||
"acls": [
|
||||
{ "action": "accept", "src": ["group:plexusers"], "dst": ["plexserver:32400"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Tailscale's admin console (Access Controls) uses the equivalent `acls`/`tagOwners`.
|
||||
|
||||
---
|
||||
|
||||
## Caveats
|
||||
|
||||
- **2026 Plex Pass enforcement.** Reports indicate Plex now requires Plex Pass /
|
||||
Remote Watch Pass on the **server account** for *remote* streaming even over
|
||||
Tailscale. `LanNetworksBandwidth` makes Plex treat the tailnet as local (which
|
||||
historically sidestepped the cap and the entitlement gate); if your build still
|
||||
gates, the lever is on the server account, not the client. VPN connectivity
|
||||
works regardless.
|
||||
- **TLS / certificates.** Capable clients reach `https://<ip>:32400` via Plex's
|
||||
auto-generated `plex.direct` hostname (valid cert). For a strict client, use
|
||||
`--secure preferred` (default) or `--url-scheme http`; WireGuard already
|
||||
encrypts the wire.
|
||||
- **Headscale TLS.** `--no-tls` listens on `127.0.0.1:8080` for a reverse proxy;
|
||||
otherwise built-in Let's Encrypt needs ports 80 + 443 reachable.
|
||||
- **POSIX/systemd only.** Targets Debian/Ubuntu-family Plex hosts.
|
||||
|
||||
Interoperability/remote-access tooling for infrastructure you operate yourself.
|
||||
It ships no Plex code and bypasses no account authentication.
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# headscale-server-setup.sh -- OPTIONAL self-hosted coordination server.
|
||||
#
|
||||
# Use instead of Tailscale's control plane when you want no account limits and
|
||||
# full control over who may join. Run on a PUBLIC Debian 12+/Ubuntu 22.04+ VPS
|
||||
# with a DNS name pointing at it. It:
|
||||
# 1. installs the official Headscale .deb (latest release, or --version)
|
||||
# 2. points server_url at https://<domain> and enables built-in Let's Encrypt
|
||||
# TLS (unless --no-tls, for running behind your own reverse proxy)
|
||||
# 3. starts the systemd service
|
||||
# 4. creates a user and mints a reusable pre-auth key
|
||||
#
|
||||
# The Plex host and every client then join with:
|
||||
# sudo tailscale up --login-server https://<domain> --authkey <preauthkey>
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "${SCRIPT_DIR}/lib/common.sh" || { echo "missing ${SCRIPT_DIR}/lib/common.sh" >&2; exit 1; }
|
||||
enable_error_trap
|
||||
|
||||
readonly CFG="/etc/headscale/config.yaml"
|
||||
DOMAIN=""
|
||||
USER_NAME="plex"
|
||||
VERSION="" # auto-detect latest if empty
|
||||
EXPIRY="720h" # preauth key lifetime (30 days)
|
||||
USE_TLS=1
|
||||
readonly LISTEN_PLAIN="127.0.0.1:8080"
|
||||
PREAUTH_KEY=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: sudo $0 --domain hs.example.com [options]
|
||||
|
||||
--domain NAME Public DNS name for this Headscale server (required).
|
||||
--user NAME Headscale user to create (default: $USER_NAME).
|
||||
--version VER Headscale version (default: latest GitHub release).
|
||||
--expiration DUR Pre-auth key lifetime, Go duration (default: $EXPIRY).
|
||||
--no-tls Listen on $LISTEN_PLAIN for a reverse proxy (no built-in TLS).
|
||||
--dry-run Print actions without changing anything.
|
||||
-h, --help This help.
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--domain) DOMAIN="$2"; shift 2;;
|
||||
--user) USER_NAME="$2"; shift 2;;
|
||||
--version) VERSION="$2"; shift 2;;
|
||||
--expiration) EXPIRY="$2"; shift 2;;
|
||||
--no-tls) USE_TLS=0; shift;;
|
||||
--dry-run) DRY_RUN=1; shift;;
|
||||
-h|--help) usage; exit 0;;
|
||||
*) die "unknown option: $1 (see --help)";;
|
||||
esac
|
||||
done
|
||||
require_root
|
||||
[[ -n "$DOMAIN" ]] || die "--domain is required"
|
||||
[[ "$DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || die "--domain looks invalid: $DOMAIN"
|
||||
[[ "$EXPIRY" =~ ^[0-9]+[smhd]$ ]] || die "--expiration must be a Go duration like 720h, got: $EXPIRY"
|
||||
[[ -n "$USER_NAME" ]] || die "--user must not be empty"
|
||||
need_cmd curl
|
||||
need_cmd dpkg
|
||||
}
|
||||
|
||||
detect_version() {
|
||||
[[ -n "$VERSION" ]] && { printf '%s' "$VERSION"; return; }
|
||||
local tag
|
||||
tag="$(curl -fsSL https://api.github.com/repos/juanfont/headscale/releases/latest \
|
||||
| sed -n 's/.*"tag_name":[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n1)"
|
||||
[[ -n "$tag" ]] || die "could not detect latest Headscale version; pass --version X.Y.Z"
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
install_headscale() {
|
||||
if have_cmd headscale; then
|
||||
ok "headscale already installed ($(headscale version 2>/dev/null | head -n1))"
|
||||
return
|
||||
fi
|
||||
local ver arch url tmp
|
||||
ver="$(detect_version)"
|
||||
arch="$(dpkg --print-architecture)"
|
||||
url="https://github.com/juanfont/headscale/releases/download/v${ver}/headscale_${ver}_linux_${arch}.deb"
|
||||
tmp="$(mktemp --suffix=.deb)"
|
||||
log "downloading Headscale v${ver} (${arch})"
|
||||
run curl -fsSL -o "$tmp" "$url"
|
||||
log "installing package"
|
||||
run apt-get install -y "$tmp"
|
||||
run rm -f "$tmp"
|
||||
}
|
||||
|
||||
# set or append a top-level scalar key in the YAML config (other keys untouched)
|
||||
set_yaml() {
|
||||
local key="$1" val="$2"
|
||||
if grep -qE "^[[:space:]]*${key}:" "$CFG"; then
|
||||
run sed -i -E "s|^([[:space:]]*)${key}:.*|\1${key}: ${val}|" "$CFG"
|
||||
elif [[ $DRY_RUN -eq 1 ]]; then
|
||||
echo " + append ${key}: ${val} >> $CFG"
|
||||
else
|
||||
printf '%s: %s\n' "$key" "$val" >> "$CFG"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_headscale() {
|
||||
[[ -f "$CFG" ]] || die "expected config at $CFG (did the package install correctly?)"
|
||||
run cp -a "$CFG" "${CFG}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
set_yaml server_url "https://${DOMAIN}"
|
||||
if [[ $USE_TLS -eq 1 ]]; then
|
||||
set_yaml listen_addr "0.0.0.0:443"
|
||||
set_yaml tls_letsencrypt_hostname "${DOMAIN}"
|
||||
set_yaml tls_letsencrypt_challenge_type "HTTP-01"
|
||||
set_yaml tls_letsencrypt_listen ":http"
|
||||
warn "built-in TLS: ports 80 (ACME challenge) and 443 must be reachable."
|
||||
else
|
||||
set_yaml listen_addr "$LISTEN_PLAIN"
|
||||
warn "--no-tls: terminate TLS at a reverse proxy in front of $LISTEN_PLAIN."
|
||||
fi
|
||||
ok "configured $CFG (server_url=https://${DOMAIN})"
|
||||
}
|
||||
|
||||
start_headscale() {
|
||||
run systemctl enable --now headscale
|
||||
if [[ $DRY_RUN -eq 0 ]]; then
|
||||
sleep 2
|
||||
systemctl is-active --quiet headscale \
|
||||
&& ok "headscale is running" \
|
||||
|| warn "headscale not active; check 'journalctl -u headscale -e'"
|
||||
fi
|
||||
}
|
||||
|
||||
provision_user() {
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
echo " + headscale users create $USER_NAME"
|
||||
echo " + headscale preauthkeys create --user $USER_NAME --reusable --expiration $EXPIRY"
|
||||
return
|
||||
fi
|
||||
if ! headscale users list 2>/dev/null | grep -qw "$USER_NAME"; then
|
||||
log "creating user '$USER_NAME'"
|
||||
headscale users create "$USER_NAME" || warn "users create failed (may already exist)"
|
||||
else
|
||||
ok "user '$USER_NAME' already exists"
|
||||
fi
|
||||
log "minting reusable pre-auth key (valid $EXPIRY)"
|
||||
# Newer headscale wants the user id; older accepts the name. Try name, then id.
|
||||
PREAUTH_KEY="$(headscale preauthkeys create --user "$USER_NAME" --reusable --expiration "$EXPIRY" 2>/dev/null | tail -n1 || true)"
|
||||
if [[ -z "$PREAUTH_KEY" || "$PREAUTH_KEY" == *" "* ]]; then
|
||||
local uid
|
||||
uid="$(headscale users list 2>/dev/null | awk -v u="$USER_NAME" '$0 ~ u {print $1; exit}')"
|
||||
[[ -n "$uid" ]] && PREAUTH_KEY="$(headscale preauthkeys create --user "$uid" --reusable --expiration "$EXPIRY" 2>/dev/null | tail -n1 || true)"
|
||||
fi
|
||||
if [[ -n "$PREAUTH_KEY" ]]; then
|
||||
ok "pre-auth key (treat as a secret): $PREAUTH_KEY"
|
||||
else
|
||||
warn "could not auto-mint a key; run: headscale preauthkeys create --user $USER_NAME --reusable --expiration $EXPIRY"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
install_headscale
|
||||
configure_headscale
|
||||
start_headscale
|
||||
provision_user
|
||||
|
||||
cat <<EOF
|
||||
|
||||
$(ok "Headscale ready at https://${DOMAIN}")
|
||||
|
||||
Join the Plex server and every client with:
|
||||
sudo tailscale up --login-server https://${DOMAIN} --authkey ${PREAUTH_KEY:-<preauth-key>}
|
||||
|
||||
On the Plex host, do VPN + Plex config in one step:
|
||||
sudo ./plex-tailscale-setup.sh --login-server https://${DOMAIN} --authkey ${PREAUTH_KEY:-<preauth-key>}
|
||||
|
||||
Manage access:
|
||||
headscale users list
|
||||
headscale nodes list
|
||||
headscale preauthkeys create --user ${USER_NAME} --reusable --expiration ${EXPIRY}
|
||||
EOF
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,76 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Shared helpers for the plex-tailnet scripts. SOURCE this file; do not run it.
|
||||
# Keeping the generic concerns (logging, dry-run execution, prompts, guards,
|
||||
# secret redaction) here removes duplication between the setup scripts and keeps
|
||||
# each script focused on its own orchestration.
|
||||
|
||||
# --- colour-aware logging (colours only on a TTY) ---------------------------
|
||||
_c() { [[ -t 1 ]] && printf '%s' "$1" || true; }
|
||||
log() { printf '%s[*]%s %s\n' "$(_c $'\033[1;34m')" "$(_c $'\033[0m')" "$*"; }
|
||||
ok() { printf '%s[+]%s %s\n' "$(_c $'\033[1;32m')" "$(_c $'\033[0m')" "$*"; }
|
||||
warn() { printf '%s[!]%s %s\n' "$(_c $'\033[1;33m')" "$(_c $'\033[0m')" "$*" >&2; }
|
||||
die() { printf '%s[x]%s %s\n' "$(_c $'\033[1;31m')" "$(_c $'\033[0m')" "$*" >&2; exit 1; }
|
||||
|
||||
# --- command execution that honours DRY_RUN ---------------------------------
|
||||
: "${DRY_RUN:=0}"
|
||||
run() {
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
printf ' +'; printf ' %q' "$@"; echo
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- fail fast with a located diagnostic ------------------------------------
|
||||
# Usage: enable_error_trap (after sourcing). Tolerated failures must be
|
||||
# guarded with `|| true` / `|| warn ...` as usual.
|
||||
__err_trap() { warn "aborted (exit $1) near line $2"; exit "$1"; }
|
||||
enable_error_trap() { trap '__err_trap "$?" "$LINENO"' ERR; }
|
||||
|
||||
# --- guards / predicates ----------------------------------------------------
|
||||
require_root() { [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "must run as root (use sudo)"; }
|
||||
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"; }
|
||||
have_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||||
is_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )); }
|
||||
|
||||
# --- interactive prompts (read the controlling terminal directly) -----------
|
||||
ask_yes_no() { # question [default Y|N] -> 0 = yes, 1 = no
|
||||
local q="$1" def="${2:-Y}" ans prompt
|
||||
[[ "$def" == "Y" ]] && prompt="[Y/n]" || prompt="[y/N]"
|
||||
read -r -p "$(printf '%s[?]%s %s %s ' "$(_c $'\033[1;36m')" "$(_c $'\033[0m')" "$q" "$prompt")" ans </dev/tty || ans=""
|
||||
ans="${ans:-$def}"
|
||||
[[ "$ans" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
ask_choice() { # question default opt... -> echoes the chosen value (prompt on stderr)
|
||||
local q="$1" def="$2"; shift 2
|
||||
local opts=("$@") i ans o
|
||||
{
|
||||
printf '%s[?]%s %s\n' "$(_c $'\033[1;36m')" "$(_c $'\033[0m')" "$q"
|
||||
for i in "${!opts[@]}"; do
|
||||
printf ' %d) %s%s\n' "$((i + 1))" "${opts[$i]}" "$([[ ${opts[$i]} == "$def" ]] && echo ' (default)')"
|
||||
done
|
||||
printf ' choice [%s]: ' "$def"
|
||||
} >&2
|
||||
read -r ans </dev/tty || ans=""
|
||||
[[ -z "$ans" ]] && { printf '%s' "$def"; return; }
|
||||
if [[ "$ans" =~ ^[0-9]+$ ]] && (( ans >= 1 && ans <= ${#opts[@]} )); then
|
||||
printf '%s' "${opts[$((ans - 1))]}"; return
|
||||
fi
|
||||
for o in "${opts[@]}"; do [[ "$ans" == "$o" ]] && { printf '%s' "$o"; return; }; done
|
||||
printf '%s' "$def"
|
||||
}
|
||||
|
||||
# --- secret redaction for logging -------------------------------------------
|
||||
# redact_after FLAG ARG... -> echoes ARGs with the value following FLAG masked.
|
||||
redact_after() {
|
||||
local flag="$1"; shift
|
||||
local out=() mask=0 a
|
||||
for a in "$@"; do
|
||||
if [[ $mask -eq 1 ]]; then out+=("***"); mask=0
|
||||
else out+=("$a"); [[ "$a" == "$flag" ]] && mask=1; fi
|
||||
done
|
||||
printf '%s' "${out[*]}"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Read and edit Plex ``Preferences.xml`` attributes.
|
||||
|
||||
Used by ``plex-tailscale-setup.sh``. The XML logic lives here -- not in a bash
|
||||
heredoc -- so it is cohesive, reviewable, and independently testable. The shell
|
||||
owns the lifecycle (stop Plex, back up, restore ownership, restart); this owns
|
||||
the document.
|
||||
|
||||
plex_prefs.py merge PREFS [--custom-url URL] [--lan CIDR[,CIDR...]]
|
||||
[--secure 0|1|2] [--relay 0|1]
|
||||
plex_prefs.py get PREFS ATTR
|
||||
|
||||
``merge`` is additive and idempotent: list attributes gain only missing values;
|
||||
scalar attributes are set only when a value is supplied. Unrelated attributes
|
||||
(tokens, machine identity, ...) are preserved.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol, cast
|
||||
|
||||
try:
|
||||
import defusedxml.ElementTree as ET
|
||||
except ModuleNotFoundError:
|
||||
sys.exit("plex_prefs: missing dependency: install python3-defusedxml")
|
||||
|
||||
|
||||
class _PrefsElement(Protocol):
|
||||
tag: str
|
||||
|
||||
def get(self, key: str, default: str = "") -> str: ...
|
||||
def set(self, key: str, value: str) -> None: ...
|
||||
|
||||
|
||||
class _PrefsTree(Protocol):
|
||||
def getroot(self) -> _PrefsElement: ...
|
||||
def write(self, file_or_filename: str, encoding: str, xml_declaration: bool) -> None: ...
|
||||
|
||||
|
||||
def _load(path: str) -> tuple[_PrefsTree, _PrefsElement]:
|
||||
try:
|
||||
tree = cast(_PrefsTree, cast(object, ET.parse(path)))
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
sys.exit(f"plex_prefs: cannot read {path}: {exc}")
|
||||
root = tree.getroot()
|
||||
if root.tag != "Preferences":
|
||||
sys.exit(f"plex_prefs: unexpected root <{root.tag}>; refusing to edit {path}")
|
||||
return tree, root
|
||||
|
||||
|
||||
def _merge_csv(root: _PrefsElement, attr: str, additions: list[str]) -> None:
|
||||
items = [x for x in (s.strip() for s in root.get(attr, "").split(",")) if x]
|
||||
for value in additions:
|
||||
if value and value not in items:
|
||||
items.append(value)
|
||||
root.set(attr, ",".join(items))
|
||||
|
||||
|
||||
def cmd_merge(args: argparse.Namespace) -> int:
|
||||
prefs = cast(str, args.prefs)
|
||||
custom_url = cast(str, args.custom_url)
|
||||
lan = cast(str, args.lan)
|
||||
secure = cast(str, args.secure)
|
||||
relay = cast(str, args.relay)
|
||||
|
||||
tree, root = _load(prefs)
|
||||
if custom_url:
|
||||
_merge_csv(root, "customConnections", [custom_url])
|
||||
if lan:
|
||||
_merge_csv(root, "LanNetworksBandwidth", [c for c in lan.split(",") if c])
|
||||
if secure in ("0", "1", "2"):
|
||||
root.set("secureConnections", secure)
|
||||
if relay in ("0", "1"):
|
||||
root.set("RelayEnabled", relay)
|
||||
tree.write(prefs, encoding="utf-8", xml_declaration=True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_get(args: argparse.Namespace) -> int:
|
||||
prefs = cast(str, args.prefs)
|
||||
attr = cast(str, args.attr)
|
||||
|
||||
_, root = _load(prefs)
|
||||
print(root.get(attr, ""))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="plex_prefs", description=__doc__)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
m = sub.add_parser("merge", help="merge tailnet settings into Preferences.xml")
|
||||
_ = m.add_argument("prefs")
|
||||
_ = m.add_argument("--custom-url", default="")
|
||||
_ = m.add_argument("--lan", default="")
|
||||
_ = m.add_argument("--secure", default="", help="0=Required 1=Preferred 2=Disabled")
|
||||
_ = m.add_argument("--relay", default="", help="0=disable 1=enable Plex Relay")
|
||||
m.set_defaults(func=cmd_merge)
|
||||
|
||||
g = sub.add_parser("get", help="print one Preferences.xml attribute")
|
||||
_ = g.add_argument("prefs")
|
||||
_ = g.add_argument("attr")
|
||||
g.set_defaults(func=cmd_get)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
func = cast(Callable[[argparse.Namespace], int], args.func)
|
||||
return func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# plex-tailscale-setup.sh -- run on the LINUX host that runs Plex Media Server.
|
||||
#
|
||||
# Makes a local Plex server reachable by remote users over a Tailscale /
|
||||
# Headscale mesh VPN: no router port-forwarding, no Plex Relay, no patching.
|
||||
#
|
||||
# 1. install/join Tailscale (Tailscale's control plane, or your Headscale)
|
||||
# 2. ask a few security questions (skippable with flags or --yes)
|
||||
# 3. edit Preferences.xml safely (Plex stopped, backed up, ownership restored)
|
||||
# 4. optionally lock the firewall to the tailnet
|
||||
# 5. restart Plex and run a health check (also available as `--healthcheck`)
|
||||
#
|
||||
# Target: Debian/Ubuntu-family with systemd. Requires: tailscale (auto-installed),
|
||||
# python3, python3-defusedxml, curl. Run as root (except --healthcheck).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "${SCRIPT_DIR}/lib/common.sh" || { echo "missing ${SCRIPT_DIR}/lib/common.sh" >&2; exit 1; }
|
||||
enable_error_trap
|
||||
readonly PREFS_PY="${SCRIPT_DIR}/lib/plex_prefs.py"
|
||||
|
||||
# ---- defaults --------------------------------------------------------------
|
||||
readonly PREFS_DEFAULT='/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Preferences.xml'
|
||||
PREFS="${PLEX_PREFS:-$PREFS_DEFAULT}"
|
||||
SERVICE="plexmediaserver"
|
||||
PLEX_PORT="32400"
|
||||
URL_SCHEME="https"
|
||||
SECURE="" # ask | required|preferred|disabled|keep
|
||||
RELAY="" # ask | disable|keep
|
||||
FIREWALL="" # ask | none|tailnet|lan
|
||||
LOGIN_SERVER=""
|
||||
AUTHKEY=""
|
||||
TS_HOSTNAME=""
|
||||
TS_IFACE="tailscale0"
|
||||
readonly TAILNET_V4="100.64.0.0/10"
|
||||
readonly TAILNET_V6="fd7a:115c:a1e0::/48"
|
||||
SKIP_TAILSCALE=0
|
||||
SKIP_PLEX=0
|
||||
ASSUME_YES=0
|
||||
HEALTHCHECK_ONLY=0
|
||||
INTERACTIVE=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: sudo $0 [options]
|
||||
|
||||
Connectivity:
|
||||
--login-server URL Use a self-hosted Headscale control server.
|
||||
--authkey KEY Pre-auth/auth key (unattended join; never logged).
|
||||
--hostname NAME Tailnet hostname for this node.
|
||||
--ts-iface NAME Tailscale interface (default: $TS_IFACE).
|
||||
|
||||
Plex:
|
||||
--prefs PATH Preferences.xml path (quote it -- it has spaces).
|
||||
--service NAME systemd unit name (default: $SERVICE).
|
||||
--port N Plex port (default: $PLEX_PORT).
|
||||
--url-scheme S https|http for the published URL (default: https).
|
||||
|
||||
Security (prompted interactively unless set here or with --yes):
|
||||
--secure MODE required|preferred|disabled|keep (default: preferred).
|
||||
--disable-relay Set RelayEnabled=0 (recommended on a tailnet).
|
||||
--keep-relay Leave Plex Relay untouched.
|
||||
--firewall MODE none|tailnet|lan (default: none).
|
||||
|
||||
Control:
|
||||
--healthcheck Run health checks only and exit (no changes, no root).
|
||||
--skip-tailscale Do not touch Tailscale.
|
||||
--skip-plex Do not touch Plex config.
|
||||
-y, --yes Non-interactive: accept defaults.
|
||||
--dry-run Print actions without changing anything.
|
||||
-h, --help This help.
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--login-server) LOGIN_SERVER="$2"; shift 2;;
|
||||
--authkey) AUTHKEY="$2"; shift 2;;
|
||||
--hostname) TS_HOSTNAME="$2"; shift 2;;
|
||||
--ts-iface) TS_IFACE="$2"; shift 2;;
|
||||
--prefs) PREFS="$2"; shift 2;;
|
||||
--service) SERVICE="$2"; shift 2;;
|
||||
--port) PLEX_PORT="$2"; shift 2;;
|
||||
--url-scheme) URL_SCHEME="$2"; shift 2;;
|
||||
--secure) SECURE="$2"; shift 2;;
|
||||
--disable-relay) RELAY="disable"; shift;;
|
||||
--keep-relay) RELAY="keep"; shift;;
|
||||
--firewall) FIREWALL="$2"; shift 2;;
|
||||
--healthcheck) HEALTHCHECK_ONLY=1; shift;;
|
||||
--skip-tailscale) SKIP_TAILSCALE=1; shift;;
|
||||
--skip-plex) SKIP_PLEX=1; shift;;
|
||||
-y|--yes|--non-interactive) ASSUME_YES=1; shift;;
|
||||
--dry-run) DRY_RUN=1; shift;;
|
||||
-h|--help) usage; exit 0;;
|
||||
*) die "unknown option: $1 (see --help)";;
|
||||
esac
|
||||
done
|
||||
case "$URL_SCHEME" in http|https) ;; *) die "--url-scheme must be http or https";; esac
|
||||
is_port "$PLEX_PORT" || die "--port must be 1-65535, got: $PLEX_PORT"
|
||||
[[ $ASSUME_YES -eq 0 && -t 0 ]] && INTERACTIVE=1 || INTERACTIVE=0
|
||||
}
|
||||
|
||||
# ---- security questionnaire ------------------------------------------------
|
||||
resolve_security_options() {
|
||||
if [[ -z "$SECURE" ]]; then
|
||||
[[ $INTERACTIVE -eq 1 ]] \
|
||||
&& SECURE="$(ask_choice 'Secure connections between clients and server:' preferred required preferred disabled keep)" \
|
||||
|| SECURE="preferred"
|
||||
fi
|
||||
if [[ -z "$RELAY" ]]; then
|
||||
if [[ $INTERACTIVE -eq 1 ]]; then
|
||||
ask_yes_no 'Disable Plex Relay (recommended -- you reach the server via the tailnet)?' Y && RELAY=disable || RELAY=keep
|
||||
else RELAY=disable; fi
|
||||
fi
|
||||
if [[ -z "$FIREWALL" ]]; then
|
||||
[[ $INTERACTIVE -eq 1 ]] \
|
||||
&& FIREWALL="$(ask_choice "Lock down Plex ${PLEX_PORT}/tcp? (tailnet=VPN only, lan=VPN+home LAN, none=leave)" none tailnet lan none)" \
|
||||
|| FIREWALL="none"
|
||||
fi
|
||||
case "$SECURE" in required|preferred|disabled|keep) ;; *) die "--secure must be required|preferred|disabled|keep";; esac
|
||||
case "$RELAY" in disable|keep) ;; *) die "relay choice must be disable|keep";; esac
|
||||
case "$FIREWALL" in none|tailnet|lan) ;; *) die "--firewall must be none|tailnet|lan";; esac
|
||||
log "security: secureConnections=$SECURE, relay=$RELAY, firewall=$FIREWALL"
|
||||
}
|
||||
|
||||
# ---- tailscale -------------------------------------------------------------
|
||||
install_tailscale() {
|
||||
if have_cmd tailscale; then
|
||||
ok "tailscale already installed ($(tailscale version 2>/dev/null | head -n1))"
|
||||
else
|
||||
need_cmd curl
|
||||
log "installing Tailscale via official script"
|
||||
if [[ $DRY_RUN -eq 1 ]]; then echo " + curl -fsSL https://tailscale.com/install.sh | sh"
|
||||
else curl -fsSL https://tailscale.com/install.sh | sh; fi
|
||||
fi
|
||||
run systemctl enable --now tailscaled
|
||||
}
|
||||
|
||||
join_tailnet() {
|
||||
local args=(up --reset)
|
||||
[[ -n "$LOGIN_SERVER" ]] && args+=(--login-server "$LOGIN_SERVER")
|
||||
[[ -n "$AUTHKEY" ]] && args+=(--authkey "$AUTHKEY")
|
||||
[[ -n "$TS_HOSTNAME" ]] && args+=(--hostname "$TS_HOSTNAME")
|
||||
log "bringing up tailscale: tailscale $(redact_after --authkey "${args[@]}")"
|
||||
[[ -z "$AUTHKEY" ]] && warn "no --authkey: 'tailscale up' prints a login URL; open it to authenticate."
|
||||
# Do not route the auth key through run(): its dry-run echo would print the
|
||||
# secret. The redacted command was already logged above.
|
||||
[[ $DRY_RUN -eq 1 ]] && return 0
|
||||
tailscale "${args[@]}"
|
||||
}
|
||||
|
||||
tailnet_ip_soft() { tailscale ip -4 2>/dev/null | head -n1 || true; }
|
||||
|
||||
# ---- plex ------------------------------------------------------------------
|
||||
secure_value() {
|
||||
case "$1" in required) echo 0;; preferred) echo 1;; disabled) echo 2;; *) echo "";; esac
|
||||
}
|
||||
|
||||
get_attr() { python3 "$PREFS_PY" get "$PREFS" "$1" 2>/dev/null || true; }
|
||||
|
||||
configure_plex() {
|
||||
local ts_ip="$1"
|
||||
[[ -f "$PREFS" ]] || die "Preferences.xml not found at: $PREFS (pass --prefs; quote the path)"
|
||||
[[ -f "$PREFS_PY" ]] || die "missing helper: $PREFS_PY"
|
||||
need_cmd python3
|
||||
|
||||
local url="${URL_SCHEME}://${ts_ip}:${PLEX_PORT}"
|
||||
local owner mode sv relay_val
|
||||
owner="$(stat -c '%U:%G' "$PREFS")"
|
||||
mode="$(stat -c '%a' "$PREFS")"
|
||||
sv="$(secure_value "$SECURE")"
|
||||
[[ "$RELAY" == "disable" ]] && relay_val="0" || relay_val=""
|
||||
|
||||
log "stopping $SERVICE (Plex rewrites Preferences.xml on exit; edit while stopped)"
|
||||
run systemctl stop "$SERVICE" || warn "could not stop $SERVICE; continuing"
|
||||
|
||||
local bak; bak="${PREFS}.bak.$(date +%Y%m%d%H%M%S)"
|
||||
run cp -a "$PREFS" "$bak"
|
||||
ok "backup written: $bak"
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
log "[dry-run] merge customConnections += $url"
|
||||
log "[dry-run] merge LanNetworksBandwidth += $TAILNET_V4,$TAILNET_V6"
|
||||
[[ -n "$sv" ]] && log "[dry-run] set secureConnections = $sv ($SECURE)"
|
||||
[[ -n "$relay_val" ]] && log "[dry-run] set RelayEnabled = 0 (disable relay)"
|
||||
else
|
||||
python3 "$PREFS_PY" merge "$PREFS" \
|
||||
--custom-url "$url" --lan "${TAILNET_V4},${TAILNET_V6}" \
|
||||
--secure "$sv" --relay "$relay_val"
|
||||
ok "Preferences.xml updated"
|
||||
fi
|
||||
|
||||
run chown "$owner" "$PREFS"
|
||||
run chmod "$mode" "$PREFS"
|
||||
log "starting $SERVICE"
|
||||
run systemctl start "$SERVICE"
|
||||
|
||||
if [[ $DRY_RUN -eq 0 ]]; then
|
||||
log "waiting for Plex to answer locally..."
|
||||
local i
|
||||
for i in $(seq 1 20); do
|
||||
curl -fsS "http://127.0.0.1:${PLEX_PORT}/identity" >/dev/null 2>&1 && { ok "Plex is up locally"; return 0; }
|
||||
sleep 1
|
||||
done
|
||||
warn "Plex did not answer on :${PLEX_PORT} within 20s; check 'systemctl status $SERVICE'"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- firewall (only ever touches ${PLEX_PORT}/tcp; SSH stays open) ----------
|
||||
configure_firewall() {
|
||||
local mode="$1"
|
||||
[[ "$mode" == "none" ]] && { log "firewall: left unchanged"; return 0; }
|
||||
|
||||
if have_cmd ufw && ufw status 2>/dev/null | grep -qi '^Status: active'; then
|
||||
log "firewall: ufw active -- restricting ${PLEX_PORT}/tcp"
|
||||
run ufw allow in on "$TS_IFACE" to any port "$PLEX_PORT" proto tcp || true
|
||||
if [[ "$mode" == "lan" ]]; then
|
||||
local n
|
||||
for n in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do
|
||||
run ufw allow from "$n" to any port "$PLEX_PORT" proto tcp || true
|
||||
done
|
||||
fi
|
||||
run ufw deny "$PLEX_PORT"/tcp || true
|
||||
ok "ufw: ${PLEX_PORT}/tcp limited to tailnet$([[ "$mode" == lan ]] && echo ' + private LAN')"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if have_cmd firewall-cmd && firewall-cmd --state 2>/dev/null | grep -qi running; then
|
||||
log "firewall: firewalld running -- restricting ${PLEX_PORT}/tcp"
|
||||
run firewall-cmd --permanent --zone=trusted --change-interface="$TS_IFACE" || true
|
||||
run firewall-cmd --permanent --remove-port="$PLEX_PORT"/tcp || true
|
||||
if [[ "$mode" == "lan" ]]; then
|
||||
local n
|
||||
for n in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do
|
||||
run firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=$n port port=$PLEX_PORT protocol=tcp accept" || true
|
||||
done
|
||||
fi
|
||||
run firewall-cmd --reload || true
|
||||
ok "firewalld: $TS_IFACE trusted; ${PLEX_PORT}/tcp not exposed publicly"
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "no ACTIVE managed firewall (ufw/firewalld) found; not touching firewall (avoiding lockout)."
|
||||
warn "Manual nftables equivalent (only filters ${PLEX_PORT}/tcp, safe for SSH):"
|
||||
cat >&2 <<EOF
|
||||
nft add table inet plexlock
|
||||
nft 'add chain inet plexlock input { type filter hook input priority -10 ; }'
|
||||
nft add rule inet plexlock input iifname "lo" accept
|
||||
nft add rule inet plexlock input iifname "$TS_IFACE" tcp dport ${PLEX_PORT} accept
|
||||
$( [[ "$mode" == lan ]] && echo " nft add rule inet plexlock input ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } tcp dport ${PLEX_PORT} accept" )
|
||||
nft add rule inet plexlock input tcp dport ${PLEX_PORT} drop
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---- health check ----------------------------------------------------------
|
||||
HC_PASS=0; HC_WARN=0; HC_FAIL=0
|
||||
hc() { # label status detail
|
||||
local label="$1" status="$2" detail="${3:-}" sym col
|
||||
case "$status" in
|
||||
PASS) sym="+"; col=$'\033[1;32m'; HC_PASS=$((HC_PASS + 1));;
|
||||
WARN) sym="!"; col=$'\033[1;33m'; HC_WARN=$((HC_WARN + 1));;
|
||||
FAIL) sym="x"; col=$'\033[1;31m'; HC_FAIL=$((HC_FAIL + 1));;
|
||||
esac
|
||||
printf ' %s[%s]%s %-26s %s\n' "$(_c "$col")" "$sym" "$(_c $'\033[0m')" "$label" "$detail"
|
||||
}
|
||||
|
||||
healthcheck() {
|
||||
local ts_ip="${1:-}"
|
||||
HC_PASS=0; HC_WARN=0; HC_FAIL=0
|
||||
printf '\n%sHealth check%s\n' "$(_c $'\033[1m')" "$(_c $'\033[0m')"
|
||||
|
||||
if have_cmd tailscale; then
|
||||
tailscale status >/dev/null 2>&1 && hc "Tailscale backend" PASS "running" \
|
||||
|| hc "Tailscale backend" FAIL "down / logged out (run 'tailscale up')"
|
||||
local ip; ip="$(tailnet_ip_soft)"
|
||||
[[ -n "$ip" ]] && hc "Tailnet IPv4" PASS "$ip" || hc "Tailnet IPv4" FAIL "no address assigned"
|
||||
[[ -z "$ts_ip" || "$ts_ip" == "<"* ]] && ts_ip="$ip"
|
||||
else
|
||||
hc "Tailscale" FAIL "not installed"
|
||||
fi
|
||||
|
||||
systemctl is-active --quiet "$SERVICE" 2>/dev/null \
|
||||
&& hc "Plex service" PASS "$SERVICE active" \
|
||||
|| hc "Plex service" WARN "$SERVICE not active (or no systemd)"
|
||||
|
||||
curl -fsS --max-time 8 "http://127.0.0.1:${PLEX_PORT}/identity" >/dev/null 2>&1 \
|
||||
&& hc "Plex local API" PASS "127.0.0.1:${PLEX_PORT}" \
|
||||
|| hc "Plex local API" FAIL "no response on :${PLEX_PORT}"
|
||||
|
||||
if [[ -n "$ts_ip" && "$ts_ip" != "<"* ]]; then
|
||||
curl -fsSk --max-time 8 "http://${ts_ip}:${PLEX_PORT}/identity" >/dev/null 2>&1 \
|
||||
&& hc "Plex via tailnet IP" PASS "${ts_ip}:${PLEX_PORT}" \
|
||||
|| hc "Plex via tailnet IP" WARN "unreachable at ${ts_ip}:${PLEX_PORT} (firewall/not joined?)"
|
||||
fi
|
||||
|
||||
if [[ -f "$PREFS" ]] && have_cmd python3; then
|
||||
local cc lan rly
|
||||
cc="$(get_attr customConnections)"; lan="$(get_attr LanNetworksBandwidth)"; rly="$(get_attr RelayEnabled)"
|
||||
[[ "$cc" == *":${PLEX_PORT}"* ]] && hc "customConnections" PASS "$cc" || hc "customConnections" WARN "no tailnet URL (${cc:-empty})"
|
||||
[[ "$lan" == *"100.64.0.0/10"* ]] && hc "LAN networks" PASS "tailnet treated as LAN" || hc "LAN networks" WARN "tailnet range missing (${lan:-empty})"
|
||||
[[ "$rly" == "0" ]] && hc "Plex Relay" PASS "disabled" || hc "Plex Relay" WARN "enabled (RelayEnabled=${rly:-unset})"
|
||||
else
|
||||
hc "Preferences.xml" WARN "not readable at $PREFS"
|
||||
fi
|
||||
|
||||
if have_cmd ufw && ufw status 2>/dev/null | grep -qi '^Status: active'; then
|
||||
ufw status 2>/dev/null | grep -q "$PLEX_PORT" \
|
||||
&& hc "Firewall (ufw)" PASS "${PLEX_PORT}/tcp rules present" \
|
||||
|| hc "Firewall (ufw)" WARN "${PLEX_PORT}/tcp open on all interfaces"
|
||||
elif have_cmd firewall-cmd && firewall-cmd --state 2>/dev/null | grep -qi running; then
|
||||
hc "Firewall (firewalld)" PASS "running"
|
||||
else
|
||||
hc "Firewall" WARN "no managed firewall active"
|
||||
fi
|
||||
|
||||
printf '\n %s%d passed%s, %s%d warnings%s, %s%d failed%s\n' \
|
||||
"$(_c $'\033[1;32m')" "$HC_PASS" "$(_c $'\033[0m')" \
|
||||
"$(_c $'\033[1;33m')" "$HC_WARN" "$(_c $'\033[0m')" \
|
||||
"$(_c $'\033[1;31m')" "$HC_FAIL" "$(_c $'\033[0m')"
|
||||
[[ $HC_FAIL -eq 0 ]]
|
||||
}
|
||||
|
||||
# ---- main ------------------------------------------------------------------
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
if [[ $HEALTHCHECK_ONLY -eq 1 ]]; then
|
||||
if healthcheck "$(tailnet_ip_soft)"; then exit 0; else exit 1; fi
|
||||
fi
|
||||
|
||||
require_root
|
||||
resolve_security_options
|
||||
|
||||
local ts_ip="<tailscale-ip>"
|
||||
if [[ $SKIP_TAILSCALE -eq 0 ]]; then
|
||||
install_tailscale
|
||||
join_tailnet
|
||||
ts_ip="$(tailnet_ip_soft)"
|
||||
[[ -n "$ts_ip" ]] || die "could not read tailscale IPv4 (authenticated? 'tailscale status')"
|
||||
ok "this node's tailnet IPv4: $ts_ip"
|
||||
else
|
||||
ts_ip="$(tailnet_ip_soft)"; ts_ip="${ts_ip:-<tailscale-ip>}"
|
||||
warn "--skip-tailscale: using existing tailnet IP $ts_ip"
|
||||
fi
|
||||
|
||||
[[ $SKIP_PLEX -eq 0 ]] && configure_plex "$ts_ip" || warn "--skip-plex: not modifying Plex"
|
||||
configure_firewall "$FIREWALL"
|
||||
[[ $DRY_RUN -eq 0 ]] && healthcheck "$ts_ip" || true
|
||||
|
||||
cat <<EOF
|
||||
|
||||
$(ok "Server setup complete.")
|
||||
|
||||
Published Plex connection : ${URL_SCHEME}://${ts_ip}:${PLEX_PORT}
|
||||
Tailnet treated as LAN : ${TAILNET_V4}, ${TAILNET_V6}
|
||||
Security : secureConnections=${SECURE}, relay=${RELAY}, firewall=${FIREWALL}
|
||||
|
||||
For each remote user:
|
||||
1. Install Tailscale: https://tailscale.com/download
|
||||
$( [[ -n "$LOGIN_SERVER" ]] && echo " 2. Join your Headscale: sudo tailscale up --login-server $LOGIN_SERVER --authkey <their-preauthkey>" \
|
||||
|| echo " 2. Sign in to the SAME tailnet, or invite them to it." )
|
||||
3. Open Plex, sign in; the server appears over the tailnet.
|
||||
Shared users still need a library share (Settings > Users & Sharing).
|
||||
|
||||
Re-run health checks any time: sudo $0 --healthcheck
|
||||
See README.md for ACLs and the 2026 Plex Pass caveat.
|
||||
EOF
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Verifier: reads the live Plex process to confirm the crack installed.
|
||||
# arg1 = PID of "Plex Media Server"
|
||||
# Checks:
|
||||
# - apply_feature_list_xml (file vaddr 0x1167490) prologue overwritten with a
|
||||
# trampoline JMP (FF 25 ...) => hook installed.
|
||||
# - g_feature_bitset_slots (file vaddr 0x15AE5D8, 14 x u64) => feature bits.
|
||||
import sys, struct, binascii
|
||||
|
||||
pid = int(sys.argv[1])
|
||||
MAIN = "/usr/lib/plexmediaserver/Plex Media Server"
|
||||
APPLY = 0x1167490
|
||||
BITSET = 0x15AE5D8
|
||||
|
||||
base = None
|
||||
with open("/proc/%d/maps" % pid) as f:
|
||||
for line in f:
|
||||
if line.rstrip().endswith(MAIN):
|
||||
base = int(line.split("-", 1)[0], 16)
|
||||
break
|
||||
if base is None:
|
||||
print("ERROR: base mapping not found")
|
||||
sys.exit(1)
|
||||
print("base = 0x%x" % base)
|
||||
|
||||
def rd(off, n):
|
||||
with open("/proc/%d/mem" % pid, "rb") as f:
|
||||
f.seek(base + off)
|
||||
return f.read(n)
|
||||
|
||||
fn = rd(APPLY, 16)
|
||||
print("apply_feature_list_xml[0:16] = " + binascii.hexlify(fn).decode())
|
||||
print("hook installed (prologue == jmp FF 25)? %s" % (fn[:2] == b"\xff\x25"))
|
||||
|
||||
qs = struct.unpack("<14Q", rd(BITSET, 112))
|
||||
for i, q in enumerate(qs):
|
||||
print(" slot %2d = 0x%016x" % (i, q))
|
||||
print("all 14 qwords fully 0xFF..F? %s" % all(q == 0xFFFFFFFFFFFFFFFF for q in qs))
|
||||
print("all used low-bytes set (every feature enabled)? %s" % all((q & 0xFF) == 0xFF for q in qs))
|
||||
@@ -0,0 +1,798 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#include "hook.hpp"
|
||||
|
||||
// Use auto-generated patterns from build-time discovery if available.
|
||||
// Falls back to hardcoded patterns for standalone builds without the
|
||||
// generated header.
|
||||
#if __has_include("patterns_generated.h")
|
||||
#include "patterns_generated.h"
|
||||
#else
|
||||
static const char* PATTERN_PREF_GETTER =
|
||||
"55 48 89 E5 41 57 41 56 53 48 83 EC ? 48 89 F3 49 89 FE 0F B6 46 17 48 89 F1 84 C0";
|
||||
static const char* PATTERN_BITSET_REF =
|
||||
"48 8D 0D ? ? ? ? 48 8B 94 05 ? ? ? ? 48 87 14 08";
|
||||
static const char* PATTERN_BS_INIT =
|
||||
"55 48 89 E5 41 57 41 56 41 55 41 54 53 48 81 EC ? ? 00 00 49 89 FE 48 8D 9D ? ? ? ? 48 89 DF E8 ? ? ? ? 48 8B 1B 48 85 DB";
|
||||
static const char* PATTERN_LEGACY_USF =
|
||||
"55 48 89 E5 48 8B 07 48 85 C0 74 09";
|
||||
static const char* PATTERN_LEGACY_MF =
|
||||
"55 48 89 E5 41 57 41 56 53 48 83 EC ? 49 89 F7 4C 8D 77";
|
||||
#endif
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/mman.h>
|
||||
#ifndef __aarch64__
|
||||
#include "Zydis.h"
|
||||
#endif
|
||||
#include "webhook_handler.hpp"
|
||||
|
||||
struct FeatureGuidEntry
|
||||
{
|
||||
const char* uuid;
|
||||
int feature_code;
|
||||
int bitset_slot;
|
||||
uint8_t bit_mask;
|
||||
const char* alias;
|
||||
};
|
||||
|
||||
// GUID catalog retained for future targeted hooks or diagnostics.
|
||||
// Current hook path still uses Godmode and enables every feature.
|
||||
[[maybe_unused]] static constexpr FeatureGuidEntry kFeatureGuidCatalog[] =
|
||||
{
|
||||
{"db965785-ca5c-46fd-bab6-7b3d29c18492", 0, 0, 0x01, nullptr},
|
||||
{"fd6683b9-1426-4b00-840f-cd5fb0904a6a", 1, 0, 0x02, nullptr},
|
||||
{"7ef84008-9a02-43f9-a22f-86102fd66922", 2, 0, 0x04, nullptr},
|
||||
{"075954ad-56ef-4f5e-9519-9cfb0ed05827", 3, 0, 0x08, nullptr},
|
||||
{"16abced2-1e64-4f01-b64f-f8ef41b1ea6c", 4, 0, 0x10, nullptr},
|
||||
{"a19d495a-1cef-4f7c-ab77-5186e63e17f7", 5, 0, 0x20, "loudness"},
|
||||
{"dcecabdf-68cf-4067-8013-73bd9ea3940b", 6, 0, 0x40, nullptr},
|
||||
{"52ee04dc-2b82-4142-9e0a-e7ce8087c5b6", 7, 0, 0x80, nullptr},
|
||||
{"59127bcf-acc8-4e97-ad88-8ba6380880b9", 8, 1, 0x01, "pro_install"},
|
||||
{"ec64b6f6-e804-4ef3-b114-9d5c63e1a941", 9, 1, 0x02, nullptr},
|
||||
{"ee352392-2934-4061-ba35-5f3189f19ab4", 10, 1, 0x04, nullptr},
|
||||
{"cc987706-05d8-4c1f-9386-2e86f402706d", 11, 1, 0x08, nullptr},
|
||||
{"b83c8dc9-5a01-4b7a-a7c9-5870c8a6e21b", 12, 1, 0x10, nullptr},
|
||||
{"65685ff8-4375-4e4c-a806-ec1f0b4a8b7f", 13, 1, 0x20, "livetv"},
|
||||
{"6380e085-02fe-43b5-8bff-380fa4f2423c", 14, 1, 0x40, "trailers"},
|
||||
{"b46d16ae-cbd6-4226-8ee9-ab2b27e5dd42", 15, 1, 0x80, "unsupportedtuners"},
|
||||
{"9c982beb-c676-4d6f-a777-ff5d37ec3081", 16, 2, 0x01, nullptr},
|
||||
{"dbdc0575-9fc7-4706-9e2d-fca98d10ad71", 17, 2, 0x02, nullptr},
|
||||
{"ce30800e-9c3c-4a1f-8bb3-d93d6149ff5f", 18, 2, 0x04, nullptr},
|
||||
{"e4532fb1-b2e8-4269-9225-b804657cb3ba", 19, 2, 0x08, "roku-dogfood"},
|
||||
{"84a754b0-d1ca-4433-af2d-c949bf4b4936", 20, 2, 0x10, "hwtranscode"},
|
||||
{"bf1f3608-e44e-48cb-84d7-11a13f29b090", 21, 2, 0x20, nullptr},
|
||||
{"850f3d1e-3f38-44c1-9c0c-e3c9127b8b5a", 22, 2, 0x40, "photosV6-edit"},
|
||||
{"0e2acda2-d70d-4df6-96e0-f63cf264d217", 23, 2, 0x80, nullptr},
|
||||
{"05690239-443e-43fb-bc1a-95b5d916ca63", 24, 3, 0x01, "session_bandwidth_restrictions"},
|
||||
{"ea791163-c28d-4b7c-af88-bcc9553b206d", 25, 3, 0x02, nullptr},
|
||||
{"e093a02d-5532-4506-948a-2e994beb032b", 26, 3, 0x04, nullptr},
|
||||
{"d4b4e08a-9201-4c99-9a52-8f2de8ff25cd", 27, 3, 0x08, nullptr},
|
||||
{"00cc618e-eb08-4e0e-9221-82b4835dd89b", 28, 3, 0x10, nullptr},
|
||||
{"d9f42aea-bc9d-47db-9814-cd7a577aff48", 29, 3, 0x20, nullptr},
|
||||
{"d85cb60c-0986-4a02-b1e1-36c64c609712", 30, 3, 0x40, nullptr},
|
||||
{"c55d5900-b546-416d-a8c5-45b24a13e9bc", 31, 3, 0x80, "download_certificates"},
|
||||
{"2797e341-b062-46ed-862f-0acbba5dd522", 32, 4, 0x01, nullptr},
|
||||
{"c43d8d0f-7aa3-4fef-b9ad-4902580c90ce", 33, 4, 0x02, "incremental-epg"},
|
||||
{"e8230c74-0940-4b91-9e20-6571eb068086", 34, 4, 0x04, "dvr"},
|
||||
{"044a1fac-6b55-47d0-9933-25a035709432", 35, 4, 0x08, nullptr},
|
||||
{"4ca03b04-54c1-4f9f-aea2-f813ae48f317", 36, 4, 0x10, "session_kick"},
|
||||
{"a536a6e1-0ece-498a-bf64-99b53c27de3a", 37, 4, 0x20, nullptr},
|
||||
{"76ddd91e-8321-4916-94b6-ded8e3727a64", 38, 4, 0x40, nullptr},
|
||||
{"ebbe0bd5-7b9f-4c50-92d2-122eb35b61ad", 39, 4, 0x80, nullptr},
|
||||
{"b2403ac6-4885-4971-8b96-59353fd87c72", 40, 5, 0x01, "home"},
|
||||
{"56cd352b-0d47-436d-aced-f20db3508de5", 41, 5, 0x02, nullptr},
|
||||
{"d49a726d-ef0e-4a04-9ffb-fd018306d3b7", 42, 5, 0x04, nullptr},
|
||||
{"0eee866d-782b-4dfd-b42b-3bbe8eb0af16", 43, 5, 0x08, "server-manager"},
|
||||
{"1417df52-986e-4e4b-8dcd-3997fbc5c976", 44, 5, 0x10, "collections"},
|
||||
{"4264b94c-cb40-4935-83b4-7b5c49d35e7f", 45, 5, 0x20, "remote_watch_pass"}, // g_feature_bits_remote_media&0x20; gates remote playback/download (FeatureManager_matches_client_policy)
|
||||
{"88aba3a3-bd62-42a5-91bb-0558a4c1db57", 46, 5, 0x40, nullptr},
|
||||
{"c7ae6f8f-05e6-48bb-9024-c05c1dc3c43e", 47, 5, 0x80, "kevin-bacon"},
|
||||
{"b58d7f28-7b4a-49bb-97a7-152645505f28", 48, 6, 0x01, "item_clusters"},
|
||||
{"62b1e357-5450-41d8-9b60-c7705f750849", 49, 6, 0x02, nullptr},
|
||||
{"c225b90f-d4b6-4286-a4dd-2492aa017b63", 50, 6, 0x04, nullptr},
|
||||
{"1f952ea5-0837-44cb-8539-a69a14a75d4a", 51, 6, 0x08, nullptr},
|
||||
{"d20f9af2-fdb1-4927-99eb-a2eb8fbff799", 52, 6, 0x10, "shared-radio"},
|
||||
{"644c4466-05fa-45e0-a478-c594cf81778f", 53, 6, 0x20, nullptr},
|
||||
{"6f82ca43-6117-4e55-ae0e-5ea3b3e99a96", 54, 6, 0x40, "webhooks"},
|
||||
{"9dc1df45-fb45-4be1-9ab2-eb23eb57f082", 55, 6, 0x80, "sync"},
|
||||
{"0de49fa2-30cc-4b54-a22d-ff860c1bf3af", 56, 7, 0x01, nullptr},
|
||||
{"a6f3f9b3-c10c-4b94-ad59-755e30ac6c90", 57, 7, 0x02, nullptr},
|
||||
{"8536058d-e1dd-4ae7-b30f-e8b059b7cc17", 58, 7, 0x04, nullptr},
|
||||
{"e7cea823-02e5-48c4-a501-d37b82bf132f", 59, 7, 0x08, nullptr},
|
||||
{"2573654f-0985-4cef-9c53-28e78cc62f26", 60, 7, 0x10, nullptr},
|
||||
{"e954ef21-08b4-411e-a1f0-7551f1e57b11", 61, 7, 0x20, nullptr},
|
||||
{"84309650-eb7a-41e8-8b6c-a260f084bb9d", 62, 7, 0x40, nullptr},
|
||||
{"bcd82ac2-f32e-4a23-bb48-88090248c5db", 63, 7, 0x80, nullptr},
|
||||
{"ea442c16-044a-4fa7-8461-62643f313c62", 64, 8, 0x01, nullptr},
|
||||
{"07f804e6-28e6-4beb-b5c3-f2aefc88b938", 65, 8, 0x02, nullptr},
|
||||
{"32cc8bf5-b425-4582-a52d-71b4f1cf436b", 66, 8, 0x04, "content_filter"},
|
||||
{"3f28df36-2648-41b6-b2ef-36c2e1509467", 67, 8, 0x08, nullptr},
|
||||
{"67c80530-eae3-4500-a9fa-9b6947d0f6d1", 68, 8, 0x10, nullptr},
|
||||
{"222020fb-1504-492d-af33-a0b80a49558a", 69, 8, 0x20, nullptr},
|
||||
{"5f4ac7c1-a619-4c17-9f06-7b5564566c94", 70, 8, 0x40, nullptr},
|
||||
{"a548af72-b804-4d05-8569-52785952d31d", 71, 8, 0x80, nullptr},
|
||||
{"99d17487-7106-4d42-a3b1-c92f68b73165", 72, 9, 0x01, "news"},
|
||||
{"abd37b14-706c-461f-8255-fa9563882af3", 73, 9, 0x02, "adaptive_bitrate"},
|
||||
{"c9a08c83-fbd1-4f2c-ac21-6b35a0acea0e", 74, 9, 0x04, nullptr},
|
||||
{"ba8459cd-81fe-4799-93e2-84358717bfb4", 75, 9, 0x08, nullptr},
|
||||
{"002c9f1a-2fc0-4812-b85b-0e6140f21a0f", 76, 9, 0x10, "lyrics"},
|
||||
{"fb34e64d-cd89-47b8-8bae-a6d20c542bae", 77, 9, 0x20, "camera_upload"},
|
||||
{"c757f4d0-2ce6-42d8-ab73-b4808b97cc81", 78, 9, 0x40, nullptr},
|
||||
{"ff204a84-8ff1-4d9e-bf5e-378c97bceb10", 79, 9, 0x80, nullptr},
|
||||
{"1df3cd16-faf2-4d37-8349-1fcf3713bf1d", 80, 10, 0x01, nullptr},
|
||||
{"53d7b3d9-f1f4-4584-9434-9380295db9fe", 81, 10, 0x02, nullptr},
|
||||
{"926bc176-58ca-47da-b8e3-080ed14ea6ba", 82, 10, 0x04, nullptr},
|
||||
{"a0220fbb-3a79-4041-8642-add6abf70eb5", 83, 10, 0x08, nullptr},
|
||||
{"cbae4949-1643-46f3-a488-71836b025d63", 84, 10, 0x10, nullptr},
|
||||
{"93bf35b9-3b62-4a8a-b09b-5c85437fa67b", 85, 10, 0x20, nullptr},
|
||||
{"ea02d5cc-d9d1-49a4-ab46-bc54c39f739a", 86, 10, 0x40, nullptr},
|
||||
{"bc8d1fca-deb0-4d0a-a6f4-12cfd681002d", 87, 10, 0x80, "hardware_transcoding"},
|
||||
{"300231e0-69aa-4dce-97f4-52d8c00e3e8c", 88, 11, 0x01, "radio"},
|
||||
{"e6eda780-8db7-4114-8179-2f581207d58f", 89, 11, 0x02, nullptr},
|
||||
{"6ab6677b-ad9b-444f-9ca1-b8027d05b3e1", 90, 11, 0x04, nullptr},
|
||||
{"3c376154-d47e-4bbf-9428-2ea2592fd20a", 91, 11, 0x08, nullptr},
|
||||
{"82999dd3-a2be-482e-9f44-357879b4f603", 92, 11, 0x10, "pass"},
|
||||
{"5d819d02-5d04-4116-8eec-f49def4e2d6f", 93, 11, 0x20, "federated-auth"},
|
||||
{"4866e9e9-ad14-4c2b-bd92-49576f320fd7", 94, 11, 0x40, "ump-matching-pref"},
|
||||
{"1facf910-786e-46bb-894e-ea0b41e3fa3e", 95, 11, 0x80, nullptr},
|
||||
{"fef9b829-af7e-430c-a757-d80de818f211", 96, 12, 0x01, nullptr},
|
||||
{"4b522f91-ae89-4f62-af9c-76f44d8ef61c", 97, 12, 0x02, "tuner-sharing"},
|
||||
{"3a2b0cb6-1519-4431-98e2-823c248c70eb", 98, 12, 0x04, "photosV6-tv-albums"},
|
||||
{"d413fb56-de7b-40e4-acd0-f3dbb7c9e104", 99, 12, 0x08, "premium_music_metadata"},
|
||||
{"9aea4ca5-2095-4619-9339-88c1e662fde6", 100, 12, 0x10, nullptr},
|
||||
{"cd0ef747-af8a-414b-9d3a-dd02b6454db9", 101, 12, 0x20, nullptr},
|
||||
{"04d7d794-b76c-49ef-9184-52f8f1f501ee", 102, 12, 0x40, nullptr},
|
||||
{"1844737f-1a87-45c3-ab20-01435959e63c", 103, 12, 0x80, "music_videos"},
|
||||
{"8d15fdf2-89dc-407e-a2f6-0e7c31daa09a", 104, 13, 0x01, nullptr},
|
||||
{"d14556be-ae6d-4407-89d0-b83953f4789a", 105, 13, 0x02, "type-first"},
|
||||
{"0a348865-4f87-46dc-8bb2-f37637975724", 106, 13, 0x04, "photos-v5"},
|
||||
{"3d572099-e243-49bb-9f94-17de7703f9f9", 107, 13, 0x08, "advanced-playback-settings"},
|
||||
{"8fd37970-6e4e-4f00-a64a-e70b52f18e94", 108, 13, 0x10, "music-analysis"},
|
||||
{"8b46de05-1f96-4278-87b3-010ba5b1e386", 109, 13, 0x20, nullptr},
|
||||
{"4cd4dc0e-6cbe-456c-9988-9f073fadcd73", 110, 13, 0x40, nullptr},
|
||||
};
|
||||
|
||||
[[maybe_unused]] static constexpr size_t kFeatureGuidCatalogCount = sizeof(kFeatureGuidCatalog) / sizeof(kFeatureGuidCatalog[0]);
|
||||
|
||||
static uint64_t (*g_feature_flags)[14] = nullptr;
|
||||
|
||||
// Trampoline pointers (thunks to the original functions).
|
||||
static uint64_t (*_bitset_init)(uintptr_t) = nullptr;
|
||||
static uint64_t (*_is_feature_available)(uintptr_t, const char**) = nullptr;
|
||||
static uint64_t* (*_map_find)(uintptr_t*, const char**) = nullptr;
|
||||
static bool (*_is_user_feature_set)(uintptr_t, int, int) = nullptr;
|
||||
static uint64_t (*_sub_122B2F2)(uintptr_t, char*) = nullptr;
|
||||
static uint64_t (*_sub_125ACA6)(uintptr_t) = nullptr;
|
||||
|
||||
// Returns the runtime [start, end) of the main program's executable code
|
||||
// by parsing /proc/self/maps for every r-xp segment of "Plex Media Server".
|
||||
//
|
||||
// dl_iterate_phdr cannot be used because under musl's dynamic linker the
|
||||
// LD_PRELOAD library's constructor runs BEFORE the main PIE binary is
|
||||
// loaded, so dl_iterate_phdr returns 0 callbacks.
|
||||
TextSpan get_dottext_info()
|
||||
{
|
||||
FILE* maps = fopen("/proc/self/maps", "r");
|
||||
if(!maps)
|
||||
return {false, 0, 0};
|
||||
|
||||
uintptr_t text_start = UINTPTR_MAX;
|
||||
uintptr_t text_end = 0;
|
||||
char line[8192];
|
||||
|
||||
while(fgets(line, sizeof(line), maps))
|
||||
{
|
||||
// Format: start-end perm offset dev inode [path]
|
||||
//
|
||||
// We want every r-xp (read-execute, non-writable) segment of the
|
||||
// main PIE binary, whose path ends in "Plex Media Server".
|
||||
// PMS maps the executable text as several adjacent executable ranges;
|
||||
// scanning only the first range misses the hook signatures.
|
||||
//
|
||||
// Codec .so files live under .../Codecs/... and also contain
|
||||
// "Plex Media Server" in their path, so we check for the exact
|
||||
// path suffix instead of a substring.
|
||||
|
||||
if(const char* path = strstr(line, "/usr/lib/plexmediaserver/"))
|
||||
{
|
||||
path += strlen("/usr/lib/plexmediaserver/");
|
||||
if(strncmp(path, "Plex Media Server", 17) != 0)
|
||||
continue; // codec .so or plugin, skip
|
||||
}
|
||||
else
|
||||
{
|
||||
continue; // not our binary
|
||||
}
|
||||
|
||||
// Parse permission field (field #2, after address range).
|
||||
const char* perm = line;
|
||||
while(*perm && *perm != ' ') perm++;
|
||||
while(*perm == ' ') perm++;
|
||||
|
||||
if(perm[0] != 'r' || perm[1] != '-' || perm[2] != 'x' || perm[3] != 'p')
|
||||
continue; // not an executable mapping
|
||||
|
||||
// Parse address range.
|
||||
const char* addr = line;
|
||||
char* end = nullptr;
|
||||
const uintptr_t seg_start = strtoull(addr, &end, 16);
|
||||
if(!end || *end != '-') continue;
|
||||
|
||||
const uintptr_t seg_end = strtoull(end + 1, &end, 16);
|
||||
if(!end) continue;
|
||||
|
||||
if(seg_start < text_start) text_start = seg_start;
|
||||
if(seg_end > text_end) text_end = seg_end;
|
||||
}
|
||||
|
||||
fclose(maps);
|
||||
|
||||
if(text_end <= text_start)
|
||||
return {false, 0, 0};
|
||||
|
||||
return {true, text_start, text_end};
|
||||
}
|
||||
|
||||
#ifndef __aarch64__
|
||||
OptAddr create_hook(uintptr_t from, uintptr_t to)
|
||||
{
|
||||
ZydisDecoder decoder;
|
||||
ZydisDecodedInstruction instruction;
|
||||
ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64);
|
||||
|
||||
auto trampoline_mem = static_cast<uint8_t*>(mmap(nullptr, getpagesize(), PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0));
|
||||
|
||||
if(trampoline_mem == MAP_FAILED)
|
||||
{
|
||||
return {false, 0};
|
||||
}
|
||||
|
||||
size_t offset = 0;
|
||||
|
||||
while(offset < 14)
|
||||
{
|
||||
if(ZYAN_SUCCESS(ZydisDecoderDecodeInstruction(&decoder, nullptr, reinterpret_cast<void*>(from + offset), ZYDIS_MAX_INSTRUCTION_LENGTH, &instruction)))
|
||||
{
|
||||
memcpy(trampoline_mem + offset, reinterpret_cast<void*>(from + offset), instruction.length);
|
||||
offset += instruction.length;
|
||||
}
|
||||
else
|
||||
{
|
||||
munmap(trampoline_mem, getpagesize());
|
||||
return {false, 0};
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t shellcode[] =
|
||||
{
|
||||
0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, // jmp [rip+0x06]
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // ?
|
||||
};
|
||||
|
||||
// Jump to original code in trampoline and make trampoline executable
|
||||
*reinterpret_cast<uintptr_t*>(&shellcode[6]) = from + offset;
|
||||
memcpy(trampoline_mem + offset, shellcode, sizeof(shellcode));
|
||||
const size_t trampoline_size = offset + sizeof(shellcode);
|
||||
|
||||
if(mprotect(trampoline_mem, trampoline_size, PROT_READ|PROT_EXEC) != 0)
|
||||
{
|
||||
munmap(trampoline_mem, getpagesize());
|
||||
return {false, 0};
|
||||
}
|
||||
|
||||
// Jump to target code
|
||||
*reinterpret_cast<uintptr_t*>(&shellcode[6]) = to;
|
||||
|
||||
// mprotect requires a page-aligned address. The target function sits
|
||||
// inside .text, which is mapped at page granularity. Align down to
|
||||
// the page boundary so mprotect succeeds.
|
||||
const uintptr_t from_page = from & ~(static_cast<uintptr_t>(getpagesize()) - 1);
|
||||
|
||||
if(mprotect(reinterpret_cast<void*>(from_page), getpagesize(), PROT_READ|PROT_WRITE|PROT_EXEC) != 0)
|
||||
{
|
||||
munmap(trampoline_mem, getpagesize());
|
||||
return {false, 0};
|
||||
}
|
||||
|
||||
memcpy(reinterpret_cast<void*>(from), shellcode, sizeof(shellcode));
|
||||
|
||||
// Flush instruction cache so the CPU sees the modified code.
|
||||
// On x86 this is a no-op at the hardware level (icache is coherent)
|
||||
// but acts as a compiler barrier to prevent reordering the memcpy
|
||||
// past the subsequent mprotect.
|
||||
__builtin___clear_cache(reinterpret_cast<char*>(from),
|
||||
reinterpret_cast<char*>(from) + sizeof(shellcode));
|
||||
|
||||
if(mprotect(reinterpret_cast<void*>(from_page), getpagesize(), PROT_READ|PROT_EXEC) != 0)
|
||||
{
|
||||
// Memory is still RWX (suboptimal but not fatal).
|
||||
}
|
||||
|
||||
return {true, reinterpret_cast<uintptr_t>(trampoline_mem)};
|
||||
}
|
||||
#endif // __aarch64__
|
||||
|
||||
#ifdef __aarch64__
|
||||
// ARM64 hook: uses a 16-byte LDR X17, [PC, #8]; BR X17 trampoline.
|
||||
// ARM64 has fixed 4-byte instructions so we don't need Zydis to decode lengths.
|
||||
//
|
||||
// Hook injection (16 bytes at `from`):
|
||||
// LDR X17, [PC, #8] ; 4 bytes: load target address from literal pool
|
||||
// BR X17 ; 4 bytes: jump to target
|
||||
// <8 bytes target> ; 8 bytes: literal pool with hook function address
|
||||
//
|
||||
// Trampoline (at least 28 bytes):
|
||||
// 16 bytes original instructions (4 × 4-byte)
|
||||
// LDR X17, [PC, #8] ; 4 bytes: load from+16 address
|
||||
// BR X17 ; 4 bytes: jump back
|
||||
// <8 bytes from+16> ; 8 bytes: literal pool
|
||||
//
|
||||
static OptAddr create_hook_arm64(uintptr_t from, uintptr_t to)
|
||||
{
|
||||
// Allocate a page for the trampoline.
|
||||
auto tramp = static_cast<uint8_t*>(mmap(nullptr, getpagesize(),
|
||||
PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0));
|
||||
if(tramp == MAP_FAILED)
|
||||
return {false, 0};
|
||||
|
||||
// Copy 16 bytes (4 ARM64 instructions) from the hook point.
|
||||
memcpy(tramp, reinterpret_cast<void*>(from), 16);
|
||||
|
||||
// Append jump-back to original code past the hook point.
|
||||
// LDR X17, [PC, #8]: offset = 8/4 = 2, encoding = 0x58000051
|
||||
// BR X17: 0xD61F0220
|
||||
const uint32_t ldr_x17 = 0x58000051; // LDR X17, [PC, #8]
|
||||
const uint32_t br_x17 = 0xD61F0220; // BR X17
|
||||
|
||||
memcpy(tramp + 16, &ldr_x17, 4);
|
||||
memcpy(tramp + 20, &br_x17, 4);
|
||||
*reinterpret_cast<uintptr_t*>(tramp + 24) = from + 16;
|
||||
|
||||
const size_t tramp_size = 32; // 16 copied + 16 jump = 32
|
||||
|
||||
if(mprotect(tramp, getpagesize(), PROT_READ|PROT_EXEC) != 0)
|
||||
{
|
||||
munmap(tramp, getpagesize());
|
||||
return {false, 0};
|
||||
}
|
||||
|
||||
// Make the hook point writable.
|
||||
const uintptr_t from_page = from & ~(static_cast<uintptr_t>(getpagesize()) - 1);
|
||||
if(mprotect(reinterpret_cast<void*>(from_page), getpagesize(),
|
||||
PROT_READ|PROT_WRITE|PROT_EXEC) != 0)
|
||||
{
|
||||
munmap(tramp, getpagesize());
|
||||
return {false, 0};
|
||||
}
|
||||
|
||||
// Write the hook: LDR X17, [PC, #8]; BR X17; <8-byte target addr>
|
||||
memcpy(reinterpret_cast<void*>(from), &ldr_x17, 4);
|
||||
memcpy(reinterpret_cast<void*>(from + 4), &br_x17, 4);
|
||||
*reinterpret_cast<uintptr_t*>(from + 8) = to;
|
||||
|
||||
// Flush instruction cache (required on ARM64 for modified code).
|
||||
__builtin___clear_cache(reinterpret_cast<char*>(from),
|
||||
reinterpret_cast<char*>(from) + 16);
|
||||
__builtin___clear_cache(reinterpret_cast<char*>(tramp),
|
||||
reinterpret_cast<char*>(tramp) + tramp_size);
|
||||
|
||||
// Restore text page to read-execute.
|
||||
mprotect(reinterpret_cast<void*>(from_page), getpagesize(), PROT_READ|PROT_EXEC);
|
||||
|
||||
return {true, reinterpret_cast<uintptr_t>(tramp)};
|
||||
}
|
||||
|
||||
// ARM64 hook callbacks.
|
||||
|
||||
// Hook for the feature-check function (SSO-check prologue on x1 or x0).
|
||||
// On ARM64, functions matching `ldrb w?, [x?, #0x17]` are candidate
|
||||
// feature checkers. We return `true` to mark every feature as available.
|
||||
// Currently unimplemented — we discover targets by sig_scan below.
|
||||
// (The actual callback is chosen based on which signature matched.)
|
||||
|
||||
// Bitset lock — same as x86-64: force all 14 slots to UINT64_MAX.
|
||||
// The ARM64 FeatureManager still uses the same std::bitset<896> layout.
|
||||
// We locate the feature flags pointer by scanning for LDR+DUP+STP pattern
|
||||
// that addresses the global bitset, similar to the lea-rel32 on x86-64.
|
||||
|
||||
// ARM64 hook that forces all feature bits on after initialization.
|
||||
static uint64_t (*_arm64_bitset_init)(uintptr_t) = nullptr;
|
||||
static uint64_t (*_arm64_feature_check)(uintptr_t, uintptr_t) = nullptr;
|
||||
static uintptr_t g_arm64_feature_flags = 0;
|
||||
|
||||
static uint64_t hook_arm64_bitset_init(uintptr_t rcx)
|
||||
{
|
||||
auto ret = _arm64_bitset_init(rcx);
|
||||
|
||||
if(g_arm64_feature_flags != 0)
|
||||
{
|
||||
// Force all 14 × uint64_t feature slots on.
|
||||
for(int i = 0; i < 14; i++)
|
||||
reinterpret_cast<uint64_t*>(g_arm64_feature_flags)[i] = UINT64_MAX;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Generic feature-check interceptor: return true for any feature.
|
||||
static uint64_t hook_arm64_feature_check(uintptr_t a, uintptr_t b)
|
||||
{
|
||||
(void)a; (void)b;
|
||||
return 1; // feature available
|
||||
}
|
||||
#endif // __aarch64__
|
||||
|
||||
OptAddr sig_scan(uintptr_t start, uintptr_t end, const char* pattern)
|
||||
{
|
||||
static constexpr uint16_t WILDCARD = 0xFFFF;
|
||||
static constexpr size_t MAX_PAT = 128;
|
||||
|
||||
uint16_t pat[MAX_PAT];
|
||||
size_t pat_count = 0;
|
||||
size_t i = 0;
|
||||
size_t len = strlen(pattern);
|
||||
|
||||
while(i < len && pat_count < MAX_PAT)
|
||||
{
|
||||
if(pattern[i] == ' ')
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(pattern[i] == '?')
|
||||
{
|
||||
pat[pat_count++] = WILDCARD;
|
||||
if(i + 1 < len && pattern[i + 1] == '?') i++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
char hex[3] = {pattern[i], i + 1 < len ? pattern[i + 1] : '\0', '\0'};
|
||||
pat[pat_count++] = static_cast<uint16_t>(strtol(hex, nullptr, 16));
|
||||
i += 2;
|
||||
}
|
||||
|
||||
if(pat_count == 0 || pat_count > end - start)
|
||||
{
|
||||
return {false, 0};
|
||||
}
|
||||
|
||||
for(uintptr_t addr = start; addr <= end - pat_count; addr++)
|
||||
{
|
||||
bool mismatch = false;
|
||||
|
||||
for(size_t x = 0; x < pat_count; x++)
|
||||
{
|
||||
if(pat[x] != WILDCARD && *reinterpret_cast<const uint8_t*>(addr + x) != static_cast<uint8_t>(pat[x]))
|
||||
{
|
||||
mismatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(!mismatch)
|
||||
{
|
||||
return {true, addr};
|
||||
}
|
||||
}
|
||||
|
||||
return {false, 0};
|
||||
}
|
||||
|
||||
uintptr_t follow_call_rel32(const uintptr_t address)
|
||||
{
|
||||
return address + 5 + *reinterpret_cast<uint32_t*>(address + 1);
|
||||
}
|
||||
|
||||
bool process_feature([[maybe_unused]] const char* guid)
|
||||
{
|
||||
// Godmode: Enable ALL features regardless of GUID
|
||||
// This is more robust against PMS updates as it doesn't depend on knowing specific GUIDs
|
||||
return true;
|
||||
}
|
||||
|
||||
uint64_t hook_is_feature_available(uintptr_t user, const char** feature)
|
||||
{
|
||||
if(process_feature(*feature))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _is_feature_available(user, feature);
|
||||
}
|
||||
|
||||
uint64_t* hook_map_find(uintptr_t* rcx, const char** str)
|
||||
{
|
||||
if(str != nullptr && process_feature(*str))
|
||||
{
|
||||
static uint64_t FAKE_PTR = 0;
|
||||
|
||||
return &FAKE_PTR;
|
||||
}
|
||||
|
||||
return _map_find(rcx, str);
|
||||
}
|
||||
|
||||
uint64_t hook_bitset_init(uintptr_t rcx)
|
||||
{
|
||||
auto ret = _bitset_init(rcx);
|
||||
|
||||
if(g_feature_flags)
|
||||
{
|
||||
for(int i = 0; i < 14; i++)
|
||||
{
|
||||
(*g_feature_flags)[i] = UINT64_MAX;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool hook_is_user_feature_set([[maybe_unused]] uintptr_t rcx, [[maybe_unused]] int expected, [[maybe_unused]] int feature)
|
||||
{
|
||||
return static_cast<bool>(expected);
|
||||
}
|
||||
|
||||
uint64_t hook_sub_122B2F2(uintptr_t this_ptr, char* key)
|
||||
{
|
||||
if(_sub_122B2F2) {
|
||||
if(strcmp(key, "WebHooksEnabled") == 0) {
|
||||
const char true_str[] = "1";
|
||||
// "1" is 2 bytes + null within 22-byte SSO threshold.
|
||||
// Construct SSO std::string: bytes 0=N, byte 23=22-N.
|
||||
// For length 1: byte 23 = 22-1 = 21 = 0x15.
|
||||
memcpy(reinterpret_cast<void*>(this_ptr + 16), true_str, 2);
|
||||
reinterpret_cast<uint8_t*>(this_ptr)[23] = 0x15;
|
||||
return 1;
|
||||
}
|
||||
return _sub_122B2F2(this_ptr, key);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint64_t hook_sub_125ACA6(uintptr_t manager)
|
||||
{
|
||||
if(_sub_125ACA6)
|
||||
{
|
||||
const auto ret = _sub_125ACA6(manager);
|
||||
webhook_set_manager(reinterpret_cast<void*>(manager));
|
||||
webhook_inject_into_manager(reinterpret_cast<void*>(manager));
|
||||
return ret;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void hook()
|
||||
{
|
||||
auto info = get_dottext_info();
|
||||
|
||||
if(!info.ok)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const uintptr_t start = info.start;
|
||||
const uintptr_t end = info.end;
|
||||
|
||||
#ifdef __aarch64__
|
||||
// ── ARM64 hook targets ──────────────────────────────────────────────
|
||||
//
|
||||
// Use signatures extracted from the ARM64 Plex binary analysis
|
||||
// (get_arm64_sigs.py output). The binary has no fixed addresses; all
|
||||
// targets are found by byte-pattern scanning.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Hook the preference init function (identified by "WebHooksEnabled"
|
||||
// ADRP+ADD reference) to force the preference on.
|
||||
// 2. Hook feature-check functions (identified by SSO prologue near
|
||||
// hasPlexPass string references) to return true.
|
||||
// 3. If a global feature-bitset is discoverable, force all slots on.
|
||||
|
||||
// STEP A1: Feature bitset — scan for LDR + DUP + STP pattern that
|
||||
// references the global feature flags array.
|
||||
//
|
||||
// The ARM64 binary still uses std::bitset<896> = 14 × uint64_t.
|
||||
// We look for a LDR (literal load of a global address) followed by a
|
||||
// DUP that broadcasts a feature slot index, followed by an AND/ANDS
|
||||
// test instruction — the same check_feature pattern on ARM64.
|
||||
//
|
||||
// Pattern: LDR Xt, #page (ADRP) / page-offset-of-g_feature_flags (ADD)
|
||||
// or a global pointer LDR via literal pool.
|
||||
//
|
||||
// If found, hook the first FeatureManager init function we can sig_scan
|
||||
// and force all bits on after init.
|
||||
|
||||
// Try to find the feature flags array by scanning for a function that
|
||||
// loads from a single global pointer (the bitset) in a loop-like pattern.
|
||||
// The ARM64 equivalent of the x86-64 `lea rcx, [rip+?]` pattern is an
|
||||
// ADRP+ADD sequence.
|
||||
//
|
||||
// We use a heuristic: look for ADRP X0, #page ; ADD X0, X0, #off immediately
|
||||
// followed by a BL call — this is a common pattern for passing the bitset
|
||||
// address to FeatureManager functions.
|
||||
if(const auto fflags_sig = sig_scan(start, end,
|
||||
"00 00 00 90 ?? ?? ?? 91 ?? ?? ?? 94"); fflags_sig.ok)
|
||||
{
|
||||
// fflags_sig.addr points to: ADRP X0, #page
|
||||
// Decode: ADRP X0, #page @ fflags_sig.addr => base = page
|
||||
// ADD X0, X0, #off @ fflags_sig.addr + 4 => addr = page + off
|
||||
//
|
||||
// ARM64 ADRP encoding:
|
||||
// |31|29|28|24|23| 5|4|0|
|
||||
// |1 immlo| 10000 | immhi| Rd|
|
||||
// imm = immlo:immhi (21-bit signed, shifted by 12)
|
||||
//
|
||||
// ARM64 ADD (immediate) encoding:
|
||||
// |31|24|23|22|21| 10 |9|5|4|0|
|
||||
// |10010001|sh| imm12 |Rn| Rd|
|
||||
//
|
||||
// Extract ADRP immediate:
|
||||
const uint32_t adrp_inst = *reinterpret_cast<const uint32_t*>(fflags_sig.addr);
|
||||
const uint32_t adrp_immlo = (adrp_inst >> 29) & 3;
|
||||
const uint32_t adrp_immhi = (adrp_inst >> 5) & 0x7FFFF;
|
||||
int64_t adrp_imm = (static_cast<int64_t>((adrp_immlo << 19) | adrp_immhi) << 43) >> 43;
|
||||
const uintptr_t adrp_page = (fflags_sig.addr & ~0xFFFULL) + adrp_imm;
|
||||
|
||||
// Extract ADD immediate:
|
||||
const uint32_t add_inst = *reinterpret_cast<const uint32_t*>(fflags_sig.addr + 4);
|
||||
const uint32_t add_imm12 = (add_inst >> 10) & 0xFFF;
|
||||
const uintptr_t flags_addr = adrp_page + add_imm12;
|
||||
|
||||
g_arm64_feature_flags = flags_addr;
|
||||
}
|
||||
|
||||
// Try to find and hook a FeatureManager init function.
|
||||
// ARM64 FeatureManager functions have a large stack frame (many stp pairs).
|
||||
// We look for the prologue with 6+ stp pairs (12+ register saves).
|
||||
if(g_arm64_feature_flags || true) // always try
|
||||
{
|
||||
// Signature from the FeatureManager constructor-like function at ~0x658070:
|
||||
// stp x29, x30, [sp, #-0x30]! ; stp x20, x19, [sp, #0x10] ; ...
|
||||
if(const auto fm = sig_scan(start, end,
|
||||
"FD 7B 03 A9 F4 4F 04 A9 FD C3 00 91"); fm.ok)
|
||||
{
|
||||
if(auto tramp = create_hook_arm64(fm.addr,
|
||||
reinterpret_cast<uintptr_t>(hook_arm64_bitset_init)); tramp.ok)
|
||||
{
|
||||
_arm64_bitset_init = reinterpret_cast<decltype(_arm64_bitset_init)>(tramp.addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// STEP A2: Feature-check function — candidate with SSO-check prologue.
|
||||
// The function at ~0xeba0b4 reads [x0+0x17] (std::string SSO byte) and
|
||||
// compares against known feature strings. Hooking it to always return
|
||||
// true unlocks features regardless of the bitset.
|
||||
//
|
||||
// Signature: FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 08 5C 40 39
|
||||
// (stp x29,x30,[sp,#-0x10]!; str x19,[sp,#8]; mov x29,sp; ldrb w8,[x0,#0x17])
|
||||
if(const auto fc = sig_scan(start, end,
|
||||
"FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 08 5C 40 39"); fc.ok)
|
||||
{
|
||||
if(auto tramp = create_hook_arm64(fc.addr,
|
||||
reinterpret_cast<uintptr_t>(hook_arm64_feature_check)); tramp.ok)
|
||||
{
|
||||
_arm64_feature_check = reinterpret_cast<decltype(_arm64_feature_check)>(tramp.addr);
|
||||
}
|
||||
}
|
||||
|
||||
// STEP A3: Preference init — the function that stores "WebHooksEnabled"
|
||||
// during startup. Hook it so we can force the default to "1" after init.
|
||||
//
|
||||
// Signature (from 0x10dd904): 6 stp pairs + mov x29, sp + sub sp, sp, #0x250
|
||||
// FD 7B BA A9 FC 6F 01 A9 FA 67 02 A9 F8 5F 03 A9 F6 57 04 A9 F4 4F 05 A9
|
||||
// FD 03 00 91 FF 43 09 D1
|
||||
//
|
||||
// Delay implementation until ARM64 build is verified on target.
|
||||
// TODO: Implement after verifying feature unlock works.
|
||||
|
||||
// STEP A4: WebHooksEnabled preference getter — find by SSO-check on x1 (2nd arg).
|
||||
// Pattern: `?? 5C 40 39` where ?? encodes register w? and x1 base register.
|
||||
// ldrb w8, [x1, #0x17] = 0x39400000 | (0x17 << 10) | (1 << 5) | 8 = 0x39405C28
|
||||
// ldrb w9, [x1, #0x17] = 0x39405C29
|
||||
// etc. We use a wildcard for the destination register.
|
||||
//
|
||||
// The surrounding prologue should also save multiple callee-saved registers.
|
||||
if(const auto wh = sig_scan(start, end,
|
||||
"FD 7B ?? A9 ?? ?? ?? A9 FD 03 00 91 ?? 5C 40 39 ??"); wh.ok)
|
||||
{
|
||||
if(auto tramp = create_hook_arm64(wh.addr,
|
||||
reinterpret_cast<uintptr_t>(hook_arm64_feature_check)); tramp.ok)
|
||||
{
|
||||
// Same callback as feature check for now — returns true for any
|
||||
// getter call. TODO: implement string-key matching like x86-64.
|
||||
_arm64_feature_check = reinterpret_cast<decltype(_arm64_feature_check)>(tramp.addr);
|
||||
}
|
||||
}
|
||||
|
||||
// ARM64 hook complete.
|
||||
return;
|
||||
#endif
|
||||
|
||||
// STEP 1: sub_122B2F2 (the generic preference getter): force WebHooksEnabled.
|
||||
if(const auto wh = sig_scan(start, end,
|
||||
PATTERN_PREF_GETTER); wh.ok)
|
||||
{
|
||||
if(auto trampoline = create_hook(wh.addr, reinterpret_cast<uintptr_t>(hook_sub_122B2F2)); trampoline.ok)
|
||||
{
|
||||
_sub_122B2F2 = reinterpret_cast<decltype(_sub_122B2F2)>(trampoline.addr);
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: sub_125E524+0x6DD inline patch (forces feature bit return to true)
|
||||
// Temporarily disabled — suspected to corrupt adjacent instructions.
|
||||
// The bitset_init hook (step 3) achieves the same goal cleanly.
|
||||
#if 0
|
||||
if(const auto sa = sig_scan(start, end,
|
||||
"55 48 89 E5 41 57 41 56 41 55 41 54 53 48 81 EC ? ? ? ? 49 89 F7 49 89 FC 41 BE ? ? ? ? 4C 03 77 28"); sa.ok)
|
||||
{
|
||||
const uintptr_t target = sa.addr + 0x6DD;
|
||||
const uintptr_t page = target & ~(getpagesize() - 1);
|
||||
|
||||
if(mprotect(reinterpret_cast<void*>(page), getpagesize(), PROT_READ|PROT_WRITE|PROT_EXEC) == 0)
|
||||
{
|
||||
const uint8_t patch[] = {0xB0, 0x01, 0x90, 0x90, 0x90};
|
||||
memcpy(reinterpret_cast<void*>(target), patch, sizeof(patch));
|
||||
mprotect(reinterpret_cast<void*>(page), getpagesize(), PROT_READ|PROT_EXEC);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// [WEBHOOK DISABLED] sub_125ACA6 hook — causes SIGSEGV at startup with
|
||||
// the mprotect fix. The `create_hook` trampoline now works (page-aligned)
|
||||
// and this hook fires during PMS init, but the manager layout offsets
|
||||
// (+0x78 for map, +0x40 for vector pointers) are version-specific and
|
||||
// crash on this build (1.43.2.10687). The socket-level interposer
|
||||
// (webhook_handler.cpp) works independently via LD_PRELOAD and handles
|
||||
// webhook CRUD without this hook.
|
||||
//
|
||||
// Re-enable *only* after verifying the struct layout matches.
|
||||
//
|
||||
|
||||
// STEP 3: Modern path (PMS BETA 2024/08/13+): force the entire feature bitset.
|
||||
if(const auto bitset = sig_scan(start, end, PATTERN_BITSET_REF); bitset.ok)
|
||||
{
|
||||
const uintptr_t addr = bitset.addr + 7 + *reinterpret_cast<uint32_t*>(bitset.addr + 3);
|
||||
g_feature_flags = reinterpret_cast<uint64_t(*)[14]>(addr);
|
||||
|
||||
if(const auto bs_init = sig_scan(start, end,
|
||||
PATTERN_BS_INIT); bs_init.ok)
|
||||
{
|
||||
if(auto trampoline = create_hook(bs_init.addr, reinterpret_cast<uintptr_t>(hook_bitset_init)); trampoline.ok)
|
||||
{
|
||||
_bitset_init = reinterpret_cast<decltype(_bitset_init)>(trampoline.addr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 4: Legacy fallback — pre-2024/08/13 per-feature hooks.
|
||||
if(const auto usf = sig_scan(start, end, PATTERN_LEGACY_USF); usf.ok)
|
||||
{
|
||||
if(auto trampoline = create_hook(usf.addr, reinterpret_cast<uintptr_t>(hook_is_user_feature_set)); trampoline.ok)
|
||||
{
|
||||
_is_user_feature_set = reinterpret_cast<decltype(_is_user_feature_set)>(trampoline.addr);
|
||||
}
|
||||
}
|
||||
|
||||
if(const auto ifa_ref = sig_scan(start, end, "E8 ? ? ? ? 86 43"); ifa_ref.ok) {
|
||||
const auto ifa = follow_call_rel32(ifa_ref.addr);
|
||||
|
||||
if(auto trampoline = create_hook(ifa, reinterpret_cast<uintptr_t>(hook_is_feature_available)); trampoline.ok)
|
||||
{
|
||||
_is_feature_available = reinterpret_cast<decltype(_is_feature_available)>(trampoline.addr);
|
||||
}
|
||||
}
|
||||
|
||||
if(const auto mf = sig_scan(start, end, PATTERN_LEGACY_MF); mf.ok)
|
||||
{
|
||||
if(auto trampoline = create_hook(mf.addr, reinterpret_cast<uintptr_t>(hook_map_find)); trampoline.ok)
|
||||
{
|
||||
_map_find = reinterpret_cast<decltype(_map_find)>(trampoline.addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Return type for functions that may fail (no C++ exceptions/rtti).
|
||||
// check `ok` before using `addr` / `start` / `end`.
|
||||
struct OptAddr { bool ok; uintptr_t addr; };
|
||||
struct TextSpan { bool ok; uintptr_t start; uintptr_t end; };
|
||||
|
||||
TextSpan get_dottext_info();
|
||||
OptAddr create_hook(uintptr_t from, uintptr_t to);
|
||||
OptAddr sig_scan(uintptr_t start, uintptr_t end, const char* pattern);
|
||||
uintptr_t follow_call_rel32(uintptr_t address);
|
||||
|
||||
// Hook callbacks (thunk style).
|
||||
uint64_t hook_is_feature_available(uintptr_t rcx, const char** guid);
|
||||
uint64_t* hook_map_find(uintptr_t* rcx, const char** str);
|
||||
uint64_t hook_bitset_init(uintptr_t rcx);
|
||||
bool hook_is_user_feature_set(uintptr_t rcx, int expected, int feature);
|
||||
uint64_t hook_sub_122B2F2(uintptr_t prefs, char* key);
|
||||
uint64_t hook_sub_125ACA6(uintptr_t manager);
|
||||
|
||||
void hook();
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#include "hook.hpp"
|
||||
#include "webhook_handler.hpp"
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The old constructor called hook() synchronously, but under musl the
|
||||
// LD_PRELOAD library's constructor runs *before* the main binary is loaded
|
||||
// → dl_iterate_phdr returned 0 callbacks, and /proc/self/maps doesn't yet
|
||||
// contain the PMS text segment → hook() = no-op.
|
||||
//
|
||||
// We now combine two strategies:
|
||||
//
|
||||
// 1. Try hook() immediately (best-effort – will fail if the binary
|
||||
// isn't mapped yet, which is both harmless and informative).
|
||||
//
|
||||
// 2. Override a function that PMS calls during runtime startup but well
|
||||
// after all libraries are loaded. When that override first fires, it
|
||||
// calls hook() and then passes through to the real function.
|
||||
//
|
||||
// Strategy 2 uses a trigger that's guaranteed to fire during normal PMS
|
||||
// operation but AFTER boost::uuids, epoll, and the HTTP server are all
|
||||
// initialised. We override `epoll_create1` (called by the event loop)
|
||||
// rather than `bind` (which the musl dynamic linker may reference during
|
||||
// loading, causing boost UUID crashes).
|
||||
//
|
||||
// Both strategies are idempotent: hook() is guarded by the TextSpan check
|
||||
// on the inside (returns immediately if text range not found / hooks
|
||||
// already set), and the deferred trigger is a one-shot flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static int g_hooks_attempted = 0;
|
||||
|
||||
// ---- deferred trigger: epoll_create1 override ----
|
||||
//
|
||||
// PMS uses epoll for its event loop. The first epoll_create1 call happens
|
||||
// during server initialisation, long after the main binary is fully mapped.
|
||||
// This is a safer trigger than bind (which libc's NSS/resolver may call
|
||||
// during dlopen, triggering boost::uuids crashes).
|
||||
|
||||
#include <sys/epoll.h>
|
||||
#include <dlfcn.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/syscall.h>
|
||||
|
||||
typedef int (*RealEpollFn)(int);
|
||||
|
||||
static int g_epoll_triggered = 0;
|
||||
|
||||
int epoll_create1(int flags)
|
||||
{
|
||||
if(!g_epoll_triggered)
|
||||
{
|
||||
g_epoll_triggered = 1;
|
||||
hook();
|
||||
|
||||
RealEpollFn real = (RealEpollFn)dlsym(RTLD_NEXT, "epoll_create1");
|
||||
if(real)
|
||||
return real(flags);
|
||||
return syscall(SYS_epoll_create1, flags);
|
||||
}
|
||||
|
||||
RealEpollFn real = (RealEpollFn)dlsym(RTLD_NEXT, "epoll_create1");
|
||||
if(real)
|
||||
return real(flags);
|
||||
return syscall(SYS_epoll_create1, flags);
|
||||
}
|
||||
|
||||
__attribute__((constructor)) void init_so()
|
||||
{
|
||||
// This library is LD_PRELOADed into the (musl) "Plex Media Server" process.
|
||||
// Plex later spawns glibc /bin/sh helpers (Plex Tuner Service, Plex Script
|
||||
// Host, transcoders) which would inherit LD_PRELOAD and fail to load this
|
||||
// musl .so ("/bin/sh: error while loading shared libraries"). The constructor
|
||||
// runs before main() and before any child is spawned, so clearing LD_PRELOAD
|
||||
// here scopes the preload to this process only.
|
||||
unsetenv("LD_PRELOAD");
|
||||
|
||||
// Socket hooks in webhook_handler.cpp are active as soon as the library is
|
||||
// preloaded. Resolve their real libc targets before PMS startup code reads
|
||||
// from /dev/urandom; otherwise our read() interposer would fail early reads.
|
||||
webhook_handler_init();
|
||||
|
||||
// Strategy 1: skipped — hook() will be triggered by the first
|
||||
// epoll_create1 call, which happens during PMS's event loop init
|
||||
// after everything is loaded.
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// Traffic logger for Plex Media Server.
|
||||
// LD_PRELOAD library that hooks socket syscalls and logs all network traffic.
|
||||
//
|
||||
// Activation:
|
||||
// export PLEX_TRAFFIC_LOG=/tmp/pms_traffic.log
|
||||
// LD_PRELOAD=.../plexmediaserver_traffic_logger.so ...
|
||||
//
|
||||
// Log format:
|
||||
// [HH:MM:SS.mmm] DIR fd=N [peer:port] N bytes
|
||||
// 00000000 48 54 54 50 2f 31 2e 31 20 32 30 30 20 4f 4b HTTP/1.1 200 OK
|
||||
// ...
|
||||
//
|
||||
// Limitations:
|
||||
// - Connection tracking uses a fixed-size array (CONN_MAX = 4096).
|
||||
// FDs beyond that are logged without peer address.
|
||||
// - No sendmsg/recvmsg support (fallback to byte-count-only log).
|
||||
|
||||
#include "traffic_logger.hpp"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifndef RTLD_NEXT
|
||||
#define RTLD_NEXT ((void*)(-1))
|
||||
#endif
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum bytes to hex-dump per log entry. 0 = dump entire payload.
|
||||
static constexpr size_t MAX_DUMP = 256;
|
||||
|
||||
/// Maximum tracked file descriptors.
|
||||
static constexpr int CONN_MAX = 4096;
|
||||
|
||||
/// Maximum hex dump line width (bytes displayed per line).
|
||||
static constexpr int HEX_COLS = 16;
|
||||
|
||||
/// Format string length buffers.
|
||||
static constexpr int TS_LEN = 32;
|
||||
static constexpr int PEER_LEN = 48;
|
||||
|
||||
// ── Per-connection state ───────────────────────────────────────────────────
|
||||
|
||||
struct ConnInfo
|
||||
{
|
||||
char peer[PEER_LEN]; // "1.2.3.4:56789" or "[::1]:56789"
|
||||
uint64_t start_ns; // monotonic timestamp of connect/accept
|
||||
bool active;
|
||||
};
|
||||
|
||||
/// Connection table indexed by file descriptor.
|
||||
static ConnInfo g_conns[CONN_MAX];
|
||||
|
||||
/// Guards g_conns.
|
||||
static pthread_mutex_t g_conn_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
/// Log file descriptor, or -1 if inactive.
|
||||
static int g_log_fd = -1;
|
||||
|
||||
/// Guards g_log_fd writes.
|
||||
static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
/// Whether we failed initialisation (suppress further attempts).
|
||||
static bool g_init_failed = false;
|
||||
|
||||
// ── Real function pointers (resolved via dlsym) ────────────────────────────
|
||||
|
||||
extern "C"
|
||||
{
|
||||
static ssize_t (*real_send)(int, const void*, size_t, int) = nullptr;
|
||||
static ssize_t (*real_sendto)(int, const void*, size_t, int,
|
||||
const struct sockaddr*, socklen_t) = nullptr;
|
||||
static ssize_t (*real_recv)(int, void*, size_t, int) = nullptr;
|
||||
static ssize_t (*real_recvfrom)(int, void*, size_t, int,
|
||||
struct sockaddr*, socklen_t*) = nullptr;
|
||||
static int (*real_connect)(int, const struct sockaddr*, socklen_t) = nullptr;
|
||||
static int (*real_accept)(int, struct sockaddr*, socklen_t*) = nullptr;
|
||||
static int (*real_accept4)(int, struct sockaddr*, socklen_t*, int) = nullptr;
|
||||
static int (*real_close)(int) = nullptr;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Monotonic time in nanoseconds.
|
||||
static uint64_t now_ns(void)
|
||||
{
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec;
|
||||
}
|
||||
|
||||
/// Format a sockaddr as "ip:port" into buf.
|
||||
static void fmt_peer(const struct sockaddr* sa, socklen_t salen, char* buf, size_t bufsize)
|
||||
{
|
||||
if(!sa || salen < sizeof(sa_family_t))
|
||||
{
|
||||
snprintf(buf, bufsize, "???");
|
||||
return;
|
||||
}
|
||||
|
||||
if(sa->sa_family == AF_INET && salen >= sizeof(struct sockaddr_in))
|
||||
{
|
||||
const auto* sin = (const struct sockaddr_in*)sa;
|
||||
inet_ntop(AF_INET, &sin->sin_addr, buf, bufsize);
|
||||
size_t n = strlen(buf);
|
||||
snprintf(buf + n, bufsize - n, ":%d", (int)ntohs(sin->sin_port));
|
||||
}
|
||||
else if(sa->sa_family == AF_INET6 && salen >= sizeof(struct sockaddr_in6))
|
||||
{
|
||||
const auto* sin6 = (const struct sockaddr_in6*)sa;
|
||||
buf[0] = '[';
|
||||
inet_ntop(AF_INET6, &sin6->sin6_addr, buf + 1, bufsize - 8);
|
||||
size_t n = strlen(buf);
|
||||
snprintf(buf + n, bufsize - n, "]:%d", (int)ntohs(sin6->sin6_port));
|
||||
}
|
||||
else
|
||||
{
|
||||
snprintf(buf, bufsize, "af=%d", sa->sa_family);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the peer string for an fd, or "?" if unknown.
|
||||
static const char* peer_for_fd(int fd, char* fallback_buf)
|
||||
{
|
||||
if(fd >= 0 && fd < CONN_MAX)
|
||||
{
|
||||
pthread_mutex_lock(&g_conn_lock);
|
||||
if(g_conns[fd].active)
|
||||
{
|
||||
strcpy(fallback_buf, g_conns[fd].peer);
|
||||
pthread_mutex_unlock(&g_conn_lock);
|
||||
return fallback_buf;
|
||||
}
|
||||
pthread_mutex_unlock(&g_conn_lock);
|
||||
}
|
||||
fallback_buf[0] = '?';
|
||||
fallback_buf[1] = '\0';
|
||||
return fallback_buf;
|
||||
}
|
||||
|
||||
/// Record peer + start time for an fd in the connection table.
|
||||
static void track_conn(int fd, const char* peer)
|
||||
{
|
||||
if(fd < 0 || fd >= CONN_MAX) return;
|
||||
|
||||
pthread_mutex_lock(&g_conn_lock);
|
||||
g_conns[fd].active = true;
|
||||
g_conns[fd].start_ns = now_ns();
|
||||
strncpy(g_conns[fd].peer, peer, sizeof(g_conns[fd].peer) - 1);
|
||||
g_conns[fd].peer[sizeof(g_conns[fd].peer) - 1] = '\0';
|
||||
pthread_mutex_unlock(&g_conn_lock);
|
||||
}
|
||||
|
||||
// ── Logging ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Write a hex+ASCII dump of `len` bytes starting at `data`.
|
||||
static void log_hexdump(const unsigned char* data, size_t len)
|
||||
{
|
||||
if(len == 0 || g_log_fd < 0) return;
|
||||
|
||||
if(len > MAX_DUMP) len = MAX_DUMP;
|
||||
|
||||
// Local line buffer to minimise syscall count.
|
||||
char line[128];
|
||||
size_t off = 0;
|
||||
|
||||
for(size_t i = 0; i < len; i += HEX_COLS)
|
||||
{
|
||||
size_t remain = len - i;
|
||||
size_t row = remain < HEX_COLS ? remain : HEX_COLS;
|
||||
|
||||
// Address.
|
||||
off = (size_t)snprintf(line, sizeof(line), " %08zx ", i);
|
||||
|
||||
// Hex bytes (left half, right half).
|
||||
size_t h;
|
||||
for(h = 0; h < row && h < 8; h++)
|
||||
off += (size_t)snprintf(line + off, sizeof(line) - off, " %02x", data[i + h]);
|
||||
if(row <= 8)
|
||||
off += (size_t)snprintf(line + off, sizeof(line) - off, " "); // spacer
|
||||
for(; h < row; h++)
|
||||
off += (size_t)snprintf(line + off, sizeof(line) - off, " %02x", data[i + h]);
|
||||
// Pad to column 60.
|
||||
for(size_t p = row; p < HEX_COLS; p++)
|
||||
{
|
||||
off += (size_t)snprintf(line + off, sizeof(line) - off, " ");
|
||||
if(p == 7) off += (size_t)snprintf(line + off, sizeof(line) - off, " ");
|
||||
}
|
||||
|
||||
// ASCII view.
|
||||
off += (size_t)snprintf(line + off, sizeof(line) - off, " |");
|
||||
for(size_t j = 0; j < row; j++)
|
||||
{
|
||||
unsigned char c = data[i + j];
|
||||
line[off++] = (c >= 32 && c < 127) ? (char)c : '.';
|
||||
}
|
||||
line[off++] = '|';
|
||||
line[off++] = '\n';
|
||||
write(g_log_fd, line, off);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a timestamped log line with metadata and optional hex dump.
|
||||
static void log_traffic(char dir, int fd, const char* peer,
|
||||
const unsigned char* data, size_t len,
|
||||
const char* suffix)
|
||||
{
|
||||
if(g_log_fd < 0) return;
|
||||
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
|
||||
struct tm tm_buf;
|
||||
localtime_r(&ts.tv_sec, &tm_buf);
|
||||
|
||||
char timestamp[TS_LEN];
|
||||
strftime(timestamp, sizeof(timestamp), "%T", &tm_buf);
|
||||
|
||||
char line[512];
|
||||
int n = snprintf(line, sizeof(line),
|
||||
"[%s.%03ld] %c %s fd=%d [%s]%s%zu byte%s\n",
|
||||
timestamp, (long)(ts.tv_nsec / 1000000),
|
||||
dir,
|
||||
(dir == 'S' || dir == 's') ? "SEND" :
|
||||
(dir == 'R' || dir == 'r') ? "RECV" :
|
||||
(dir == 'C') ? "CONN" :
|
||||
(dir == 'A') ? "ACPT" :
|
||||
(dir == 'X') ? "CLSE" :
|
||||
"????",
|
||||
fd, peer,
|
||||
suffix ? suffix : "",
|
||||
len, len == 1 ? "" : "s");
|
||||
|
||||
pthread_mutex_lock(&g_log_lock);
|
||||
write(g_log_fd, line, (size_t)n);
|
||||
if(data && len > 0)
|
||||
{
|
||||
log_hexdump(data, len);
|
||||
}
|
||||
pthread_mutex_unlock(&g_log_lock);
|
||||
}
|
||||
|
||||
// ── Hooked functions ───────────────────────────────────────────────────────
|
||||
|
||||
extern "C" ssize_t send(int sockfd, const void* buf, size_t len, int flags)
|
||||
{
|
||||
if(!real_send) return (ssize_t)-1;
|
||||
ssize_t ret = real_send(sockfd, buf, len, flags);
|
||||
|
||||
char fallback[PEER_LEN];
|
||||
log_traffic('S', sockfd, peer_for_fd(sockfd, fallback),
|
||||
(const unsigned char*)buf, (size_t)(ret > 0 ? ret : 0), nullptr);
|
||||
return ret;
|
||||
}
|
||||
|
||||
extern "C" ssize_t sendto(int sockfd, const void* buf, size_t len, int flags,
|
||||
const struct sockaddr* dest_addr, socklen_t addrlen)
|
||||
{
|
||||
if(!real_sendto) return (ssize_t)-1;
|
||||
ssize_t ret = real_sendto(sockfd, buf, len, flags, dest_addr, addrlen);
|
||||
|
||||
if(ret > 0)
|
||||
{
|
||||
char peer_buf[PEER_LEN];
|
||||
if(dest_addr)
|
||||
{
|
||||
fmt_peer(dest_addr, addrlen, peer_buf, sizeof(peer_buf));
|
||||
}
|
||||
else
|
||||
{
|
||||
char fallback[PEER_LEN];
|
||||
strcpy(peer_buf, peer_for_fd(sockfd, fallback));
|
||||
}
|
||||
log_traffic('s', sockfd, peer_buf,
|
||||
(const unsigned char*)buf, (size_t)ret, nullptr);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
extern "C" ssize_t recv(int sockfd, void* buf, size_t len, int flags)
|
||||
{
|
||||
if(!real_recv) return (ssize_t)-1;
|
||||
ssize_t ret = real_recv(sockfd, buf, len, flags);
|
||||
|
||||
if(ret > 0)
|
||||
{
|
||||
char fallback[PEER_LEN];
|
||||
log_traffic('R', sockfd, peer_for_fd(sockfd, fallback),
|
||||
(const unsigned char*)buf, (size_t)ret, nullptr);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
extern "C" ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags,
|
||||
struct sockaddr* src_addr, socklen_t* addrlen)
|
||||
{
|
||||
if(!real_recvfrom) return (ssize_t)-1;
|
||||
ssize_t ret = real_recvfrom(sockfd, buf, len, flags, src_addr, addrlen);
|
||||
|
||||
if(ret > 0)
|
||||
{
|
||||
char peer_buf[PEER_LEN];
|
||||
if(src_addr && addrlen)
|
||||
{
|
||||
fmt_peer(src_addr, *addrlen, peer_buf, sizeof(peer_buf));
|
||||
}
|
||||
else
|
||||
{
|
||||
char fallback[PEER_LEN];
|
||||
strcpy(peer_buf, peer_for_fd(sockfd, fallback));
|
||||
}
|
||||
log_traffic('r', sockfd, peer_buf,
|
||||
(const unsigned char*)buf, (size_t)ret, nullptr);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
extern "C" int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen)
|
||||
{
|
||||
int ret = real_connect ? real_connect(sockfd, addr, addrlen) : -1;
|
||||
|
||||
// Track the connection even if it fails (EINPROGRESS is normal for non-blocking).
|
||||
if(addr)
|
||||
{
|
||||
char peer_buf[PEER_LEN];
|
||||
fmt_peer(addr, addrlen, peer_buf, sizeof(peer_buf));
|
||||
|
||||
track_conn(sockfd, peer_buf);
|
||||
|
||||
log_traffic('C', sockfd, peer_buf, nullptr, 0,
|
||||
ret == 0 ? nullptr : (errno == EINPROGRESS ? " (EINPROGRESS)" : " (FAIL)"));
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
extern "C" int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen)
|
||||
{
|
||||
int client = real_accept ? real_accept(sockfd, addr, addrlen) : -1;
|
||||
|
||||
if(client >= 0 && addr && addrlen)
|
||||
{
|
||||
char peer_buf[PEER_LEN];
|
||||
fmt_peer(addr, *addrlen, peer_buf, sizeof(peer_buf));
|
||||
|
||||
track_conn(client, peer_buf);
|
||||
|
||||
log_traffic('A', client, peer_buf, nullptr, 0, nullptr);
|
||||
|
||||
// Also log the accept call from the listening socket perspective.
|
||||
char local[PEER_LEN];
|
||||
snprintf(local, sizeof(local), "LISTEN");
|
||||
log_traffic('a', sockfd, local, nullptr, 0, nullptr);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
extern "C" int accept4(int sockfd, struct sockaddr* addr, socklen_t* addrlen, int flags)
|
||||
{
|
||||
int client = real_accept4 ? real_accept4(sockfd, addr, addrlen, flags) : -1;
|
||||
|
||||
if(client >= 0 && addr && addrlen)
|
||||
{
|
||||
char peer_buf[PEER_LEN];
|
||||
fmt_peer(addr, *addrlen, peer_buf, sizeof(peer_buf));
|
||||
|
||||
track_conn(client, peer_buf);
|
||||
|
||||
log_traffic('A', client, peer_buf, nullptr, 0, nullptr);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
extern "C" int close(int fd)
|
||||
{
|
||||
if(fd >= 0 && fd < CONN_MAX)
|
||||
{
|
||||
pthread_mutex_lock(&g_conn_lock);
|
||||
bool was_active = g_conns[fd].active;
|
||||
if(was_active)
|
||||
{
|
||||
uint64_t age_ns = now_ns() - g_conns[fd].start_ns;
|
||||
char info[64];
|
||||
if(g_conns[fd].start_ns > 0)
|
||||
{
|
||||
unsigned long secs = (unsigned long)(age_ns / 1000000000ULL);
|
||||
snprintf(info, sizeof(info), " (lifetime %lus)", secs);
|
||||
}
|
||||
else
|
||||
{
|
||||
info[0] = '\0';
|
||||
}
|
||||
log_traffic('X', fd, g_conns[fd].peer, nullptr, 0, info);
|
||||
g_conns[fd].active = false;
|
||||
}
|
||||
pthread_mutex_unlock(&g_conn_lock);
|
||||
}
|
||||
|
||||
return real_close ? real_close(fd) : -1;
|
||||
}
|
||||
|
||||
// ── Initialisation ─────────────────────────────────────────────────────────
|
||||
|
||||
__attribute__((constructor)) void traffic_logger_init(void)
|
||||
{
|
||||
if(g_init_failed) return;
|
||||
|
||||
const char* log_path = getenv("PLEX_TRAFFIC_LOG");
|
||||
|
||||
// Always resolve real function pointers so hooks work correctly even when
|
||||
// logging is disabled. Otherwise every hooked call returns -1 (the nullptr
|
||||
// guard), which causes PMS to spin at 100 % CPU retrying failed I/O.
|
||||
bool ok = true;
|
||||
bool log_active = false;
|
||||
|
||||
#define RESOLVE(name_, var_) do { \
|
||||
var_ = reinterpret_cast<decltype(var_)>(dlsym(RTLD_NEXT, name_)); \
|
||||
if(!var_) { ok = false; } \
|
||||
} while(0)
|
||||
|
||||
RESOLVE("send", real_send);
|
||||
RESOLVE("sendto", real_sendto);
|
||||
RESOLVE("recv", real_recv);
|
||||
RESOLVE("recvfrom", real_recvfrom);
|
||||
RESOLVE("connect", real_connect);
|
||||
RESOLVE("accept", real_accept);
|
||||
RESOLVE("accept4", real_accept4);
|
||||
RESOLVE("close", real_close);
|
||||
|
||||
#undef RESOLVE
|
||||
|
||||
// Open log file if env var is set.
|
||||
if(log_path && log_path[0] != '\0')
|
||||
{
|
||||
g_log_fd = open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
|
||||
if(g_log_fd >= 0)
|
||||
{
|
||||
log_active = true;
|
||||
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
struct tm tm_buf;
|
||||
localtime_r(&ts.tv_sec, &tm_buf);
|
||||
char ts_buf[64];
|
||||
strftime(ts_buf, sizeof(ts_buf), "%Y-%m-%d %T", &tm_buf);
|
||||
|
||||
char hdr[256];
|
||||
int n = snprintf(hdr, sizeof(hdr),
|
||||
"# Plex Media Server traffic log — started %s\n"
|
||||
"# PID=%d LOG_PATH=%s MAX_DUMP=%zu\n"
|
||||
"## [time] DIR fd [peer] N bytes [hex dump]\n",
|
||||
ts_buf, (int)getpid(), log_path, MAX_DUMP);
|
||||
write(g_log_fd, hdr, (size_t)(n > 0 ? n : 0));
|
||||
|
||||
char msg[128];
|
||||
n = snprintf(msg, sizeof(msg),
|
||||
"# traffic logger initialized — %d hooks installed\n",
|
||||
ok ? 8 : 0);
|
||||
write(g_log_fd, msg, (size_t)(n > 0 ? n : 0));
|
||||
}
|
||||
}
|
||||
|
||||
// When env var is unset: pointers are resolved, g_log_fd stays -1, hooks
|
||||
// call through to libc with no logging overhead (no-op fast path).
|
||||
if(!ok)
|
||||
{
|
||||
g_init_failed = true;
|
||||
if(g_log_fd >= 0)
|
||||
{
|
||||
const char* warn = "# WARNING: some dlsym(RTLD_NEXT, ...) calls failed — hooks degraded\n";
|
||||
write(g_log_fd, warn, strlen(warn));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// NOTE: this library interposes recv()/recvfrom()/close(), the same symbols the
|
||||
// webhook handler interposes. The two .so files therefore cannot be LD_PRELOADed
|
||||
// together — only the first-loaded definition of each symbol wins. Use one or the
|
||||
// other per process.
|
||||
|
||||
// Initialise the traffic logger. Called automatically via library constructor;
|
||||
// no-op unless PLEX_TRAFFIC_LOG environment variable is set to a writable path.
|
||||
void traffic_logger_init(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// NOTE: this handler interposes recv()/recvfrom()/close(), the same symbols the
|
||||
// traffic logger interposes. The two .so files cannot be LD_PRELOADed together —
|
||||
// only the first-loaded definition of each symbol wins. Use one or the other.
|
||||
|
||||
// Initialise the webhook handler: resolve real socket function pointers
|
||||
// and set up the webhooks JSON file path. Must be called once at startup
|
||||
// (e.g. from the LD_PRELOAD library constructor or hook()).
|
||||
void webhook_handler_init(void);
|
||||
|
||||
// Inject webhooks from webhooks.json into the WebhookManager's internal
|
||||
// per-user std::vector<std::string>. Called from the sub_125ACA6 hook
|
||||
// right after the original function runs.
|
||||
void webhook_inject_into_manager(void* manager);
|
||||
|
||||
// Remember the live WebhookManager pointer so CRUD changes can refresh the
|
||||
// in-memory dispatch list without requiring a PMS restart.
|
||||
void webhook_set_manager(void* manager);
|
||||
|
||||
// Set the resolved address of sub_125E524 (webhook map find/create).
|
||||
void webhook_set_sub_125E524(void* addr);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Zydis (vendored)
|
||||
|
||||
Single-file amalgamation of the [Zydis](https://github.com/zyantific/zydis)
|
||||
x86/x86-64 disassembler. The hook engine uses it to decode and copy
|
||||
instruction-aligned bytes when installing a trampoline (so a relocated prologue
|
||||
stays valid).
|
||||
|
||||
- **Upstream:** https://github.com/zyantific/zydis
|
||||
- **License:** MIT (see the header in `Zydis.h` and upstream `LICENSE`)
|
||||
|
||||
`Zydis.c` / `Zydis.h` are generated amalgamations — regenerate from upstream
|
||||
instead of hand-editing them here.
|
||||
Vendored
+54990
File diff suppressed because it is too large.
Load diff
Vendored
+12113
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,159 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
||||
# Plex_Patch — Windows x64
|
||||
|
||||
Feature-unlock patch for **Plex Media Server** on **Windows x64**. Port of the
|
||||
Linux `LD_PRELOAD` approach to a DLL injector model.
|
||||
|
||||
> ⚠️ **Disclaimer** — Educational / reverse-engineering only, on software you
|
||||
> legally own and run yourself. No Plex code is shipped. Use at your own risk.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
plex_inject.exe ──CreateRemoteThread(LoadLibraryA)──▶ Plex Media Server.exe
|
||||
│
|
||||
plex_patch.dll (DllMain)
|
||||
│
|
||||
┌────────────────────────────────┘
|
||||
▼
|
||||
1. Parse PE image (base, .text, .data bounds)
|
||||
2. Version guard: scan for "1.43.2.10687" — refuse if wrong
|
||||
3. Resolve g_feature_bitset via RVA 0x1D9E670 (bounds-checked)
|
||||
4. Immediate force: 14 × InterlockedExchange(0xFFFFFFFF)
|
||||
5. Hook FeatureManager_set_features_from_uuids (Zydis trampoline)
|
||||
→ calls original, then re-forces all bits
|
||||
6. Guard thread: polls 2s, re-forces if any dword reverted
|
||||
```
|
||||
|
||||
Two complementary mechanisms keep every feature bit on:
|
||||
|
||||
- **Hook** (`trampoline.h`): an inline 14-byte `jmp [rip+0]` redirect on the
|
||||
populator function. After the original runs (so Plex's internal state is
|
||||
consistent), the hook atomically forces all 14 dwords to `0xFFFFFFFF`. This
|
||||
is deterministic — every MyPlex refresh immediately becomes "all-enabled."
|
||||
- **Guard thread** (`feature_patch.h`): belt-and-suspenders. Polls at 2 s
|
||||
and re-forces if any dword reverted — catches code paths that write the
|
||||
bitset outside the hooked function, or a failed hook install.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
windows/
|
||||
├── src/
|
||||
│ ├── log.h zero-alloc OutputDebugString logging
|
||||
│ ├── pe_image.h PE base, section bounds, version guard, RVA resolution
|
||||
│ ├── sig_scan.h byte-pattern scanner + RIP-relative resolver
|
||||
│ ├── trampoline.h x64 inline hook engine (14-byte, Zydis prologue decode)
|
||||
│ ├── feature_patch.h orchestration: discover → hook → force → guard
|
||||
│ ├── dllmain.cpp DLL entry (defers work to a thread: loader-lock safe)
|
||||
│ └── injector.cpp attach-to-running or launch-suspended injector
|
||||
├── build.bat zig 0.13.0 build (reuses vendored Zydis)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**Layering (inward dependencies only):**
|
||||
|
||||
| Layer | Module | Depends on |
|
||||
|-------|--------|------------|
|
||||
| Primitives | `log.h` | `<windows.h>` only |
|
||||
| Discovery | `pe_image.h` | `log` |
|
||||
| Discovery | `sig_scan.h` | nothing (pure) |
|
||||
| Engine | `trampoline.h` | `log`, Zydis |
|
||||
| Orchestration | `feature_patch.h` | `pe_image`, `trampoline`, `log` |
|
||||
| Entry | `dllmain.cpp` | `feature_patch` |
|
||||
| Launcher | `injector.cpp` | `<windows.h>` only (standalone binary) |
|
||||
|
||||
### Design decisions
|
||||
|
||||
- **RVA + version guard** over blind signature scan as the primary discovery.
|
||||
The version string must be present in the image before any hardcoded RVA is
|
||||
used, so a wrong build gets a clean refusal, not memory corruption.
|
||||
`sig_scan.h` is included for future update-resilience.
|
||||
- **Hook + guard thread** (belt and suspenders) over either alone. The hook
|
||||
catches refreshes deterministically; the guard catches edge cases and
|
||||
compensates if the hook fails. Either alone would work; together they are
|
||||
robust.
|
||||
- **No Zydis for the injector.** Only the DLL links Zydis (for prologue
|
||||
decode). The injector is a small standalone binary using only kernel32.
|
||||
- **`InterlockedExchange`** for all bitset writes, matching the binary's own
|
||||
atomic store sequence exactly (not a memset; each dword is written atomically).
|
||||
- **DllMain defers to a thread.** Heavy work (PE parsing, hook install, guard
|
||||
spawn) runs outside the loader lock, avoiding the DllMain deadlock trap.
|
||||
|
||||
### Security
|
||||
|
||||
- Version guard: the patch refuses to activate on an unknown build.
|
||||
- Bounds-check: RVAs are validated against the PE section table.
|
||||
- No shell: the injector passes an argv list, never a command string.
|
||||
- The DLL logs to `OutputDebugString`, never to disk (no file creation).
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
Requires `zig` 0.13.0 (same as the Linux build; auto-downloaded to
|
||||
`toolchain/` by `build.sh`).
|
||||
|
||||
```bat
|
||||
cd windows
|
||||
build.bat
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- `build\plex_patch.dll` (723 KB — includes vendored Zydis)
|
||||
- `build\plex_inject.exe` (153 KB)
|
||||
|
||||
## Use
|
||||
|
||||
**Attach to a running PMS:**
|
||||
```bat
|
||||
build\plex_inject.exe
|
||||
```
|
||||
|
||||
**Launch PMS through the injector (recommended — hook installs before features load):**
|
||||
```bat
|
||||
build\plex_inject.exe --launch "C:\Program Files\Plex\Plex Media Server\Plex Media Server.exe"
|
||||
```
|
||||
|
||||
Copy both files to any directory; the injector resolves `plex_patch.dll`
|
||||
relative to its own path.
|
||||
|
||||
**Verify:** open [DebugView](https://learn.microsoft.com/en-us/sysinternals/downloads/debugview)
|
||||
and look for `[plex_patch INF]` messages:
|
||||
```
|
||||
[plex_patch INF] version guard passed (build 1.43.2.10687)
|
||||
[plex_patch INF] g_feature_bitset at 0x7FF...
|
||||
[plex_patch INF] hook installed on FeatureManager_set_features_from_uuids
|
||||
[plex_patch INF] guard thread started (interval 2000 ms)
|
||||
[plex_patch INF] feature bitset forced after set_features
|
||||
```
|
||||
|
||||
## Known values (build 1.43.2.10687)
|
||||
|
||||
| Symbol | RVA | Size | IDB name |
|
||||
|--------|-----|------|----------|
|
||||
| `g_feature_bitset` | `0x1D9E670` | 56 B (14 × DWORD) | `g_feature_bitset` |
|
||||
| `FeatureManager_set_features_from_uuids` | `0x0BC8060` | — | `FeatureManager_set_features_from_uuids` |
|
||||
| `g_feature_uuid_code_table` | `0x19C9600` | ~2.2 KB (111 × 20 B) | `g_feature_uuid_code_table` |
|
||||
| `FeatureManager_refresh_from_myplex` | `0x0BC6D10` | — | `FeatureManager_refresh_from_myplex` |
|
||||
|
||||
For a new PMS build: update `kExpectedVersion`, `kBitsetRVA`, and `kPopulatorRVA`
|
||||
in `feature_patch.h`, or add a signature-scan fallback using `sig_scan.h`.
|
||||
|
||||
---
|
||||
|
||||
## Differences from the Linux version
|
||||
|
||||
| Aspect | Linux (`src/hook.cpp`) | Windows (`windows/`) |
|
||||
|--------|----------------------|---------------------|
|
||||
| Injection | `LD_PRELOAD` | `CreateRemoteThread` + `LoadLibraryA` |
|
||||
| Module discovery | `dl_iterate_phdr` | PE header parsing (`GetModuleHandle`) |
|
||||
| Memory protection | `mmap` / `mprotect` | `VirtualAlloc` / `VirtualProtect` |
|
||||
| Cache flush | not needed (x86 coherent) | `FlushInstructionCache` (required by API) |
|
||||
| Bitset storage | 14 × uint64 (libstdc++ `std::bitset`) | 14 × uint32 (MSVC `std::bitset`) |
|
||||
| Guard thread | not needed (hook-only on Linux) | 2 s poll (belt-and-suspenders) |
|
||||
| Disassembler | Zydis (vendored, same) | Zydis (vendored, same) |
|
||||
@@ -0,0 +1,62 @@
|
||||
@echo off
|
||||
REM SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
REM Build plex_patch.dll + plex_inject.exe for Windows x64.
|
||||
REM Requires zig 0.13.0 (auto-fetched to toolchain/ by the Linux build).
|
||||
setlocal
|
||||
|
||||
cd /d "%~dp0"
|
||||
set ROOT=%~dp0..
|
||||
|
||||
REM Resolve zig: %ZIG% override, then toolchain dir, then PATH.
|
||||
if defined ZIG if exist "%ZIG%" goto :found
|
||||
set ZIG=%ROOT%\toolchain\zig-windows-x86_64-0.13.0\zig.exe
|
||||
if exist "%ZIG%" goto :found
|
||||
where zig >nul 2>&1 && set ZIG=zig && goto :found
|
||||
echo [x] zig not found. Set ZIG= or run build.sh first (which downloads zig).
|
||||
exit /b 1
|
||||
|
||||
:found
|
||||
echo [*] Using zig: %ZIG%
|
||||
"%ZIG%" version
|
||||
|
||||
set TARGET=x86_64-windows-gnu
|
||||
set CFLAGS=-target %TARGET% -O2 -I "%ROOT%\third_party\zydis" -I src
|
||||
set CXXFLAGS=-target %TARGET% -std=c++20 -O2 -I "%ROOT%\third_party\zydis" -I src
|
||||
|
||||
if not exist build mkdir build
|
||||
|
||||
echo [*] compiling Zydis.c (C)
|
||||
"%ZIG%" cc %CFLAGS% -c "%ROOT%\third_party\zydis\Zydis.c" -o build\Zydis.o
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo [*] compiling dllmain.cpp (C++)
|
||||
"%ZIG%" c++ %CXXFLAGS% -c src\dllmain.cpp -o build\dllmain.o
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo [*] linking plex_patch.dll
|
||||
"%ZIG%" c++ -target %TARGET% -shared -o build\plex_patch.dll build\dllmain.o build\Zydis.o -lkernel32
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo [*] compiling + linking plex_inject.exe
|
||||
"%ZIG%" c++ %CXXFLAGS% -o build\plex_inject.exe src\injector.cpp -lkernel32
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
del /q build\Zydis.o build\dllmain.o 2>nul
|
||||
|
||||
echo.
|
||||
echo [+] BUILD SUCCESSFUL
|
||||
echo build\plex_patch.dll - godmode DLL (inject into PMS)
|
||||
echo build\plex_inject.exe - injector (finds or launches PMS)
|
||||
echo.
|
||||
echo Usage:
|
||||
echo 1. Copy both files to any directory.
|
||||
echo 2. Start Plex Media Server normally, then:
|
||||
echo build\plex_inject.exe
|
||||
echo Or launch PMS through the injector:
|
||||
echo build\plex_inject.exe --launch "C:\Program Files\Plex\Plex Media Server\Plex Media Server.exe"
|
||||
echo 3. Verify with DebugView: look for [plex_patch INF] messages.
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo [x] BUILD FAILED
|
||||
exit /b 1
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// DLL entry point for the Plex feature-unlock patch (Windows x64).
|
||||
//
|
||||
// Loaded into the Plex Media Server process via the injector. Work is deferred
|
||||
// to a background thread: DllMain runs under the loader lock, where calling
|
||||
// non-trivial APIs (thread sync, LoadLibrary, ...) is forbidden. The
|
||||
// background thread waits for Plex to finish init, then applies the patch.
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
#include "feature_patch.h"
|
||||
#include "log.h"
|
||||
|
||||
static DWORD WINAPI patch_entry(LPVOID) {
|
||||
plex::log(plex::LogLevel::kInfo,
|
||||
"plex_patch DLL loaded (pid %lu)", ::GetCurrentProcessId());
|
||||
|
||||
const auto result = plex::apply_patch();
|
||||
|
||||
if (!result.version_ok) {
|
||||
plex::log(plex::LogLevel::kError,
|
||||
"patch ABORTED: build mismatch (expected %s)", plex::kExpectedVersion);
|
||||
return 1;
|
||||
}
|
||||
plex::log(plex::LogLevel::kInfo,
|
||||
"patch applied: bitset=%s hook=%s guard=%s",
|
||||
result.bitset_ok ? "OK" : "FAIL",
|
||||
result.hook_ok ? "OK" : "SKIP",
|
||||
result.guard_ok ? "OK" : "FAIL");
|
||||
return 0;
|
||||
}
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID /*reserved*/) {
|
||||
if (reason == DLL_PROCESS_ATTACH) {
|
||||
::DisableThreadLibraryCalls(hModule);
|
||||
HANDLE t = ::CreateThread(nullptr, 0, patch_entry, nullptr, 0, nullptr);
|
||||
if (t) ::CloseHandle(t); // detach; thread runs independently
|
||||
} else if (reason == DLL_PROCESS_DETACH) {
|
||||
plex::remove_patch();
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#pragma once
|
||||
//
|
||||
// Feature-unlock patch for Plex Media Server (Windows x64).
|
||||
//
|
||||
// Two complementary mechanisms ensure every feature bit stays on:
|
||||
//
|
||||
// 1. **Hook** ``FeatureManager_set_features_from_uuids`` (the function that
|
||||
// populates the bitset after fetching /api/v2/features). The hook calls
|
||||
// the original, then atomically forces all 14 dwords to 0xFFFFFFFF. This
|
||||
// is deterministic: every refresh immediately becomes "all-enabled."
|
||||
//
|
||||
// 2. **Guard thread** polls at 2 s and re-forces if any dword reverted
|
||||
// (belt-and-suspenders for code paths that write the bitset outside the
|
||||
// hooked function, or if the hook fails to install).
|
||||
//
|
||||
// Discovery uses **RVA + version guard**: the expected build string must be
|
||||
// present in the image before any RVA is applied. If the guard fails (wrong
|
||||
// build), the patch refuses to activate rather than corrupting memory.
|
||||
//
|
||||
// All writes use ``InterlockedExchange``, matching the binary's own atomic
|
||||
// store sequence exactly (14 × ``_InterlockedExchange``).
|
||||
//
|
||||
// Known values — Plex Media Server 1.43.2.10687-563d026ea (Windows x64):
|
||||
// g_feature_bitset RVA 0x1D9E670 (14 dwords, 56 bytes)
|
||||
// FeatureManager_set_features_from_uuids RVA 0x0BC8060
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
#include "log.h"
|
||||
#include "pe_image.h"
|
||||
#include "trampoline.h"
|
||||
|
||||
namespace plex {
|
||||
|
||||
// ---- constants (build-specific) -------------------------------------------
|
||||
|
||||
inline constexpr const char kExpectedVersion[] = "1.43.2.10687";
|
||||
inline constexpr uint32_t kBitsetRVA = 0x1D9E670;
|
||||
inline constexpr uint32_t kPopulatorRVA = 0x0BC8060;
|
||||
inline constexpr uint32_t kBitsetDwords = 14;
|
||||
inline constexpr uint32_t kBitsetBytes = kBitsetDwords * sizeof(LONG);
|
||||
inline constexpr DWORD kGuardIntervalMs = 2000;
|
||||
inline constexpr DWORD kGuardInitialDelayMs = 5000; // let Plex finish startup
|
||||
|
||||
// ---- bitset operations (pure, testable) -----------------------------------
|
||||
|
||||
inline void force_bitset(volatile LONG* bitset) {
|
||||
for (uint32_t i = 0; i < kBitsetDwords; ++i)
|
||||
::InterlockedExchange(&bitset[i], static_cast<LONG>(0xFFFFFFFF));
|
||||
}
|
||||
|
||||
inline bool bitset_is_full(volatile LONG* bitset) {
|
||||
for (uint32_t i = 0; i < kBitsetDwords; ++i)
|
||||
if (::InterlockedCompareExchange(&bitset[i], 0, 0) != static_cast<LONG>(0xFFFFFFFF))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- hook callback --------------------------------------------------------
|
||||
|
||||
// Microsoft x64 ABI: __fastcall (rcx, rdx, r8, r9).
|
||||
// Signature from IDB: void __fastcall sub_140BC8060(char *a1, __int64 a2)
|
||||
using SetFeaturesFn = void(__fastcall*)(void* this_ptr, void* uuid_vec);
|
||||
|
||||
inline volatile LONG* g_bitset_ptr = nullptr;
|
||||
inline SetFeaturesFn g_original_fn = nullptr;
|
||||
|
||||
void __fastcall hooked_set_features(void* this_ptr, void* uuid_vec) {
|
||||
// Call the real populator so Plex's internal state is consistent.
|
||||
if (g_original_fn) g_original_fn(this_ptr, uuid_vec);
|
||||
// Now force every feature bit on.
|
||||
if (g_bitset_ptr) {
|
||||
force_bitset(g_bitset_ptr);
|
||||
log(LogLevel::kInfo, "feature bitset forced after set_features");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- guard thread ---------------------------------------------------------
|
||||
|
||||
inline std::atomic<bool> g_guard_active{false};
|
||||
inline HANDLE g_guard_thread = nullptr;
|
||||
|
||||
DWORD WINAPI guard_thread_fn(LPVOID) {
|
||||
log(LogLevel::kInfo, "guard thread: waiting %lu ms for Plex startup", kGuardInitialDelayMs);
|
||||
for (DWORD elapsed = 0; elapsed < kGuardInitialDelayMs && g_guard_active.load(); elapsed += 500)
|
||||
::Sleep(500);
|
||||
|
||||
while (g_guard_active.load()) {
|
||||
if (g_bitset_ptr && !bitset_is_full(g_bitset_ptr)) {
|
||||
force_bitset(g_bitset_ptr);
|
||||
log(LogLevel::kInfo, "guard thread: re-forced feature bitset");
|
||||
}
|
||||
::Sleep(kGuardIntervalMs);
|
||||
}
|
||||
log(LogLevel::kInfo, "guard thread: stopped");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- orchestration --------------------------------------------------------
|
||||
|
||||
struct PatchResult {
|
||||
bool version_ok = false;
|
||||
bool bitset_ok = false;
|
||||
bool hook_ok = false;
|
||||
bool guard_ok = false;
|
||||
};
|
||||
|
||||
inline PatchResult apply_patch() {
|
||||
PatchResult r;
|
||||
|
||||
auto img = get_main_image();
|
||||
if (!img) {
|
||||
log(LogLevel::kError, "failed to parse PE image");
|
||||
return r;
|
||||
}
|
||||
|
||||
// Version guard: refuse to patch an unknown build.
|
||||
r.version_ok = verify_version(*img, kExpectedVersion);
|
||||
if (!r.version_ok) {
|
||||
log(LogLevel::kError, "version guard FAILED: expected '%s' not found in image",
|
||||
kExpectedVersion);
|
||||
return r;
|
||||
}
|
||||
log(LogLevel::kInfo, "version guard passed (build %s)", kExpectedVersion);
|
||||
|
||||
// Resolve the feature bitset.
|
||||
g_bitset_ptr = resolve_data_rva<volatile LONG>(*img, kBitsetRVA, kBitsetBytes);
|
||||
r.bitset_ok = (g_bitset_ptr != nullptr);
|
||||
if (!r.bitset_ok) {
|
||||
log(LogLevel::kError, "bitset RVA 0x%X resolves outside writable data", kBitsetRVA);
|
||||
return r;
|
||||
}
|
||||
log(LogLevel::kInfo, "g_feature_bitset at %p (base %p + 0x%X)",
|
||||
const_cast<const void*>(reinterpret_cast<const volatile void*>(g_bitset_ptr)),
|
||||
reinterpret_cast<void*>(img->base), kBitsetRVA);
|
||||
|
||||
// Immediate force (features may already be loaded).
|
||||
force_bitset(g_bitset_ptr);
|
||||
|
||||
// Hook the populator so future refreshes are caught deterministically.
|
||||
const auto* populator = resolve_text_rva(*img, kPopulatorRVA);
|
||||
if (populator) {
|
||||
auto tramp = create_hook(
|
||||
reinterpret_cast<uintptr_t>(populator),
|
||||
reinterpret_cast<uintptr_t>(&hooked_set_features));
|
||||
if (tramp) {
|
||||
g_original_fn = reinterpret_cast<SetFeaturesFn>(*tramp);
|
||||
r.hook_ok = true;
|
||||
log(LogLevel::kInfo, "hook installed on FeatureManager_set_features_from_uuids");
|
||||
} else {
|
||||
log(LogLevel::kWarn, "hook install failed; guard thread will compensate");
|
||||
}
|
||||
} else {
|
||||
log(LogLevel::kWarn, "populator RVA 0x%X outside .text; skipping hook", kPopulatorRVA);
|
||||
}
|
||||
|
||||
// Guard thread: belt-and-suspenders re-force on a 2 s poll.
|
||||
g_guard_active.store(true);
|
||||
g_guard_thread = ::CreateThread(nullptr, 0, guard_thread_fn, nullptr, 0, nullptr);
|
||||
r.guard_ok = (g_guard_thread != nullptr);
|
||||
if (r.guard_ok)
|
||||
log(LogLevel::kInfo, "guard thread started (interval %lu ms)", kGuardIntervalMs);
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
inline void remove_patch() {
|
||||
g_guard_active.store(false);
|
||||
if (g_guard_thread) {
|
||||
::WaitForSingleObject(g_guard_thread, 5000);
|
||||
::CloseHandle(g_guard_thread);
|
||||
g_guard_thread = nullptr;
|
||||
}
|
||||
// Note: the trampoline and hook-site patch are NOT reversed on unload.
|
||||
// Reversing an inline hook while threads may be executing the trampoline is
|
||||
// unsafe. The DLL stays loaded for the process lifetime anyway.
|
||||
}
|
||||
|
||||
} // namespace plex
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// plex_inject.exe — Inject ``plex_patch.dll`` into Plex Media Server.
|
||||
//
|
||||
// Two modes:
|
||||
// plex_inject.exe — find the running PMS, inject into it
|
||||
// plex_inject.exe --launch PATH — spawn PMS suspended, inject, resume
|
||||
//
|
||||
// The DLL path is resolved relative to the injector's own location so the two
|
||||
// files can live side by side anywhere on disk (no need to copy to Program Files).
|
||||
//
|
||||
// Elevation: the injector must run as the same user as PMS or as Administrator
|
||||
// (OpenProcess needs PROCESS_ALL_ACCESS).
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <tlhelp32.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
static void err(const char* msg) {
|
||||
std::fprintf(stderr, "[x] %s (GetLastError=%lu)\n", msg, ::GetLastError());
|
||||
}
|
||||
|
||||
static bool get_own_dir(char* buf, size_t buflen) {
|
||||
DWORD n = ::GetModuleFileNameA(nullptr, buf, static_cast<DWORD>(buflen));
|
||||
if (n == 0 || n >= buflen) return false;
|
||||
// Strip the exe name, keep trailing backslash.
|
||||
char* last = std::strrchr(buf, '\\');
|
||||
if (!last) last = std::strrchr(buf, '/');
|
||||
if (last) *(last + 1) = '\0'; else buf[0] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static DWORD find_process(const char* name) {
|
||||
HANDLE snap = ::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if (snap == INVALID_HANDLE_VALUE) return 0;
|
||||
PROCESSENTRY32 pe{};
|
||||
pe.dwSize = sizeof(pe);
|
||||
DWORD pid = 0;
|
||||
if (::Process32First(snap, &pe)) {
|
||||
do {
|
||||
if (_stricmp(pe.szExeFile, name) == 0) { pid = pe.th32ProcessID; break; }
|
||||
} while (::Process32Next(snap, &pe));
|
||||
}
|
||||
::CloseHandle(snap);
|
||||
return pid;
|
||||
}
|
||||
|
||||
static bool inject_dll(HANDLE proc, const char* dll_path) {
|
||||
const size_t path_len = std::strlen(dll_path) + 1;
|
||||
|
||||
// Allocate memory in the target for the DLL path string.
|
||||
void* remote_buf = ::VirtualAllocEx(
|
||||
proc, nullptr, path_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||
if (!remote_buf) { err("VirtualAllocEx failed"); return false; }
|
||||
|
||||
if (!::WriteProcessMemory(proc, remote_buf, dll_path, path_len, nullptr)) {
|
||||
err("WriteProcessMemory failed");
|
||||
::VirtualFreeEx(proc, remote_buf, 0, MEM_RELEASE);
|
||||
return false;
|
||||
}
|
||||
|
||||
// LoadLibraryA is at the same address in every process (kernel32 is always
|
||||
// mapped at its preferred base on Windows x64).
|
||||
auto load_lib = reinterpret_cast<LPTHREAD_START_ROUTINE>(
|
||||
::GetProcAddress(::GetModuleHandleA("kernel32.dll"), "LoadLibraryA"));
|
||||
if (!load_lib) { err("GetProcAddress(LoadLibraryA) failed"); return false; }
|
||||
|
||||
HANDLE thread = ::CreateRemoteThread(
|
||||
proc, nullptr, 0, load_lib, remote_buf, 0, nullptr);
|
||||
if (!thread) { err("CreateRemoteThread failed"); return false; }
|
||||
|
||||
::WaitForSingleObject(thread, 10000);
|
||||
DWORD exit_code = 0;
|
||||
::GetExitCodeThread(thread, &exit_code);
|
||||
::CloseHandle(thread);
|
||||
::VirtualFreeEx(proc, remote_buf, 0, MEM_RELEASE);
|
||||
|
||||
if (exit_code == 0) {
|
||||
err("LoadLibraryA returned NULL in the target (DLL load failed)");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- main ------------------------------------------------------------------
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::printf("[*] plex_inject — Plex Media Server feature patch injector\n");
|
||||
|
||||
// Resolve the DLL path relative to the injector binary.
|
||||
char dir[MAX_PATH]{};
|
||||
if (!get_own_dir(dir, sizeof(dir))) { err("cannot determine own directory"); return 1; }
|
||||
char dll_path[MAX_PATH]{};
|
||||
std::snprintf(dll_path, sizeof(dll_path), "%splex_patch.dll", dir);
|
||||
|
||||
// Check the DLL exists before attempting injection.
|
||||
if (::GetFileAttributesA(dll_path) == INVALID_FILE_ATTRIBUTES) {
|
||||
std::fprintf(stderr, "[x] DLL not found: %s\n", dll_path);
|
||||
return 1;
|
||||
}
|
||||
std::printf("[+] DLL: %s\n", dll_path);
|
||||
|
||||
bool launched = false;
|
||||
HANDLE proc = nullptr;
|
||||
HANDLE main_thread = nullptr;
|
||||
DWORD pid = 0;
|
||||
|
||||
if (argc >= 3 && std::strcmp(argv[1], "--launch") == 0) {
|
||||
// Spawn PMS suspended, inject before it runs.
|
||||
STARTUPINFOA si{}; si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi{};
|
||||
if (!::CreateProcessA(argv[2], nullptr, nullptr, nullptr, FALSE,
|
||||
CREATE_SUSPENDED, nullptr, nullptr, &si, &pi)) {
|
||||
err("CreateProcess failed");
|
||||
return 1;
|
||||
}
|
||||
proc = pi.hProcess;
|
||||
main_thread = pi.hThread;
|
||||
pid = pi.dwProcessId;
|
||||
launched = true;
|
||||
std::printf("[+] launched PMS (pid %lu) suspended\n", pid);
|
||||
} else {
|
||||
// Attach to an already-running PMS.
|
||||
pid = find_process("Plex Media Server.exe");
|
||||
if (!pid) { err("Plex Media Server.exe not found (is it running?)"); return 1; }
|
||||
proc = ::OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
|
||||
if (!proc) { err("OpenProcess failed (run as admin?)"); return 1; }
|
||||
std::printf("[+] attached to PMS (pid %lu)\n", pid);
|
||||
}
|
||||
|
||||
bool ok = inject_dll(proc, dll_path);
|
||||
if (ok) {
|
||||
std::printf("[+] plex_patch.dll injected into pid %lu\n", pid);
|
||||
} else {
|
||||
std::fprintf(stderr, "[x] injection failed\n");
|
||||
}
|
||||
|
||||
if (launched) {
|
||||
if (ok) {
|
||||
::ResumeThread(main_thread);
|
||||
std::printf("[+] PMS main thread resumed\n");
|
||||
} else {
|
||||
::TerminateProcess(proc, 1);
|
||||
std::printf("[!] PMS terminated (injection failed)\n");
|
||||
}
|
||||
::CloseHandle(main_thread);
|
||||
}
|
||||
::CloseHandle(proc);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#pragma once
|
||||
//
|
||||
// Minimal, zero-allocation logging to OutputDebugString.
|
||||
//
|
||||
// Every log line is prefixed with "plex_patch" so it stands out in DbgView /
|
||||
// WinDbg. The format matches the Linux side's printf style. No heap allocs;
|
||||
// buffer is on the stack so this is safe inside DllMain / loader-lock context
|
||||
// for short messages (truncated at 511 chars rather than crashing).
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
|
||||
namespace plex {
|
||||
|
||||
enum class LogLevel : uint8_t { kDebug, kInfo, kWarn, kError };
|
||||
|
||||
inline void log(LogLevel level, const char* fmt, ...) {
|
||||
static constexpr const char* kPrefix[] = {"DBG", "INF", "WRN", "ERR"};
|
||||
char buf[512];
|
||||
const int hdr = std::snprintf(
|
||||
buf, sizeof(buf), "[plex_patch %s] ",
|
||||
kPrefix[static_cast<uint8_t>(level)]);
|
||||
|
||||
std::va_list args;
|
||||
va_start(args, fmt);
|
||||
std::vsnprintf(buf + hdr, sizeof(buf) - hdr, fmt, args);
|
||||
va_end(args);
|
||||
|
||||
::OutputDebugStringA(buf);
|
||||
}
|
||||
|
||||
} // namespace plex
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#pragma once
|
||||
//
|
||||
// PE image introspection: base address, section bounds, version guard.
|
||||
//
|
||||
// Replaces the Linux ``dl_iterate_phdr`` path. All functions operate on the
|
||||
// in-process image at the address returned by ``GetModuleHandleW(NULL)``,
|
||||
// so they are valid under ASLR and usable from an injected DLL.
|
||||
//
|
||||
// Design:
|
||||
// - ``ImageInfo`` is a plain aggregate (no methods, no invariants to break),
|
||||
// constructed by ``get_main_image()``.
|
||||
// - ``verify_version()`` is a pure scan with no side effects.
|
||||
// - ``resolve_rva()`` returns a typed pointer, bounds-checked against the
|
||||
// writable data section so a wrong RVA cannot silently corrupt code.
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
namespace plex {
|
||||
|
||||
// Section-level bounds for the main executable.
|
||||
struct ImageInfo {
|
||||
uintptr_t base = 0; // Module base (HMODULE)
|
||||
uintptr_t image_size = 0; // SizeOfImage from the optional header
|
||||
uintptr_t text_start = 0; // First executable byte
|
||||
uintptr_t text_end = 0; // One past the last executable byte
|
||||
uintptr_t data_start = 0; // First writable, non-executable byte
|
||||
uintptr_t data_end = 0; // One past the last such byte
|
||||
};
|
||||
|
||||
// Parse the PE headers of the main executable.
|
||||
inline std::optional<ImageInfo> get_main_image() {
|
||||
const auto base = reinterpret_cast<uintptr_t>(::GetModuleHandleW(nullptr));
|
||||
if (!base) return std::nullopt;
|
||||
|
||||
const auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
|
||||
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return std::nullopt;
|
||||
|
||||
const auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS64*>(base + dos->e_lfanew);
|
||||
if (nt->Signature != IMAGE_NT_SIGNATURE) return std::nullopt;
|
||||
if (nt->FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64) return std::nullopt;
|
||||
|
||||
ImageInfo info{};
|
||||
info.base = base;
|
||||
info.image_size = nt->OptionalHeader.SizeOfImage;
|
||||
|
||||
const auto* sec = IMAGE_FIRST_SECTION(nt);
|
||||
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sec) {
|
||||
const uintptr_t start = base + sec->VirtualAddress;
|
||||
const uintptr_t end = start + sec->Misc.VirtualSize;
|
||||
|
||||
if (sec->Characteristics & IMAGE_SCN_MEM_EXECUTE) {
|
||||
if (!info.text_start || start < info.text_start) info.text_start = start;
|
||||
if (end > info.text_end) info.text_end = end;
|
||||
}
|
||||
// Writable, non-executable = data/bss (where the bitset lives).
|
||||
if ((sec->Characteristics & IMAGE_SCN_MEM_WRITE) &&
|
||||
!(sec->Characteristics & IMAGE_SCN_MEM_EXECUTE)) {
|
||||
if (!info.data_start || start < info.data_start) info.data_start = start;
|
||||
if (end > info.data_end) info.data_end = end;
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
// Scan the image for a build-version string. Returns true iff the exact
|
||||
// version is found, guarding all hardcoded RVAs against applying to the
|
||||
// wrong build.
|
||||
inline bool verify_version(const ImageInfo& img, std::string_view expected) {
|
||||
if (expected.empty() || !img.base || !img.image_size) return false;
|
||||
|
||||
const auto* haystack = reinterpret_cast<const char*>(img.base);
|
||||
const size_t limit = img.image_size - expected.size();
|
||||
|
||||
for (size_t i = 0; i <= limit; ++i) {
|
||||
if (std::memcmp(haystack + i, expected.data(), expected.size()) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert an RVA to a typed pointer, bounds-checked against the writable data
|
||||
// section. Returns nullptr if the address falls outside .data/.bss.
|
||||
template <typename T>
|
||||
T* resolve_data_rva(const ImageInfo& img, uint32_t rva, size_t extent = sizeof(T)) {
|
||||
const uintptr_t va = img.base + rva;
|
||||
if (va < img.data_start || va + extent > img.data_end) return nullptr;
|
||||
return reinterpret_cast<T*>(va);
|
||||
}
|
||||
|
||||
// Convert an RVA to a code pointer (bounds-checked against .text).
|
||||
inline const uint8_t* resolve_text_rva(const ImageInfo& img, uint32_t rva) {
|
||||
const uintptr_t va = img.base + rva;
|
||||
if (va < img.text_start || va >= img.text_end) return nullptr;
|
||||
return reinterpret_cast<const uint8_t*>(va);
|
||||
}
|
||||
|
||||
} // namespace plex
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#pragma once
|
||||
//
|
||||
// Byte-pattern signature scanner.
|
||||
//
|
||||
// Ported from the Linux ``hook.cpp`` ``sig_scan()`` with one addition:
|
||||
// ``resolve_rip_rel32()`` decodes a RIP-relative displacement at a matched
|
||||
// site, which is how we recover absolute addresses from x64 instructions.
|
||||
//
|
||||
// Pattern format (same as IDA/Linux side):
|
||||
// "48 8D 0D ?? ?? ?? ??" hex bytes; ?? = one-byte wildcard
|
||||
//
|
||||
// This is a pure scan over [start, end) — no allocations, no side effects.
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace plex {
|
||||
|
||||
// A compiled pattern ready for scanning. Opaque; use ``compile_pattern()``.
|
||||
struct Pattern {
|
||||
struct Atom { uint8_t byte; bool wild; };
|
||||
std::vector<Atom> atoms;
|
||||
};
|
||||
|
||||
// Compile a hex+wildcard string into a scannable pattern.
|
||||
inline std::optional<Pattern> compile_pattern(std::string_view text) {
|
||||
Pattern pat;
|
||||
for (size_t i = 0; i < text.size(); ) {
|
||||
const char c = text[i];
|
||||
if (c == ' ') { ++i; continue; }
|
||||
if (c == '?') {
|
||||
// Consume '?' or '??'.
|
||||
if (i + 1 < text.size() && text[i + 1] == '?') ++i;
|
||||
pat.atoms.push_back({0, true});
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
// Two hex characters.
|
||||
if (i + 1 >= text.size()) return std::nullopt;
|
||||
char pair[3] = {text[i], text[i + 1], '\0'};
|
||||
char* end = nullptr;
|
||||
const unsigned long v = std::strtoul(pair, &end, 16);
|
||||
if (end != pair + 2 || v > 0xFF) return std::nullopt;
|
||||
pat.atoms.push_back({static_cast<uint8_t>(v), false});
|
||||
i += 2;
|
||||
}
|
||||
if (pat.atoms.empty()) return std::nullopt;
|
||||
return pat;
|
||||
}
|
||||
|
||||
// Scan [start, end) for the first occurrence of ``pat``.
|
||||
inline std::optional<uintptr_t> sig_scan(
|
||||
uintptr_t start, uintptr_t end, const Pattern& pat) {
|
||||
const size_t len = pat.atoms.size();
|
||||
if (len == 0 || end <= start || end - start < len) return std::nullopt;
|
||||
|
||||
const auto* mem = reinterpret_cast<const uint8_t*>(start);
|
||||
const size_t limit = (end - start) - len;
|
||||
|
||||
for (size_t i = 0; i <= limit; ++i) {
|
||||
bool match = true;
|
||||
for (size_t j = 0; j < len; ++j) {
|
||||
if (!pat.atoms[j].wild && mem[i + j] != pat.atoms[j].byte) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) return start + i;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Convenience: compile + scan in one call.
|
||||
inline std::optional<uintptr_t> sig_scan(
|
||||
uintptr_t start, uintptr_t end, std::string_view pattern_text) {
|
||||
auto pat = compile_pattern(pattern_text);
|
||||
if (!pat) return std::nullopt;
|
||||
return sig_scan(start, end, *pat);
|
||||
}
|
||||
|
||||
// Resolve a RIP-relative ``disp32`` at ``inst_addr + disp_offset`` within an
|
||||
// instruction of ``inst_len`` bytes. Returns the absolute target address.
|
||||
//
|
||||
// Use case: a ``lea rcx, [rip + disp32]`` at a matched site lets us recover
|
||||
// the address of a global (e.g. the feature bitset) without hardcoding its RVA.
|
||||
inline uintptr_t resolve_rip_rel32(
|
||||
uintptr_t inst_addr, size_t disp_offset, size_t inst_len) {
|
||||
const auto disp = *reinterpret_cast<const int32_t*>(inst_addr + disp_offset);
|
||||
return inst_addr + inst_len + disp;
|
||||
}
|
||||
|
||||
} // namespace plex
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#pragma once
|
||||
//
|
||||
// x86-64 inline hook via a 14-byte absolute indirect jump.
|
||||
//
|
||||
// Ported from the Linux ``create_hook()`` in ``src/hook.cpp``.
|
||||
// Differences from the POSIX version:
|
||||
// - VirtualAlloc / VirtualProtect instead of mmap / mprotect.
|
||||
// - FlushInstructionCache after patching (required on Windows).
|
||||
// - The hook site and trampoline share the same 14-byte shellcode layout:
|
||||
// FF 25 00 00 00 00 <8-byte absolute target> // jmp [rip+0]
|
||||
//
|
||||
// Zydis (vendored, MIT) decodes the prologue to ensure we relocate only
|
||||
// complete instructions. The trampoline contains:
|
||||
// [relocated prologue bytes] [14-byte jmp to original+offset]
|
||||
// and the patched call site is:
|
||||
// [14-byte jmp to hook function]
|
||||
//
|
||||
// Returns the trampoline address (= pointer to the "original" function that
|
||||
// the hook body calls through to execute the un-hooked path).
|
||||
//
|
||||
// Thread safety: installing a hook while other threads may be executing the
|
||||
// target function is inherently racy on x64 (no single atomic 14-byte write).
|
||||
// Install hooks early — from DLL_PROCESS_ATTACH on a suspended process — to
|
||||
// avoid this.
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
|
||||
#include "Zydis.h"
|
||||
#include "log.h"
|
||||
|
||||
namespace plex {
|
||||
|
||||
// Absolute indirect jump: ``jmp [rip+0]`` followed by an 8-byte address.
|
||||
inline constexpr size_t kJmpSize = 14;
|
||||
|
||||
inline void write_abs_jmp(uint8_t* site, uintptr_t target) {
|
||||
// FF 25 00 00 00 00 = jmp qword ptr [rip+0]
|
||||
site[0] = 0xFF;
|
||||
site[1] = 0x25;
|
||||
site[2] = site[3] = site[4] = site[5] = 0x00;
|
||||
std::memcpy(site + 6, &target, 8);
|
||||
}
|
||||
|
||||
// Install a 14-byte inline hook at ``from``, redirecting to ``to``.
|
||||
// Returns the trampoline (original function entry) on success.
|
||||
inline std::optional<uintptr_t> create_hook(uintptr_t from, uintptr_t to) {
|
||||
ZydisDecoder decoder;
|
||||
ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64);
|
||||
ZydisDecodedInstruction inst;
|
||||
|
||||
// --- 1. Determine prologue length (>= 14 bytes of complete instructions) ---
|
||||
size_t stolen = 0;
|
||||
while (stolen < kJmpSize) {
|
||||
const auto* ip = reinterpret_cast<const void*>(from + stolen);
|
||||
if (!ZYAN_SUCCESS(ZydisDecoderDecodeInstruction(
|
||||
&decoder, nullptr, ip, 15 /*max x64 len*/, &inst))) {
|
||||
log(LogLevel::kError, "trampoline: failed to decode at %p+%zu",
|
||||
reinterpret_cast<void*>(from), stolen);
|
||||
return std::nullopt;
|
||||
}
|
||||
stolen += inst.length;
|
||||
}
|
||||
|
||||
// --- 2. Allocate the trampoline (RW, flipped to RX after write) ----------
|
||||
const size_t tramp_size = stolen + kJmpSize;
|
||||
auto* tramp = static_cast<uint8_t*>(
|
||||
::VirtualAlloc(nullptr, tramp_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
|
||||
if (!tramp) {
|
||||
log(LogLevel::kError, "trampoline: VirtualAlloc failed (%lu)", ::GetLastError());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Copy the stolen prologue bytes, then append a jump back to from+stolen.
|
||||
std::memcpy(tramp, reinterpret_cast<const void*>(from), stolen);
|
||||
write_abs_jmp(tramp + stolen, from + stolen);
|
||||
|
||||
DWORD old_prot = 0;
|
||||
::VirtualProtect(tramp, tramp_size, PAGE_EXECUTE_READ, &old_prot);
|
||||
::FlushInstructionCache(::GetCurrentProcess(), tramp, tramp_size);
|
||||
|
||||
// --- 3. Patch the original site to jump to our hook ----------------------
|
||||
DWORD site_prot = 0;
|
||||
if (!::VirtualProtect(reinterpret_cast<void*>(from), kJmpSize,
|
||||
PAGE_EXECUTE_READWRITE, &site_prot)) {
|
||||
log(LogLevel::kError, "trampoline: VirtualProtect(hook site) failed (%lu)",
|
||||
::GetLastError());
|
||||
::VirtualFree(tramp, 0, MEM_RELEASE);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
write_abs_jmp(reinterpret_cast<uint8_t*>(from), to);
|
||||
|
||||
::VirtualProtect(reinterpret_cast<void*>(from), kJmpSize, site_prot, &site_prot);
|
||||
::FlushInstructionCache(::GetCurrentProcess(), reinterpret_cast<void*>(from), kJmpSize);
|
||||
|
||||
log(LogLevel::kInfo, "hook installed: %p -> %p (trampoline at %p, %zu stolen bytes)",
|
||||
reinterpret_cast<void*>(from), reinterpret_cast<void*>(to),
|
||||
static_cast<void*>(tramp), stolen);
|
||||
return reinterpret_cast<uintptr_t>(tramp);
|
||||
}
|
||||
|
||||
} // namespace plex
|
||||
Binary file not shown.
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-discover hook signatures from the Plex Media Server binary.
|
||||
|
||||
For each hook target, the script tries these strategies in order:
|
||||
1. Broad structural pattern (opcodes with displacements/immediates wildcarded)
|
||||
2. String-anchored discovery: find a key string, find the LEA referencing it,
|
||||
backtrack to the function prologue, auto-generate a pattern by disassembling
|
||||
the prologue and wildcarding all displacement/immediate operands via capstone.
|
||||
3. Relationship-based: search within another discovered function's body.
|
||||
|
||||
If all strategies fail, the build fails with diagnostics.
|
||||
|
||||
Usage:
|
||||
python3 discover_patterns.py <PMS binary> -o patterns_generated.h
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import struct
|
||||
import argparse
|
||||
|
||||
try:
|
||||
from capstone import Cs, CS_ARCH_X86, CS_MODE_64
|
||||
except ImportError:
|
||||
print("ERROR: capstone not installed. Run: pip install capstone", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Capstone operand type constants
|
||||
CS_OP_REG = 1
|
||||
CS_OP_IMM = 2
|
||||
CS_OP_MEM = 3
|
||||
|
||||
# Capstone x86 register IDs
|
||||
X86_REG_RIP = 41
|
||||
|
||||
|
||||
# ── ELF parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_elf_segments(data):
|
||||
if data[:4] != b'\x7fELF':
|
||||
raise ValueError("Not an ELF file")
|
||||
e_phoff = struct.unpack('<Q', data[32:40])[0]
|
||||
e_phentsize = struct.unpack('<H', data[54:56])[0]
|
||||
e_phnum = struct.unpack('<H', data[56:58])[0]
|
||||
segs = []
|
||||
for i in range(e_phnum):
|
||||
off = e_phoff + i * e_phentsize
|
||||
if struct.unpack('<I', data[off:off+4])[0] != 1:
|
||||
continue
|
||||
segs.append((
|
||||
struct.unpack('<Q', data[off+8:off+16])[0], # p_offset
|
||||
struct.unpack('<Q', data[off+16:off+24])[0], # p_vaddr
|
||||
struct.unpack('<Q', data[off+32:off+40])[0], # p_filesz
|
||||
))
|
||||
return segs
|
||||
|
||||
|
||||
def file_to_vaddr(segments, file_off):
|
||||
for p_offset, p_vaddr, p_filesz in segments:
|
||||
if p_offset <= file_off < p_offset + p_filesz:
|
||||
return file_off - p_offset + p_vaddr
|
||||
return file_off
|
||||
|
||||
|
||||
def vaddr_to_file(segments, vaddr):
|
||||
for p_offset, p_vaddr, p_filesz in segments:
|
||||
if p_vaddr <= vaddr < p_vaddr + p_filesz:
|
||||
return vaddr - p_vaddr + p_offset
|
||||
return vaddr
|
||||
|
||||
|
||||
# ── Byte pattern matching ──────────────────────────────────────────────────
|
||||
|
||||
def parse_pattern(p):
|
||||
out = []
|
||||
for tok in p.split():
|
||||
if tok in ('??', '?'):
|
||||
out.append(None)
|
||||
else:
|
||||
out.append(int(tok, 16))
|
||||
return out
|
||||
|
||||
|
||||
def find_matches(data, pattern):
|
||||
matches = []
|
||||
plen = len(pattern)
|
||||
for i in range(len(data) - plen + 1):
|
||||
if all(pb is None or data[i+j] == pb for j, pb in enumerate(pattern)):
|
||||
matches.append(i)
|
||||
return matches
|
||||
|
||||
|
||||
def pattern_to_str(pat):
|
||||
return ' '.join(f'{b:02X}' if b is not None else '?' for b in pat)
|
||||
|
||||
|
||||
# ── Instruction analysis (capstone) ────────────────────────────────────────
|
||||
|
||||
LEGACY_PREFIXES = {0x26, 0x2e, 0x36, 0x3e, 0x64, 0x65, 0x66, 0x67, 0xf0, 0xf2, 0xf3}
|
||||
|
||||
|
||||
def find_modrm_pos(raw, size):
|
||||
"""Find the ModRM byte position in an x86-64 instruction's raw bytes."""
|
||||
pos = 0
|
||||
while pos < size and raw[pos] in LEGACY_PREFIXES:
|
||||
pos += 1
|
||||
if pos < size and 0x40 <= raw[pos] <= 0x4f:
|
||||
pos += 1 # REX
|
||||
if pos < size and raw[pos] == 0xc5:
|
||||
pos += 2 # VEX 2-byte
|
||||
elif pos < size and raw[pos] == 0xc4:
|
||||
pos += 3 # VEX 3-byte
|
||||
if pos < size:
|
||||
if raw[pos] == 0x0f:
|
||||
pos += 1
|
||||
if pos < size and raw[pos] in (0x38, 0x3a):
|
||||
pos += 1
|
||||
pos += 1 # opcode byte
|
||||
return pos if pos < size else -1
|
||||
|
||||
|
||||
def wildcard_instruction(insn):
|
||||
"""Return a list of (byte|None) for an instruction, wildcarding all
|
||||
displacement and immediate operands. None means wildcard."""
|
||||
raw = list(insn.bytes)
|
||||
size = insn.size
|
||||
wildcard = [False] * size
|
||||
|
||||
has_rip_mem = False
|
||||
has_nonrip_mem_disp = False
|
||||
mem_disp = 0
|
||||
has_imm = False
|
||||
|
||||
for op in insn.operands:
|
||||
if op.type == CS_OP_IMM:
|
||||
has_imm = True
|
||||
elif op.type == CS_OP_MEM:
|
||||
if op.mem.base == X86_REG_RIP:
|
||||
has_rip_mem = True
|
||||
elif op.mem.disp != 0 and op.mem.base != 0:
|
||||
has_nonrip_mem_disp = True
|
||||
mem_disp = op.mem.disp
|
||||
|
||||
# Determine immediate total size and how many LOW bytes to wildcard.
|
||||
# imm_total: full immediate width. imm_wc: how many low bytes to
|
||||
# wildcard (high bytes kept for specificity, e.g. 0x00 for small frames).
|
||||
imm_total = 0
|
||||
imm_wc = 0
|
||||
if has_imm:
|
||||
mnem = insn.mnemonic
|
||||
if mnem in ('call', 'jmp') or mnem.startswith('j'):
|
||||
imm_total = 4 if size >= 5 else 1
|
||||
imm_wc = imm_total
|
||||
elif mnem in ('sub', 'add', 'cmp'):
|
||||
if size == 4:
|
||||
imm_total = 1; imm_wc = 1
|
||||
else:
|
||||
imm_total = 4; imm_wc = 2 # wildcard low 2, keep high 2 zeros
|
||||
elif mnem == 'mov':
|
||||
if size >= 10:
|
||||
imm_total = 8; imm_wc = 8
|
||||
elif size >= 7:
|
||||
imm_total = 4; imm_wc = 4
|
||||
else:
|
||||
imm_total = 1; imm_wc = 1
|
||||
elif mnem == 'push':
|
||||
imm_total = 1 if size == 2 else 4; imm_wc = imm_total
|
||||
elif mnem == 'test':
|
||||
imm_total = 1 if size <= 4 else 4; imm_wc = imm_total
|
||||
else:
|
||||
imm_total = min(4, size - 1); imm_wc = imm_total
|
||||
|
||||
# Determine displacement size
|
||||
disp_size = 0
|
||||
if has_rip_mem:
|
||||
disp_size = 4
|
||||
elif has_nonrip_mem_disp:
|
||||
modrm_pos = find_modrm_pos(raw, size)
|
||||
if modrm_pos >= 0:
|
||||
mod_field = (raw[modrm_pos] >> 6) & 3
|
||||
if mod_field == 1:
|
||||
disp_size = 1
|
||||
elif mod_field == 2:
|
||||
disp_size = 4
|
||||
if disp_size == 0:
|
||||
disp_size = 1 if -128 <= mem_disp <= 127 else 4
|
||||
|
||||
# Wildcard displacement bytes (immediately before the immediate field)
|
||||
if disp_size > 0:
|
||||
disp_start = size - imm_total - disp_size
|
||||
for i in range(max(0, disp_start), min(size, disp_start + disp_size)):
|
||||
wildcard[i] = True
|
||||
|
||||
# Wildcard the LOW imm_wc bytes of the immediate (keep high bytes)
|
||||
if imm_total > 0:
|
||||
imm_start = size - imm_total
|
||||
for i in range(max(0, imm_start), min(size, imm_start + imm_wc)):
|
||||
wildcard[i] = True
|
||||
|
||||
return [(raw[i] if not wildcard[i] else None) for i in range(size)]
|
||||
|
||||
|
||||
def auto_wildcard_pattern(data, segments, func_file_off, length):
|
||||
"""Disassemble a function and generate a byte pattern with all
|
||||
displacement/immediate operands auto-wildcarded."""
|
||||
md = Cs(CS_ARCH_X86, CS_MODE_64)
|
||||
md.detail = True
|
||||
vaddr = file_to_vaddr(segments, func_file_off)
|
||||
code = data[func_file_off:func_file_off + length + 32]
|
||||
|
||||
pattern = []
|
||||
bytes_consumed = 0
|
||||
|
||||
for insn in md.disasm(code, vaddr):
|
||||
if bytes_consumed >= length:
|
||||
break
|
||||
wc_bytes = wildcard_instruction(insn)
|
||||
for b in wc_bytes:
|
||||
if bytes_consumed >= length:
|
||||
break
|
||||
pattern.append(b)
|
||||
bytes_consumed += 1
|
||||
|
||||
while len(pattern) < length:
|
||||
pattern.append(None)
|
||||
|
||||
return pattern[:length]
|
||||
|
||||
|
||||
# ── Function discovery ─────────────────────────────────────────────────────
|
||||
|
||||
def find_string_in_binary(data, search_string):
|
||||
"""Find all occurrences of a null-terminated string in the binary."""
|
||||
needle = search_string.encode() + b'\x00'
|
||||
results = []
|
||||
start = 0
|
||||
while True:
|
||||
idx = data.find(needle, start)
|
||||
if idx == -1:
|
||||
break
|
||||
results.append(idx)
|
||||
start = idx + 1
|
||||
return results
|
||||
|
||||
|
||||
def find_lea_refs_to_string(data, segments, string_file_off):
|
||||
"""Find all LEA reg,[rip+disp32] instructions that reference a string."""
|
||||
results = []
|
||||
for p_offset, p_vaddr, p_filesz in segments:
|
||||
end = min(p_offset + p_filesz, len(data) - 7)
|
||||
for i in range(p_offset, end):
|
||||
if data[i] in (0x48, 0x4c) and data[i+1] == 0x8D:
|
||||
modrm = data[i+2]
|
||||
if (modrm & 0xC7) == 0x05:
|
||||
disp = struct.unpack('<i', data[i+3:i+7])[0]
|
||||
insn_vaddr = file_to_vaddr(segments, i)
|
||||
target_vaddr = insn_vaddr + 7 + disp
|
||||
target_file = vaddr_to_file(segments, target_vaddr)
|
||||
if target_file == string_file_off:
|
||||
reg = (modrm >> 3) & 7
|
||||
if data[i] == 0x4c:
|
||||
reg += 8
|
||||
results.append((i, reg))
|
||||
return results
|
||||
|
||||
|
||||
def find_func_start_backwards(data, file_off, max_scan=16384):
|
||||
"""Scan backwards for a function prologue (55 48 89 E5)."""
|
||||
for j in range(file_off, max(0, file_off - max_scan), -1):
|
||||
if data[j:j+4] == b'\x55\x48\x89\xe5':
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def find_func_end(data, segments, func_file_off, max_insns=5000):
|
||||
"""Find the end of a function by scanning for ret/int3 after the prologue."""
|
||||
md = Cs(CS_ARCH_X86, CS_MODE_64)
|
||||
vaddr = file_to_vaddr(segments, func_file_off)
|
||||
code = data[func_file_off:func_file_off + 16384]
|
||||
count = 0
|
||||
for insn in md.disasm(code, vaddr):
|
||||
count += 1
|
||||
if count > max_insns:
|
||||
break
|
||||
if insn.mnemonic in ('ret', 'repret', 'ud2'):
|
||||
return func_file_off + insn.address - vaddr + insn.size
|
||||
return func_file_off + 8192
|
||||
|
||||
|
||||
def string_anchored_discovery(data, segments, search_string, pattern_length,
|
||||
expected_func_start_prefix=None):
|
||||
"""Discover a function by finding a string reference, then backtracking
|
||||
to the function prologue. Auto-generates a pattern with wildcarded
|
||||
displacement/immediate operands."""
|
||||
string_locations = find_string_in_binary(data, search_string)
|
||||
if not string_locations:
|
||||
return None, f"string '{search_string}' not found in binary"
|
||||
|
||||
for string_off in string_locations:
|
||||
lea_refs = find_lea_refs_to_string(data, segments, string_off)
|
||||
for lea_off, reg in lea_refs:
|
||||
func_start = find_func_start_backwards(data, lea_off)
|
||||
if not func_start:
|
||||
continue
|
||||
if expected_func_start_prefix:
|
||||
prefix = data[func_start:func_start + len(expected_func_start_prefix)]
|
||||
if prefix != expected_func_start_prefix:
|
||||
continue
|
||||
pattern = auto_wildcard_pattern(data, segments, func_start, pattern_length)
|
||||
matches = find_matches(data, pattern)
|
||||
if matches:
|
||||
return pattern, f"found via string '{search_string}' -> LEA at 0x{lea_off:08x} -> func at 0x{func_start:08x} ({len(matches)} match(es))"
|
||||
|
||||
return None, f"string '{search_string}' found but no enclosing function prologue"
|
||||
|
||||
|
||||
def relationship_based_discovery(data, segments, ref_func_off, search_pattern_str,
|
||||
search_range=8192):
|
||||
"""Search within a function's body for a structural pattern."""
|
||||
pat = parse_pattern(search_pattern_str)
|
||||
end = min(len(data), ref_func_off + search_range)
|
||||
matches = []
|
||||
for i in range(ref_func_off, end - len(pat) + 1):
|
||||
if all(pb is None or data[i+j] == pb for j, pb in enumerate(pat)):
|
||||
matches.append(i)
|
||||
if matches:
|
||||
return pat, f"found {len(matches)} match(es) within function body"
|
||||
return None, "pattern not found within function body"
|
||||
|
||||
|
||||
# ── Hook target definitions ────────────────────────────────────────────────
|
||||
|
||||
TARGETS = [
|
||||
{
|
||||
"name": "PREF_GETTER",
|
||||
"broad_pattern": "55 48 89 E5 41 57 41 56 53 48 83 EC ? 48 89 F3 49 89 FE 0F B6 46 17 48 89 F1 84 C0",
|
||||
"string_anchor": None,
|
||||
"relates_to": None,
|
||||
"expected_matches": (1, 3),
|
||||
"required": True,
|
||||
"length": 26,
|
||||
},
|
||||
{
|
||||
"name": "BITSET_REF",
|
||||
"broad_pattern": "48 8D 0D ? ? ? ? 48 8B 94 05 ? ? ? ? 48 87 14 08",
|
||||
"string_anchor": None,
|
||||
"relates_to": ("BS_INIT", "48 8D 0D ? ? ? ? 48 8B 94 05 ? ? ? ? 48 87 14 08"),
|
||||
"expected_matches": (1, 4),
|
||||
"required": True,
|
||||
"length": 18,
|
||||
},
|
||||
{
|
||||
"name": "BS_INIT",
|
||||
"broad_pattern": "55 48 89 E5 41 57 41 56 41 55 41 54 53 48 81 EC ? ? 00 00 49 89 FE 48 8D 9D ? ? ? ? 48 89 DF E8 ? ? ? ? 48 8B 1B 48 85 DB",
|
||||
"string_anchor": "//feature",
|
||||
"string_prefix": b'\x55\x48\x89\xe5\x41\x57\x41\x56\x41\x55\x41\x54\x53',
|
||||
"relates_to": None,
|
||||
"expected_matches": (1, 1),
|
||||
"required": True,
|
||||
"length": 44,
|
||||
},
|
||||
{
|
||||
"name": "LEGACY_USF",
|
||||
"broad_pattern": "55 48 89 E5 48 8B 07 48 85 C0 74 09",
|
||||
"string_anchor": None,
|
||||
"relates_to": None,
|
||||
"expected_matches": (1, 5),
|
||||
"required": False,
|
||||
"length": 12,
|
||||
},
|
||||
{
|
||||
"name": "LEGACY_MF",
|
||||
"broad_pattern": "55 48 89 E5 41 57 41 56 53 48 83 EC ? 49 89 F7 4C 8D 77",
|
||||
"string_anchor": None,
|
||||
"relates_to": None,
|
||||
"expected_matches": (1, 3),
|
||||
"required": False,
|
||||
"length": 18,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def generate_header(patterns, output_path):
|
||||
with open(output_path, 'w') as f:
|
||||
f.write("#pragma once\n")
|
||||
f.write("// Auto-generated by discover_patterns.py — DO NOT EDIT.\n")
|
||||
f.write("// Hook signatures discovered from the PMS binary at build time.\n\n")
|
||||
for name, pattern_str, match_count, method in patterns:
|
||||
f.write(f'// {name}: {match_count} match(es) — {method}\n')
|
||||
f.write(f'static const char* PATTERN_{name} = "{pattern_str}";\n\n')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('pms_path')
|
||||
parser.add_argument('-o', '--output', default='patterns_generated.h')
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.pms_path, 'rb') as f:
|
||||
data = f.read()
|
||||
segments = parse_elf_segments(data)
|
||||
print(f"Loaded {args.pms_path} ({len(data)} bytes, {len(segments)} LOAD segments)")
|
||||
|
||||
results = []
|
||||
discovered_func_offsets = {}
|
||||
all_ok = True
|
||||
|
||||
# Pass 1: discover targets via broad pattern or string-anchored (independent)
|
||||
# Pass 2: discover relationship-dependent targets using results from pass 1
|
||||
pending = []
|
||||
for target in TARGETS:
|
||||
name = target['name']
|
||||
broad = parse_pattern(target['broad_pattern'])
|
||||
lo, hi = target['expected_matches']
|
||||
|
||||
# Strategy 1: broad structural pattern
|
||||
matches = find_matches(data, broad)
|
||||
if lo <= len(matches) <= hi:
|
||||
print(f" [OK] {name}: broad pattern ({len(matches)} matches)")
|
||||
for m in matches[:3]:
|
||||
print(f" 0x{m:08x}: {data[m:m+min(len(broad)+8,32)].hex(' ')}")
|
||||
results.append((name, pattern_to_str(broad), len(matches), f"broad pattern ({len(matches)} matches)"))
|
||||
discovered_func_offsets[name] = matches[0] if matches else None
|
||||
continue
|
||||
|
||||
# Strategy 2: string-anchored discovery
|
||||
if target.get('string_anchor'):
|
||||
pattern, detail = string_anchored_discovery(
|
||||
data, segments, target['string_anchor'],
|
||||
target['length'], target.get('string_prefix'))
|
||||
if pattern:
|
||||
matches = find_matches(data, pattern)
|
||||
if lo <= len(matches) <= hi:
|
||||
print(f" [OK] {name}: string-anchored ({len(matches)} matches)")
|
||||
print(f" {detail}")
|
||||
for m in matches[:3]:
|
||||
print(f" 0x{m:08x}: {data[m:m+min(len(pattern)+8,32)].hex(' ')}")
|
||||
results.append((name, pattern_to_str(pattern), len(matches), f"string-anchored: {detail}"))
|
||||
discovered_func_offsets[name] = matches[0] if matches else None
|
||||
continue
|
||||
|
||||
# Defer relationship-based to pass 2
|
||||
if target.get('relates_to'):
|
||||
pending.append(target)
|
||||
elif target['required']:
|
||||
print(f" [FAIL] {name}: all strategies failed")
|
||||
print(f" Broad pattern: {pattern_to_str(broad)}")
|
||||
if target.get('string_anchor'):
|
||||
print(f" String anchor: '{target['string_anchor']}'")
|
||||
all_ok = False
|
||||
results.append((name, pattern_to_str(broad), 0, "FAILED"))
|
||||
else:
|
||||
print(f" [SKIP] {name}: not found (optional)")
|
||||
results.append((name, pattern_to_str(broad), 0, "skipped (optional)"))
|
||||
|
||||
# Pass 2: relationship-based discovery (depends on pass 1 results)
|
||||
for target in pending:
|
||||
name = target['name']
|
||||
broad = parse_pattern(target['broad_pattern'])
|
||||
lo, hi = target['expected_matches']
|
||||
ref_name, rel_pattern = target['relates_to']
|
||||
ref_off = discovered_func_offsets.get(ref_name)
|
||||
|
||||
if ref_off:
|
||||
# Search within the referenced function's body
|
||||
func_end = find_func_end(data, segments, ref_off)
|
||||
pattern, detail = relationship_based_discovery(
|
||||
data, segments, ref_off, rel_pattern, search_range=func_end - ref_off + 256)
|
||||
if pattern:
|
||||
matches = find_matches(data, pattern)
|
||||
if lo <= len(matches) <= hi:
|
||||
print(f" [OK] {name}: relationship ({ref_name}) ({len(matches)} matches)")
|
||||
print(f" {detail}")
|
||||
for m in matches[:3]:
|
||||
print(f" 0x{m:08x}: {data[m:m+min(len(pattern)+8,32)].hex(' ')}")
|
||||
results.append((name, pattern_to_str(pattern), len(matches), f"relationship ({ref_name}): {detail}"))
|
||||
discovered_func_offsets[name] = matches[0] if matches else None
|
||||
continue
|
||||
|
||||
# Relationship failed — try broad as last resort
|
||||
matches = find_matches(data, broad)
|
||||
if lo <= len(matches) <= hi:
|
||||
print(f" [OK] {name}: broad pattern ({len(matches)} matches) [fallback]")
|
||||
results.append((name, pattern_to_str(broad), len(matches), f"broad pattern fallback ({len(matches)} matches)"))
|
||||
discovered_func_offsets[name] = matches[0] if matches else None
|
||||
continue
|
||||
|
||||
if target['required']:
|
||||
print(f" [FAIL] {name}: all strategies failed (broad + string + relationship)")
|
||||
all_ok = False
|
||||
results.append((name, pattern_to_str(broad), 0, "FAILED"))
|
||||
else:
|
||||
print(f" [SKIP] {name}: not found (optional)")
|
||||
results.append((name, pattern_to_str(broad), 0, "skipped (optional)"))
|
||||
|
||||
if all_ok:
|
||||
generate_header(results, args.output)
|
||||
print(f"\nAll required patterns discovered. Header written to {args.output}")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nFAILED: one or more required patterns not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verify that all x86-64 hook signatures in Freeloader/src/hook.cpp match
|
||||
the Plex Media Server binary. Exits non-zero if any signature is missing.
|
||||
|
||||
Usage:
|
||||
python3 verify_signatures.py <path-to-Plex Media Server binary>
|
||||
|
||||
When a signature fails, a partial-match diagnostic is printed to help
|
||||
locate the new pattern.
|
||||
"""
|
||||
import sys
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
|
||||
# ── Signature definitions ─────────────────────────────────────────────────
|
||||
# These mirror the sig_scan() calls in Freeloader/src/hook.cpp (x86-64 path).
|
||||
# Each entry: (step_name, pattern_string, must_match)
|
||||
|
||||
SIGNATURES = [
|
||||
(
|
||||
"STEP1 sub_122B2F2 (preference getter)",
|
||||
"55 48 89 E5 41 57 41 56 53 48 83 EC 18 48 89 F3 49 89 FE 0F B6 46 17 48 89 F1 84 C0",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"STEP3 bitset reference (lea rcx + mov rdx + xchg)",
|
||||
"48 8D 0D ? ? ? ? 48 8B 94 05 ? ? ? ? 48 87 14 08",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"STEP3 bs_init (FeatureManager constructor)",
|
||||
"55 48 89 E5 41 57 41 56 41 55 41 54 53 48 81 EC ? ? 00 00 49 89 FE 48 8D 9D ? ? ? ? 48 89 DF E8 ? ? ? ? 48 8B 1B 48 85 DB",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"STEP4 legacy is_user_feature_set",
|
||||
"55 48 89 E5 48 8B 07 48 85 C0 74 09",
|
||||
False, # fallback — may not be needed if STEP3 works
|
||||
),
|
||||
(
|
||||
"STEP4 legacy map_find",
|
||||
"55 48 89 E5 41 57 41 56 53 48 83 EC ? 49 89 F7 4C 8D 77",
|
||||
False, # fallback
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parse_pattern(p):
|
||||
out = []
|
||||
for tok in p.split():
|
||||
if tok in ("??", "?"):
|
||||
out.append(None)
|
||||
else:
|
||||
out.append(int(tok, 16))
|
||||
return out
|
||||
|
||||
|
||||
def find_matches(data, pattern):
|
||||
matches = []
|
||||
plen = len(pattern)
|
||||
for i in range(len(data) - plen + 1):
|
||||
if all(pb is None or data[i + j] == pb for j, pb in enumerate(pattern)):
|
||||
matches.append(i)
|
||||
return matches
|
||||
|
||||
|
||||
def partial_match_length(data, offset, pattern):
|
||||
"""How many leading bytes of the pattern match at this offset."""
|
||||
matched = 0
|
||||
for j, pb in enumerate(pattern):
|
||||
if offset + j >= len(data):
|
||||
break
|
||||
if pb is not None and data[offset + j] != pb:
|
||||
break
|
||||
matched += 1
|
||||
return matched
|
||||
|
||||
|
||||
def best_partial_matches(data, pattern, top_n=5):
|
||||
"""Find the offsets with the longest leading match of the pattern."""
|
||||
scores = []
|
||||
plen = len(pattern)
|
||||
for i in range(len(data) - min(plen, 8) + 1):
|
||||
score = partial_match_length(data, i, pattern)
|
||||
if score >= min(8, plen): # at least 8 bytes or full pattern
|
||||
scores.append((score, i))
|
||||
scores.sort(reverse=True)
|
||||
return scores[:top_n]
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print(f"Usage: {sys.argv[0]} <path-to-Plex Media Server>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
pms_path = sys.argv[1]
|
||||
|
||||
try:
|
||||
with open(pms_path, "rb") as f:
|
||||
data = f.read()
|
||||
except Exception as e:
|
||||
print(f"ERROR: cannot read {pms_path}: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
print(f"Verifying signatures against: {pms_path} ({len(data)} bytes)")
|
||||
print()
|
||||
|
||||
all_required_ok = True
|
||||
any_required_missing = False
|
||||
|
||||
for name, pattern_str, required in SIGNATURES:
|
||||
pattern = parse_pattern(pattern_str)
|
||||
matches = find_matches(data, pattern)
|
||||
|
||||
if matches:
|
||||
status = "OK"
|
||||
detail = f"{len(matches)} match(es)"
|
||||
if len(matches) > 1 and required:
|
||||
status = "WARN"
|
||||
detail = f"{len(matches)} matches (expected 1)"
|
||||
print(f" [{status}] {name}: {detail}")
|
||||
for m in matches[:3]:
|
||||
ctx = data[m : m + min(len(pattern) + 8, 32)].hex(" ")
|
||||
print(f" 0x{m:08x}: {ctx}")
|
||||
else:
|
||||
if required:
|
||||
print(f" [FAIL] {name}: NOT FOUND")
|
||||
any_required_missing = True
|
||||
all_required_ok = False
|
||||
|
||||
# Print partial matches to help locate the new pattern
|
||||
partials = best_partial_matches(data, pattern)
|
||||
if partials:
|
||||
print(f" Best partial matches (leading bytes):")
|
||||
for score, off in partials:
|
||||
ctx = data[off : off + min(len(pattern) + 8, 32)].hex(" ")
|
||||
print(
|
||||
f" 0x{off:08x} ({score}/{len(pattern)} bytes): {ctx}"
|
||||
)
|
||||
else:
|
||||
print(f" No partial matches found (>=8 leading bytes).")
|
||||
else:
|
||||
print(f" [SKIP] {name}: not found (optional fallback)")
|
||||
|
||||
print()
|
||||
if all_required_ok:
|
||||
print("All required signatures matched. Build can proceed.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(
|
||||
"REQUIRED SIGNATURE(S) MISSING — the hook will not work on this PMS "
|
||||
"version.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Update the patterns in Freeloader/src/hook.cpp and rebuild.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# In-container launcher for Plex Media Server. Sets LD_PRELOAD only for the
|
||||
# final exec so the (musl) PMS process is preloaded without affecting glibc
|
||||
# helper children (Tuner, Script Host, transcoders).
|
||||
|
||||
set -eu
|
||||
|
||||
export PLEX_MEDIA_SERVER_INFO_VENDOR="$(grep ^NAME= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
|
||||
export PLEX_MEDIA_SERVER_INFO_MODEL="$(uname -m)"
|
||||
export PLEX_MEDIA_SERVER_INFO_PLATFORM_VERSION="$(grep ^VERSION= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
|
||||
|
||||
export LD_PRELOAD="/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so"
|
||||
|
||||
exec "/usr/lib/plexmediaserver/Plex Media Server" "$@"
|
||||
Reference in new issue
Block a user