Compare commits

..

1 Commits

Author SHA1 Message Date
holger krekel
f65ecc23fa fix: DNS check timeout with IPv6-broken authoritative NS
Add IPv4 fallback for authoritative NS queries in query_dns().
When dig @ns returns no useful answer (e.g. IPv6 transport to
the NS times out), retry with -4 to force IPv4. Also limits
timeout and tries (+timeout=10 +tries=2) to avoid hanging.

Fixes #851
2026-04-30 23:59:41 +02:00
30 changed files with 174 additions and 308 deletions

View File

@@ -1,35 +0,0 @@
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" ]
# 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@d39fe34c39cee6d760c3479325e8dc82b66a8928
with:
cmlxc_commands: |
cmlxc init
# single cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --no-dns cm0
cmlxc -v test-cmdeploy --no-dns cm0
# cross cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo cm1
cmlxc -v test-cmdeploy --no-dns cm0 cm1
# cross cmdeploy/madmail relay tests
cmlxc -v deploy-madmail mad0
cmlxc -v test-cmdeploy --no-dns cm0 mad0

View File

@@ -1,4 +1,4 @@
name: CI name: Run unit-tests and container-based deploy+test verification
on: on:
# Triggers when a PR is merged into main or a direct push occurs # Triggers when a PR is merged into main or a direct push occurs
@@ -29,7 +29,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false persist-credentials: false
- name: download filtermail - name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.4/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox
@@ -57,7 +57,7 @@ jobs:
lxc-test: lxc-test:
name: LXC deploy and test name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@d39fe34c39cee6d760c3479325e8dc82b66a8928 uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.10.0
with: with:
cmlxc_commands: | cmlxc_commands: |
cmlxc init cmlxc init

View File

@@ -10,7 +10,6 @@ dependencies = [
"filelock", "filelock",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'", "crypt-r >= 3.13.1 ; python_version >= '3.11'",
"domain-validator",
] ]
[tool.setuptools] [tool.setuptools]

View File

@@ -1,8 +1,6 @@
import ipaddress
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
from domain_validator import DomainValidator
from chatmaild.user import User from chatmaild.user import User
@@ -21,19 +19,7 @@ def read_config(inipath):
class Config: class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
raw_domain = params["mail_domain"] self.mail_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_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["max_mailbox_size"] self.max_mailbox_size = params["max_mailbox_size"]
@@ -54,9 +40,6 @@ class Config:
self.filtermail_http_port_incoming = int( self.filtermail_http_port_incoming = int(
params.get("filtermail_http_port_incoming", "10082") params.get("filtermail_http_port_incoming", "10082")
) )
self.filtermail_lmtp_port_transport = int(
params.get("filtermail_lmtp_port_transport", "10083")
)
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025")) self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.get("postfix_reinject_port_incoming", "10026") params.get("postfix_reinject_port_incoming", "10026")
@@ -67,7 +50,7 @@ class Config:
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"
if "iroh_relay" not in params: if "iroh_relay" not in params:
self.iroh_relay = "https://" + raw_domain self.iroh_relay = "https://" + params["mail_domain"]
self.enable_iroh_relay = True self.enable_iroh_relay = True
else: else:
self.iroh_relay = params["iroh_relay"].strip() self.iroh_relay = params["iroh_relay"].strip()
@@ -93,17 +76,17 @@ class Config:
) )
self.tls_cert_mode = "external" self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts self.tls_cert_path, self.tls_key_path = parts
elif raw_domain.startswith("_") or self.ipv4_relay: elif self.mail_domain.startswith("_"):
self.tls_cert_mode = "self" self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key" self.tls_key_path = "/etc/ssl/private/mailserver.key"
else: else:
self.tls_cert_mode = "acme" self.tls_cert_mode = "acme"
self.tls_cert_path = f"/var/lib/acme/live/{raw_domain}/fullchain" self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain"
self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey" self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey"
# deprecated option # deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{raw_domain}") mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
self.mailboxes_dir = Path(mbdir.strip()) self.mailboxes_dir = Path(mbdir.strip())
# old unused option (except for first migration from sqlite to maildir store) # old unused option (except for first migration from sqlite to maildir store)
@@ -189,27 +172,3 @@ def get_default_config_content(mail_domain, **overrides):
lines.append(line) lines.append(line)
content = "\n".join(lines) content = "\n".join(lines)
return content 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
def format_arpa_address(address: str) -> str:
if is_valid_ipv4(address):
return ipaddress.IPv4Address(address).reverse_pointer
DomainValidator().validate_domain_re(address)
return address
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

View File

@@ -2,6 +2,7 @@
"""CGI script for creating new accounts.""" """CGI script for creating new accounts."""
import ipaddress
import json import json
import secrets import secrets
import string import string
@@ -14,6 +15,16 @@ ALPHANUMERIC = string.ascii_lowercase + string.digits
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation 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): def create_newemail_dict(config: Config):
user = "".join( user = "".join(
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length) secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
@@ -22,22 +33,16 @@ def create_newemail_dict(config: Config):
secrets.choice(ALPHANUMERIC_PUNCT) secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3) for _ in range(config.password_min_length + 3)
) )
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}") return dict(email=f"{user}@{wrap_ip(config.mail_domain)}", password=f"{password}")
def create_dclogin_url(config, email, password): def create_dclogin_url(email, password):
"""Build a dclogin: URL with credentials and self-signed cert acceptance. """Build a dclogin: URL with credentials and self-signed cert acceptance.
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates. can connect to servers with self-signed TLS certificates.
""" """
if config.ipv4_relay: return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3"
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(): def print_new_account():
@@ -46,9 +51,7 @@ def print_new_account():
result = dict(email=creds["email"], password=creds["password"]) result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self": if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url( result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
config, creds["email"], creds["password"]
)
print("Content-Type: application/json") print("Content-Type: application/json")
print("") print("")

View File

@@ -31,11 +31,6 @@ def example_config(make_config):
return make_config("chat.example.org") return make_config("chat.example.org")
@pytest.fixture
def ipv4_config(make_config):
return make_config("1.3.3.7")
@pytest.fixture @pytest.fixture
def maildomain(example_config): def maildomain(example_config):
return example_config.mail_domain return example_config.mail_domain

View File

@@ -1,14 +1,6 @@
from contextlib import nullcontext as does_not_raise
import pytest import pytest
from chatmaild.config import ( from chatmaild.config import parse_size_mb, read_config
format_arpa_address,
format_mail_domain,
is_valid_ipv4,
parse_size_mb,
read_config,
)
def test_read_config_basic(example_config): def test_read_config_basic(example_config):
@@ -21,12 +13,6 @@ def test_read_config_basic(example_config):
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"
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): def test_read_config_basic_using_defaults(tmp_path, maildomain):
@@ -149,45 +135,3 @@ def test_max_mailbox_size_mb(make_config):
config = make_config("chat.example.org") config = make_config("chat.example.org")
assert config.max_mailbox_size == "500M" assert config.max_mailbox_size == "500M"
assert config.max_mailbox_size_mb == 500 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)
@pytest.mark.parametrize(
["input", "result", "exception"],
[
("example.org", "example.org", does_not_raise()),
("1.3.3.7", "7.3.3.1.in-addr.arpa", does_not_raise()),
("fe::1", None, pytest.raises(ValueError)),
("12394142", None, pytest.raises(ValueError)),
],
)
def test_format_arpa_address(input, result, exception):
with exception:
assert result == format_arpa_address(input)
@pytest.mark.parametrize(
["input", "result", "exception"],
[
("example.org", "example.org", does_not_raise()),
("1.3.3.7", "[1.3.3.7]", does_not_raise()),
("fe::1", None, pytest.raises(ValueError)),
("12394142", None, pytest.raises(ValueError)),
],
)
def test_format_mail_domain(input, result, exception):
with exception:
assert result == format_mail_domain(input)

View File

