Checking a sending IP's reverse DNS and HELO in stdlib Python, before Spamhaus's November requirement

2026-11-03来源 dev.to DevOpsEnglish

Spamhaus's Sender Guidelines now open with a dated note: "Starting in November 2026, we are gradually rolling out a requirement for every sending IP to have a valid HELO, a reverse DNS (PTR) record, and a matching A record." The page lists that as a minimum requirement, and says a missing or mismatched reverse DNS "is one of the most common reasons for an IP to be considered for listing".

None of this is new as a practice. What is new is a dated rollout from Spamhaus. If you send mail from a VPS, a home lab, or a small app on your own IP, it is worth checking your sending address today. The check fits in the Python standard library over DNS-over-HTTPS, so I wrote one and ran it on 3 November 2026. The script is below in eight blocks; pasted in order into one file, it runs.

What the three checks are

Spamhaus spells out each one.

  • Reverse DNS. "Every sending IP must have a reverse DNS entry pointing to a fully qualified domain name (FQDN)."
  • Forward confirmation. "That hostname must, in turn, resolve back to the same sending IP address. This is known as forward-confirmed reverse DNS (FCrDNS)."
  • HELO. "The hostname the server announces in the HELO/EHLO command must resolve to the IP it is connecting from, and must stay consistent across connections from that IP."

The FCrDNS part has a precise statement in an RFC. RFC 8601 section 3 calls it "iprev" and gives it as an algorithm: if the client's address is I, the PTR names for I are the set N, and the union of the A and AAAA answers for every member of N is L, "then this test is successful if I is an element of L". It also says the number of names you look up "MUST" be limited, and points at SPF's limit of 10 as an example. The same section is frank that "there is some contention regarding the wisdom and reliability of this test". It also points to a DNSOP document that recommends applications avoid using the test "as a means of authentication or security".

The HELO part has an older rule pulling the other way. RFC 5321 section 4.1.4 says a receiving server "MAY verify" that the EHLO name matches the client's address, but "MUST NOT refuse to accept a message on that basis". That rule binds a server inside an SMTP session. A blocklist deciding which addresses to list is a different decision, made elsewhere, and Spamhaus's page is about that decision.

Asking DNS without dig

There is no dig on the machine I used, so every lookup goes to Google Public DNS's JSON API. One helper is enough. Record types are passed as numbers, because the API returns numeric types in its answers and comparing against strings silently matches nothing.

# fcrdns_check.py - forward-confirmed reverse DNS and HELO checks, stdlib only. # Usage: python3 fcrdns_check.py IP [HELO] or python3 fcrdns_check.py --eml FILE import ipaddress, json, re, sys, urllib.parse, urllib.request from email import message_from_binary_file, policy # --- block 1: one DNS question over DNS-over-HTTPS A, CNAME, PTR, AAAA = 1, 5, 12, 28 def doh(name, rrtype): q = urllib.parse.urlencode({"name": name, "type": rrtype}) with urllib.request.urlopen(f"https://dns.google/resolve?{q}", timeout=10) as r: reply = json.load(r) return reply["Status"], reply.get("Answer", []) 

The PTR side

The reverse name for an address is the address written backwards under in-addr.arpa for IPv4, or nibble by nibble under ip6.arpa for IPv6 (RFC 3596 section 2.5). Python's ipaddress module already builds it, so there is no string reversing to get wrong:

# --- block 2: the PTR side def ptr_names(ip): rname = ipaddress.ip_address(ip).reverse_pointer status, answers = doh(rname, PTR) names = [a["data"].rstrip(".").lower() for a in answers if a["type"] == PTR] return rname, status, names 

For 2a01:111:f403:c206::1 that property gives a 32-nibble name ending in ip6.arpa, which is tedious to build by hand.

The forward side, compared as addresses

For each PTR name, look up A and AAAA and keep the results as ipaddress objects rather than strings. The reason is IPv6: the same address has many spellings.

>>> "2001:db8::1" == "2001:0db8:0:0::1" False >>> ipaddress.ip_address("2001:db8::1") == ipaddress.ip_address("2001:0db8:0:0::1") True 

A string comparison would fail a correctly configured IPv6 sender. The helper also notes when the answer came through a CNAME. RFC 1912 section 2.1 says "PTR records must point back to a valid A record, not a alias defined by a CNAME". A resolver follows the CNAME for you, so the match can still succeed, but it is worth printing.

