From dbd92a6b268ee652e841ced15d3c38508b27d2b1 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 30 Mar 2026 08:06:48 +0200 Subject: [PATCH] refactor(cmdeploy): unify zone-file handling to use actual BIND format Remove chatmail.zone.j2 and build DNS records directly in dns.py using standard BIND format (name TTL IN type rdata) as it is easy enough to parse back. Add parse_zone_records() for consuming the new format, update rdns.py check_zonefile() and test data accordingly. --- cmdeploy/src/cmdeploy/chatmail.zone.j2 | 32 --------- cmdeploy/src/cmdeploy/dns.py | 71 +++++++++++++++----- cmdeploy/src/cmdeploy/remote/rdns.py | 12 ++-- cmdeploy/src/cmdeploy/tests/data/zftest.zone | 33 ++++----- cmdeploy/src/cmdeploy/tests/test_dns.py | 39 +++++++---- 5 files changed, 106 insertions(+), 81 deletions(-) delete mode 100644 cmdeploy/src/cmdeploy/chatmail.zone.j2 diff --git a/cmdeploy/src/cmdeploy/chatmail.zone.j2 b/cmdeploy/src/cmdeploy/chatmail.zone.j2 deleted file mode 100644 index 9915ae68..00000000 --- a/cmdeploy/src/cmdeploy/chatmail.zone.j2 +++ /dev/null @@ -1,32 +0,0 @@ -; -; Required DNS entries for chatmail servers -; -{% if A %} -{{ mail_domain }}. A {{ A }} -{% endif %} -{% if AAAA %} -{{ mail_domain }}. AAAA {{ AAAA }} -{% endif %} -{{ mail_domain }}. MX 10 {{ mail_domain }}. -{% if strict_tls %} -_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}" -mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}. -{% endif %} -www.{{ mail_domain }}. CNAME {{ mail_domain }}. -{{ dkim_entry }} - -; -; Recommended DNS entries for interoperability and security-hardening -; -{{ mail_domain }}. TXT "v=spf1 a ~all" -_dmarc.{{ mail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s" - -{% if acme_account_url %} -{{ mail_domain }}. CAA 0 issue "letsencrypt.org;accounturi={{ acme_account_url }}" -{% endif %} -_adsp._domainkey.{{ mail_domain }}. TXT "dkim=discardable" - -_submission._tcp.{{ mail_domain }}. SRV 0 1 587 {{ mail_domain }}. -_submissions._tcp.{{ mail_domain }}. SRV 0 1 465 {{ mail_domain }}. -_imap._tcp.{{ mail_domain }}. SRV 0 1 143 {{ mail_domain }}. -_imaps._tcp.{{ mail_domain }}. SRV 0 1 993 {{ mail_domain }}. diff --git a/cmdeploy/src/cmdeploy/dns.py b/cmdeploy/src/cmdeploy/dns.py index 05421b9e..adfdb3f4 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -1,11 +1,26 @@ import datetime -import importlib - -from jinja2 import Template from . import remote +def parse_zone_records(text): + """Yield ``(name, ttl, rtype, rdata)`` from standard BIND-format text. + + Skips comment lines (starting with ``;``) and blank lines. + Each record line must have the format ``name TTL IN type rdata``. + """ + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith(";"): + continue + try: + name, ttl, _in, rtype, rdata = line.split(None, 4) + except ValueError: + raise ValueError(f"Bad zone record line: {line!r}") from None + name = name.rstrip(".") + yield name, ttl, rtype.upper(), rdata + + def get_initial_remote_data(sshexec, mail_domain): return sshexec.logged( call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain) @@ -31,13 +46,36 @@ def get_filled_zone_file(remote_data): if not sts_id: remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M") - template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2") - content = template.read_text() - zonefile = Template(content).render(**remote_data) - lines = [x.strip() for x in zonefile.split("\n") if x.strip()] + d = remote_data["mail_domain"] + lines = ["; Required DNS entries"] + if remote_data.get("A"): + lines.append(f"{d}. 3600 IN A {remote_data['A']}") + if remote_data.get("AAAA"): + lines.append(f"{d}. 3600 IN AAAA {remote_data['AAAA']}") + lines.append(f"{d}. 3600 IN MX 10 {d}.") + if remote_data.get("strict_tls"): + lines.append( + f'_mta-sts.{d}. 3600 IN TXT "v=STSv1; id={remote_data["sts_id"]}"' + ) + lines.append(f"mta-sts.{d}. 3600 IN CNAME {d}.") + lines.append(f"www.{d}. 3600 IN CNAME {d}.") + lines.append(remote_data["dkim_entry"]) lines.append("") - zonefile = "\n".join(lines) - return zonefile + lines.append("; Recommended DNS entries") + lines.append(f'{d}. 3600 IN TXT "v=spf1 a ~all"') + lines.append(f'_dmarc.{d}. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s"') + if remote_data.get("acme_account_url"): + lines.append( + f"{d}. 3600 IN CAA 0 issue" + f' "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"' + ) + lines.append(f'_adsp._domainkey.{d}. 3600 IN TXT "dkim=discardable"') + lines.append(f"_submission._tcp.{d}. 3600 IN SRV 0 1 587 {d}.") + lines.append(f"_submissions._tcp.{d}. 3600 IN SRV 0 1 465 {d}.") + lines.append(f"_imap._tcp.{d}. 3600 IN SRV 0 1 143 {d}.") + lines.append(f"_imaps._tcp.{d}. 3600 IN SRV 0 1 993 {d}.") + lines.append("") + return "\n".join(lines) def check_full_zone(sshexec, remote_data, out, zonefile) -> int: @@ -53,18 +91,19 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int: if required_diff: out.red("Please set required DNS entries at your DNS provider:\n") for line in required_diff: - out(line) - out("") + out.print(line) + out.print() returncode = 1 if remote_data.get("dkim_entry") in required_diff: - out( - "If the DKIM entry above does not work with your DNS provider, you can try this one:\n" + out.print( + "If the DKIM entry above does not work with your DNS provider," + " you can try this one:\n" ) - out(remote_data.get("web_dkim_entry") + "\n") + out.print(remote_data.get("web_dkim_entry") + "\n") if recommended_diff: - out("WARNING: these recommended DNS entries are not set:\n") + out.print("WARNING: these recommended DNS entries are not set:\n") for line in recommended_diff: - out(line) + out.print(line) if not (recommended_diff or required_diff): out.green("Great! All your DNS entries are verified and correct.") diff --git a/cmdeploy/src/cmdeploy/remote/rdns.py b/cmdeploy/src/cmdeploy/remote/rdns.py index 4b686495..3b1d5292 100644 --- a/cmdeploy/src/cmdeploy/remote/rdns.py +++ b/cmdeploy/src/cmdeploy/remote/rdns.py @@ -58,8 +58,8 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector): dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw)) web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw)) return ( - f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"', - f'{dkim_selector}._domainkey.{mail_domain}. TXT "{web_dkim_value}"', + f'{dkim_selector}._domainkey.{mail_domain}. 3600 IN TXT "{dkim_value}"', + f'{dkim_selector}._domainkey.{mail_domain}. 3600 IN TXT "{web_dkim_value}"', ) @@ -94,9 +94,11 @@ def check_zonefile(zonefile, verbose=True): if not zf_line.strip() or zf_line.startswith(";"): continue print(f"dns-checking {zf_line!r}") if verbose else log_progress("") - zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2) - zf_domain = zf_domain.rstrip(".") - zf_value = zf_value.strip() + parts = zf_line.split(None, 4) + zf_domain = parts[0].rstrip(".") + # parts[1]=TTL, parts[2]=IN, parts[3]=type, parts[4]=rdata + zf_typ = parts[3] + zf_value = parts[4].strip() query_value = query_dns(zf_typ, zf_domain) if zf_value != query_value: assert zf_typ in ("A", "AAAA", "CNAME", "CAA", "SRV", "MX", "TXT"), zf_line diff --git a/cmdeploy/src/cmdeploy/tests/data/zftest.zone b/cmdeploy/src/cmdeploy/tests/data/zftest.zone index 4934c2b4..7ee9f0bf 100644 --- a/cmdeploy/src/cmdeploy/tests/data/zftest.zone +++ b/cmdeploy/src/cmdeploy/tests/data/zftest.zone @@ -1,17 +1,18 @@ -; Required DNS entries for chatmail servers -zftest.testrun.org. A 135.181.204.127 -zftest.testrun.org. AAAA 2a01:4f9:c012:52f4::1 -zftest.testrun.org. MX 10 zftest.testrun.org. -_mta-sts.zftest.testrun.org. TXT "v=STSv1; id=202403211706" -mta-sts.zftest.testrun.org. CNAME zftest.testrun.org. -www.zftest.testrun.org. CNAME zftest.testrun.org. -opendkim._domainkey.zftest.testrun.org. TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s" +; Required DNS entries +zftest.testrun.org. 3600 IN A 135.181.204.127 +zftest.testrun.org. 3600 IN AAAA 2a01:4f9:c012:52f4::1 +zftest.testrun.org. 3600 IN MX 10 zftest.testrun.org. +_mta-sts.zftest.testrun.org. 3600 IN TXT "v=STSv1; id=202403211706" +mta-sts.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org. +www.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org. +opendkim._domainkey.zftest.testrun.org. 3600 IN TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s" + ; Recommended DNS entries -_submission._tcp.zftest.testrun.org. SRV 0 1 587 zftest.testrun.org. -_submissions._tcp.zftest.testrun.org. SRV 0 1 465 zftest.testrun.org. -_imap._tcp.zftest.testrun.org. SRV 0 1 143 zftest.testrun.org. -_imaps._tcp.zftest.testrun.org. SRV 0 1 993 zftest.testrun.org. -zftest.testrun.org. CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956" -zftest.testrun.org. TXT "v=spf1 a:zftest.testrun.org ~all" -_dmarc.zftest.testrun.org. TXT "v=DMARC1;p=reject;adkim=s;aspf=s" -_adsp._domainkey.zftest.testrun.org. TXT "dkim=discardable" +zftest.testrun.org. 3600 IN TXT "v=spf1 a ~all" +_dmarc.zftest.testrun.org. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s" +zftest.testrun.org. 3600 IN CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956" +_adsp._domainkey.zftest.testrun.org. 3600 IN TXT "dkim=discardable" +_submission._tcp.zftest.testrun.org. 3600 IN SRV 0 1 587 zftest.testrun.org. +_submissions._tcp.zftest.testrun.org. 3600 IN SRV 0 1 465 zftest.testrun.org. +_imap._tcp.zftest.testrun.org. 3600 IN SRV 0 1 143 zftest.testrun.org. +_imaps._tcp.zftest.testrun.org. 3600 IN SRV 0 1 993 zftest.testrun.org. diff --git a/cmdeploy/src/cmdeploy/tests/test_dns.py b/cmdeploy/src/cmdeploy/tests/test_dns.py index 7802948f..fc9cd299 100644 --- a/cmdeploy/src/cmdeploy/tests/test_dns.py +++ b/cmdeploy/src/cmdeploy/tests/test_dns.py @@ -3,7 +3,7 @@ from copy import deepcopy import pytest from cmdeploy import remote -from cmdeploy.dns import check_full_zone, check_initial_remote_data +from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records @pytest.fixture @@ -125,18 +125,33 @@ class TestPerformInitialChecks: assert not l +def test_parse_zone_records(): + text = """ + ; This is a comment + some.domain. 3600 IN A 1.1.1.1 + + ; Another comment + www.some.domain. 3600 IN CNAME some.domain. + """ + records = list(parse_zone_records(text)) + assert records == [ + ("some.domain", "3600", "A", "1.1.1.1"), + ("www.some.domain", "3600", "CNAME", "some.domain."), + ] + + +def test_parse_zone_records_invalid_line(): + text = "invalid line" + with pytest.raises(ValueError, match="Bad zone record line"): + list(parse_zone_records(text)) + + def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False): - for zf_line in zonefile.split("\n"): - if zf_line.startswith("#"): - if "Recommended" in zf_line and only_required: - return - continue - if not zf_line.strip(): - continue - zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2) - zf_domain = zf_domain.rstrip(".") - zf_value = zf_value.strip() - mockdns_base.setdefault(zf_typ, {})[zf_domain] = zf_value + if only_required: + # Only take records before the "; Recommended" section + zonefile = zonefile.split("; Recommended")[0] + for name, ttl, rtype, rdata in parse_zone_records(zonefile): + mockdns_base.setdefault(rtype, {})[name] = rdata class MockSSHExec: