From 7c7f1cad7f7a174a25b9f9a82ee218029ebaf6d6 Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 19 Jan 2024 10:17:48 +0000 Subject: [PATCH] Replace rspamd with OpenDKIM OpenDKIM configuration has two Lua scripts defining strict DKIM policy. screen.lua filters out signatures that do not correspond to the From: domain so they are not even checked. final.lua rejects mail if it is not outgoing and has no valid DKIM signatures. OpenDKIM is configured as a milter on port 25 smtpd to check DKIM signatures and on mail reinjecting smtpd to sign outgoing messages with DKIM signatures. --- cmdeploy/src/cmdeploy/__init__.py | 207 +++++++++--------- cmdeploy/src/cmdeploy/dns.py | 13 +- cmdeploy/src/cmdeploy/opendkim/KeyTable | 2 +- cmdeploy/src/cmdeploy/opendkim/final.lua | 28 +++ cmdeploy/src/cmdeploy/opendkim/opendkim.conf | 27 ++- cmdeploy/src/cmdeploy/opendkim/screen.lua | 21 ++ cmdeploy/src/cmdeploy/postfix/main.cf.j2 | 3 - cmdeploy/src/cmdeploy/postfix/master.cf.j2 | 6 +- cmdeploy/src/cmdeploy/rspamd/disabled.conf | 1 - .../src/cmdeploy/rspamd/dkim_signing.conf.j2 | 10 - .../src/cmdeploy/rspamd/force_actions.conf | 60 ----- cmdeploy/src/cmdeploy/rspamd/options.inc | 1 - 12 files changed, 174 insertions(+), 205 deletions(-) create mode 100644 cmdeploy/src/cmdeploy/opendkim/final.lua create mode 100644 cmdeploy/src/cmdeploy/opendkim/screen.lua delete mode 100644 cmdeploy/src/cmdeploy/rspamd/disabled.conf delete mode 100644 cmdeploy/src/cmdeploy/rspamd/dkim_signing.conf.j2 delete mode 100644 cmdeploy/src/cmdeploy/rspamd/force_actions.conf delete mode 100644 cmdeploy/src/cmdeploy/rspamd/options.inc diff --git a/cmdeploy/src/cmdeploy/__init__.py b/cmdeploy/src/cmdeploy/__init__.py index 92712da..9c9601d 100644 --- a/cmdeploy/src/cmdeploy/__init__.py +++ b/cmdeploy/src/cmdeploy/__init__.py @@ -126,6 +126,101 @@ def _install_remote_venv_with_chatmaild(config) -> None: ) +def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool: + """Configures OpenDKIM""" + need_restart = False + + 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, + ) + + apt.packages( + name="apt install opendkim opendkim-tools", + packages=["opendkim", "opendkim-tools"], + ) + + 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 + + screen_script = files.put( + src=importlib.resources.files(__package__).joinpath("opendkim/screen.lua"), + dest="/etc/opendkim/screen.lua", + user="root", + group="root", + mode="644", + ) + need_restart |= screen_script.changed + + final_script = files.put( + src=importlib.resources.files(__package__).joinpath("opendkim/final.lua"), + dest="/etc/opendkim/final.lua", + user="root", + group="root", + mode="644", + ) + need_restart |= final_script.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 @@ -305,105 +400,9 @@ 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 _remove_rspamd() -> None: + """Remove rspamd""" + apt.packages(name="Remove rspamd", packages="rspamd", present=False) def check_config(config): @@ -494,15 +493,15 @@ def deploy_chatmail(config_path: Path) -> None: 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) + _remove_rspamd() + opendkim_need_restart = _configure_opendkim(mail_domain, "opendkim") systemd.service( - name="Start and enable rspamd", - service="rspamd.service", + name="Start and enable OpenDKIM", + service="opendkim.service", running=True, enabled=True, - restarted=rspamd_need_restart, + restarted=opendkim_need_restart, ) systemd.service( diff --git a/cmdeploy/src/cmdeploy/dns.py b/cmdeploy/src/cmdeploy/dns.py index 657592d..e879c47 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -61,9 +61,6 @@ def show_dns(args, out) -> int: 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...") @@ -72,9 +69,7 @@ def show_dns(args, out) -> int: except subprocess.CalledProcessError: print("Please run `cmdeploy run` first.") return 1 - dkim_entry = read_dkim_entries( - out.shell_output(f"{ssh} -- cat /var/lib/rspamd/dkim/{mail_domain}.dkim.zone") - ) + dkim_entry = read_dkim_entries(out.shell_output(f"{ssh} -- opendkim-genzone -F")) ipv6 = dns.get_ipv6() reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain) @@ -140,7 +135,7 @@ def show_dns(args, out) -> int: continue if current != value: to_print.append(line) - if " IN TXT ( " in line: + if "IN TXT ( " in line: started_dkim_parsing = True dkim_lines = [line] if started_dkim_parsing and line.startswith('"'): @@ -148,8 +143,8 @@ def show_dns(args, out) -> int: 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 != data: + current = "( %s )" % (current.replace('" "', '"\n "')) + if current.replace(";", "\\;") != data: to_print.append(dkim_entry) else: to_print.append(dkim_entry) diff --git a/cmdeploy/src/cmdeploy/opendkim/KeyTable b/cmdeploy/src/cmdeploy/opendkim/KeyTable index 63758ee..bb2f6fb 100644 --- a/cmdeploy/src/cmdeploy/opendkim/KeyTable +++ b/cmdeploy/src/cmdeploy/opendkim/KeyTable @@ -1 +1 @@ -dkim._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/dkim.private +{{ config.opendkim_selector }}._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/{{ config.opendkim_selector }}.private diff --git a/cmdeploy/src/cmdeploy/opendkim/final.lua b/cmdeploy/src/cmdeploy/opendkim/final.lua new file mode 100644 index 0000000..133f778 --- /dev/null +++ b/cmdeploy/src/cmdeploy/opendkim/final.lua @@ -0,0 +1,28 @@ +if odkim.internal_ip(ctx) == 1 then + -- Outgoing message will be signed, + -- no need to look for signatures. + return nil +end + +nsigs = odkim.get_sigcount(ctx) +if nsigs == nil then + return nil +end + +for i = 1, nsigs do + sig = odkim.get_sighandle(ctx, i - 1) + sigres = odkim.sig_result(sig) + + -- All signatures that do not correspond to From: + -- were ignored in screen.lua and return sigres -1. + -- + -- Any valid signature that was not ignored like this + -- means the message is acceptable. + if sigres == 0 then + return nil + end +end + +odkim.set_reply(ctx, "554", "5.7.1", "No valid DKIM signature found") +odkim.set_result(ctx, SMFIS_REJECT) +return nil diff --git a/cmdeploy/src/cmdeploy/opendkim/opendkim.conf b/cmdeploy/src/cmdeploy/opendkim/opendkim.conf index 2a515cd..fb91406 100644 --- a/cmdeploy/src/cmdeploy/opendkim/opendkim.conf +++ b/cmdeploy/src/cmdeploy/opendkim/opendkim.conf @@ -8,10 +8,12 @@ SyslogSuccess yes # oversigned, because it is often the identity key used by reputation systems # and thus somewhat security sensitive. Canonicalization relaxed/simple -#Mode sv -#SubDomains no OversignHeaders From +On-BadSignature reject +On-KeyNotFound reject +On-NoSignature reject + # Signing domain, selector, and key (required). For example, perform signing # for domain "example.com" with selector "2020" (2020._domainkey.example.com), # using the private key stored in /etc/dkimkeys/example.private. More granular @@ -22,6 +24,15 @@ KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private KeyTable /etc/dkimkeys/KeyTable SigningTable refile:/etc/dkimkeys/SigningTable +# Sign Autocrypt header in addition to the default specified in RFC 6376. +SignHeaders *,+autocrypt + +# Script to ignore signatures that do not correspond to the From: domain. +ScreenPolicyScript /etc/opendkim/screen.lua + +# Script to reject mails without a valid DKIM signature. +FinalPolicyScript /etc/opendkim/final.lua + # In Debian, opendkim runs as user "opendkim". A umask of 007 is required when # using a local socket with MTAs that access the socket as a non-privileged # user (for example, Postfix). You may need to add user "postfix" to group @@ -29,22 +40,10 @@ SigningTable refile:/etc/dkimkeys/SigningTable UserID opendkim UMask 007 -# Socket for the MTA connection (required). If the MTA is inside a chroot jail, -# it must be ensured that the socket is accessible. In Debian, Postfix runs in -# a chroot in /var/spool/postfix, therefore a Unix socket would have to be -# configured as shown on the last line below. -#Socket local:/run/opendkim/opendkim.sock -#Socket inet:8891@localhost -#Socket inet:8891 Socket local:/var/spool/postfix/opendkim/opendkim.sock PidFile /run/opendkim/opendkim.pid -# Hosts for which to sign rather than verify, default is 127.0.0.1. See the -# OPERATION section of opendkim(8) for more information. -#InternalHosts 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12 - # The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided # by the package dns-root-data. TrustAnchorFile /usr/share/dns/root.key -#Nameservers 127.0.0.1 diff --git a/cmdeploy/src/cmdeploy/opendkim/screen.lua b/cmdeploy/src/cmdeploy/opendkim/screen.lua new file mode 100644 index 0000000..0f083e2 --- /dev/null +++ b/cmdeploy/src/cmdeploy/opendkim/screen.lua @@ -0,0 +1,21 @@ +-- Ignore signatures that do not correspond to the From: domain. + +from_domain = odkim.get_fromdomain(ctx) +if from_domain == nil then + return nil +end + +n = odkim.get_sigcount(ctx) +if n == nil then + return nil +end + +for i = 1, n do + sig = odkim.get_sighandle(ctx, i - 1) + sig_domain = odkim.sig_getdomain(sig) + if from_domain ~= sig_domain then + odkim.sig_ignore(sig) + end +end + +return nil diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index 098a7e5..14bdad5 100644 --- a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 @@ -45,6 +45,3 @@ inet_protocols = all virtual_transport = lmtp:unix:private/dovecot-lmtp virtual_mailbox_domains = {{ config.mail_domain }} - -smtpd_milters = inet:127.0.0.1:11332 -non_smtpd_milters = $smtpd_milters diff --git a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 index 0ff7e79..b59acd2 100644 --- a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 @@ -11,9 +11,10 @@ # ========================================================================== {% if debug == true %} smtp inet n - y - - smtpd -v -{% else %} +{%- else %} smtp inet n - y - - smtpd -{% endif %} +{%- endif %} + -o smtpd_milters=unix:opendkim/opendkim.sock submission inet n - y - - smtpd -o syslog_name=postfix/submission -o smtpd_tls_security_level=encrypt @@ -78,6 +79,7 @@ filter unix - n n - - lmtp # Local SMTP server for reinjecting filered mail. localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd -o syslog_name=postfix/reinject + -o smtpd_milters=unix:opendkim/opendkim.sock -o cleanup_service_name=authclean # Cleanup `Received` headers for authenticated mail diff --git a/cmdeploy/src/cmdeploy/rspamd/disabled.conf b/cmdeploy/src/cmdeploy/rspamd/disabled.conf deleted file mode 100644 index a6ee831..0000000 --- a/cmdeploy/src/cmdeploy/rspamd/disabled.conf +++ /dev/null @@ -1 +0,0 @@ -enabled = false; diff --git a/cmdeploy/src/cmdeploy/rspamd/dkim_signing.conf.j2 b/cmdeploy/src/cmdeploy/rspamd/dkim_signing.conf.j2 deleted file mode 100644 index d0c5c96..0000000 --- a/cmdeploy/src/cmdeploy/rspamd/dkim_signing.conf.j2 +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 58f5b92..0000000 --- a/cmdeploy/src/cmdeploy/rspamd/force_actions.conf +++ /dev/null @@ -1,60 +0,0 @@ -rules { - ## Reject on missing or invalid DKIM signatures. - ## - ## We require DKIM signature on incoming mails regardless of DMARC policy. - - # R_DKIM_REJECT: DKIM reject inserted by `dkim` module. - REJECT_INVALID_DKIM { - action = "reject"; - expression = "R_DKIM_REJECT"; - message = "Rejected due to invalid DKIM signature"; - } - - # R_DKIM_PERMFAIL: permanent failure inserted by `dkim` module e.g. no DKIM DNS record found. - REJECT_PERMFAIL_DKIM { - action = "reject"; - expression = "R_DKIM_PERMFAIL"; - message = "Rejected due to missing DKIM DNS entry"; - } - - # No DKIM signature (R_DKIM_NA symbol inserted by `dkim` module). - REJECT_MISSING_DKIM { - action = "reject"; - expression = "R_DKIM_NA"; - message = "Rejected due to missing DKIM signature"; - } - - - ## Reject on SPF failure. - - # - SPF failure (R_SPF_FAIL) - # - SPF permanent failure, e.g. failed to resolve DNS record referenced from SPF (R_SPF_PERMFAIL) - REJECT_SPF { - action = "reject"; - expression = "R_SPF_FAIL | R_SPF_PERMFAIL"; - message = "Rejected due to failed SPF check"; - } - - # Reject on DMARC policy check failure. - REJECT_DMARC { - action = "reject"; - expression = "DMARC_POLICY_REJECT"; - message = "Rejected due to DMARC policy"; - } - - - # 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 -} diff --git a/cmdeploy/src/cmdeploy/rspamd/options.inc b/cmdeploy/src/cmdeploy/rspamd/options.inc deleted file mode 100644 index a8a76a4..0000000 --- a/cmdeploy/src/cmdeploy/rspamd/options.inc +++ /dev/null @@ -1 +0,0 @@ -filters = "dkim";