@@ -19,35 +19,24 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"] assert ac1["password"] != ac2["password"]
def test_create_newemail_dict_ip(ipv4_config): def test_create_newemail_dict_ip(make_config):
ac = create_newemail_dict(ipv4_config) config = make_config("1.2.3.4")
assert ac["email"].endswith("@[1.3.3.7]") ac = create_newemail_dict(config)
assert ac["email"].endswith("@[1.2.3.4]")
def test_create_dclogin_url(example_config): def test_create_dclogin_url():
addr = "user@example.org" url = create_dclogin_url("user@example.org", "p@ss w+rd")
password = "p@ss w+rd"
url = create_dclogin_url(example_config, addr, password)
assert url.startswith("dclogin:") assert url.startswith("dclogin:")
assert "v=1" in url assert "v=1" in url
assert "ic=3" in url assert "ic=3" in url
assert addr in url assert "user@example.org" in url
# password special chars must be encoded # password special chars must be encoded
assert "p%40ss" in url assert "p%40ss" in url
assert "w%2Brd" 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): def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account() print_new_account()

View File

@@ -87,12 +87,10 @@ def run_cmd_options(parser):
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host) sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme" strict_tls = args.config.tls_cert_mode == "acme"
if args.config.ipv4_relay:
args.dns_check_disabled = True
if not args.dns_check_disabled: if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) 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): if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
@@ -121,8 +119,6 @@ def run_cmd(args, out):
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: 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("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again") out.red("Run 'cmdeploy run' again")
elif args.config.ipv4_relay:
out.green("Deploy completed.")
else: else:
out.green("Deploy completed, call `cmdeploy dns` next.") out.green("Deploy completed, call `cmdeploy dns` next.")
return 0 return 0
@@ -144,10 +140,6 @@ def dns_cmd_options(parser):
def dns_cmd(args, out): def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """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 ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
tls_cert_mode = args.config.tls_cert_mode tls_cert_mode = args.config.tls_cert_mode
@@ -185,7 +177,7 @@ def status_cmd_options(parser):
def status_cmd(args, out): def status_cmd(args, out):
"""Display status for online chatmail instance.""" """Display status for online chatmail instance."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}") out.green(f"chatmail domain: {args.config.mail_domain}")

View File

@@ -468,7 +468,7 @@ class ChatmailVenvDeployer(Deployer):
def configure(self): def configure(self):
_configure_remote_venv_with_chatmaild(self.config) _configure_remote_venv_with_chatmaild(self.config)
configure_remote_units(self.config.mail_domain_bare, self.units) configure_remote_units(self.config.mail_domain, self.units)
def activate(self): def activate(self):
activate_remote_units(self.units) activate_remote_units(self.units)
@@ -584,7 +584,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
""" """
config = read_config(config_path) config = read_config(config_path)
check_config(config) check_config(config)
bare_host = config.mail_domain_bare mail_domain = config.mail_domain
if website_only: if website_only:
Deployment().perform_stages([WebsiteDeployer(config)]) Deployment().perform_stages([WebsiteDeployer(config)])
@@ -635,7 +635,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
) )
exit(1) exit(1)
tls_deployer = get_tls_deployer(config, bare_host) tls_deployer = get_tls_deployer(config, mail_domain)
all_deployers = [ all_deployers = [
ChatmailDeployer(config), ChatmailDeployer(config),
@@ -643,13 +643,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
FiltermailDeployer(), FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),
UnboundDeployer(config), UnboundDeployer(config),
TurnDeployer(bare_host), TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
tls_deployer, tls_deployer,
WebsiteDeployer(config), WebsiteDeployer(config),
ChatmailVenvDeployer(config), ChatmailVenvDeployer(config),
MtastsDeployer(), MtastsDeployer(),
OpendkimDeployer(config.mail_domain), OpendkimDeployer(mail_domain),
# Dovecot should be started before Postfix # Dovecot should be started before Postfix
# because it creates authentication socket # because it creates authentication socket
# required by Postfix. # required by Postfix.

View File

@@ -73,7 +73,7 @@ class DovecotDeployer(Deployer):
) )
def configure(self): def configure(self):
configure_remote_units(self.config.mail_domain_bare, self.units) configure_remote_units(self.config.mail_domain, self.units)
config_restart, self.daemon_reload = _configure_dovecot(self.config) config_restart, self.daemon_reload = _configure_dovecot(self.config)
self.need_restart |= config_restart self.need_restart |= config_restart

View File

@@ -7,7 +7,6 @@ listen = 0.0.0.0
protocols = imap lmtp protocols = imap lmtp
auth_mechanisms = plain auth_mechanisms = plain
auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[]
{% if debug == true %} {% if debug == true %}
auth_verbose = yes auth_verbose = yes

View File

@@ -7,7 +7,7 @@ from cmdeploy.basedeploy import Deployer, get_resource
class FiltermailDeployer(Deployer): class FiltermailDeployer(Deployer):
services = ["filtermail", "filtermail-incoming", "filtermail-transport"] services = ["filtermail", "filtermail-incoming"]
bin_path = "/usr/local/bin/filtermail" bin_path = "/usr/local/bin/filtermail"
config_path = "/usr/local/lib/chatmaild/chatmail.ini" config_path = "/usr/local/lib/chatmaild/chatmail.ini"
@@ -26,10 +26,10 @@ class FiltermailDeployer(Deployer):
return return
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.4/filtermail-{arch}" url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "5295115952c72e4c4ec3c85546e094b4155a4c702c82bd71fcdcb744dc73adf6", "x86_64": "48b3fb80c092d00b9b0a0ef77a8673496da3b9aed5ec1851e1df936d5589d62f",
"aarch64": "6892244f17b8f26ccb465766e96028e7222b3c8adefca9fc6bfe9ff332ca8dff", "aarch64": "c65bd5f45df187d3d65d6965a285583a3be0f44a6916ff12909ff9a8d702c22e",
}[arch] }[arch]
self.need_restart |= files.download( self.need_restart |= files.download(
name="Download filtermail", name="Download filtermail",

View File

@@ -1,11 +0,0 @@
[Unit]
Description=Chatmail transport service
[Service]
ExecStart={{ bin_path }} {{ config_path }} transport
Restart=always
RestartSec=30
User=vmail
[Install]
WantedBy=multi-user.target

View File

@@ -73,7 +73,7 @@ http {
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
location /mxdeliv { location /mxdeliv/ {
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }}; proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
} }

View File

@@ -54,16 +54,14 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
tls_preempt_cipherlist = yes tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.postfix_myhostname }} myhostname = {{ config.mail_domain }}
alias_maps = hash:/etc/aliases alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases alias_database = hash:/etc/aliases
# When postfix receives mail for $mydestination, # Postfix does not deliver mail for any domain by itself.
# it hands it over to dovecot via $local_transport. # Primary domain is listed in `virtual_mailbox_domains` instead
mydestination = {{ config.mail_domain }} # and handed over to Dovecot.
local_transport = lmtp:unix:private/dovecot-lmtp mydestination =
# postfix doesn't check whether local users exist or not:
local_recipient_maps =
relayhost = relayhost =
{% if disable_ipv6 %} {% if disable_ipv6 %}
@@ -81,6 +79,24 @@ inet_protocols = ipv4
inet_protocols = all inet_protocols = all
{% endif %} {% endif %}
# Postfix does not try IPv4 and IPv6 connections
# concurrently as of version 3.7.11.
#
# When relay has both A (IPv4) and AAAA (IPv6) records,
# but broken IPv6 connectivity,
# every second message is delayed by the connection timeout
# <https://www.postfix.org/postconf.5.html#smtp_connect_timeout>
# which defaults to 30 seconds. Reducing timeouts is not a solution
# as this will result in a failure to connect to slow servers.
#
# As a workaround we always prefer IPv4 when it is available.
#
# The setting is documented at
# <https://www.postfix.org/postconf.5.html#smtp_address_preference>
smtp_address_preference=ipv4
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
mua_client_restrictions = permit_sasl_authenticated, reject mua_client_restrictions = permit_sasl_authenticated, reject
@@ -93,12 +109,3 @@ smtpd_sender_login_maps = regexp:/etc/postfix/login_map
# Do not lookup SMTP client hostnames to reduce delays # Do not lookup SMTP client hostnames to reduce delays
# and avoid unnecessary DNS requests. # and avoid unnecessary DNS requests.
smtpd_peername_lookup = no smtpd_peername_lookup = no
# Use filtermail-transport to relay messages.
# We can't force postfix to split messages per destination,
# when specifying a custom next-hop,
# so instead this is handled in filtermail.
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
lmtp-filtermail_initial_destination_concurrency=10000
lmtp-filtermail_destination_concurrency_limit=10000

View File

@@ -80,9 +80,8 @@ filter unix - n n - - lmtp
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean -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 # Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
@@ -101,8 +100,3 @@ filter unix - n n - - lmtp
# cannot send unprotected Subject. # cannot send unprotected Subject.
authclean unix n - - - 0 cleanup authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup -o header_checks=regexp:/etc/postfix/submission_header_cleanup
lmtp-filtermail unix - - y - 10000 lmtp
-o syslog_name=postfix/lmtp-filtermail
-o lmtp_header_checks=
-o lmtp_tls_security_level=none

View File

@@ -64,11 +64,13 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
) )
def query_dns(typ, domain): def query_dns(typ, domain, shell_exec=None):
if shell_exec is None:
shell_exec = shell
# Get autoritative nameserver from the SOA record. # Get autoritative nameserver from the SOA record.
soa_answers = [ soa_answers = [
x.split() x.split()
for x in shell( for x in shell_exec(
f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress
).split("\n") ).split("\n")
] ]
@@ -78,8 +80,27 @@ def query_dns(typ, domain):
ns = soa[0][4] ns = soa[0][4]
# Query authoritative nameserver directly to bypass DNS cache. # Query authoritative nameserver directly to bypass DNS cache.
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress) return _dig_authoritative(ns, domain, typ, shell_exec=shell_exec)
return next((line for line in res.split("\n") if not line.startswith(";")), "")
def _parse_dig_result(output):
"""Return first non-comment, non-empty line from dig output, or empty string."""
lines = [line for line in output.split("\n") if not line.startswith(";")]
return next((line for line in lines if line.strip()), "")
def _dig_authoritative(ns, domain, typ, shell_exec=None):
"""Query authoritative NS, falling back to IPv4-only if default fails."""
if shell_exec is None:
shell_exec = shell
# limit timeout and tries to not hang on a broken default NS
cmd = f"dig @{ns} -r -q {domain} -t {typ} +short +timeout=10 +tries=2"
result = _parse_dig_result(shell_exec(cmd, print=log_progress))
if result:
return result
# Fallback: force IPv4 transport (handles broken IPv6 to NS)
return _parse_dig_result(shell_exec(cmd + " -4", print=log_progress))
def check_zonefile(zonefile, verbose=True): def check_zonefile(zonefile, verbose=True):

View File