# --- block 3: the forward side, as address objects def forward(name): addrs, via_cname = set(), False for rrtype in (A, AAAA): _, answers = doh(name, rrtype) for a in answers: if a["type"] == CNAME: via_cname = True elif a["type"] == rrtype: addrs.add(ipaddress.ip_address(a["data"])) return addrs, via_cname 

iprev, as the RFC states it

With both halves in place, the test is a few lines, and the limit of 10 names comes straight from RFC 8601's pointer to SPF:

# --- block 4: iprev as RFC 8601 section 3 states it def fcrdns(ip, limit=10): client = ipaddress.ip_address(ip) rname, status, names = ptr_names(ip) result = {"ip": ip, "ptr_query": rname, "ptr_status": status, "ptr": names, "checked": [], "pass": False} for name in names[:limit]: addrs, via_cname = forward(name) result["checked"].append((name, sorted(map(str, addrs)), via_cname)) if client in addrs: result["pass"] = True return result 

A PTR answer with several names passes if any one of them leads back to the address. Every name past the tenth is ignored.

The HELO name

The HELO check is the same forward lookup, run on the name the client announced. RFC 5321's grammar allows EHLO with an address literal such as [192.0.2.1] when the client has no meaningful name, but then there is no name to resolve, so the function says so instead of guessing.

# --- block 5: the HELO name def helo_check(helo, ip): helo = helo.rstrip(".").lower() if helo.startswith("["): return {"helo": helo, "fqdn": False, "resolves_to_ip": False, "note": "address literal, no name to check"} addrs, _ = forward(helo) return {"helo": helo, "fqdn": "." in helo, "resolves_to_ip": ipaddress.ip_address(ip) in addrs, "forward": sorted(map(str, addrs))} 

Spamhaus also asks senders to avoid "generic or auto-generated rDNS entries for dedicated IPs", and gives examples like static-203-0-113-25.hosting.example.net. There is no exact test for "generic", but a name that spells out the address forwards or backwards is the usual shape, so the script flags it as a note rather than a failure:

# --- block 5b: does the PTR name just spell out the address? def looks_generic(name, ip): ip = ipaddress.ip_address(ip) if ip.version == 4: o = str(ip).split(".") patterns = ["[.-]".join(o), "[.-]".join(reversed(o))] else: patterns = [ip.exploded.replace(":", "")[-8:]] flat = name.replace("-", ".") return any(re.search(p, flat) for p in patterns) 

I tried it on Spamhaus's three example names with 203.0.113.25 as the address. It flags static-203-0-113-25.hosting.example.net. It misses 203.113.0.25.broadband.example.net, whose octets are in neither order, and vps-25.dedicated.hosting.example.com, which carries only the last octet. It does not flag a branded name such as o3.email2.mlh.io. Treat it as a hint that catches the common shape, nothing more.

Getting the IP and HELO from a message you received

To check a sender, you need the address and the HELO name it actually used. Both are in the Received field that the receiving server added. In RFC 5321 section 4.4 the from clause carries the name the client gave in EHLO, followed by a parenthesised part that the grammar labels "Information derived by server from TCP connection not client EHLO". So in

Received: from o3.email2.mlh.io (o3.email2.mlh.io. [168.245.48.148]) 

the first o3.email2.mlh.io is what the client said, and the part in parentheses is what the receiving server saw: the reverse name it looked up and the connecting address.

# --- block 6: HELO and IP from the Received field your own MX added RECEIVED = re.compile(r"from\s+(\S+)\s+\((\S*?)\.?\s*\[(?:IPv6:)?([0-9a-fA-F.:]+)\]\)") def first_hop(path): with open(path, "rb") as f: msg = message_from_binary_file(f, policy=policy.default) for field in msg.get_all("Received", []): m = RECEIVED.search(" ".join(str(field).split())) if m: helo, rdns, ip = m.groups() return helo, rdns, ip raise SystemExit("no Received field with a from (... [ip]) clause") 

The loop skips fields without a from (... [address]) clause, such as a mailbox provider's internal hops. The pattern matches the common from helo (rdns [ip]) layout. Other servers format the field differently, so adjust it for yours, and only trust the field your own receiving server added. Everything below it was written by the sender.

Running it

The last block prints a report:

