Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
40 changes: 40 additions & 0 deletions .github/workflows/ci-no-dns.yaml
Original file line number Diff line number Diff line change
@@ -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" ]

Comment thread
hpk42 marked this conversation as resolved.
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
7 changes: 4 additions & 3 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,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
Expand All @@ -76,3 +76,4 @@ jobs:
cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini cm0 mad0
cmlxc -v test-mini mad0 cm0

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
35 changes: 29 additions & 6 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 @@ -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"]
Expand Down Expand Up @@ -53,7 +67,7 @@ def __init__(self, inipath, params):
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()
Expand All @@ -79,17 +93,17 @@ 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 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)
Expand Down Expand Up @@ -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
27 changes: 12 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,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():
Expand All @@ -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("")
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
26 changes: 25 additions & 1 deletion chatmaild/src/chatmaild/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)
25 changes: 18 additions & 7 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 Down
12 changes: 10 additions & 2 deletions cmdeploy/src/cmdeploy/cmdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
10 changes: 5 additions & 5 deletions cmdeploy/src/cmdeploy/deployers.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ def install(self):

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)
Expand Down Expand Up @@ -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)])
Expand Down Expand Up @@ -526,21 +526,21 @@ 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),
LegacyRemoveDeployer(),
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.
Expand Down
Loading
Loading