@@ -89,11 +89,12 @@ def test_concurrent_logins_same_account(
assert login_results.get() assert login_results.get()
def test_no_vrfy(cmfactory, chatmail_config, maildomain): def test_no_vrfy(cmfactory, chatmail_config):
ac = cmfactory.get_online_account() ac = cmfactory.get_online_account()
addr = ac.get_config("addr") addr = ac.get_config("addr")
domain = chatmail_config.mail_domain
s = smtplib.SMTP(maildomain) s = smtplib.SMTP(domain)
s.starttls() s.starttls()
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")

View File

@@ -8,7 +8,6 @@ import pytest
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
from chatmaild.config import is_valid_ipv4
class TestSSHExecutor: class TestSSHExecutor:
@@ -22,8 +21,6 @@ class TestSSHExecutor:
assert out == out2 assert out == out2
def test_perform_initial(self, sshexec, maildomain): def test_perform_initial(self, sshexec, maildomain):
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is not a domain")
res = sshexec( res = sshexec(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )

View File

@@ -15,7 +15,7 @@ def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1) (ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr") user = ac1.get_config("addr")
password = ac1.get_config("mail_pw") password = ac1.get_config("mail_pw")
host = user.split("@")[1].strip("[").strip("]") host = user.split("@")[1]
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(user, password) mailbox.login(user, password)
mailbox.dc_ac = ac1 mailbox.dc_ac = ac1
@@ -178,7 +178,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2.wait_for_incoming_msg() user2.wait_for_incoming_msg()
addr = user2.get_config("addr") addr = user2.get_config("addr")
host = addr.split("@")[1].strip("[").strip("]") host = addr.split("@")[1]
pw = user2.get_config("mail_pw") pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw) mailbox.login(addr, pw)

View File

@@ -10,8 +10,7 @@ import time
from pathlib import Path from pathlib import Path
import pytest import pytest
from chatmaild.config import read_config, format_mail_domain, is_valid_ipv4 from chatmaild.config import read_config
conftestdir = Path(__file__).parent conftestdir = Path(__file__).parent
@@ -59,12 +58,7 @@ def chatmail_config(pytestconfig):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def maildomain(chatmail_config): def maildomain(chatmail_config):
return chatmail_config.mail_domain_bare return chatmail_config.mail_domain
@pytest.fixture(scope="session")
def maildomain_deliverable(maildomain):
return format_mail_domain(maildomain)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -284,6 +278,7 @@ def gencreds(chatmail_config):
def gen(domain=None): def gen(domain=None):
domain = domain if domain else chatmail_config.mail_domain domain = domain if domain else chatmail_config.mail_domain
addr_domain = f"[{domain}]" if _is_ip(domain) else domain
while 1: while 1:
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
@@ -297,7 +292,7 @@ def gencreds(chatmail_config):
password = "".join( password = "".join(
random.choices(alphanumeric, k=chatmail_config.password_min_length) random.choices(alphanumeric, k=chatmail_config.password_min_length)
) )
yield f"{user}@{domain}", f"{password}" yield f"{user}@{addr_domain}", f"{password}"
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@@ -322,8 +317,7 @@ class ChatmailACFactory:
def _make_transport(self, domain): def _make_transport(self, domain):
"""Build a transport config dict for the given domain.""" """Build a transport config dict for the given domain."""
domain_deliverable = format_mail_domain(domain) addr, password = self.gencreds(domain)
addr, password = self.gencreds(domain_deliverable)
transport = { transport = {
"addr": addr, "addr": addr,
"password": password, "password": password,
@@ -332,7 +326,7 @@ class ChatmailACFactory:
"imapServer": domain, "imapServer": domain,
"smtpServer": domain, "smtpServer": domain,
} }
if domain.startswith("_") or is_valid_ipv4(domain): if self.chatmail_config.tls_cert_mode == "self":
transport["certificateChecks"] = "acceptInvalidCertificates" transport["certificateChecks"] = "acceptInvalidCertificates"
return transport return transport
@@ -347,8 +341,7 @@ class ChatmailACFactory:
accounts = [] accounts = []
for _ in range(num): for _ in range(num):
account = self.dc.add_account() account = self.dc.add_account()
domain_deliverable = format_mail_domain(domain) addr, password = self.gencreds(domain)
addr, password = self.gencreds(domain_deliverable)
if _is_ip(domain): if _is_ip(domain):
# Use DCLOGIN scheme with explicit server hosts, # Use DCLOGIN scheme with explicit server hosts,
# matching how madmail presents its addresses to users. # matching how madmail presents its addresses to users.

View File

@@ -39,14 +39,6 @@ class TestCmdline:
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "deleting config file" in out.lower() 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): def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()

View File

@@ -214,3 +214,59 @@ class TestZonefileChecks:
assert not mockout.captured_red assert not mockout.captured_red
assert "correct" in mockout.captured_green[0] assert "correct" in mockout.captured_green[0]
assert not mockout.captured_red assert not mockout.captured_red
class TestDigAuthoritative:
@pytest.fixture
def dig_auth(self):
"""Helper that calls _dig_authoritative with a recording shell function."""
calls = []
def run(first_result, ipv4_result=None):
def shell(cmd, print=print):
calls.append(cmd)
if "-4" in cmd:
return ipv4_result or ""
return first_result
result = remote.rdns._dig_authoritative(
"ns1.example.", "example.com", "A", shell_exec=shell
)
return result, calls
return run
def test_ipv4_fallback_on_error(self, dig_auth):
"""Fallback to -4 when first query returns only error lines."""
result, calls = dig_auth(
first_result=(
";; communications error to 2a01:4f8:10a:1044::80#53: timed out\n"
";; communications error to 2a01:4f8:10a:1044::80#53: timed out\n"
),
ipv4_result="1.2.3.4",
)
assert len(calls) == 2
assert "-4" not in calls[0]
assert "-4" in calls[1]
assert result == "1.2.3.4"
def test_no_fallback_on_success(self, dig_auth):
"""No -4 fallback when first query returns a valid answer."""
result, calls = dig_auth(first_result="1.2.3.4")
assert result == "1.2.3.4"
assert len(calls) == 1
def test_fallback_with_mixed_error_lines(self, dig_auth):
"""Fallback triggers when output has only error lines mixed with empty."""
result, calls = dig_auth(
first_result=(
";; communications error to 2a01:4f8::80#53: timed out\n"
"\n"
";; communications error to 2a01:4f8::80#53: timed out\n"
),
ipv4_result=";; some warning\n1.2.3.4",
)
assert len(calls) == 2
assert result == "1.2.3.4"

