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..2c8680fe 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -1,11 +1,22 @@ 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.""" + 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 +42,39 @@ 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"] + + def append_record(name, rtype, rdata, ttl=3600): + lines.append(f"{name:<40} {ttl:<6} IN {rtype:<5} {rdata}") + + lines = ["; Required DNS entries"] + if remote_data.get("A"): + append_record(f"{d}.", "A", remote_data["A"]) + if remote_data.get("AAAA"): + append_record(f"{d}.", "AAAA", remote_data["AAAA"]) + append_record(f"{d}.", "MX", f"10 {d}.") + if remote_data.get("strict_tls"): + append_record(f"_mta-sts.{d}.", "TXT", f'"v=STSv1; id={remote_data["sts_id"]}"') + append_record(f"mta-sts.{d}.", "CNAME", f"{d}.") + append_record(f"www.{d}.", "CNAME", f"{d}.") + lines.append(remote_data["dkim_entry"]) lines.append("") - zonefile = "\n".join(lines) - return zonefile + lines.append("; Recommended DNS entries") + append_record(f"{d}.", "TXT", '"v=spf1 a ~all"') + append_record(f"_dmarc.{d}.", "TXT", '"v=DMARC1;p=reject;adkim=s;aspf=s"') + if remote_data.get("acme_account_url"): + append_record( + f"{d}.", + "CAA", + f'0 issue "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"', + ) + append_record(f"_adsp._domainkey.{d}.", "TXT", '"dkim=discardable"') + append_record(f"_submission._tcp.{d}.", "SRV", f"0 1 587 {d}.") + append_record(f"_submissions._tcp.{d}.", "SRV", f"0 1 465 {d}.") + append_record(f"_imap._tcp.{d}.", "SRV", f"0 1 143 {d}.") + append_record(f"_imaps._tcp.{d}.", "SRV", f"0 1 993 {d}.") + lines.append("") + return "\n".join(lines) def check_full_zone(sshexec, remote_data, out, zonefile) -> int: @@ -58,7 +95,8 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int: 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" + "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") if recommended_diff: diff --git a/cmdeploy/src/cmdeploy/remote/rdns.py b/cmdeploy/src/cmdeploy/remote/rdns.py index 4b686495..581ecd8a 100644 --- a/cmdeploy/src/cmdeploy/remote/rdns.py +++ b/cmdeploy/src/cmdeploy/remote/rdns.py @@ -57,9 +57,10 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector): dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s" dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw)) web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw)) + name = f"{dkim_selector}._domainkey.{mail_domain}." return ( - f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"', - f'{dkim_selector}._domainkey.{mail_domain}. TXT "{web_dkim_value}"', + f'{name:<40} 3600 IN TXT "{dkim_value}"', + f'{name:<40} 3600 IN TXT "{web_dkim_value}"', ) @@ -94,7 +95,7 @@ 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, _ttl, _in, zf_typ, zf_value = zf_line.split(None, 4) zf_domain = zf_domain.rstrip(".") zf_value = zf_value.strip() query_value = query_dns(zf_typ, zf_domain) diff --git a/cmdeploy/src/cmdeploy/tests/data/zftest.zone b/cmdeploy/src/cmdeploy/tests/data/zftest.zone index 4934c2b4..fbce1e5e 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..828c52fb 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,49 @@ 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. + + ; Multi-word rdata + some.domain. 3600 IN MX 10 mail.some.domain. + + ; DKIM record (single line, multi-word TXT rdata) + dkim._domainkey.some.domain. 3600 IN TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG" "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA" + + ; Another TXT record + _dmarc.some.domain. 3600 IN TXT "v=DMARC1;p=reject" + """ + records = list(parse_zone_records(text)) + assert records == [ + ("some.domain", "3600", "A", "1.1.1.1"), + ("www.some.domain", "3600", "CNAME", "some.domain."), + ("some.domain", "3600", "MX", "10 mail.some.domain."), + ( + "dkim._domainkey.some.domain", + "3600", + "TXT", + '"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG" "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"', + ), + ("_dmarc.some.domain", "3600", "TXT", '"v=DMARC1;p=reject"'), + ] + + +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: + zonefile = zonefile.split("; Recommended")[0] + for name, ttl, rtype, rdata in parse_zone_records(zonefile): + mockdns_base.setdefault(rtype, {})[name] = rdata class MockSSHExec: