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,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 "$@"
|
||||
Reference in new issue
Block a user