Compare commits

..

1 Commits

Author SHA1 Message Date
Mark Felder
59dceb202d feat: metadata service: make turnserver socket path configurable 2026-05-12 11:34:41 -07:00
11 changed files with 198 additions and 141 deletions

View File

@@ -26,6 +26,7 @@ chatmail-expire = "chatmaild.expire:daily_expire_main"
chatmail-quota-expire = "chatmaild.expire:quota_expire_main" chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main" lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"
[project.entry-points.pytest11] [project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin" "chatmaild.testplugin" = "chatmaild.tests.plugin"

View File

@@ -10,7 +10,12 @@ from chatmaild.user import User
def read_config(inipath): def read_config(inipath):
assert Path(inipath).exists(), inipath assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath) cfg = iniconfig.IniConfig(inipath)
return Config(inipath, params=cfg.sections["params"]) params = cfg.sections["params"]
default_config_content = get_default_config_content(params["mail_domain"])
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
class Config: class Config:
@@ -31,18 +36,16 @@ class Config:
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60)) self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10)) self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params.get("max_mailbox_size", "500M") self.max_mailbox_size = params["max_mailbox_size"]
self.max_message_size = int(params.get("max_message_size", 31457280)) self.max_message_size = int(params.get("max_message_size", "31457280"))
self.delete_mails_after = params.get("delete_mails_after", "20") self.delete_mails_after = params["delete_mails_after"]
self.delete_large_after = params.get("delete_large_after", "7") self.delete_large_after = params["delete_large_after"]
self.delete_inactive_users_after = int( self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
params.get("delete_inactive_users_after", 90) self.username_min_length = int(params["username_min_length"])
) self.username_max_length = int(params["username_max_length"])
self.username_min_length = int(params.get("username_min_length", 9)) self.password_min_length = int(params["password_min_length"])
self.username_max_length = int(params.get("username_max_length", 9)) self.passthrough_senders = params["passthrough_senders"].split()
self.password_min_length = int(params.get("password_min_length", 9)) self.passthrough_recipients = params["passthrough_recipients"].split()
self.passthrough_senders = params.get("passthrough_senders", "").split()
self.passthrough_recipients = params.get("passthrough_recipients", "").split()
self.www_folder = params.get("www_folder", "") self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080")) self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
@@ -63,9 +66,7 @@ class Config:
self.acme_email = params.get("acme_email", "") self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true"
self.turn_socket_path = params.get( self.turn_socket_path = params.get("turn_socket_path", "/run/chatmail-turn/turn.socket")
"turn_socket_path", "/run/chatmail-turn/turn.socket"
)
if "iroh_relay" not in params: if "iroh_relay" not in params:
self.iroh_relay = "https://" + raw_domain self.iroh_relay = "https://" + raw_domain
self.enable_iroh_relay = True self.enable_iroh_relay = True
@@ -164,7 +165,31 @@ def get_default_config_content(mail_domain, **overrides):
for name, value in extra.items(): for name, value in extra.items():
new_line = f"{name} = {value}" new_line = f"{name} = {value}"
new_lines.append(new_line) new_lines.append(new_line)
return "\n".join(new_lines)
content = "\n".join(new_lines)
# apply testrun privacy overrides
if mail_domain.endswith(".testrun.org"):
override_inipath = inidir.joinpath("override-testrun.ini")
privacy = iniconfig.IniConfig(override_inipath)["privacy"]
lines = []
for line in content.split("\n"):
for key, value in privacy.items():
value_lines = value.format(mail_domain=mail_domain).strip().split("\n")
if not line.startswith(f"{key} =") or not value_lines:
continue
if len(value_lines) == 1:
lines.append(f"{key} = {value}")
else:
lines.append(f"{key} =")
for vl in value_lines:
lines.append(f" {vl}")
break
else:
lines.append(line)
content = "\n".join(lines)
return content
def is_valid_ipv4(address: str) -> bool: def is_valid_ipv4(address: str) -> bool:

View File

