mirror of
https://github.com/chatmail/relay.git
synced 2026-05-10 07:54:36 +00:00
refactor: unify DNS zone-file to standard BIND format
This commit is contained in:
@@ -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 }}.
|
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import importlib
|
|
||||||
|
|
||||||
from jinja2 import Template
|
|
||||||
|
|
||||||
from . import remote
|
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):
|
def get_initial_remote_data(sshexec, mail_domain):
|
||||||
return sshexec.logged(
|
return sshexec.logged(
|
||||||
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
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:
|
if not sts_id:
|
||||||
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
|
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
|
||||||
|
|
||||||
template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
|
d = remote_data["mail_domain"]
|
||||||
content = template.read_text()
|
|
||||||
zonefile = Template(content).render(**remote_data)
|
def append_record(name, rtype, rdata, ttl=3600):
|
||||||
lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
|
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("")
|
lines.append("")
|
||||||
zonefile = "\n".join(lines)
|
lines.append("; Recommended DNS entries")
|
||||||
return zonefile
|
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:
|
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
|
returncode = 1
|
||||||
if remote_data.get("dkim_entry") in required_diff:
|
if remote_data.get("dkim_entry") in required_diff:
|
||||||
out(
|
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")
|
out(remote_data.get("web_dkim_entry") + "\n")
|
||||||
if recommended_diff:
|
if recommended_diff:
|
||||||
|
|||||||
@@ -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_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
|
||||||
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
|
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
|
||||||
web_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 (
|
return (
|
||||||
f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"',
|
f'{name:<40} 3600 IN TXT "{dkim_value}"',
|
||||||
f'{dkim_selector}._domainkey.{mail_domain}. TXT "{web_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(";"):
|
if not zf_line.strip() or zf_line.startswith(";"):
|
||||||
continue
|
continue
|
||||||
print(f"dns-checking {zf_line!r}") if verbose else log_progress("")
|
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_domain = zf_domain.rstrip(".")
|
||||||
zf_value = zf_value.strip()
|
zf_value = zf_value.strip()
|
||||||
query_value = query_dns(zf_typ, zf_domain)
|
query_value = query_dns(zf_typ, zf_domain)
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
; Required DNS entries for chatmail servers
|
; Required DNS entries
|
||||||
zftest.testrun.org. A 135.181.204.127
|
zftest.testrun.org. 3600 IN A 135.181.204.127
|
||||||
zftest.testrun.org. AAAA 2a01:4f9:c012:52f4::1
|
zftest.testrun.org. 3600 IN AAAA 2a01:4f9:c012:52f4::1
|
||||||
zftest.testrun.org. MX 10 zftest.testrun.org.
|
zftest.testrun.org. 3600 IN MX 10 zftest.testrun.org.
|
||||||
_mta-sts.zftest.testrun.org. TXT "v=STSv1; id=202403211706"
|
_mta-sts.zftest.testrun.org. 3600 IN TXT "v=STSv1; id=202403211706"
|
||||||
mta-sts.zftest.testrun.org. CNAME zftest.testrun.org.
|
mta-sts.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org.
|
||||||
www.zftest.testrun.org. CNAME zftest.testrun.org.
|
www.zftest.testrun.org. 3600 IN 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"
|
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
|
; Recommended DNS entries
|
||||||
_submission._tcp.zftest.testrun.org. SRV 0 1 587 zftest.testrun.org.
|
zftest.testrun.org. 3600 IN TXT "v=spf1 a ~all"
|
||||||
_submissions._tcp.zftest.testrun.org. SRV 0 1 465 zftest.testrun.org.
|
_dmarc.zftest.testrun.org. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
||||||
_imap._tcp.zftest.testrun.org. SRV 0 1 143 zftest.testrun.org.
|
zftest.testrun.org. 3600 IN CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
|
||||||
_imaps._tcp.zftest.testrun.org. SRV 0 1 993 zftest.testrun.org.
|
_adsp._domainkey.zftest.testrun.org. 3600 IN TXT "dkim=discardable"
|
||||||
zftest.testrun.org. CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
|
_submission._tcp.zftest.testrun.org. 3600 IN SRV 0 1 587 zftest.testrun.org.
|
||||||
zftest.testrun.org. TXT "v=spf1 a:zftest.testrun.org ~all"
|
_submissions._tcp.zftest.testrun.org. 3600 IN SRV 0 1 465 zftest.testrun.org.
|
||||||
_dmarc.zftest.testrun.org. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
_imap._tcp.zftest.testrun.org. 3600 IN SRV 0 1 143 zftest.testrun.org.
|
||||||
_adsp._domainkey.zftest.testrun.org. TXT "dkim=discardable"
|
_imaps._tcp.zftest.testrun.org. 3600 IN SRV 0 1 993 zftest.testrun.org.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from copy import deepcopy
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cmdeploy import remote
|
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
|
@pytest.fixture
|
||||||
@@ -125,18 +125,49 @@ class TestPerformInitialChecks:
|
|||||||
assert not l
|
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):
|
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
|
||||||
for zf_line in zonefile.split("\n"):
|
if only_required:
|
||||||
if zf_line.startswith("#"):
|
zonefile = zonefile.split("; Recommended")[0]
|
||||||
if "Recommended" in zf_line and only_required:
|
for name, ttl, rtype, rdata in parse_zone_records(zonefile):
|
||||||
return
|
mockdns_base.setdefault(rtype, {})[name] = rdata
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class MockSSHExec:
|
class MockSSHExec:
|
||||||
|
|||||||
Reference in New Issue
Block a user