Skip to content
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci-no-dns.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -57,7 +57,7 @@ jobs:

lxc-test:
name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.10.0
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@d39fe34c39cee6d760c3479325e8dc82b66a8928
with:
cmlxc_commands: |
cmlxc init
Expand Down
1 change: 1 addition & 0 deletions chatmaild/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies = [
"filelock",
"requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
"domain-validator",
]

[tool.setuptools]
Expand Down
33 changes: 31 additions & 2 deletions chatmaild/src/chatmaild/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import ipaddress
from pathlib import Path

import iniconfig
from domain_validator import DomainValidator

from chatmaild.user import User

Expand All @@ -20,6 +22,8 @@ class Config:
def __init__(self, inipath, params):
self._inipath = inipath
self.mail_domain = params["mail_domain"]
self.mail_domain_hostname = format_arpa_address(params["mail_domain"])
self.mail_domain_deliverable = format_deliverable_domain(params["mail_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"]
Expand Down Expand Up @@ -79,7 +83,7 @@ def __init__(self, inipath, params):
)
self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts
elif self.mail_domain.startswith("_"):
elif self.mail_domain.startswith("_") or is_valid_ipv4(params["mail_domain"]):
self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key"
Expand All @@ -89,7 +93,9 @@ def __init__(self, inipath, params):
self.tls_key_path = f"/var/lib/acme/live/{self.mail_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/{self.mail_domain_deliverable}"
)
self.mailboxes_dir = Path(mbdir.strip())

# old unused option (except for first migration from sqlite to maildir store)
Expand Down Expand Up @@ -175,3 +181,26 @@ 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


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
Comment thread
missytake marked this conversation as resolved.


def format_deliverable_domain(mail_domain: str) -> str:
if is_valid_ipv4(mail_domain):
return f"[{mail_domain}]"
DomainValidator().validate_domain_re(mail_domain)
return mail_domain
4 changes: 2 additions & 2 deletions chatmaild/src/chatmaild/doveauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,15 @@ def handle_lookup(self, parts):
if namespace == "shared":
if type == "userdb":
user = args[0]
if user.endswith(f"@{config.mail_domain}"):
if user.endswith(f"@{config.mail_domain_deliverable}"):
res = self.lookup_userdb(user)
if res:
reply_command = "O"
else:
reply_command = "N"
elif type == "passdb":
user = args[1]
if user.endswith(f"@{config.mail_domain}"):
if user.endswith(f"@{config.mail_domain_deliverable}"):
res = self.lookup_passdb(user, cleartext_password=args[0])
if res:
reply_command = "O"
Expand Down
29 changes: 14 additions & 15 deletions chatmaild/src/chatmaild/newemail.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

"""CGI script for creating new accounts."""

import ipaddress
import json
import secrets
import string
Expand All @@ -15,16 +14,6 @@
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)
Expand All @@ -33,16 +22,24 @@ 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_deliverable}", 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.mail_domain != config.mail_domain_deliverable:
imap_host = "&ih=" + config.mail_domain
smtp_host = "&sh=" + config.mail_domain
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():
Expand All @@ -51,7 +48,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("")
Expand Down
5 changes: 5 additions & 0 deletions chatmaild/src/chatmaild/tests/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 57 additions & 1 deletion chatmaild/src/chatmaild/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
from contextlib import nullcontext as does_not_raise

import pytest

from chatmaild.config import parse_size_mb, read_config
from chatmaild.config import (
format_arpa_address,
format_deliverable_domain,
is_valid_ipv4,
parse_size_mb,
read_config,
)


def test_read_config_basic(example_config):
Expand All @@ -13,6 +21,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.mail_domain_deliverable == "chat.example.org"


def test_read_config_deliverable(ipv4_config):
assert ipv4_config.mail_domain == "1.3.3.7"
assert ipv4_config.mail_domain_deliverable == "[1.3.3.7]"


def test_read_config_basic_using_defaults(tmp_path, maildomain):
Expand Down Expand Up @@ -135,3 +149,45 @@ 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)


@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_deliverable_domain(input, result, exception):
with exception:
assert result == format_deliverable_domain(input)
20 changes: 14 additions & 6 deletions chatmaild/src/chatmaild/tests/test_doveauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,20 @@ def test_invalid_username_length(example_config):
config.username_min_length = 6
config.username_max_length = 10
password = create_newemail_dict(config)["password"]
assert not is_allowed_to_create(config, f"a1234@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"012345@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"0123456@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"0123456789@{config.mail_domain}", password)
assert not is_allowed_to_create(
config, f"0123456789x@{config.mail_domain}", password
config, f"a1234@{config.mail_domain_deliverable}", password
)
assert is_allowed_to_create(
config, f"012345@{config.mail_domain_deliverable}", password
)
assert is_allowed_to_create(
config, f"0123456@{config.mail_domain_deliverable}", password
)
assert is_allowed_to_create(
config, f"0123456789@{config.mail_domain_deliverable}", password
)
assert not is_allowed_to_create(
config, f"0123456789x@{config.mail_domain_deliverable}", password
)


Expand Down Expand Up @@ -124,7 +132,7 @@ def test_invalid_localpart_characters(make_config):
"""Test that is_allowed_to_create rejects localparts with invalid characters."""
config = make_config("chat.example.org", {"username_min_length": "3"})
password = "zequ0Aimuchoodaechik"
domain = config.mail_domain
domain = config.mail_domain_deliverable

# valid localparts
assert is_allowed_to_create(config, f"abc123@{domain}", password)
Expand Down
2 changes: 1 addition & 1 deletion chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def test_one_mail(
print(line.decode("ascii"), file=sys.stderr)
pytest.fail("starting filtermail failed")

addr = f"user1@{config.mail_domain}"
addr = f"user1@{config.mail_domain_deliverable}"
config.get_user(addr).set_password("l1k2j3l1k2j3l")

# send encrypted mail
Expand Down
27 changes: 19 additions & 8 deletions chatmaild/src/chatmaild/tests/test_newmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -45,7 +56,7 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf
assert lines[0] == "Content-Type: application/json"
assert not lines[1]
dic = json.loads(lines[2])
assert dic["email"].endswith(f"@{example_config.mail_domain}")
assert dic["email"].endswith(f"@{example_config.mail_domain_deliverable}")
assert len(dic["password"]) >= 10
# default tls_cert=acme should not include dclogin_url
assert "dclogin_url" not in dic
Expand Down
Loading
Loading