diff --git a/cmdeploy/src/cmdeploy/chatmail.zone.j2 b/cmdeploy/src/cmdeploy/chatmail.zone.j2 index edf0c28e..41a5f151 100644 --- a/cmdeploy/src/cmdeploy/chatmail.zone.j2 +++ b/cmdeploy/src/cmdeploy/chatmail.zone.j2 @@ -1,8 +1,8 @@ {% if ipv4 %} -{{ chatmail_domain }}. A {{ ' '.join(ipv4) }} +{{ chatmail_domain }}. A {{ ipv4 }} {% endif %} {% if ipv6 %} -{{ chatmail_domain }}. AAAA {{ ' '.join(ipv6) }} +{{ chatmail_domain }}. AAAA {{ ipv6 }} {% endif %} {{ chatmail_domain }}. MX 10 {{ chatmail_domain }}. _submission._tcp.{{ chatmail_domain }}. SRV 0 1 587 {{ chatmail_domain }}. diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index aa0a7cdc..6fdc2d4b 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -16,7 +16,7 @@ from chatmaild.config import read_config, write_initial_config from termcolor import colored from . import remote_funcs -from .dns import show_dns +from .dns import NoIPRecords, show_dns from .sshexec import SSHExec # @@ -67,7 +67,7 @@ def run_cmd(args, out): out.green("Deploy completed, call `cmdeploy test` next.") elif not remote_data["acme_account_url"]: out.red("Deploy completed but letsencrypt not configured") - out.red("Run 'cmdeploy dns' or 'cmdeploy run' again") + out.red("Run 'cmdeploy run' again") retcode = 0 else: out.red("Deploy failed") @@ -85,6 +85,10 @@ def dns_cmd_options(parser): def dns_cmd(args, out): """Check DNS entries and optionally generate dns zone file.""" retcode, remote_data = show_dns(args, out) + for name in ["acme_account_url", "dkim_entry"]: + if not remote_data[name]: + # dns run insists on all records present + return 1 return retcode @@ -305,6 +309,9 @@ def main(args=None): except KeyboardInterrupt: out.red("KeyboardInterrupt") sys.exit(130) + except NoIPRecords as e: + out.red(str(e)) + sys.exit(1) if __name__ == "__main__": diff --git a/cmdeploy/src/cmdeploy/dns.py b/cmdeploy/src/cmdeploy/dns.py index 96b72130..b51c2705 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -7,7 +7,11 @@ from jinja2 import Template from . import remote_funcs -def show_dns(args, out, fullcheck=True) -> int: +class NoIPRecords(Exception): + """Indicates that no DNS A or AAAA record is present.""" + + +def show_dns(args, out) -> int: """Check existing DNS records, optionally write them to zone file and return (exitcode, remote_data) tuple.""" template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2") @@ -23,9 +27,11 @@ def show_dns(args, out, fullcheck=True) -> int: remote_data = sshexec(remote_funcs.perform_initial_checks, mail_domain=mail_domain) if not remote_data["ipv4"] and not remote_data["ipv6"]: - print() - print(f"no A and also no AAAA record set for domain: {mail_domain}") - raise SystemExit(1) + raise NoIPRecords(f"No A or AAAA DNS records set for {mail_domain}!") + + sts_id = remote_data.get("sts_id") + if not sts_id: + sts_id = datetime.datetime.now().strftime("%Y%m%d%H%M") content = template.read_text() zonefile = Template(content).render( @@ -33,14 +39,12 @@ def show_dns(args, out, fullcheck=True) -> int: dkim_entry=remote_data.get("dkim_entry"), ipv4=remote_data["ipv4"], ipv6=remote_data["ipv6"], - sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"), + sts_id=sts_id, chatmail_domain=args.config.mail_domain, ) zonefile = "\n".join([x.strip() for x in zonefile.split("\n") if x.strip()]) - to_print = sshexec( - remote_funcs.check_zonefile, zonefile=zonefile, fullcheck=fullcheck - ) + to_print = sshexec(remote_funcs.check_zonefile, zonefile=zonefile) if not args.verbose: print() diff --git a/cmdeploy/src/cmdeploy/remote_funcs.py b/cmdeploy/src/cmdeploy/remote_funcs.py index 9628d99e..32e02bec 100644 --- a/cmdeploy/src/cmdeploy/remote_funcs.py +++ b/cmdeploy/src/cmdeploy/remote_funcs.py @@ -36,16 +36,30 @@ def perform_initial_checks(mail_domain): shell("apt-get install -y dnsutils") shell(f"unbound-control flush_zone {mail_domain}", fail_ok=True) res["dkim_entry"] = get_dkim_entry(mail_domain, dkim_selector="opendkim") + res["ipv4"] = query_dns("A", mail_domain) res["ipv6"] = query_dns("AAAA", mail_domain) + + # parse out sts-id if exists + val = query_dns("TXT", f"_mta-sts.{mail_domain}") + if val: + # "v=STSv1; id={{ sts_id }}" + parts = val.split("id=") + if len(parts) == 2: + val = parts[1].rstrip('"') + res["sts_id"] = val + return res def get_dkim_entry(mail_domain, dkim_selector): - dkim_pubkey = shell( - f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private " - "-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'" - ) + try: + dkim_pubkey = shell( + f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private " + "-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'" + ) + except CalledProcessError: + return 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)) return f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"' @@ -54,10 +68,11 @@ def get_dkim_entry(mail_domain, dkim_selector): def query_dns(typ, domain): res = shell(f"dig -r -q {domain} -t {typ} +short") print(res) - return set(filter(None, res.split("\n"))) + if res: + return res.split("\n")[0] -def check_zonefile(zonefile, fullcheck): +def check_zonefile(zonefile): diff = [] for zf_line in zonefile.splitlines(): @@ -66,21 +81,10 @@ def check_zonefile(zonefile, fullcheck): zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2) zf_domain = zf_domain.rstrip(".") zf_value = zf_value.strip() - query_values = query_dns(zf_typ, zf_domain) - if zf_value in query_values: - continue - - if zf_typ == "CAA" and zf_value.endswith('accounturi="'): - # this is an initial run where acmetool did not work yet - continue - - if query_values and zf_typ == "TXT" and zf_domain.startswith("_mta-sts."): - (query_value,) = query_values - if query_value.split("id=")[0] == zf_value.split("id=")[0]: - continue - - assert zf_typ in ("A", "AAAA", "CNAME", "CAA", "SRV", "MX", "TXT"), zf_line - diff.append(zf_line) + 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.append(zf_line) return diff