diff --git a/chatmaild/pyproject.toml b/chatmaild/pyproject.toml index c043f39b..2be5834b 100644 --- a/chatmaild/pyproject.toml +++ b/chatmaild/pyproject.toml @@ -26,7 +26,6 @@ chatmail-expire = "chatmaild.expire:daily_expire_main" chatmail-quota-expire = "chatmaild.expire:quota_expire_main" chatmail-fsreport = "chatmaild.fsreport:main" lastlogin = "chatmaild.lastlogin:main" -turnserver = "chatmaild.turnserver:main" [project.entry-points.pytest11] "chatmaild.testplugin" = "chatmaild.tests.plugin" diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index db79d75d..5ce8de13 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -63,6 +63,9 @@ class Config: self.acme_email = params.get("acme_email", "") self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true" + self.turn_socket_path = params.get( + "turn_socket_path", "/run/chatmail-turn/turn.socket" + ) if "iroh_relay" not in params: self.iroh_relay = "https://" + raw_domain self.enable_iroh_relay = True diff --git a/chatmaild/src/chatmaild/metadata.py b/chatmaild/src/chatmaild/metadata.py index 03bdc2da..5ca52169 100644 --- a/chatmaild/src/chatmaild/metadata.py +++ b/chatmaild/src/chatmaild/metadata.py @@ -1,4 +1,5 @@ import logging +import socket import sys import time from contextlib import contextmanager @@ -7,7 +8,14 @@ from .config import read_config from .dictproxy import DictProxy from .filedict import FileDict from .notifier import Notifier -from .turnserver import turn_credentials + + +def turn_credentials(turn_socket_path): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket: + client_socket.settimeout(5) + client_socket.connect(turn_socket_path) + with client_socket.makefile("rb") as file: + return file.readline().decode("utf-8").strip() def _is_valid_token_timestamp(timestamp, now): @@ -79,12 +87,20 @@ class Metadata: class MetadataDictProxy(DictProxy): - def __init__(self, notifier, metadata, iroh_relay=None, turn_hostname=None): + def __init__( + self, + notifier, + metadata, + iroh_relay=None, + turn_hostname=None, + turn_socket_path=None, + ): super().__init__() self.notifier = notifier self.metadata = metadata self.iroh_relay = iroh_relay self.turn_hostname = turn_hostname + self.turn_socket_path = turn_socket_path def handle_lookup(self, parts): # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org @@ -101,7 +117,7 @@ class MetadataDictProxy(DictProxy): return f"O{self.iroh_relay}\n" case "turn": try: - res = turn_credentials() + res = turn_credentials(self.turn_socket_path) except Exception: logging.exception("failed to get TURN credentials") return "N\n" @@ -135,6 +151,7 @@ def main(): config = read_config(config_path) iroh_relay = config.iroh_relay mail_domain = config.mail_domain + socket_path = config.turn_socket_path vmail_dir = config.mailboxes_dir if not vmail_dir.exists(): @@ -152,6 +169,7 @@ def main(): metadata=metadata, iroh_relay=iroh_relay, turn_hostname=mail_domain, + turn_socket_path=socket_path, ) dictproxy.serve_forever_from_socket(socket) diff --git a/chatmaild/src/chatmaild/tests/test_metadata.py b/chatmaild/src/chatmaild/tests/test_metadata.py index c8fee553..a56852ed 100644 --- a/chatmaild/src/chatmaild/tests/test_metadata.py +++ b/chatmaild/src/chatmaild/tests/test_metadata.py @@ -324,7 +324,7 @@ def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch): turn_hostname="turn.example.org", ) - def mock_turn_credentials(): + def mock_turn_credentials(turn_socket_path): raise ConnectionRefusedError("socket not available") monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials) @@ -348,7 +348,9 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch): turn_hostname="turn.example.org", ) - monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass") + monkeypatch.setattr( + chatmaild.metadata, "turn_credentials", lambda path: "user:pass" + ) transactions = {} res = dictproxy.handle_dovecot_request( diff --git a/chatmaild/src/chatmaild/tests/test_turn.py b/chatmaild/src/chatmaild/tests/test_turn.py new file mode 100644 index 00000000..70b360cd --- /dev/null +++ b/chatmaild/src/chatmaild/tests/test_turn.py @@ -0,0 +1,46 @@ +import socket +import threading + +import pytest + +from chatmaild.metadata import turn_credentials + + +@pytest.fixture +def turn_socket(tmp_path): + sock_path = str(tmp_path / "turn.socket") + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(sock_path) + server.listen(1) + yield sock_path, server + server.close() + + +def test_turn_credentials_timeout(turn_socket): + sock_path, server = turn_socket + with pytest.raises(socket.timeout): + # Inside turn_credentials the kernel listen backlog (1) + # completes connect() without accept() + # so the client blocks on readline() until the 5s timeout fires. + turn_credentials(sock_path) + + +def test_turn_credentials_connection_refused_on_not_existing_socket(tmp_path): + missing = str(tmp_path / "nonexistent.socket") + with pytest.raises((ConnectionRefusedError, FileNotFoundError)): + turn_credentials(missing) + + +def test_turn_credentials_socket_success(turn_socket): + sock_path, server = turn_socket + + def respond(): + conn, _ = server.accept() + conn.sendall(b"testuser:testpass\n") + conn.close() + + t = threading.Thread(target=respond, daemon=True) + t.start() + + result = turn_credentials(sock_path) + assert result == "testuser:testpass" diff --git a/chatmaild/src/chatmaild/tests/test_turnserver.py b/chatmaild/src/chatmaild/tests/test_turnserver.py deleted file mode 100644 index 7764bdb3..00000000 --- a/chatmaild/src/chatmaild/tests/test_turnserver.py +++ /dev/null @@ -1,73 +0,0 @@ -import socket -import threading -import time -from unittest.mock import patch - -import pytest - -from chatmaild.turnserver import turn_credentials - -SOCKET_PATH = "/run/chatmail-turn/turn.socket" - - -@pytest.fixture -def turn_socket(tmp_path): - """Create a real Unix socket server at a temp path.""" - sock_path = str(tmp_path / "turn.socket") - server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server.bind(sock_path) - server.listen(1) - yield sock_path, server - server.close() - - -def _call_turn_credentials(sock_path): - """Call turn_credentials but connect to sock_path instead of hardcoded path.""" - original_connect = socket.socket.connect - - def patched_connect(self, address): - if address == SOCKET_PATH: - address = sock_path - return original_connect(self, address) - - with patch.object(socket.socket, "connect", patched_connect): - return turn_credentials() - - -def test_turn_credentials_timeout(turn_socket): - """Server accepts but never responds — must raise socket.timeout.""" - sock_path, server = turn_socket - - def accept_and_hang(): - conn, _ = server.accept() - time.sleep(30) - conn.close() - - t = threading.Thread(target=accept_and_hang, daemon=True) - t.start() - - with pytest.raises(socket.timeout): - _call_turn_credentials(sock_path) - - -def test_turn_credentials_connection_refused(tmp_path): - """Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError.""" - missing = str(tmp_path / "nonexistent.socket") - with pytest.raises((ConnectionRefusedError, FileNotFoundError)): - _call_turn_credentials(missing) - - -def test_turn_credentials_success(turn_socket): - """Server responds with credentials — must return stripped string.""" - sock_path, server = turn_socket - - def respond(): - conn, _ = server.accept() - conn.sendall(b"testuser:testpass\n") - conn.close() - - t = threading.Thread(target=respond, daemon=True) - t.start() - - result = _call_turn_credentials(sock_path) - assert result == "testuser:testpass" diff --git a/chatmaild/src/chatmaild/turnserver.py b/chatmaild/src/chatmaild/turnserver.py deleted file mode 100644 index a82cfc97..00000000 --- a/chatmaild/src/chatmaild/turnserver.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python3 -import socket - - -def turn_credentials() -> str: - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket: - client_socket.settimeout(5) - client_socket.connect("/run/chatmail-turn/turn.socket") - with client_socket.makefile("rb") as file: - return file.readline().decode("utf-8").strip()