DEV Community

bitpage
bitpage

Posted on Originally published at bitpage.me

Gmail bounced everything the relay sent: three broken auth mechanisms and a silent OpenDKIM

Short answer: Three independent mechanisms were broken at once, and fixing any one of them changed nothing visible. The domain had no v=spf1 record (only a dead SenderID one), the PTR resolved forward to a different server, and OpenDKIM was signing nothing because KeyTable and SigningTable were attached as refile: while their contents were in plain two-column format. That last one cost the most time: when OpenDKIM can't find a key it logs absolutely nothing, so the daemon reads active, the config parses, the key is readable, and mail still leaves unsigned.

The server is an outgoing relay for transactional mail from a couple of dozen application hosts: password resets, notifications, alerts. Inbound mail for the domain goes elsewhere, to Google Workspace as MX. Postfix on a legacy container with more than two years of uptime, OpenDKIM 2.10.3, no Ansible, edits by hand only. The daily log summary read a four-digit bounced against a single-digit sent: thousands of rejections, a handful of deliveries. Password reset mail was among the casualties, so people could not get back into their accounts. Nobody had changed anything on this box in years, which turned out to be the whole story.

TL;DR

  • Root cause (DKIM): KeyTable/SigningTable mounted as refile: while the entries were plain example.org mail._domainkey.example.org lines. Under refile: the key is a regex matched against the whole From: address, so example.org never matches noreply@example.org. Changing one word per line to file: fixed it.
  • Also broken: InternalHosts listed fewer than ten addresses against a Postfix mynetworks two to three times longer. Mail from the app hosts was being verified instead of signed.
  • Also broken: the only SPF-shaped TXT record was spf2.0/mfrom,pra SenderID. RFC 7208 discards anything that doesn't begin with v=spf1, so the domain effectively had no SPF record.
  • Also broken: the PTR pointed at a hostname whose A record led to a different machine. Forward-confirmed reverse DNS failed.
  • Trap: grep -c DKIM-Signature /var/log/mail.log returns 0 whether signing works or not. Postfix does not write message headers to the log.
  • Trap: testing with sendmail from the mail server itself passes, because 127.0.0.1 was in InternalHosts. Real traffic from other hosts kept going out bare.

Why a server nobody touched stopped being deliverable

The requirements moved while the configuration stood still. Gmail began requiring authentication from bulk senders in February 2024 and has been ramping enforcement since, and a config that predated those rules simply stopped qualifying (Email sender guidelines). There was no change window to roll back to, no deploy to blame. The rejection came back verbatim as:

Enter fullscreen mode Exit fullscreen mode

Both named mechanisms failing at once should have been the tell that this was not one bug. I read it as one bug anyway and spent the next hour paying for it.

Three of the four facts in my own notes were wrong

I started from a ticket written a day earlier that already contained a diagnosis, and rechecking it by hand killed three of its four claims. The notes said: PTR is fine and forward and reverse match; DNS is hosted on Cloudflare; DMARC is absent.

The PTR pointed at legacy.example.org, whose A record led to 198.51.100.216, a different server entirely. dig NS returned ns-*.awsdns-*, so DNS was on Route 53, not Cloudflare. DMARC was present and had been all along, v=DMARC1; p=none. One claim out of four survived. These were my own notes, from the previous day.

The other rejection message had been telling me about the PTR the whole time:

Enter fullscreen mode Exit fullscreen mode

The operative half is the second clause. Not "you have no PTR record" but "the forward entry doesn't match". I had read to the first familiar phrase, seen a PTR existed, and moved on. RFC 1912 §2.1 states the rule plainly: "Make sure your PTR and A records match. For every IP address, there should be a matching PTR record in the in-addr.arpa domain" (RFC 1912). The same section warns that getting this wrong "can cause loss of Internet services similar to not being registered in the DNS at all", which is a fair description of a bounce column four digits wide.

The fix had two possible shapes. Editing the A record of legacy.example.org to point here would have satisfied the check, but that hostname is live and fronts an unrelated service, so the blast radius covered somebody else's system. Repointing the PTR to mail.example.org, whose A record already resolved to the sending IP, changed nothing outside this server. I also set Postfix myhostname to mail.example.org so the HELO name matches the PTR.

Why the SPF record that existed didn't count

There was a TXT record on the domain, and it was worth exactly nothing, because it was a SenderID record rather than an SPF one. It began spf2.0/mfrom,pra. RFC 7208 §4.5 is unambiguous about what a verifier does with that: "Starting with the set of records that were returned by the lookup, discard records that do not begin with a version section of exactly 'v=spf1'" (RFC 7208).

