Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
25 changes: 25 additions & 0 deletions src/ts6_stream_bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from pathlib import Path

from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions src/ts6_stream_bot/pipeline/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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()
Expand Down
110 changes: 110 additions & 0 deletions src/ts6_stream_bot/ts3lib/identity_store.py
Original file line number Diff line number Diff line change
@@ -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"]
136 changes: 136 additions & 0 deletions tests/test_identity_store.py
Original file line number Diff line number Diff line change
@@ -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}
Loading