diff --git a/chatmaild/src/chatmaild/tests/mail-data/plain.eml b/chatmaild/src/chatmaild/tests/mail-data/plain.eml index 69867b8..80a2542 100644 --- a/chatmaild/src/chatmaild/tests/mail-data/plain.eml +++ b/chatmaild/src/chatmaild/tests/mail-data/plain.eml @@ -7,7 +7,7 @@ Date: Sun, 15 Oct 2023 16:41:44 +0000 Message-ID: References: Chat-Version: 1.0 -Autocrypt: addr=foobar@c2.testrun.org; prefer-encrypt=mutual; +Autocrypt: addr={from_addr}; prefer-encrypt=mutual; keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I @@ -20,4 +20,4 @@ Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no Hi! - + diff --git a/cmdeploy/src/cmdeploy/__init__.py b/cmdeploy/src/cmdeploy/__init__.py index 4edb2ef..0963c23 100644 --- a/cmdeploy/src/cmdeploy/__init__.py +++ b/cmdeploy/src/cmdeploy/__init__.py @@ -126,71 +126,6 @@ def _install_remote_venv_with_chatmaild(config) -> None: ) -def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool: - """Configures OpenDKIM""" - need_restart = False - - main_config = files.template( - src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"), - dest="/etc/opendkim.conf", - user="root", - group="root", - mode="644", - config={"domain_name": domain, "opendkim_selector": dkim_selector}, - ) - need_restart |= main_config.changed - - files.directory( - name="Add opendkim directory to /etc", - path="/etc/opendkim", - user="opendkim", - group="opendkim", - mode="750", - present=True, - ) - - keytable = files.template( - src=importlib.resources.files(__package__).joinpath("opendkim/KeyTable"), - dest="/etc/dkimkeys/KeyTable", - user="opendkim", - group="opendkim", - mode="644", - config={"domain_name": domain, "opendkim_selector": dkim_selector}, - ) - need_restart |= keytable.changed - - signing_table = files.template( - src=importlib.resources.files(__package__).joinpath("opendkim/SigningTable"), - dest="/etc/dkimkeys/SigningTable", - user="opendkim", - group="opendkim", - mode="644", - config={"domain_name": domain, "opendkim_selector": dkim_selector}, - ) - need_restart |= signing_table.changed - - files.directory( - name="Add opendkim socket directory to /var/spool/postfix", - path="/var/spool/postfix/opendkim", - user="opendkim", - group="opendkim", - mode="750", - present=True, - ) - - if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"): - server.shell( - name="Generate OpenDKIM domain keys", - commands=[ - f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}" - ], - _sudo=True, - _sudo_user="opendkim", - ) - - return need_restart - - def _install_mta_sts_daemon() -> bool: need_restart = False @@ -370,6 +305,107 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool: return need_restart +def remove_opendkim() -> None: + """Remove OpenDKIM, deprecated""" + files.file( + name="Remove legacy opendkim.conf", + path="/etc/opendkim.conf", + present=False, + ) + + files.directory( + name="Remove legacy opendkim socket directory from /var/spool/postfix", + path="/var/spool/postfix/opendkim", + present=False, + ) + + apt.packages(name="Remove openDKIM", packages="opendkim", present=False) + + +def _configure_rspamd(dkim_selector: str, mail_domain: str) -> bool: + """Configures rspamd for Rate Limiting.""" + need_restart = False + + apt.packages( + name="apt install rspamd", + packages="rspamd", + ) + + for module in ["phishing", "rbl", "hfilter", "ratelimit"]: + disabled_module_conf = files.put( + name=f"disable {module} rspamd plugin", + src=importlib.resources.files(__package__).joinpath("rspamd/disabled.conf"), + dest=f"/etc/rspamd/local.d/{module}.conf", + user="root", + group="root", + mode="644", + ) + need_restart |= disabled_module_conf.changed + + options_inc = files.put( + name="disable fuzzy checks", + src=importlib.resources.files(__package__).joinpath("rspamd/options.inc"), + dest="/etc/rspamd/local.d/options.inc", + user="root", + group="root", + mode="644", + ) + need_restart |= options_inc.changed + + # https://rspamd.com/doc/modules/force_actions.html + force_actions_conf = files.put( + name="Set up rules to reject on DKIM, SPF and DMARC fails", + src=importlib.resources.files(__package__).joinpath( + "rspamd/force_actions.conf" + ), + dest="/etc/rspamd/local.d/force_actions.conf", + user="root", + group="root", + mode="644", + ) + need_restart |= force_actions_conf.changed + + dkim_directory = "/var/lib/rspamd/dkim/" + dkim_key_path = f"{dkim_directory}{mail_domain}.{dkim_selector}.key" + dkim_dns_file = f"{dkim_directory}{mail_domain}.{dkim_selector}.zone" + + dkim_config = files.template( + src=importlib.resources.files(__package__).joinpath( + "rspamd/dkim_signing.conf.j2" + ), + dest="/etc/rspamd/local.d/dkim_signing.conf", + user="root", + group="root", + mode="644", + config={ + "dkim_selector": str(dkim_selector), + "mail_domain": mail_domain, + "dkim_key_path": dkim_key_path, + }, + ) + need_restart |= dkim_config.changed + + files.directory( + name="ensure DKIM key directory exists", + path=dkim_directory, + present=True, + user="_rspamd", + group="_rspamd", + ) + + if not host.get_fact(File, dkim_key_path): + server.shell( + name="Generate DKIM domain keys with rspamd", + commands=[ + f"rspamadm dkim_keygen -b 2048 -s {dkim_selector} -d {mail_domain} -k {dkim_key_path} > {dkim_dns_file}" + ], + _sudo=True, + _sudo_user="_rspamd", + ) + + return need_restart + + def check_config(config): mail_domain = config.mail_domain if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"): @@ -397,14 +433,6 @@ def deploy_chatmail(config_path: Path) -> None: server.group(name="Create vmail group", group="vmail", system=True) server.user(name="Create vmail user", user="vmail", group="vmail", system=True) - server.group(name="Create opendkim group", group="opendkim", system=True) - server.user( - name="Add postfix user to opendkim group for socket access", - user="postfix", - groups=["opendkim"], - system=True, - ) - # Run local DNS resolver `unbound`. # `resolvconf` takes care of setting up /etc/resolv.conf # to use 127.0.0.1 as the resolver. @@ -439,14 +467,6 @@ def deploy_chatmail(config_path: Path) -> None: packages=["dovecot-imapd", "dovecot-lmtpd"], ) - apt.packages( - name="Install OpenDKIM", - packages=[ - "opendkim", - "opendkim-tools", - ], - ) - apt.packages( name="Install nginx", packages=["nginx"], @@ -468,16 +488,18 @@ def deploy_chatmail(config_path: Path) -> None: debug = False dovecot_need_restart = _configure_dovecot(config, debug=debug) postfix_need_restart = _configure_postfix(config, debug=debug) - opendkim_need_restart = _configure_opendkim(mail_domain) mta_sts_need_restart = _install_mta_sts_daemon() nginx_need_restart = _configure_nginx(mail_domain) + remove_opendkim() + rspamd_need_restart = _configure_rspamd("dkim", mail_domain) + systemd.service( - name="Start and enable OpenDKIM", - service="opendkim.service", + name="Start and enable rspamd", + service="rspamd.service", running=True, enabled=True, - restarted=opendkim_need_restart, + restarted=rspamd_need_restart, ) systemd.service( diff --git a/cmdeploy/src/cmdeploy/chatmail.zone.f b/cmdeploy/src/cmdeploy/chatmail.zone.f index 2fd55ad..da58aa8 100644 --- a/cmdeploy/src/cmdeploy/chatmail.zone.f +++ b/cmdeploy/src/cmdeploy/chatmail.zone.f @@ -7,7 +7,7 @@ _imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}. _imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}. {chatmail_domain}. CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}" {chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all" -_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;rua=mailto:{email};ruf=mailto:{email};fo=1;adkim=r;aspf=r" +_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;rua=mailto:{email};ruf=mailto:{email};fo=1;adkim=s;aspf=s" _mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}" mta-sts.{chatmail_domain}. CNAME {chatmail_domain}. www.{chatmail_domain}. CNAME {chatmail_domain}. diff --git a/cmdeploy/src/cmdeploy/dns.py b/cmdeploy/src/cmdeploy/dns.py index e8e740a..8a1baca 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -60,6 +60,9 @@ def show_dns(args, out): continue line = line.replace("\t", " ") lines.append(line) + lines[0] = f"dkim._domainkey.{mail_domain}. IN TXT " + lines[0].strip( + "dkim._domainkey IN TXT " + ) return "\n".join(lines) print("Checking your DKIM keys and DNS entries...") @@ -68,7 +71,9 @@ def show_dns(args, out): except subprocess.CalledProcessError: print("Please run `cmdeploy run` first.") return - dkim_entry = read_dkim_entries(out.shell_output(f"{ssh} -- opendkim-genzone -F")) + dkim_entry = read_dkim_entries( + out.shell_output(f"{ssh} -- cat /var/lib/rspamd/dkim/{mail_domain}.dkim.zone") + ) ipv6 = dns.get_ipv6() reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain) @@ -142,8 +147,8 @@ def show_dns(args, out): domain, data = "\n".join(dkim_lines).split(" IN TXT ") current = dns.get("TXT", domain.strip()[:-1]) if current: - current = "( %s )" % (current.replace('" "', '"\n "')) - if current.replace(";", "\\;") != data: + current = "( %s" % (current.replace('" "', '"\n "')) + if current != data: to_print.append(dkim_entry) else: to_print.append(dkim_entry) diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index 4a20123..aba216a 100644 --- a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 @@ -46,7 +46,7 @@ inet_protocols = all virtual_transport = lmtp:unix:private/dovecot-lmtp virtual_mailbox_domains = {{ config.mail_domain }} -smtpd_milters = unix:opendkim/opendkim.sock +smtpd_milters = inet:127.0.0.1:11332 non_smtpd_milters = $smtpd_milters header_checks = regexp:/etc/postfix/submission_header_cleanup diff --git a/cmdeploy/src/cmdeploy/rspamd/disabled.conf b/cmdeploy/src/cmdeploy/rspamd/disabled.conf new file mode 100644 index 0000000..a6ee831 --- /dev/null +++ b/cmdeploy/src/cmdeploy/rspamd/disabled.conf @@ -0,0 +1 @@ +enabled = false; diff --git a/cmdeploy/src/cmdeploy/rspamd/dkim_signing.conf.j2 b/cmdeploy/src/cmdeploy/rspamd/dkim_signing.conf.j2 new file mode 100644 index 0000000..d0c5c96 --- /dev/null +++ b/cmdeploy/src/cmdeploy/rspamd/dkim_signing.conf.j2 @@ -0,0 +1,10 @@ +selector = {{ config.dkim_selector }} +use_esld = false # don't cut c1.testrun.org down to testrun.org +domain = { + {{ config.mail_domain }} { + selectors [ + selector = {{ config.dkim_selector }} + path = {{ config.dkim_key_path }} + ] + } +} \ No newline at end of file diff --git a/cmdeploy/src/cmdeploy/rspamd/force_actions.conf b/cmdeploy/src/cmdeploy/rspamd/force_actions.conf new file mode 100644 index 0000000..9ca9549 --- /dev/null +++ b/cmdeploy/src/cmdeploy/rspamd/force_actions.conf @@ -0,0 +1,30 @@ +rules { + REJECT_DKIM_SPF { + action = "reject"; + # Reject if + # - R_DKIM_RJECT: DKIM reject inserted by `dkim` module. + # - R_DKIM_PERMFAIL: permanent failure inserted by `dkim` module e.g. no DKIM DNS record found. + # - No DKIM signing (R_DKIM_NA symbol inserted by `dkim` module) + # + # - SPF failure (R_SPF_FAIL) + # - SPF permanent failure, e.g. failed to resolve DNS record referenced from SPF (R_SPF_PERMFAIL) + # + # - DMARC policy failure (DMARC_POLICY_REJECT) + # + # Do not reject if: + # - R_DKIM_TEMPFAIL, it is a DNS resolution failure + # and we do not want to lose messages because of faulty network. + # + # - R_SPF_SOFTFAIL + # - R_SPF_NEUTRAL + # - R_SPF_DNSFAIL + # - R_SPF_NA + # + # - DMARC_DNSFAIL + # - DMARC_NA + # - DMARC_POLICY_SOFTFAIL + # - DMARC_POLICY_QUARANTINE + # - DMARC_BAD_POLICY + expression = "R_DKIM_REJECT | R_DKIM_PERMFAIL | R_DKIM_NA | R_SPF_FAIL | R_SPF_PERMFAIL | DMARC_POLICY_REJECT"; + } +} diff --git a/cmdeploy/src/cmdeploy/rspamd/options.inc b/cmdeploy/src/cmdeploy/rspamd/options.inc new file mode 100644 index 0000000..a8a76a4 --- /dev/null +++ b/cmdeploy/src/cmdeploy/rspamd/options.inc @@ -0,0 +1 @@ +filters = "dkim"; diff --git a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py index 8dafc70..316e933 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py @@ -42,6 +42,16 @@ def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr): assert "500" in str(e.value) +@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"]) +def test_reject_missing_dkim(cmsetup, maildata, from_addr): + """Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected.""" + recipient = cmsetup.gen_users(1)[0] + msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string() + with smtplib.SMTP(cmsetup.maildomain, 25) as s: + with pytest.raises(smtplib.SMTPDataError, match="Spam message rejected"): + s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg) + + @pytest.mark.slow def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config): """Test that the per-account send-mail limit is exceeded."""