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
|
ARG PLEX_BASE_IMAGE=lscr.io/linuxserver/plex:latest
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y patchelf && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# COPY plexmediaserver_crack.so /config/plexmediaserver_crack.so
|
# ── Stage 1: base (PMS image, used as source for discovery) ──────────────
|
||||||
# RUN chmod 644 /config/plexmediaserver_crack.so
|
FROM ${PLEX_BASE_IMAGE} AS base
|
||||||
|
|
||||||
# Download the Plex crack
|
# ── Stage 2: discover hook patterns from the PMS binary ─────────────────
|
||||||
RUN wget -O /tmp/plexmediaserver_crack.so \
|
# Auto-discovers byte patterns by analyzing the PMS binary. If a pattern
|
||||||
https://gitgud.io/yuv420p10le/plexmediaserver_crack/-/raw/master/binaries/plexmediaserver_crack.so && \
|
# can't be found, the build fails here — before compiling or shipping.
|
||||||
cp /tmp/plexmediaserver_crack.so /config/plexmediaserver_crack.so && \
|
FROM debian:bookworm-slim AS discover
|
||||||
chmod 644 /config/plexmediaserver_crack.so
|
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
|
# ── Stage 3: build the musl .so ───────────────────────────────────────────
|
||||||
RUN PLEX_DIR=/usr/lib/plexmediaserver && \
|
FROM debian:bookworm-slim AS builder
|
||||||
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 && \
|
ARG ZIG_VERSION=0.13.0
|
||||||
patchelf --add-needed plexmediaserver_crack.so $PLEX_DIR/lib/libsoci_core.so
|
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