Discarded means the domain had no SPF record at all as far as Gmail was concerned, which is precisely what SPF [example.org] ... = did not pass was reporting. A record that looks like SPF to a human and is invisible to every verifier is worse than an empty zone, because it stops anyone from looking further. Published in its place:

v=spf1 ip4:203.0.113.51 include:_spf.google.com include:spf.example.net ~all
Enter fullscreen mode Exit fullscreen mode

Why grepping the mail log for DKIM-Signature proves nothing

Postfix does not write message headers to its log, so the count is zero whether signing works or not. My first check was grep -c "DKIM-Signature" /var/log/mail.log, which returned 0, and I took that as evidence that nothing was being signed. It wasn't evidence of anything. The method cannot distinguish a broken signer from a working one, and I used it as the basis for the next hour of work.

The check that does work is to send a message to a local mailbox and read the headers of the delivered file, /var/mail/nobody. That shows the actual DKIM-Signature header or its absence, with no inference in between. Building that first would have made every subsequent change a one-minute yes or no. I built it fourth.

OpenDKIM reports active, parses clean, and signs nothing

OpenDKIM writes no log line when it fails to find a signing key for a message, so systemctl is-active returning active alongside a clean startup is zero evidence that signing happens. I found and fixed two genuine defects on the strength of that assumption, and neither produced a signature.

The first was the config itself. /etc/opendkim.conf had been truncated to four lines, with no Mode, no KeyTable, no SigningTable. The fingerprints of a package upgrade were right there: /etc/opendkim/ had been touched recently while the files inside it were years older. I restored the missing directives and restarted. Clean start, no errors, no signature.

The second was key ownership. The private key belonged to _apt:uuidd with mode 0600, and the daemon runs as opendkim. Checking as the service user rather than as root is the only way to see this:

su -s /bin/sh opendkim -c "head -c 20 /etc/opendkim/default/mail.private"
head: cannot open '/etc/opendkim/default/mail.private' for reading: Permission denied
Enter fullscreen mode Exit fullscreen mode

A real defect, and chown opendkim:opendkim is mandatory. The key became readable. Still no signature. Two genuine bugs repaired, both necessary, neither sufficient, and my supply of hypotheses was gone.

The actual root cause was one word, and it was in the man page

The tables were attached as refile: while their contents were in plain two-column format, and under refile: the lookup key is treated as a regex matched against the entire From: address. I stopped guessing and opened man opendkim.conf on the server itself, same 2.10.3 as the running binary, which is more authoritative than whatever version the web is serving. The answer sits in one paragraph about dataset types:

"If this table specifies a regular expression file ("refile"), then the keys are wildcard patterns that are matched against the address found in the From: header field. For all other database types, the full user@host is checked first, then simply host, then user@.domain (with all superdomains checked in sequence…)"

The tables held lines like this:

example.org  mail._domainkey.example.org
Enter fullscreen mode Exit fullscreen mode

Under refile:, the pattern example.org is matched against noreply@example.org and does not match, so no key is found and the message leaves unsigned without a single log line. Under file:, the lookup falls through to the second step, "then simply host", where example.org matches exactly. The fix was one word in two lines:

-KeyTable                refile:/etc/opendkim/KeyTable
-SigningTable            refile:/etc/opendkim/SigningTable
+KeyTable                file:/etc/opendkim/KeyTable
+SigningTable            file:/etc/opendkim/SigningTable
Enter fullscreen mode Exit fullscreen mode

The other direction works too: rewrite every table entry to *@example.org and keep refile:. I rejected it because the tables covered several domains, and rewriting every line of data is a wider edit than changing the dataset type to the one that already matches the format on disk.

The second half of the same bug: InternalHosts was a fraction of mynetworks

OpenDKIM only signs mail arriving from hosts in InternalHosts; everything else it verifies instead, so any app server missing from that list was sending bare mail by design. The man page states the whole behaviour in one line, describing the option as identifying "a set internal hosts whose mail should be signed rather than verified" (typo included, as printed in 2.10.3).

InternalHosts pointed at TrustedHosts, which listed fewer than ten addresses. Postfix mynetworks listed two to three times as many. Syncing the short list against the long one tripled it, finally covering the application hosts that had been relaying through a server which was, on paper, configured to sign their mail. The two files are not required to match exactly, since they answer different questions, but a host that can relay and cannot be signed is almost always an oversight rather than a decision.

