diff --git a/.github/workflows/ci-no-dns.yaml b/.github/workflows/ci-no-dns.yaml new file mode 100644 index 00000000..bc1e425d --- /dev/null +++ b/.github/workflows/ci-no-dns.yaml @@ -0,0 +1,40 @@ +name: No-DNS + +on: + # Triggers when a PR is merged into main or a direct push occurs + push: + branches: [ "main" ] + + # Triggers for any PR (and its subsequent commits) targeting the main branch + pull_request: + branches: [ "main" ] + +permissions: {} + +# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + + +jobs: + no-dns: + name: LXC deploy and test + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6 + with: + cmlxc_version: v0.14.6 + cmlxc_commands: | + cmlxc init + # single cmdeploy relay test + cmlxc -v deploy-cmdeploy --source ./repo --type ipv4 cm0 + cmlxc -v test-cmdeploy cm0 + + # cross cmdeploy relay test (two ipv4 relays) + cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --type ipv4 cm1 + cmlxc -v test-cmdeploy cm0 cm1 + + # cross cmdeploy/madmail relay tests + cmlxc -v deploy-madmail mad0 + cmlxc -v test-cmdeploy cm0 mad0 + cmlxc -v test-mini mad0 cm0 + cmlxc -v test-mini cm0 mad0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e80a9fc4..7025157e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: Run unit-tests and container-based deploy+test verification +name: CI on: # Triggers when a PR is merged into main or a direct push occurs @@ -57,9 +57,9 @@ jobs: lxc-test: name: LXC deploy and test - uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.13.5 + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6 with: - cmlxc_version: v0.13.5 + cmlxc_version: v0.14.6 cmlxc_commands: | cmlxc init # single cmdeploy relay test @@ -76,3 +76,4 @@ jobs: cmlxc -v test-cmdeploy cm0 mad0 cmlxc -v test-mini cm0 mad0 cmlxc -v test-mini mad0 cm0 + diff --git a/chatmaild/pyproject.toml b/chatmaild/pyproject.toml index 01ef93d2..c043f39b 100644 --- a/chatmaild/pyproject.toml +++ b/chatmaild/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "filelock", "requests", "crypt-r >= 3.13.1 ; python_version >= '3.11'", + "domain-validator", ] [tool.setuptools] diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 6f9acd04..de6704c0 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -1,6 +1,8 @@ +import ipaddress from pathlib import Path import iniconfig +from domain_validator import DomainValidator from chatmaild.user import User @@ -19,7 +21,19 @@ def read_config(inipath): class Config: def __init__(self, inipath, params): self._inipath = inipath - self.mail_domain = params["mail_domain"] + raw_domain = params["mail_domain"] + self.mail_domain_bare = raw_domain + + if is_valid_ipv4(raw_domain): + self.ipv4_relay = raw_domain + self.mail_domain = f"[{raw_domain}]" + self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer + else: + DomainValidator().validate_domain_re(raw_domain) + self.ipv4_relay = None + self.mail_domain = raw_domain + self.postfix_myhostname = raw_domain + 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_mailbox_size = params["max_mailbox_size"] @@ -53,7 +67,7 @@ class Config: self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true" if "iroh_relay" not in params: - self.iroh_relay = "https://" + params["mail_domain"] + self.iroh_relay = "https://" + raw_domain self.enable_iroh_relay = True else: self.iroh_relay = params["iroh_relay"].strip() @@ -79,17 +93,17 @@ class Config: ) self.tls_cert_mode = "external" self.tls_cert_path, self.tls_key_path = parts - elif self.mail_domain.startswith("_"): + elif raw_domain.startswith("_") or self.ipv4_relay: self.tls_cert_mode = "self" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_key_path = "/etc/ssl/private/mailserver.key" else: self.tls_cert_mode = "acme" - self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain" - self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey" + self.tls_cert_path = f"/var/lib/acme/live/{raw_domain}/fullchain" + self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey" # deprecated option - mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") + mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{raw_domain}") self.mailboxes_dir = Path(mbdir.strip()) # old unused option (except for first migration from sqlite to maildir store) @@ -175,3 +189,12 @@ def get_default_config_content(mail_domain, **overrides): lines.append(line) content = "\n".join(lines) return content + + +def is_valid_ipv4(address: str) -> bool: + """Check if a mail_domain is an IPv4 address.""" + try: + ipaddress.IPv4Address(address) + return True + except ValueError: + return False diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index 4aa54aee..80888220 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -2,7 +2,6 @@ """CGI script for creating new accounts.""" -import ipaddress import json import secrets import string @@ -15,16 +14,6 @@ ALPHANUMERIC = string.ascii_lowercase + string.digits ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation -def wrap_ip(host): - if host.startswith("[") and host.endswith("]"): - return host - try: - ipaddress.ip_address(host) - return f"[{host}]" - except ValueError: - return host - - def create_newemail_dict(config: Config): user = "".join( secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length) @@ -33,16 +22,22 @@ def create_newemail_dict(config: Config): secrets.choice(ALPHANUMERIC_PUNCT) for _ in range(config.password_min_length + 3) ) - return dict(email=f"{user}@{wrap_ip(config.mail_domain)}", password=f"{password}") + return dict(email=f"{user}@{config.mail_domain}", password=f"{password}") -def create_dclogin_url(email, password): +def create_dclogin_url(config, email, password): """Build a dclogin: URL with credentials and self-signed cert acceptance. Uses ic=3 (AcceptInvalidCertificates) so chatmail clients can connect to servers with self-signed TLS certificates. """ - return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3" + if config.ipv4_relay: + imap_host = "&ih=" + config.ipv4_relay + smtp_host = "&sh=" + config.ipv4_relay + else: + imap_host = "" + smtp_host = "" + return f"dclogin:{quote(email, safe='@[]')}?p={quote(password, safe='')}&v=1{imap_host}{smtp_host}&ic=3" def print_new_account(): @@ -51,7 +46,9 @@ def print_new_account(): result = dict(email=creds["email"], password=creds["password"]) if config.tls_cert_mode == "self": - result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"]) + result["dclogin_url"] = create_dclogin_url( + config, creds["email"], creds["password"] + ) print("Content-Type: application/json") print("") diff --git a/chatmaild/src/chatmaild/tests/plugin.py b/chatmaild/src/chatmaild/tests/plugin.py index b57418a3..27de56af 100644 --- a/chatmaild/src/chatmaild/tests/plugin.py +++ b/chatmaild/src/chatmaild/tests/plugin.py @@ -31,6 +31,11 @@ def example_config(make_config): return make_config("chat.example.org") +@pytest.fixture +def ipv4_config(make_config): + return make_config("1.3.3.7") + + @pytest.fixture def maildomain(example_config): return example_config.mail_domain diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index bccea318..040b45fa 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -1,6 +1,10 @@ import pytest -from chatmaild.config import parse_size_mb, read_config +from chatmaild.config import ( + is_valid_ipv4, + parse_size_mb, + read_config, +) def test_read_config_basic(example_config): @@ -13,6 +17,12 @@ def test_read_config_basic(example_config): example_config = read_config(inipath) assert example_config.max_user_send_per_minute == 37 assert example_config.mail_domain == "chat.example.org" + assert example_config.ipv4_relay is None + + +def test_read_config_ipv4(ipv4_config): + assert ipv4_config.ipv4_relay == "1.3.3.7" + assert ipv4_config.mail_domain == "[1.3.3.7]" def test_read_config_basic_using_defaults(tmp_path, maildomain): @@ -135,3 +145,17 @@ def test_max_mailbox_size_mb(make_config): config = make_config("chat.example.org") assert config.max_mailbox_size == "500M" assert config.max_mailbox_size_mb == 500 + + +@pytest.mark.parametrize( + ["input", "result"], + [ + ("example.org", False), + ("1.3.3.7", True), + ("fe::1", False), + ("ad.1e.dag.adf", False), + ("12394142", False), + ], +) +def test_is_valid_ipv4(input, result): + assert result == is_valid_ipv4(input) diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index b266d11e..f7046ca3 100644 --- a/chatmaild/src/chatmaild/tests/test_newmail.py +++ b/chatmaild/src/chatmaild/tests/test_newmail.py @@ -19,24 +19,35 @@ def test_create_newemail_dict(example_config): assert ac1["password"] != ac2["password"] -def test_create_newemail_dict_ip(make_config): - config = make_config("1.2.3.4") - ac = create_newemail_dict(config) - assert ac["email"].endswith("@[1.2.3.4]") +def test_create_newemail_dict_ip(ipv4_config): + ac = create_newemail_dict(ipv4_config) + assert ac["email"].endswith("@[1.3.3.7]") -def test_create_dclogin_url(): - url = create_dclogin_url("user@example.org", "p@ss w+rd") +def test_create_dclogin_url(example_config): + addr = "user@example.org" + password = "p@ss w+rd" + url = create_dclogin_url(example_config, addr, password) assert url.startswith("dclogin:") assert "v=1" in url assert "ic=3" in url - assert "user@example.org" in url + assert addr in url # password special chars must be encoded assert "p%40ss" in url assert "w%2Brd" in url +def test_create_dclogin_url_ipv4(ipv4_config): + addr = "user@[1.3.3.7]" + password = "p@ss w+rd" + url = create_dclogin_url(ipv4_config, addr, password) + assert url.startswith("dclogin:") + assert "v=1" in url + assert "ic=3" in url + assert addr in url + + def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config): monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) print_new_account() diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 9fbc221d..c4ea4815 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -87,10 +87,12 @@ def run_cmd_options(parser): def run_cmd(args, out): """Deploy chatmail services on the remote server.""" - ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain + ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare sshexec = get_sshexec(ssh_host) require_iroh = args.config.enable_iroh_relay strict_tls = args.config.tls_cert_mode == "acme" + if args.config.ipv4_relay: + args.dns_check_disabled = True if not args.dns_check_disabled: remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): @@ -119,6 +121,8 @@ def run_cmd(args, out): elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: out.red("Deploy completed but letsencrypt not configured") out.red("Run 'cmdeploy run' again") + elif args.config.ipv4_relay: + out.green("Deploy completed.") else: out.green("Deploy completed, call `cmdeploy dns` next.") return 0 @@ -140,6 +144,10 @@ def dns_cmd_options(parser): def dns_cmd(args, out): """Check DNS entries and optionally generate dns zone file.""" + if args.config.ipv4_relay: + ipv4 = args.config.ipv4_relay + print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.") + return 0 ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain sshexec = get_sshexec(ssh_host, verbose=args.verbose) tls_cert_mode = args.config.tls_cert_mode @@ -177,7 +185,7 @@ def status_cmd_options(parser): def status_cmd(args, out): """Display status for online chatmail instance.""" - ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain + ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare sshexec = get_sshexec(ssh_host, verbose=args.verbose) out.green(f"chatmail domain: {args.config.mail_domain}") diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 292d64bc..10bcd14d 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -370,7 +370,7 @@ class ChatmailVenvDeployer(Deployer): def configure(self): _configure_remote_venv_with_chatmaild(self, self.config) - configure_remote_units(self, self.config.mail_domain, self.units) + configure_remote_units(self, self.config.mail_domain_bare, self.units) def activate(self): activate_remote_units(self, self.units) @@ -469,7 +469,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - """ config = read_config(config_path) check_config(config) - mail_domain = config.mail_domain + bare_host = config.mail_domain_bare if website_only: Deployment().perform_stages([WebsiteDeployer(config)]) @@ -526,7 +526,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - ) exit(1) - tls_deployer = get_tls_deployer(config, mail_domain) + tls_deployer = get_tls_deployer(config, bare_host) all_deployers = [ ChatmailDeployer(config), @@ -534,13 +534,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - FiltermailDeployer(), JournaldDeployer(), UnboundDeployer(config), - TurnDeployer(mail_domain), + TurnDeployer(bare_host), IrohDeployer(config.enable_iroh_relay), tls_deployer, WebsiteDeployer(config), ChatmailVenvDeployer(config), MtastsDeployer(), - OpendkimDeployer(mail_domain), + *([] if config.ipv4_relay else [OpendkimDeployer(bare_host)]), # Dovecot should be started before Postfix # because it creates authentication socket # required by Postfix. diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index fca4c2aa..debb7904 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -68,7 +68,7 @@ class DovecotDeployer(Deployer): ) def configure(self): - configure_remote_units(self, self.config.mail_domain, self.units) + configure_remote_units(self, self.config.mail_domain_bare, self.units) _configure_dovecot(self, self.config) def activate(self): diff --git a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 index 036bd79f..a7ce7b62 100644 --- a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 +++ b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 @@ -7,6 +7,7 @@ listen = 0.0.0.0 protocols = imap lmtp auth_mechanisms = plain +auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[] {% if debug == true %} auth_verbose = yes diff --git a/cmdeploy/src/cmdeploy/external/deployer.py b/cmdeploy/src/cmdeploy/external/deployer.py index 7d087c5b..1cca3299 100644 --- a/cmdeploy/src/cmdeploy/external/deployer.py +++ b/cmdeploy/src/cmdeploy/external/deployer.py @@ -1,4 +1,3 @@ - from pyinfra import host from pyinfra.facts.files import File @@ -21,8 +20,8 @@ class ExternalTlsDeployer(Deployer): def configure(self): # Verify cert and key exist on the remote host using pyinfra facts. for path in (self.cert_path, self.key_path): - if host.get_fact(File, path=path) is None: - raise Exception(f"External TLS file not found on server: {path}") + if host.get_fact(File, path=path) is None: + raise Exception(f"External TLS file not found on server: {path}") self.ensure_systemd_unit( "external/tls-cert-reload.path.j2", @@ -40,5 +39,3 @@ class ExternalTlsDeployer(Deployer): running=True, enabled=True, ) - - diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index 856996bb..57ef64e0 100644 --- a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 @@ -54,14 +54,17 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES tls_preempt_cipherlist = yes smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination -myhostname = {{ config.mail_domain }} +myhostname = {{ config.postfix_myhostname }} alias_maps = hash:/etc/aliases alias_database = hash:/etc/aliases -# Postfix does not deliver mail for any domain by itself. -# Primary domain is listed in `virtual_mailbox_domains` instead -# and handed over to Dovecot. -mydestination = +# When postfix receives mail for $mydestination, +# it hands it over to dovecot via $local_transport. +# Note: IP literals must be handled via local delivery / mydestination. +mydestination = {{ config.mail_domain }} +local_transport = lmtp:unix:private/dovecot-lmtp +# postfix doesn't check whether local users exist or not: +local_recipient_maps = relayhost = {% if disable_ipv6 %} @@ -79,8 +82,6 @@ inet_protocols = ipv4 inet_protocols = all {% endif %} -virtual_transport = lmtp:unix:private/dovecot-lmtp -virtual_mailbox_domains = {{ config.mail_domain }} lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup mua_client_restrictions = permit_sasl_authenticated, reject diff --git a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 index 0d6e3f79..bf108fec 100644 --- a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 @@ -80,8 +80,9 @@ filter unix - n n - - lmtp 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd -o syslog_name=postfix/reinject -o milter_macro_daemon_name=ORIGINATING - -o smtpd_milters=unix:opendkim/opendkim.sock -o cleanup_service_name=authclean +{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock +{% endif %} # Local SMTP server for reinjecting incoming filtered mail 127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_login.py b/cmdeploy/src/cmdeploy/tests/online/test_0_login.py index eab39a5c..a1ff9407 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_login.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_login.py @@ -12,7 +12,7 @@ def test_init(tmp_path, maildomain): inipath = tmp_path.joinpath("chatmail.ini") main(["init", "--config", str(inipath), maildomain]) config = read_config(inipath) - assert config.mail_domain == maildomain + assert config.mail_domain_bare == maildomain def test_capabilities(imap): @@ -89,12 +89,11 @@ def test_concurrent_logins_same_account( assert login_results.get() -def test_no_vrfy(cmfactory, chatmail_config): +def test_no_vrfy(cmfactory, chatmail_config, maildomain): ac = cmfactory.get_online_account() addr = ac.get_config("addr") - domain = chatmail_config.mail_domain - s = smtplib.SMTP(domain) + s = smtplib.SMTP(maildomain) s.starttls() s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") diff --git a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py index 52bbe02f..300b19fb 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py @@ -5,6 +5,7 @@ import subprocess import time import pytest +from chatmaild.config import is_valid_ipv4 from cmdeploy import remote from cmdeploy.cmdeploy import get_sshexec @@ -21,6 +22,8 @@ class TestSSHExecutor: assert out == out2 def test_perform_initial(self, sshexec, maildomain): + if is_valid_ipv4(maildomain): + pytest.skip(f"{maildomain} is not a domain") res = sshexec( remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) ) @@ -61,8 +64,10 @@ class TestSSHExecutor: else: pytest.fail("didn't raise exception") - def test_opendkim_restarted(self, sshexec): + def test_opendkim_restarted(self, sshexec, maildomain): """check that opendkim is not running for longer than a day.""" + if is_valid_ipv4(maildomain): + pytest.skip(f"{maildomain} is an IPv4 relay, opendkim is not installed") cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp" out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd)) datestring = out.split("=")[1] @@ -290,4 +295,6 @@ def test_nginx_access_log_only_defined_once(sshdomain): kwargs=dict(command="nginx -T 2>/dev/null"), ) access_logs = [l for l in conf.splitlines() if l.strip().startswith("access_log")] - assert len(access_logs) == 1, f"expected 1 access_log, found {len(access_logs)}: {access_logs}" + assert len(access_logs) == 1, ( + f"expected 1 access_log, found {len(access_logs)}: {access_logs}" + ) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index 947c34f9..9cf79ffc 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -15,7 +15,7 @@ def imap_mailbox(cmfactory, ssl_context): (ac1,) = cmfactory.get_online_accounts(1) user = ac1.get_config("addr") password = ac1.get_config("mail_pw") - host = user.split("@")[1] + host = user.split("@")[1].strip("[").strip("]") mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox.login(user, password) mailbox.dc_ac = ac1 @@ -178,7 +178,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context): chat.send_text("testing submission header cleanup") user2.wait_for_incoming_msg() addr = user2.get_config("addr") - host = addr.split("@")[1] + host = addr.split("@")[1].strip("[").strip("]") pw = user2.get_config("mail_pw") mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox.login(addr, pw) diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index fc5e2f26..87fa5576 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -1,5 +1,4 @@ import imaplib -import ipaddress import itertools import os import random @@ -10,19 +9,19 @@ import time from pathlib import Path import pytest -from chatmaild.config import read_config +from chatmaild.config import is_valid_ipv4, read_config +from domain_validator import DomainValidator + + +def format_mail_domain(raw_domain: str) -> str: + if is_valid_ipv4(raw_domain): + return f"[{raw_domain}]" + DomainValidator().validate_domain_re(raw_domain) + return raw_domain conftestdir = Path(__file__).parent -def _is_ip(domain): - try: - ipaddress.ip_address(domain) - return True - except ValueError: - return False - - def pytest_configure(config): config._benchresults = {} config.addinivalue_line( @@ -58,7 +57,7 @@ def chatmail_config(pytestconfig): @pytest.fixture(scope="session") def maildomain(chatmail_config): - return chatmail_config.mail_domain + return chatmail_config.mail_domain_bare @pytest.fixture(scope="session") @@ -278,7 +277,6 @@ def gencreds(chatmail_config): def gen(domain=None): domain = domain if domain else chatmail_config.mail_domain - addr_domain = f"[{domain}]" if _is_ip(domain) else domain while 1: num = next(count) alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" @@ -292,7 +290,7 @@ def gencreds(chatmail_config): password = "".join( random.choices(alphanumeric, k=chatmail_config.password_min_length) ) - yield f"{user}@{addr_domain}", f"{password}" + yield f"{user}@{domain}", f"{password}" return lambda domain=None: next(gen(domain)) @@ -317,7 +315,8 @@ class ChatmailACFactory: def _make_transport(self, domain): """Build a transport config dict for the given domain.""" - addr, password = self.gencreds(domain) + domain_deliverable = format_mail_domain(domain) + addr, password = self.gencreds(domain_deliverable) transport = { "addr": addr, "password": password, @@ -326,7 +325,7 @@ class ChatmailACFactory: "imapServer": domain, "smtpServer": domain, } - if self.chatmail_config.tls_cert_mode == "self": + if domain.startswith("_") or is_valid_ipv4(domain): transport["certificateChecks"] = "acceptInvalidCertificates" return transport @@ -341,8 +340,9 @@ class ChatmailACFactory: accounts = [] for _ in range(num): account = self.dc.add_account() - addr, password = self.gencreds(domain) - if _is_ip(domain): + domain_deliverable = format_mail_domain(domain) + addr, password = self.gencreds(domain_deliverable) + if is_valid_ipv4(domain): # Use DCLOGIN scheme with explicit server hosts, # matching how madmail presents its addresses to users. qr = ( @@ -416,10 +416,10 @@ class Remote: def iter_output(self, logcmd="", ready=None): getjournal = "journalctl -f" if not logcmd else logcmd print(self.sshdomain) - match self.sshdomain: - case "@local": command = [] - case "localhost": command = [] - case _: command = ["ssh", f"root@{self.sshdomain}"] + if self.sshdomain in ("@local", "localhost"): + command = [] + else: + command = ["ssh", f"root@{self.sshdomain}"] [command.append(arg) for arg in getjournal.split()] popen = subprocess.Popen( command, diff --git a/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py b/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py index b7f7b873..c4513bc1 100644 --- a/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py +++ b/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py @@ -39,6 +39,14 @@ class TestCmdline: out, err = capsys.readouterr() assert "deleting config file" in out.lower() + def test_dns_skip_on_ip(self, capsys, tmp_path, monkeypatch): + monkeypatch.delenv("CHATMAIL_INI", raising=False) + inipath = tmp_path / "chatmail.ini" + assert main(["init", "--config", str(inipath), "1.3.3.7"]) == 0 + assert main(["dns", "--config", str(inipath)]) == 0 + out, err = capsys.readouterr() + assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n" + def test_www_folder(example_config, tmp_path): reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() diff --git a/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py b/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py index f632912e..8c615671 100644 --- a/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py +++ b/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py @@ -23,8 +23,7 @@ def make_host(*fact_pairs): if cls not in facts: registered = ", ".join(c.__name__ for c in facts) raise LookupError( - f"unexpected get_fact({cls.__name__}); " - f"only registered: {registered}" + f"unexpected get_fact({cls.__name__}); only registered: {registered}" ) return facts[cls] diff --git a/doc/source/faq.rst b/doc/source/faq.rst index 3fd36998..cc3c74b1 100644 --- a/doc/source/faq.rst +++ b/doc/source/faq.rst @@ -15,6 +15,7 @@ goes beyond what classic email servers offer: streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch `_; - **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted + (DKIM is not enforced on :ref:`IP-only relays `) - **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating depends on established IETF standards and protocols. diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 28781f28..05e6696e 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -14,8 +14,6 @@ Minimal requirements and prerequisites You will need the following: -- Control over a domain through a DNS provider of your choice. - - A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports. IPv6 is encouraged if available. Chatmail relay servers only require 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active @@ -28,6 +26,11 @@ You will need the following: (An ed25519 private key is required due to an `upstream bug in paramiko `_) +- Control over a domain through a DNS provider of your choice + (there is experimental support for :ref:`IP-only relays `). + + +.. _setup: Setup with ``scripts/cmdeploy`` ------------------------------------- diff --git a/doc/source/index.rst b/doc/source/index.rst index 48fc1cc5..48768810 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -19,3 +19,4 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay reverse_dns related faq + iponly diff --git a/doc/source/iponly.rst b/doc/source/iponly.rst new file mode 100644 index 00000000..f18e2a51 --- /dev/null +++ b/doc/source/iponly.rst @@ -0,0 +1,40 @@ +.. _iponly: + +Hosting without DNS records +=========================== + +.. note:: + + This option is experimental and might change without notice. + +In case you don't have a domain, +for example in a local network, +you can run a chatmail relay with only an IPv4 address as well. + +To deploy a relay without a domain, +run ``cmdeploy init`` with only the IPv4 address +during the :ref:`installation steps `, +for example ``cmdeploy init 13.12.23.42``. + +Drawbacks +--------- + +- your transport encryption will only use self-signed TLS certificates, + which are vulnerable against MITM attacks. + the chatmail core's end-to-end encryption should suffice in most scenarios though. + +- your messages will not be DKIM-signed; + experimentally, most chatmail relays accept non-DKIM-signed messages from IP-only relays, + but some relays might not accept messages from yours. + + +Email addresses +--------------- + +When running without a domain, +your chatmail addresses will use the IPv4 address +in brackets as the domain part, +for example ``user@[13.12.23.42]``. +This is a valid email address format +according to :rfc:`5321`. + diff --git a/doc/source/overview.rst b/doc/source/overview.rst index 9b040b7f..85aff1a5 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -265,7 +265,8 @@ from the chatmail relay server. Email domain authentication (DKIM) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails. +Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails +(except for :ref:`IP-only relays `). Incoming emails must have a valid DKIM signature with Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature header) equal to the ``From:`` header domain. This property is checked