From 0b21b83199f7327ebff2aba4ab94043a873d7ee9 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Feb 2026 17:19:01 -0800 Subject: [PATCH] feat: metadata service: make turnserver socket path configurable also add tests for the turnserver metadata --- chatmaild/src/chatmaild/config.py | 1 + chatmaild/src/chatmaild/ini/chatmail.ini.f | 5 +- chatmaild/src/chatmaild/metadata.py | 6 +- .../src/chatmaild/tests/test_turnserver.py | 120 ++++++++++++++++++ chatmaild/src/chatmaild/turnserver.py | 4 +- 5 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 chatmaild/src/chatmaild/tests/test_turnserver.py diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 38c955c82..d64c0ed1e 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -46,6 +46,7 @@ def __init__(self, inipath, params): 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://" + params["mail_domain"] self.enable_iroh_relay = True diff --git a/chatmaild/src/chatmaild/ini/chatmail.ini.f b/chatmaild/src/chatmaild/ini/chatmail.ini.f index 29d7baa9e..b12050045 100644 --- a/chatmaild/src/chatmaild/ini/chatmail.ini.f +++ b/chatmaild/src/chatmaild/ini/chatmail.ini.f @@ -55,7 +55,10 @@ # Deployment Details # -# SMTP outgoing filtermail and reinjection +# Path to the TURN server Unix socket +turn_socket_path = /run/chatmail-turn/turn.socket + +# SMTP outgoing filtermail and reinjection filtermail_smtp_port = 10080 postfix_reinject_port = 10025 diff --git a/chatmaild/src/chatmaild/metadata.py b/chatmaild/src/chatmaild/metadata.py index df835062a..243d85235 100644 --- a/chatmaild/src/chatmaild/metadata.py +++ b/chatmaild/src/chatmaild/metadata.py @@ -76,12 +76,13 @@ def get_tokens_for_addr(self, addr): 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, config=None): super().__init__() self.notifier = notifier self.metadata = metadata self.iroh_relay = iroh_relay self.turn_hostname = turn_hostname + self.config = config def handle_lookup(self, parts): # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org @@ -101,7 +102,7 @@ def handle_lookup(self, parts): # Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay` return f"O{self.iroh_relay}\n" elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn": - res = turn_credentials() + res = turn_credentials(self.config) port = 3478 return f"O{self.turn_hostname}:{port}:{res}\n" @@ -146,6 +147,7 @@ def main(): metadata=metadata, iroh_relay=iroh_relay, turn_hostname=mail_domain, + config=config, ) dictproxy.serve_forever_from_socket(socket) diff --git a/chatmaild/src/chatmaild/tests/test_turnserver.py b/chatmaild/src/chatmaild/tests/test_turnserver.py new file mode 100644 index 000000000..6a4c99a7b --- /dev/null +++ b/chatmaild/src/chatmaild/tests/test_turnserver.py @@ -0,0 +1,120 @@ +"""Tests for turnserver functionality, particularly metadata integration.""" + +import socket +import tempfile +import threading +from pathlib import Path + +from chatmaild.config import read_config, write_initial_config +from chatmaild.metadata import MetadataDictProxy, Metadata +from chatmaild.notifier import Notifier +from chatmaild.turnserver import turn_credentials + + +def test_turn_credentials_function_with_custom_socket(): + """Test that turn_credentials function works with a custom socket path from config.""" + # Create a temporary directory and socket file + temp_dir = Path(tempfile.mkdtemp()) + temp_socket_path = temp_dir / "test_turn.socket" + + # Create a mock TURN credentials server + def mock_server(): + server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server_sock.bind(str(temp_socket_path)) + server_sock.listen(1) + + # Accept connection and send mock credentials + conn, addr = server_sock.accept() + with conn: + conn.send(b"mock_turn_credentials_abc123\n") + server_sock.close() + + # Start server in a background thread + server_thread = threading.Thread(target=mock_server, daemon=True) + server_thread.start() + + # Create a config with custom socket path + config_path = temp_dir / "chatmail.ini" + write_initial_config(config_path, "test.example.org", { + "turn_socket_path": str(temp_socket_path) + }) + config = read_config(config_path) + + # Allow time for server to start + import time + time.sleep(0.01) + + # Test that turn_credentials can connect using the config + credentials = turn_credentials(config) + assert credentials == "mock_turn_credentials_abc123" + + server_thread.join(timeout=1) # Clean up thread + + +def test_metadata_turn_lookup_integration(tmp_path): + """Test that metadata service properly handles TURN metadata lookups.""" + # Create mock config with custom turn socket path + config_path = tmp_path / "chatmail.ini" + socket_path = tmp_path / "test_turn.socket" + write_initial_config(config_path, "example.org", { + "turn_socket_path": str(socket_path) + }) + config = read_config(config_path) + + # Create mock TURN server to return credentials + def mock_turn_server(): + import os + os.makedirs(socket_path.parent, exist_ok=True) # Ensure parent directory exists + + server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server_sock.bind(str(socket_path)) + server_sock.listen(1) + + # Accept connection and send mock credentials + conn, addr = server_sock.accept() + with conn: + conn.send(b"test_creds_12345\n") + server_sock.close() + + server_thread = threading.Thread(target=mock_turn_server, daemon=True) + server_thread.start() + + import time + time.sleep(0.01) # Allow server to start + + # Create a MetadataDictProxy with config + queue_dir = tmp_path / "queue" + queue_dir.mkdir() + notifier = Notifier(queue_dir) + metadata = Metadata(tmp_path / "vmail") + + dict_proxy = MetadataDictProxy( + notifier=notifier, + metadata=metadata, + iroh_relay="https://example.org", + turn_hostname="example.org", + config=config + ) + + # Simulate a lookup for TURN credentials using the correct format + # Input: "shared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn" + # After parts[0].split("/", 2): + # - keyparts[0] = "shared" + # - keyparts[1] = "0123" + # - keyparts[2] = "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn" + # So keyname = keyparts[2] should match "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn" + parts = [ + "shared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn", + "dummy@user.org" + ] + + # Call handle_lookup directly + result = dict_proxy.handle_lookup(parts) + + # Verify the response format is correct for TURN credentials + assert result.startswith("O") # Output response starts with 'O' + assert ":3478:" in result # Contains port 3478 + assert "test_creds_12345" in result # Contains credentials returned by mock server + assert "example.org:3478:test_creds_12345" in result + + server_thread.join(timeout=1) # Clean up thread \ No newline at end of file diff --git a/chatmaild/src/chatmaild/turnserver.py b/chatmaild/src/chatmaild/turnserver.py index 1d13d3689..be38c0315 100644 --- a/chatmaild/src/chatmaild/turnserver.py +++ b/chatmaild/src/chatmaild/turnserver.py @@ -2,8 +2,8 @@ import socket -def turn_credentials() -> str: +def turn_credentials(config) -> str: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket: - client_socket.connect("/run/chatmail-turn/turn.socket") + client_socket.connect(config.turn_socket_path) with client_socket.makefile("rb") as file: return file.readline().decode("utf-8").strip()