# --- block 7: run it def report(ip, helo=None): r = fcrdns(ip) print(f"{ip}") print(f" PTR {r['ptr_query']} -> status {r['ptr_status']} {r['ptr'] or '(none)'}") for name, addrs, via_cname in r["checked"]: hop = " (via CNAME)" if via_cname else "" print(f" {name} -> {addrs or '(no A/AAAA)'}{hop}") print(f" FCrDNS: {'pass' if r['pass'] else 'FAIL'}") for name in r["ptr"]: if looks_generic(name, ip): print(f" note: {name} spells out the address (generic rDNS?)") if helo: h = helo_check(helo, ip) print(f" HELO {h['helo']}: fqdn={h['fqdn']} resolves_to_ip={h['resolves_to_ip']}" f" same_as_ptr={h['helo'] in r['ptr']}") if __name__ == "__main__": if sys.argv[1] == "--eml": helo, rdns, ip = first_hop(sys.argv[2]) print(f"Received: HELO={helo} rDNS-at-receiver={rdns} IP={ip}") report(ip, helo) else: report(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None) 

I ran it at 08:41 UTC on 3 November 2026 on three inputs. The first is the headers of a DEV newsletter that arrived on 1 November, with my address replaced by me@example.com. The second is 192.0.2.1, from a block that RFC 5737 sets aside for documentation, so it should have no PTR. The third is the same sending address with localhost as a pretend HELO, the placeholder value Spamhaus names as a "Don't".

2026-11-03 08:41 UTC $ python3 fcrdns_check.py --eml fcrdns_sample.eml Received: HELO=o3.email2.mlh.io rDNS-at-receiver=o3.email2.mlh.io IP=168.245.48.148 168.245.48.148 PTR 148.48.245.168.in-addr.arpa -> status 0 ['o3.email2.mlh.io'] o3.email2.mlh.io -> ['168.245.48.148'] FCrDNS: pass HELO o3.email2.mlh.io: fqdn=True resolves_to_ip=True same_as_ptr=True $ python3 fcrdns_check.py 192.0.2.1 192.0.2.1 PTR 1.2.0.192.in-addr.arpa -> status 3 (none) FCrDNS: FAIL $ python3 fcrdns_check.py 168.245.48.148 localhost 168.245.48.148 PTR 148.48.245.168.in-addr.arpa -> status 0 ['o3.email2.mlh.io'] o3.email2.mlh.io -> ['168.245.48.148'] FCrDNS: pass HELO localhost: fqdn=False resolves_to_ip=False same_as_ptr=False 

The newsletter's sending address passes all three checks: one PTR name, that name resolves back to the same address, and the HELO name matches both. The rDNS name sits under a different domain from the From: address, which FCrDNS does not care about. Status 3 is NXDOMAIN, so the documentation address fails at the first step, as expected. localhost fails the HELO check on both counts.

What you can fix, and what you can't

PTR records live in the reverse zone of whoever holds the address block, not in your domain's zone. On a VPS that usually means the hosting provider's control panel or a support ticket. On a dedicated IP at an email service provider it is the provider's setting, sometimes with your own branded name. On shared, hosted mail it is entirely the provider's job, and you cannot fix it from your side even when the check fails.

I saw that case on the morning I ran the script: four IPv4 addresses inside one large hosted mail provider's published SPF range returned NXDOMAIN for PTR, while the address next to one of them resolved and passed. DNS alone cannot tell you whether those addresses send mail at all. If yours is one of them, the useful step is a support ticket to the provider, not a change to your own records.

If you run the mail server yourself, the HELO name is the one setting entirely in your hands. In Postfix it is smtp_helo_name, which defaults to $myhostname. Set it to the name your PTR record points at, and make sure that name has an A (and, if you send over IPv6, AAAA) record for the sending address.

What this check does not do

  • It asks one recursive resolver. A stale cache or a split-horizon setup can give a different answer from what a receiver sees. Asking a second resolver, or the authoritative servers, is a cheap extra step.
  • It checks a single connection. Spamhaus also wants the HELO to "stay consistent across connections", and that needs your server's logs over time, not one lookup.
  • It does not check the IPv6 allocation size. Spamhaus asks for at least a /64 for IPv6 senders, and DNS cannot tell you that.
  • Spamhaus has its own checker on the same page, and says you can also send a message to helocheck@abuseat.org to see the HELO your server uses. Those report what Spamhaus sees, which is the view that matters for a listing.

If the three lines for your sending address say pass, fqdn=True and resolves_to_ip=True, the address meets the November requirement as far as public DNS can show.

Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.

原文:https://www.a8q9.cn/en/article/checking-a-sending-ip-s-reverse-dns-and-helo-in-stdlib-python-before-spamhaus-s--fb9294.html

相关文章