diff --git a/api/mailinabox.yml b/api/mailinabox.yml index f3290fb91..2b45fbd18 100644 --- a/api/mailinabox.yml +++ b/api/mailinabox.yml @@ -1262,7 +1262,7 @@ paths: $ref: '#/components/schemas/MailUserAddResponse' example: | mail user added - updated DNS: OpenDKIM configuration + updated DNS: DKIM configuration 400: description: Bad request content: @@ -1863,7 +1863,7 @@ components: type: string example: | mail user added - updated DNS: OpenDKIM configuration + updated DNS: DKIM configuration description: | Mail user add response. diff --git a/management/dns_update.py b/management/dns_update.py index 186e14a5a..c8050c1c5 100755 --- a/management/dns_update.py +++ b/management/dns_update.py @@ -108,21 +108,22 @@ def do_dns_update(env, force=False): except: shell('check_call', ["/usr/sbin/service", "nsd", "restart"]) - # Write the OpenDKIM configuration tables for all of the mail domains. + # Write the DKIM configuration tables for all of the mail domains. from mailconfig import get_mail_domains - if write_opendkim_tables(get_mail_domains(env), env): - # Settings changed. Kick opendkim. - shell('check_call', ["/usr/sbin/service", "opendkim", "restart"]) + + if write_dkim_tables(get_mail_domains(env), env): + # Settings changed. Kick dkimpy. + shell('check_call', ["/usr/sbin/service", "dkimpy-milter", "restart"]) if len(updated_domains) == 0: # If this is the only thing that changed? - updated_domains.append("OpenDKIM configuration") + updated_domains.append("DKIM configuration") # Clear bind9's DNS cache so our own DNS resolver is up to date. # (ignore errors with trap=True) shell('check_call', ["/usr/sbin/rndc", "flush"], trap=True) if len(updated_domains) == 0: - # if nothing was updated (except maybe OpenDKIM's files), don't show any output + # if nothing was updated (except maybe DKIM's files), don't show any output return "" else: return "updated DNS: " + ",".join(updated_domains) + "\n" @@ -289,10 +290,18 @@ def has_rec(qname, rtype, prefix=None): if not has_rec(None, "TXT", prefix="v=spf1 "): records.append((None, "TXT", 'v=spf1 mx -all', "Recommended. Specifies that only the box is permitted to send @%s mail." % domain)) - # Append the DKIM TXT record to the zone as generated by OpenDKIM. + # Append the DKIM TXT record to the zone as generated by DKIMpy. # Skip if the user has set a DKIM record already. - opendkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.txt') - with open(opendkim_record_file, encoding="utf-8") as orf: + dkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.dns') + with open(dkim_record_file, encoding="utf-8") as orf: + m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S) + val = "".join(re.findall(r'"([^"]+)"', m.group(2))) + if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "): + records.append((m.group(1), "TXT", val, "Recommended. Provides a way for recipients to verify that this machine sent @%s mail." % domain)) + + # Also add a ed25519 DKIM record + dkim_record_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/box-ed25519.dns') + with open(dkim_record_file, encoding="utf-8") as orf: m = re.match(r'(\S+)\s+IN\s+TXT\s+\( ((?:"[^"]+"\s+)+)\)', orf.read(), re.S) val = "".join(re.findall(r'"([^"]+)"', m.group(2))) if not has_rec(m.group(1), "TXT", prefix="v=DKIM1; "): @@ -748,14 +757,15 @@ def sign_zone(domain, zonefile, env): ######################################################################## -def write_opendkim_tables(domains, env): - # Append a record to OpenDKIM's KeyTable and SigningTable for each domain +def write_dkim_tables(domains, env): + # Append a record to DKIMpy's KeyTable and SigningTable for each domain # that we send mail from (zones and all subdomains). - opendkim_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.private') + dkim_rsa_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/mail.key') + dkim_ed_key_file = os.path.join(env['STORAGE_ROOT'], 'mail/dkim/box-ed25519.key') - if not os.path.exists(opendkim_key_file): - # Looks like OpenDKIM is not installed. + if not os.path.exists(dkim_rsa_key_file) or not os.path.exists(dkim_ed_key_file): + # Looks like DKIMpy is not installed. return False config = { @@ -777,7 +787,12 @@ def write_opendkim_tables(domains, env): # signing domain must match the sender's From: domain. "KeyTable": "".join( - f"{domain} {domain}:mail:{opendkim_key_file}\n" + f"{domain} {domain}:mail:{dkim_rsa_key_file}\n" + for domain in domains + ), + "KeyTableEd25519": + "".join( + f"{domain} {domain}:box-ed25519:{dkim_ed_key_file}\n" for domain in domains ), } @@ -785,18 +800,18 @@ def write_opendkim_tables(domains, env): did_update = False for filename, content in config.items(): # Don't write the file if it doesn't need an update. - if os.path.exists("/etc/opendkim/" + filename): - with open("/etc/opendkim/" + filename, encoding="utf-8") as f: + if os.path.exists("/etc/dkim/" + filename): + with open("/etc/dkim/" + filename, encoding="utf-8") as f: if f.read() == content: continue # The contents needs to change. - with open("/etc/opendkim/" + filename, "w", encoding="utf-8") as f: + with open("/etc/dkim/" + filename, "w", encoding="utf-8") as f: f.write(content) did_update = True # Return whether the files changed. If they didn't change, there's - # no need to kick the opendkim process. + # no need to kick the dkimpy process. return did_update ######################################################################## diff --git a/management/mail_log.py b/management/mail_log.py index 793fec093..62aff6b22 100755 --- a/management/mail_log.py +++ b/management/mail_log.py @@ -375,7 +375,7 @@ def scan_mail_log_line(line, collector): if SCAN_BLOCKED: scan_postfix_smtpd_line(date, log, collector) elif service in {"postfix/qmgr", "postfix/pickup", "postfix/cleanup", "postfix/scache", - "spampd", "postfix/anvil", "postfix/master", "opendkim", "postfix/lmtp", + "spampd", "postfix/anvil", "postfix/master", "dkimpy", "postfix/lmtp", "postfix/tlsmgr", "anvil"}: # nothing to look at return True diff --git a/management/status_checks.py b/management/status_checks.py index 51f8e6319..9de84edba 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -27,7 +27,7 @@ def get_services(): { "name": "Dovecot LMTP LDA", "port": 10026, "public": False, }, { "name": "Postgrey", "port": 10023, "public": False, }, { "name": "Spamassassin", "port": 10025, "public": False, }, - { "name": "OpenDKIM", "port": 8891, "public": False, }, + { "name": "DKIMpy", "port": 8892, "public": False, }, { "name": "OpenDMARC", "port": 8893, "public": False, }, { "name": "Mail-in-a-Box Management Daemon", "port": 10222, "public": False, }, { "name": "SSH Login (ssh)", "port": get_ssh_port(), "public": True, }, diff --git a/setup/dkim.sh b/setup/dkim.sh index 77d996ab5..d2afca030 100755 --- a/setup/dkim.sh +++ b/setup/dkim.sh @@ -1,64 +1,89 @@ #!/bin/bash -# OpenDKIM +# DKIM # -------- # -# OpenDKIM provides a service that puts a DKIM signature on outbound mail. +# DKIMpy provides a service that puts a DKIM signature on outbound mail. # # The DNS configuration for DKIM is done in the management daemon. source setup/functions.sh # load our functions source /etc/mailinabox.conf # load global vars -# Install DKIM... -echo "Installing OpenDKIM/OpenDMARC..." -apt_install opendkim opendkim-tools opendmarc +# Remove openDKIM if present +apt-get purge -qq -y opendkim opendkim-tools + +# Install DKIMpy-Milter +echo Installing DKIMpy/OpenDMARC... +apt_install dkimpy-milter python3-dkim opendmarc # Make sure configuration directories exist. -mkdir -p /etc/opendkim; -mkdir -p "$STORAGE_ROOT/mail/dkim" +mkdir -p /etc/dkim; +mkdir -p $STORAGE_ROOT/mail/dkim # Used in InternalHosts and ExternalIgnoreList configuration directives. # Not quite sure why. -echo "127.0.0.1" > /etc/opendkim/TrustedHosts +echo "127.0.0.1" > /etc/dkim/TrustedHosts # We need to at least create these files, since we reference them later. -# Otherwise, opendkim startup will fail -touch /etc/opendkim/KeyTable -touch /etc/opendkim/SigningTable - -if grep -q "ExternalIgnoreList" /etc/opendkim.conf; then - true # already done #NODOC -else - # Add various configuration options to the end of `opendkim.conf`. - cat >> /etc/opendkim.conf << EOF; -Canonicalization relaxed/simple -MinimumKeyBits 1024 -ExternalIgnoreList refile:/etc/opendkim/TrustedHosts -InternalHosts refile:/etc/opendkim/TrustedHosts -KeyTable refile:/etc/opendkim/KeyTable -SigningTable refile:/etc/opendkim/SigningTable -Socket inet:8891@127.0.0.1 -RequireSafeKeys false -EOF -fi - -# Create a new DKIM key. This creates mail.private and mail.txt +touch /etc/dkim/KeyTable +touch /etc/dkim/SigningTable + +tools/editconf.py /etc/dkimpy-milter/dkimpy-milter.conf -s \ + "MacroList=daemon_name|ORIGINATING" \ + "MacroListVerify=daemon_name|VERIFYING" \ + "Canonicalization=relaxed/simple" \ + "MinimumKeyBits=1024" \ + "InternalHosts=refile:/etc/dkim/TrustedHosts" \ + "KeyTable=refile:/etc/dkim/KeyTable" \ + "KeyTableEd25519=refile:/etc/dkim/KeyTableEd25519" \ + "SigningTable=refile:/etc/dkim/SigningTable" \ + "Socket=inet:8892@127.0.0.1" + +# Create a new DKIM key. This creates mail.key and mail.dns # in $STORAGE_ROOT/mail/dkim. The former is the private key and # the latter is the suggested DNS TXT entry which we'll include # in our DNS setup. Note that the files are named after the # 'selector' of the key, which we can change later on to support # key rotation. -# -# A 1024-bit key is seen as a minimum standard by several providers -# such as Google. But they and others use a 2048 bit key, so we'll -# do the same. Keys beyond 2048 bits may exceed DNS record limits. -if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then - opendkim-genkey -b 2048 -r -s mail -D "$STORAGE_ROOT/mail/dkim" +if [ ! -f "$STORAGE_ROOT/mail/dkim/mail.key" ]; then + # Check if there is an existing rsa key + if [ -f "$STORAGE_ROOT/mail/dkim/mail.private" ]; then + # Re-use existing key + cp -f $STORAGE_ROOT/mail/dkim/mail.private $STORAGE_ROOT/mail/dkim/mail.key + cp -f $STORAGE_ROOT/mail/dkim/mail.txt $STORAGE_ROOT/mail/dkim/mail.dns + else + # All defaults are supposed to be ok, default key for rsa is 2048 bit + dknewkey --ktype rsa $STORAGE_ROOT/mail/dkim/mail + + # Force dns entry into the format dns_update.py expects + # We use selector mail for the rsa key, to be compatible with earlier installations of Mail-in-a-Box + sed -i 's/v=DKIM1;/mail._domainkey IN TXT ( "v=DKIM1; s=email;/' $STORAGE_ROOT/mail/dkim/mail.dns + echo '" )' >> $STORAGE_ROOT/mail/dkim/mail.dns + fi + + # Change format from pkcs#8 to pkcs#1, dkimpy seemingly is not able to handle the #8 format + # See bug https://bugs.launchpad.net/dkimpy/+bug/1978835 + line=$(head -n 1 mail.key) + if [ ! "$line" = "-----BEGIN RSA PRIVATE KEY-----" ]; then + # Generate pkcs#1 key from the pkcs#8 key + openssl pkey -in $STORAGE_ROOT/mail/dkim/mail.key -traditional -out $STORAGE_ROOT/mail/dkim/mail.key.1 + mv -f $STORAGE_ROOT/mail/dkim/mail.key $STORAGE_ROOT/mail/dkim/mail.key.8 + cp -f $STORAGE_ROOT/mail/dkim/mail.key.1 $STORAGE_ROOT/mail/dkim/mail.key + fi fi -# Ensure files are owned by the opendkim user and are private otherwise. -chown -R opendkim:opendkim "$STORAGE_ROOT/mail/dkim" -chmod go-rwx "$STORAGE_ROOT/mail/dkim" +if [ ! -f "$STORAGE_ROOT/mail/dkim/box-ed25519.key" ]; then + # Generate ed25519 key + dknewkey --ktype ed25519 $STORAGE_ROOT/mail/dkim/box-ed25519 + + # For the ed25519 dns entry, we use selector box-ed25519 + sed -i 's/v=DKIM1;/box-ed25519._domainkey IN TXT ( "v=DKIM1; s=email;/' $STORAGE_ROOT/mail/dkim/box-ed25519.dns + echo '" )' >> $STORAGE_ROOT/mail/dkim/box-ed25519.dns +fi + +# Ensure files are owned by the dkimpy-milter user and are private otherwise. +chown -R dkimpy-milter:dkimpy-milter $STORAGE_ROOT/mail/dkim +chmod go-rwx $STORAGE_ROOT/mail/dkim tools/editconf.py /etc/opendmarc.conf -s \ "Syslog=true" \ @@ -88,29 +113,20 @@ tools/editconf.py /etc/opendmarc.conf -s \ tools/editconf.py /etc/opendmarc.conf -s \ "FailureReportsOnNone=false" -# AlwaysAddARHeader Adds an "Authentication-Results:" header field even to -# unsigned messages from domains with no "signs all" policy. The reported DKIM -# result will be "none" in such cases. Normally unsigned mail from non-strict -# domains does not cause the results header field to be added. This added header -# is used by spamassassin to evaluate the mail for spamminess. - -tools/editconf.py /etc/opendkim.conf -s \ - "AlwaysAddARHeader=true" - -# Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM +# Add DKIMpy and OpenDMARC as milters to postfix, which is how DKIMpy # intercepts outgoing mail to perform the signing (by adding a mail header) # and how they both intercept incoming mail to add Authentication-Results # headers. The order possibly/probably matters: OpenDMARC relies on the -# OpenDKIM Authentication-Results header already being present. +# DKIM Authentication-Results header already being present. # # Be careful. If we add other milters later, this needs to be concatenated # on the smtpd_milters line. # # The OpenDMARC milter is skipped in the SMTP submission listener by -# configuring smtpd_milters there to only list the OpenDKIM milter +# configuring smtpd_milters there to only list the DKIMpy milter # (see mail-postfix.sh). tools/editconf.py /etc/postfix/main.cf \ - "smtpd_milters=inet:127.0.0.1:8891 inet:127.0.0.1:8893"\ + "smtpd_milters=inet:127.0.0.1:8892 inet:127.0.0.1:8893"\ non_smtpd_milters=\$smtpd_milters \ milter_default_action=accept @@ -118,7 +134,7 @@ tools/editconf.py /etc/postfix/main.cf \ hide_output systemctl enable opendmarc # Restart services. -restart_service opendkim +restart_service dkimpy-milter restart_service opendmarc restart_service postfix diff --git a/setup/mail-postfix.sh b/setup/mail-postfix.sh index 7b642a2aa..18f8e9045 100755 --- a/setup/mail-postfix.sh +++ b/setup/mail-postfix.sh @@ -103,12 +103,14 @@ tools/editconf.py /etc/postfix/master.cf -s -w \ -o smtpd_tls_wrappermode=yes -o smtpd_sasl_auth_enable=yes -o syslog_name=postfix/submission - -o smtpd_milters=inet:127.0.0.1:8891 + -o smtpd_milters=inet:127.0.0.1:8892 + -o milter_macro_daemon_name=ORIGINATING -o cleanup_service_name=authclean" \ "submission=inet n - - - - smtpd -o smtpd_sasl_auth_enable=yes -o syslog_name=postfix/submission - -o smtpd_milters=inet:127.0.0.1:8891 + -o smtpd_milters=inet:127.0.0.1:8892 + -o milter_macro_daemon_name=ORIGINATING -o smtpd_tls_security_level=encrypt -o cleanup_service_name=authclean" \ "authclean=unix n - - - 0 cleanup