This is also why my manual test had lied to me earlier. Mail sent with sendmail from the relay itself originates from 127.0.0.1, which was in the list, so a signature appeared and I briefly thought the job was done. The traffic that actually mattered came from the application hosts, none of them in the file. Test along the path production traffic takes, not the path that is convenient from an SSH session.

The four defects and the check that actually proves each one

Broken Symptom you see Check that settles it
No v=spf1 record, SenderID only SPF … = did not pass dig +short TXT example.org — the string must begin v=spf1
PTR forward mismatch 550-5.7.25 … forward DNS entry does not match dig -x <ip>, then resolve that name's A record back — it must return <ip>
refile: on plain-format tables Nothing. No log line at all. Read the DKIM-Signature header of a delivered message
InternalHosts narrower than mynetworks Signs locally, unsigned from apps Send from a real application host, not from the relay

The third row is the one worth internalising. Three of these four failures announce themselves somewhere. The dataset-type bug produces no error, no warning, and a healthy-looking service, and the only way to observe it is to inspect the output rather than the process.

Step by step

  1. Build the verification path before touching anything: send to a local mailbox and read the delivered headers. Every later change then gives an immediate yes or no.
  2. Recheck every fact you were handed, including notes you wrote yourself yesterday.
  3. Read the whole SMTP rejection. 5.7.25 and 5.7.26 name different failures, and in both the specificity lives in the second clause of the sentence.
  4. Resolve the sending IP with dig -x, then resolve the answer forward. Both directions, or it doesn't count.
  5. Confirm the TXT record begins with v=spf1 rather than merely mentioning SPF.
  6. For DKIM: confirm the dataset type matches the table format, confirm the service user can read the key with su -s /bin/sh opendkim -c "…", and confirm InternalHosts covers every host in mynetworks.
  7. Send the final test from a host that is not the mail server.

The end state was DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=mail in the delivered headers, spf=pass, dmarc=pass, and 250 2.0.0 OK from Gmail. Total time was about an hour and a half, most of it spent on the silent one.

What authentication didn't fix: six messages in ten still bounce

Passing SPF and DKIM gets mail accepted, not trusted, and this domain's reputation problem survived every fix above untouched. A full day of counters after the fix still showed more rejections than deliveries — roughly six messages in ten going nowhere — and among them close to a hundred a day addressed to @iclaud.com, a typo of @icloud.com that the application has been faithfully retrying for years. Bounce rate is not the metric Google publishes a threshold for; that one is spam rate, which bulk senders are asked to keep under 0.30% in Postmaster Tools. They are different numbers, but they feed the same reputation ledger, and 0.30% is a useful sense of the scale a receiver considers acceptable. Mail with flawless authentication still lands in the spam folder if the domain spends years hammering addresses that don't exist.

The reason nobody knew is that the feedback loop had been cut at both ends. The server delivered non-delivery reports to the sender address, whose domain it considered local via mydestination, and no local user by that name existed, so the reports were discarded on arrival. That was hundreds of destroyed reports a day. The application mails a dead address, the report about it is deleted by the same server, nobody finds out, and the application keeps mailing.

What I deliberately did not do was tighten DMARC past p=none. Moving to quarantine before the statistics are clean would route legitimate mail to spam by my own hand, and there is no rush. Still outstanding: the envelope sender equals the From: address, where a dedicated bounce address with VERP belongs; certbot 0.12.0 is dead on this box, still calling the retired acme-v01 endpoint, with a certificate that expired years ago. And every change here was made by hand on a container that lives outside Ansible, so none of it exists in any repository. The next person to open this server will find no trace of why it works.

Bottom line

When a daemon reports active and does not do its job while logging nothing, stop repairing hypotheses and go read the man page for the semantics of the specific option you are relying on. Silence is not the absence of a fault. It is its own class of behaviour, and it is always described somewhere in the documentation, usually in a sentence about how lookups are performed.

The process mistake was ordering. I fixed three genuine defects (truncated config, key ownership, then the host list) before finding the one that actually mattered, and each time I expected the next test to pass. A reliable check built at minute one, reading the headers of a delivered message, would have turned each of those into a one-minute negative result instead of an hour of accumulating false hope. Build the instrument before you start turning things.


Originally published at bitpage.me — BitPage, a technical blog on backend, databases, infrastructure and incident post-mortems.

Top comments (0)