@@ -12,42 +12,42 @@ mail_domain = {mail_domain}
# #
# email sending rate per user and minute # email sending rate per user and minute
#max_user_send_per_minute = 60 max_user_send_per_minute = 60
# per-user max burst size for sending rate limiting (GCRA bucket capacity) # per-user max burst size for sending rate limiting (GCRA bucket capacity)
#max_user_send_burst_size = 10 max_user_send_burst_size = 10
# maximum mailbox size of a chatmail address # maximum mailbox size of a chatmail address
# (Oldest messages will be removed automatically, so mailboxes never run full) # Oldest messages will be removed automatically, so mailboxes never run full.
#max_mailbox_size = 500M max_mailbox_size = 500M
# maximum message size for an e-mail in bytes # maximum message size for an e-mail in bytes
#max_message_size = 31457280 max_message_size = 31457280
# days after which mails are unconditionally deleted # days after which mails are unconditionally deleted
#delete_mails_after = 20 delete_mails_after = 20
# days after which large messages (>200k) are unconditionally deleted # days after which large messages (>200k) are unconditionally deleted
#delete_large_after = 7 delete_large_after = 7
# days after which users without a successful login are deleted (database and mails) # days after which users without a successful login are deleted (database and mails)
#delete_inactive_users_after = 90 delete_inactive_users_after = 90
# minimum length a username must have # minimum length a username must have
#username_min_length = 9 username_min_length = 9
# maximum length a username can have # maximum length a username can have
#username_max_length = 9 username_max_length = 9
# minimum length a password must have # minimum length a password must have
#password_min_length = 9 password_min_length = 9
# list of chatmail addresses which can send outbound un-encrypted mail # list of chatmail addresses which can send outbound un-encrypted mail
#passthrough_senders = passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails # list of e-mail recipients for which to accept outbound un-encrypted mails
# (space-separated, item may start with "@" to whitelist whole recipient domains) # (space-separated, item may start with "@" to whitelist whole recipient domains)
#passthrough_recipients = passthrough_recipients =
# Use externally managed TLS certificates instead of built-in acmetool. # Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine). # Paths refer to files on the deployment server (not the build machine).
@@ -63,11 +63,22 @@ mail_domain = {mail_domain}
# Deployment Details # Deployment Details
# #
# 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
# SMTP incoming filtermail and reinjection
filtermail_smtp_port_incoming = 10081
postfix_reinject_port_incoming = 10026
# if set to "True" IPv6 is disabled # if set to "True" IPv6 is disabled
#disable_ipv6 = False disable_ipv6 = False
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates # Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
#acme_email = acme_email =
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail # Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
# service. # service.
@@ -100,13 +111,13 @@ mail_domain = {mail_domain}
# in per-maildir ".in/.out" files. # in per-maildir ".in/.out" files.
# Note that you need to manually cleanup these files # Note that you need to manually cleanup these files
# so use this option with caution on production servers. # so use this option with caution on production servers.
#imap_rawlog = false imap_rawlog = false
# set to true if you want to enable the IMAP COMPRESS Extension, # set to true if you want to enable the IMAP COMPRESS Extension,
# which allows IMAP connections to be efficiently compressed. # which allows IMAP connections to be efficiently compressed.
# WARNING: Enabling this makes it impossible to hibernate IMAP # WARNING: Enabling this makes it impossible to hibernate IMAP
# processes which will result in much higher memory/RAM usage. # processes which will result in much higher memory/RAM usage.
#imap_compress = false imap_compress = false
# #

View File

@@ -0,0 +1,16 @@
[privacy]
passthrough_recipients = privacy@testrun.org echo@{mail_domain}
privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel,
Reichgrafen Str. 20, 79102 Freiburg, Germany
privacy_mail = privacy@testrun.org
privacy_pdo =
Prof. Dr. Fabian Schmieder, lexICT UG (limited), Ostfeldstr. 49, 30559 Hannover.
You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO)
privacy_supervisor =
State Commissioner for Data Protection and Freedom of Information of
Baden-Württemberg in 70173 Stuttgart, Germany.

View File

@@ -1,5 +1,4 @@
import logging import logging
import socket
import sys import sys
import time import time
from contextlib import contextmanager from contextlib import contextmanager
@@ -8,14 +7,7 @@ from .config import read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
from .filedict import FileDict from .filedict import FileDict
from .notifier import Notifier 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): def _is_valid_token_timestamp(timestamp, now):
@@ -87,14 +79,7 @@ class Metadata:
class MetadataDictProxy(DictProxy): class MetadataDictProxy(DictProxy):
def __init__( def __init__(self, notifier, metadata, iroh_relay=None, turn_hostname=None, turn_socket_path=None):
self,
notifier,
metadata,
iroh_relay=None,
turn_hostname=None,
turn_socket_path=None,
):
super().__init__() super().__init__()
self.notifier = notifier self.notifier = notifier
self.metadata = metadata self.metadata = metadata

