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,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()
|
||||
Reference in new issue
Block a user