View File

@@ -1,10 +1,11 @@
from pathlib import Path import importlib.resources
from cmdeploy.www import build_webpages from cmdeploy.www import build_webpages
def test_build_webpages(tmp_path, make_config): def test_build_webpages(tmp_path, make_config):
src_dir = (Path(__file__).resolve() / "../../../../../www/src").resolve() pkgroot = importlib.resources.files("cmdeploy")
src_dir = pkgroot.joinpath("../../../www/src").resolve()
assert src_dir.exists(), src_dir assert src_dir.exists(), src_dir
config = make_config("chat.example.org") config = make_config("chat.example.org")
build_dir = tmp_path.joinpath("build") build_dir = tmp_path.joinpath("build")

View File

@@ -1,4 +1,5 @@
import hashlib import hashlib
import importlib.resources
import re import re
import time import time
import traceback import traceback
@@ -36,7 +37,7 @@ def prepare_template(source):
def get_paths(config) -> (Path, Path, Path): def get_paths(config) -> (Path, Path, Path):
reporoot = (Path(__file__).resolve() / "../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../").resolve()
www_path = Path(config.www_folder) www_path = Path(config.www_folder)
# if www_folder was not set, use default directory # if www_folder was not set, use default directory
if config.www_folder == "": if config.www_folder == "":
@@ -132,7 +133,8 @@ def find_merge_conflict(src_dir) -> Path:
def main(): def main():
reporoot = (Path(__file__).resolve() / "../../../../").resolve() path = importlib.resources.files(__package__)
reporoot = path.joinpath("../../../").resolve()
inipath = reporoot.joinpath("chatmail.ini") inipath = reporoot.joinpath("chatmail.ini")
config = read_config(inipath) config = read_config(inipath)
config.webdev = True config.webdev = True

View File

@@ -14,6 +14,8 @@ Minimal requirements and prerequisites
You will need the following: 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. - A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
@@ -26,11 +28,6 @@ You will need the following:
(An ed25519 private key is required due to an `upstream bug in (An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_) paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
- Control over a domain through a DNS provider of your choice
(there is experimental support for :ref:`DNS-less relays <iponly>`).
.. _setup:
Setup with ``scripts/cmdeploy`` Setup with ``scripts/cmdeploy``
------------------------------------- -------------------------------------

View File

@@ -19,4 +19,3 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay
reverse_dns reverse_dns
related related
faq faq
iponly

View File

@@ -1,29 +0,0 @@
.. _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 <setup>`,
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 IPv4-only relays,
but some relays might not accept messages from yours.

View File

@@ -153,7 +153,6 @@ Chatmail relay dependency diagram
autoconfig.xml --- dovecot; autoconfig.xml --- dovecot;
postfix --- |10080|filtermail-outgoing; postfix --- |10080|filtermail-outgoing;
postfix --- |10081|filtermail-incoming; postfix --- |10081|filtermail-incoming;
postfix --- |10083|filtermail-transport;
filtermail-outgoing --- |10025 reinject|postfix; filtermail-outgoing --- |10025 reinject|postfix;
filtermail-incoming --- |10026 reinject|postfix; filtermail-incoming --- |10026 reinject|postfix;
dovecot --- |doveauth.socket|doveauth; dovecot --- |doveauth.socket|doveauth;
@@ -296,7 +295,9 @@ ensured by ``filtermail`` proxy.
TLS requirements TLS requirements
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
Filtermail (used for delivery) requires a valid TLS. Postfix is configured to require valid TLS by setting
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
to ``verify``.
You can test it by resolving ``MX`` records of your relay domain and You can test it by resolving ``MX`` records of your relay domain and
then connecting to MX relays (e.g ``mx.example.org``) with then connecting to MX relays (e.g ``mx.example.org``) with