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:
benjamin committed 2026-08-19 22:33:42 +02:00
1 parent 4399a8288d
commit 72f4661bdc
72 files changed
+77927 -17

No files matched your search

+172
View File
@@ -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 "$@"