View File

@@ -13,12 +13,7 @@ def test_read_config_basic(example_config):
assert not example_config.privacy_pdo and not example_config.privacy_postal assert not example_config.privacy_pdo and not example_config.privacy_postal
inipath = example_config._inipath inipath = example_config._inipath
inipath.write_text( inipath.write_text(inipath.read_text().replace("60", "37"))
inipath.read_text().replace(
"#max_user_send_per_minute = 60",
"max_user_send_per_minute = 37",
)
)
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 37 assert example_config.max_user_send_per_minute == 37
assert example_config.mail_domain == "chat.example.org" assert example_config.mail_domain == "chat.example.org"
@@ -36,17 +31,26 @@ def test_read_config_basic_using_defaults(tmp_path, maildomain):
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 60 assert example_config.max_user_send_per_minute == 60
assert example_config.filtermail_smtp_port_incoming == 10081 assert example_config.filtermail_smtp_port_incoming == 10081
assert example_config.filtermail_smtp_port == 10080
assert example_config.postfix_reinject_port == 10025
assert example_config.max_user_send_per_minute == 60 def test_read_config_testrun(make_config):
assert example_config.max_mailbox_size == "500M" config = make_config("something.testrun.org")
assert example_config.delete_mails_after == "20" assert config.mail_domain == "something.testrun.org"
assert example_config.delete_large_after == "7" assert len(config.privacy_postal.split("\n")) > 1
assert example_config.username_min_length == 9 assert len(config.privacy_supervisor.split("\n")) > 1
assert example_config.username_max_length == 9 assert len(config.privacy_pdo.split("\n")) > 1
assert example_config.password_min_length == 9 assert config.privacy_mail == "privacy@testrun.org"
assert example_config.passthrough_recipients == [] assert config.filtermail_smtp_port == 10080
assert example_config.passthrough_senders == [] assert config.postfix_reinject_port == 10025
assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "500M"
assert config.delete_mails_after == "20"
assert config.delete_large_after == "7"
assert config.username_min_length == 9
assert config.username_max_length == 9
assert config.password_min_length == 9
assert "privacy@testrun.org" in config.passthrough_recipients
assert config.passthrough_senders == []
def test_config_userstate_paths(make_config, tmp_path): def test_config_userstate_paths(make_config, tmp_path):

View File

@@ -324,7 +324,7 @@ def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch):
turn_hostname="turn.example.org", turn_hostname="turn.example.org",
) )
def mock_turn_credentials(turn_socket_path): def mock_turn_credentials():
raise ConnectionRefusedError("socket not available") raise ConnectionRefusedError("socket not available")
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials) monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
@@ -348,9 +348,7 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
turn_hostname="turn.example.org", turn_hostname="turn.example.org",
) )
monkeypatch.setattr( monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass")
chatmaild.metadata, "turn_credentials", lambda path: "user:pass"
)
transactions = {} transactions = {}
res = dictproxy.handle_dovecot_request( res = dictproxy.handle_dovecot_request(

View File

@@ -1,46 +0,0 @@
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"

View File

@@ -0,0 +1,73 @@
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"

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
import socket
def turn_credentials(turn_socket_path) -> str:
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()

View File

@@ -1,23 +1,3 @@
# List of headers for incoming messages /^DKIM-Signature:/ IGNORE
# that must be retained for functionality and compatibility reasons /^Authentication-Results:/ IGNORE
/^From:/ DUNNO /^Received:/ IGNORE
/^Message-Id:/ DUNNO
/^Chat-Is-Post-Message:/ DUNNO
/^Content-Type:/ DUNNO
# For receiving clear-text messages (still supported in May 2026)
/^Subject:/ DUNNO
/^Date:/ DUNNO
# Senders might support Autocrypt 1 but not RFC9788 (Header Protection)
/^Autocrypt:/ DUNNO
# SecureJoin V2 protocol headers (for backward compatibility)
/^Secure-Join:/ DUNNO
/^Secure-Join-Invitenumber:/ DUNNO
/^Secure-Join-Auth:/ DUNNO
/^Secure-Join-Fingerprint:/ DUNNO
/^Secure-Join-Group:/ DUNNO
# Ignore all other headers
/.*/ IGNORE