diff --git a/.env.example b/.env.example index 3b4de2d..1103a1c 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,21 @@ TS6_SERVER_PASSWORD= TS6_DEFAULT_CHANNEL= TS6_CHANNEL_PASSWORD= +# --- TS3 identity persistence ---------------------------------------------- +# Path inside the container to the JSON file backing the bot's TS3 +# identity (P-256 keypair + hashcash offset). The default is mounted from +# the `bot-state` named volume defined in docker-compose.yml so that the +# identity survives container restarts. If you ever lose this file, the +# bot generates a new one on next start - which means the TS6 server +# treats it as a fresh client and previous stream slots may linger as +# zombies until the server's own cleanup kicks in. +# IDENTITY_PATH=/app/state/identity.json +# +# Hashcash level used to mine a *new* identity. Existing files are loaded +# as-is regardless of this value. 8 finishes in milliseconds; 20+ takes +# seconds; 24+ takes minutes. TS6 typically wants >= 8. +# IDENTITY_SECURITY_LEVEL=8 + # --- Stream parameters ----------------------------------------------------- # These shape the `setupstream` request the bot sends after connecting. # - STREAM_ACCESSIBILITY: 0 = public (anyone in the channel can join); diff --git a/docker-compose.yml b/docker-compose.yml index 27cfc35..478aee1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,12 @@ services: shm_size: "1gb" volumes: - ./src:/app/src:ro + # Persists the TS3 client identity across container restarts. + # Without this, every restart looks like a brand-new client to the + # TS6 server and old stream slots accumulate as zombies that the + # client UI still surfaces - clicks on those go nowhere because the + # owning clid is long gone. See ts3lib/identity_store.py. + - bot-state:/app/state ports: - "127.0.0.1:8080:8080" networks: @@ -42,3 +48,9 @@ services: networks: ts6-net: external: true + +volumes: + # Holds /app/state/identity.json (mode 0600). Treat the host-side path + # as sensitive: anyone with read access to the volume can impersonate + # the bot on the TS6 server. + bot-state: diff --git a/src/ts6_stream_bot/config.py b/src/ts6_stream_bot/config.py index 3d616c4..f89c738 100644 --- a/src/ts6_stream_bot/config.py +++ b/src/ts6_stream_bot/config.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -63,6 +65,29 @@ class Settings(BaseSettings): ) TS6_CHANNEL_PASSWORD: str = Field(default="", description="Channel password if any.") + # --- TS3 identity persistence ----------------------------------------- + # The TS3 client identity (P-256 keypair + hashcash offset) used to be + # regenerated on every container start. That made the TS6 server treat + # each restart as a brand-new client; stream slots from previous runs + # weren't cleaned up and accumulated as zombies that the UI still + # surfaced for join clicks. Persisting the identity to a volume means + # the same crypto identity reconnects, the server recognises us, and + # any old session is dropped via the normal client-disconnect path. + IDENTITY_PATH: Path = Field( + default=Path("/app/state/identity.json"), + description=( + "Where the persistent TS3 identity is stored (JSON, mode 0600). " + "Mount a volume over its parent directory to survive restarts." + ), + ) + IDENTITY_SECURITY_LEVEL: int = Field( + default=8, + description=( + "Hashcash security level used when generating a fresh identity " + "(only for the very first start; existing files are loaded as-is)." + ), + ) + # --- Stream parameters ------------------------------------------------- # These shape the `setupstream` request the bot sends on connect. # Defaults match what the TS6 client UI uses for a normal screen-share. diff --git a/src/ts6_stream_bot/pipeline/controller.py b/src/ts6_stream_bot/pipeline/controller.py index eeb38e1..3047f49 100644 --- a/src/ts6_stream_bot/pipeline/controller.py +++ b/src/ts6_stream_bot/pipeline/controller.py @@ -43,7 +43,7 @@ from ts6_stream_bot.pipeline.video_capture import VideoCapture, VideoCaptureConfig from ts6_stream_bot.sources import StreamSource, resolve_source from ts6_stream_bot.ts3lib.client import Ts3Client, Ts3ClientOptions -from ts6_stream_bot.ts3lib.identity import generate_identity_async +from ts6_stream_bot.ts3lib.identity_store import load_or_generate_identity log = structlog.get_logger(__name__) @@ -220,7 +220,10 @@ async def _connect_ts6(self) -> None: server_password_set=bool(settings.TS6_SERVER_PASSWORD), channel_password_set=bool(settings.TS6_CHANNEL_PASSWORD), ) - identity = await generate_identity_async(security_level=8) + identity = await load_or_generate_identity( + settings.IDENTITY_PATH, + security_level=settings.IDENTITY_SECURITY_LEVEL, + ) log.info("controller.ts6_identity_ready", uid=identity.uid) client = Ts3Client() diff --git a/src/ts6_stream_bot/ts3lib/identity_store.py b/src/ts6_stream_bot/ts3lib/identity_store.py new file mode 100644 index 0000000..0a6b938 --- /dev/null +++ b/src/ts6_stream_bot/ts3lib/identity_store.py @@ -0,0 +1,110 @@ +"""Disk-backed persistence for the TS3 client identity. + +A fresh hashcash-mined identity is cheap (level 8 finishes in milliseconds) +but it is the *cryptographic identifier* the TS6 server uses to recognise +the bot across reconnects. If we generate a new one on every container +start, every previous session looks like an unrelated client to the +server: stream slots from those past sessions never get cleaned up, +they pile up as zombies that the TS6 client UI still offers for +"Join", and clicking a zombie sends the request into the void (no log +on the live bot, UI hangs at "connecting"). + +Persisting the identity to a volume restores the expected lifecycle: +the bot reconnects with the same key, the server cleans up the old +session via its normal client-disconnect path, and the new +``setupstream`` is the only stream slot the channel exposes. + +The on-disk format is the JSON dict produced by ``Identity.to_dict`` +(``privateKeyBigInt`` / ``keyOffset`` / ``publicKeyString`` / ``uid``). +We write to a tempfile in the same directory and ``os.replace`` over +the target so a crash mid-write can't leave a half-written file. +File mode is 0600 so the host-side volume only exposes the key to +whoever already has filesystem access to that directory. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +import structlog + +from ts6_stream_bot.ts3lib.identity import ( + Identity, + export_public_key_string, + generate_identity_async, + restore_identity, +) + +log = structlog.get_logger(__name__) + + +async def load_or_generate_identity(path: Path, *, security_level: int) -> Identity: + """Return the identity stored at ``path``; generate + save one if the + file is missing. ``security_level`` is only consulted for the + generation branch - existing files are trusted as-is so that the + server-side recognition remains stable even if the operator bumps + the level later (see module docstring).""" + existing = _try_load(path) + if existing is not None: + log.info("identity_store.loaded", path=str(path), uid=existing.uid) + return existing + + log.info("identity_store.generating", path=str(path), security_level=security_level) + identity = await generate_identity_async(security_level=security_level) + _save(path, identity) + log.info("identity_store.saved", path=str(path), uid=identity.uid) + return identity + + +def _try_load(path: Path) -> Identity | None: + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + log.warning("identity_store.load_failed", path=str(path), error=str(exc)) + return None + try: + identity = restore_identity(data) + # ``restore_identity`` is permissive; force the lazy private-key + # derivation and cross-check that the stored public-key string + # matches what we derive from the private scalar. A corrupt or + # tampered file gets rejected here rather than later inside the + # signing loop, where the failure is harder to diagnose. + derived = export_public_key_string(identity.public_key) + if derived != identity.public_key_string: + raise ValueError("public key in file does not match private scalar") + except (KeyError, ValueError, TypeError) as exc: + log.warning("identity_store.restore_failed", path=str(path), error=str(exc)) + return None + return identity + + +def _save(path: Path, identity: Identity) -> None: + """Atomic write: tempfile in the same directory + ``os.replace``. The + same-dir constraint matters because ``replace`` is only atomic across + a single filesystem, and the parent directory is the only spot we + know shares the target's filesystem.""" + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(identity.to_dict(), indent=2, sort_keys=True) + + fd, tmp_str = tempfile.mkstemp( + prefix=path.name + ".", + suffix=".tmp", + dir=str(path.parent), + ) + tmp_path = Path(tmp_str) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(payload) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + +__all__ = ["load_or_generate_identity"] diff --git a/tests/test_identity_store.py b/tests/test_identity_store.py new file mode 100644 index 0000000..afcfe74 --- /dev/null +++ b/tests/test_identity_store.py @@ -0,0 +1,136 @@ +"""Tests for the disk-backed identity store. + +The point of the store is that two consecutive calls return the SAME +crypto identity (so the TS6 server recognises us across restarts) - +that's the round-trip test below. Failure-mode tests confirm corrupt +or unreadable files don't lock the bot out forever; we always fall +back to generating a fresh identity rather than refusing to start. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import stat +from pathlib import Path +from unittest.mock import patch + +import pytest + +from ts6_stream_bot.ts3lib.identity import generate_identity +from ts6_stream_bot.ts3lib.identity_store import load_or_generate_identity + + +@pytest.fixture +def store_path(tmp_path: Path) -> Path: + return tmp_path / "state" / "identity.json" + + +async def test_first_call_generates_and_persists(store_path: Path) -> None: + """Cold start: file missing, helper mines + writes it.""" + assert not store_path.exists() + + ident = await load_or_generate_identity(store_path, security_level=0) + + assert store_path.is_file() + payload = json.loads(store_path.read_text(encoding="utf-8")) + assert payload["uid"] == ident.uid + assert payload["publicKeyString"] == ident.public_key_string + assert int(payload["privateKeyBigInt"]) == ident.private_scalar + + +async def test_second_call_returns_same_identity(store_path: Path) -> None: + """Warm start: file exists, helper loads it byte-identical.""" + first = await load_or_generate_identity(store_path, security_level=0) + second = await load_or_generate_identity(store_path, security_level=0) + + assert first.uid == second.uid + assert first.public_key_string == second.public_key_string + assert first.private_scalar == second.private_scalar + + +async def test_warm_start_does_not_call_generator(store_path: Path) -> None: + """If the file already exists, generation must be skipped entirely + (mining is the expensive part we're trying to avoid on every restart).""" + seed = generate_identity(security_level=0) + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text(json.dumps(seed.to_dict()), encoding="utf-8") + + with patch( + "ts6_stream_bot.ts3lib.identity_store.generate_identity_async", + ) as gen_mock: + result = await load_or_generate_identity(store_path, security_level=24) + + gen_mock.assert_not_called() + assert result.uid == seed.uid + + +async def test_corrupt_json_falls_back_to_fresh_generation(store_path: Path) -> None: + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_text("not valid json {", encoding="utf-8") + + ident = await load_or_generate_identity(store_path, security_level=0) + + # The corrupt file is overwritten with a valid serialization. + payload = json.loads(store_path.read_text(encoding="utf-8")) + assert payload["uid"] == ident.uid + + +async def test_invalid_keypair_data_falls_back(store_path: Path) -> None: + """Well-formed JSON whose private scalar doesn't match the embedded + public key should be discarded - rejecting the file leaves the + bot stuck, regenerating moves us forward.""" + store_path.parent.mkdir(parents=True, exist_ok=True) + bad = { + "privateKeyBigInt": "12345", + "keyOffset": "0", + "publicKeyString": "not-a-real-key", + "uid": "deadbeef", + } + store_path.write_text(json.dumps(bad), encoding="utf-8") + + ident = await load_or_generate_identity(store_path, security_level=0) + + # New identity, not the bogus one. + assert ident.uid != "deadbeef" + assert json.loads(store_path.read_text(encoding="utf-8"))["uid"] == ident.uid + + +async def test_saved_file_has_owner_only_permissions(store_path: Path) -> None: + """The identity is the bot's TS6 credential - readable by only us.""" + await load_or_generate_identity(store_path, security_level=0) + mode = stat.S_IMODE(os.stat(store_path).st_mode) + assert mode == 0o600 + + +async def test_creates_missing_parent_directory(tmp_path: Path) -> None: + """``IDENTITY_PATH`` defaults to ``/app/state/identity.json``; the + ``state`` directory may not exist yet on a brand-new volume.""" + nested = tmp_path / "a" / "b" / "c" / "identity.json" + assert not nested.parent.exists() + + await load_or_generate_identity(nested, security_level=0) + + assert nested.is_file() + + +async def test_no_tempfile_left_behind_after_save(store_path: Path) -> None: + """Atomic-rename uses a sibling tempfile; on success it must be gone.""" + await load_or_generate_identity(store_path, security_level=0) + leftovers = [p for p in store_path.parent.iterdir() if p.suffix == ".tmp"] + assert leftovers == [] + + +async def test_concurrent_first_calls_converge(store_path: Path) -> None: + """Two coroutines racing on a cold start may both generate, but the + file ends up readable and the helper is otherwise crash-free.""" + results = await asyncio.gather( + load_or_generate_identity(store_path, security_level=0), + load_or_generate_identity(store_path, security_level=0), + ) + + assert all(r.uid for r in results) + # Whichever write won, the file is parseable afterwards. + payload = json.loads(store_path.read_text(encoding="utf-8")) + assert payload["uid"] in {r.uid for r in results}