DNS Doctor: a plain-English health score for a domain
A zero-dependency Node.js CLI, web app and library that checks a domain's DNS, SSL, SPF, DKIM and DMARC and tells you what to fix first.
DNS Doctor takes a domain name and checks five things: its DNS, its HTTPS certificate, and the three records that protect its email (SPF, DKIM and DMARC). It gives each one a score, combines them into one grade, and lists what to change, most important first.
Most tools for this show you the raw records and leave you to work out what they mean. I wanted one that says what is wrong in plain sentences and gives the fix for each problem. This is the top of a real run against expired.badssl.com, a test site whose certificate expired in 2015:
$ dnsdoctor expired.badssl.com
DNS Doctor · expired.badssl.com
38/100 grade F █████████░░░░░░░░░░░░░░░
expired.badssl.com has serious problems (4 critical issues) that put visitors or your email at
risk. Most urgent: Visitors will see a security warning because the certificate has expired.
DNS 100 Your DNS is set up correctly and resilient.
SSL 40 Visitors will see security warnings. Your certificate needs fixing.
SPF 0 No SPF record. Anyone can claim to send email from your domain.
DKIM 50 We couldn't find any DKIM keys on the usual names.
DMARC 0 No DMARC policy, so forged email from your domain may still be delivered.
SSL · Is your website connection secure?
✔ HTTPS connection
expired.badssl.com accepts secure HTTPS connections.
✖ Trusted certificate
Visitors will see a security warning because the certificate has expired.
✔ Certificate matches domain
The certificate covers expired.badssl.com.
✖ Expiry date
The certificate expired 4209 days ago (2015-04-12).
...
What to fix, most important first
1. [SSL] Install a certificate from a trusted authority. Let’s Encrypt is free and renews
automatically.
2. [SSL] Renew the certificate now, and set up automatic renewal so it doesn’t happen again.
3. [DMARC] Add a TXT record at _dmarc.expired.badssl.com with "v=DMARC1; p=none;
rua=mailto:dmarc-reports@expired.badssl.com" to start monitoring, then move to
p=quarantine and p=reject once reports look clean.
...
It runs as a CLI, as a small web app and as a Node.js library. There are no npm dependencies. Everything uses Node’s own dns, tls, http and crypto modules.
Try it#
It needs Node.js 18.17 or newer.
git clone https://github.com/0xgitpushpray/dnsdoctor.git
cd dnsdoctor
node bin/dnsdoctor.js example.com # check a domain in the terminal
npm start # or open the web app at http://localhost:3000
npm link inside the folder gives you a global dnsdoctor command. The input does not have to be a bare domain. https://www.Example.com/page?x=1 and jane@example.com both check example.com, because whatever you paste is cleaned up first: the scheme, user part, path, port, trailing dot and leading www. are removed, and the name is converted to ASCII so internationalized domains work. An IP address is refused with a message instead of a confusing report:
$ dnsdoctor 8.8.8.8
That looks like an IP address. DNS Doctor checks domain names, like example.com.
What it checks#
Each category answers one question:
| Category | Weight | Question | Checks |
|---|---|---|---|
| DNS | 20% | Can people find your domain? | domain exists, at least two nameservers, website address, IPv6, mail servers that resolve, null MX, CAA, DNSSEC |
| SSL | 25% | Is your website connection secure? | HTTPS reachable, trusted certificate, hostname match, days to expiry, TLS version, http→https redirect, HSTS |
| SPF | 15% | Who is allowed to send your email? | one record, valid syntax, the all policy, the 10-lookup limit, broken includes, deprecated ptr |
| DKIM | 15% | Is your email signed? | keys under 47 common selectors, key validity, RSA key size, Ed25519, revoked keys, test mode |
| DMARC | 25% | What happens to forged email? | one record, policy, pct, subdomain policy, reports, inheritance from the parent domain |
How the score works#
Every check returns pass, warn, fail or info, and has a weight. A pass earns the full weight, a warning half of it and a failure nothing. info is a note that does not count, such as “your DNS isn’t signed with DNSSEC, but most domains aren’t”.
const STATUS_VALUE = { pass: 1, warn: 0.5, fail: 0 };
export function scoreChecks(checks) {
let earned = 0;
let possible = 0;
for (const c of checks) {
if (!(c.status in STATUS_VALUE) || !(c.weight > 0)) continue;
earned += STATUS_VALUE[c.status] * c.weight;
possible += c.weight;
}
if (!possible) return null;
const score = Math.round((100 * earned) / possible);
return checks.some((c) => c.critical && c.status === 'fail') ? Math.min(score, CRITICAL_CAP) : score;
}
The last line matters most. Some failures mean the category is not doing its job at all: an untrusted certificate, two SPF records, +all, an invalid DMARC policy. These are marked critical, and a critical failure caps its category at 40, however much else passes.
On expired.badssl.com, five of the seven SSL checks pass or warn. The weighted sum is 8.5 out of 15, which is 57. That is a passing-looking number for a site that every browser blocks with a full-page warning. The cap brings it down to the 40 you see above.
The overall score is the weighted average of the categories, using the weights in the table. For expired.badssl.com that is (100×20 + 40×25 + 0×15 + 50×15 + 0×25) / 100 = 37.5, which rounds to 38 and grade F. The grades are A at 90 and above, then B, C and D at 80, 70 and 60, and F below that.
Not counting what does not apply#
A score that punishes you for things you do not need is wrong, so categories that do not apply are left out of the average instead of scoring zero. SSL is skipped when neither the domain nor www. points to a web server. DKIM is skipped for a domain that has said it never sends email: no MX records and an SPF record of exactly v=spf1 -all.
example.com is like that. It publishes a null MX and v=spf1 -all, so DKIM drops out and the other four categories are averaged over 85 instead of 100:
$ dnsdoctor example.com
DNS Doctor · example.com
96/100 grade A ███████████████████████░
example.com is in excellent health. 3 small tweaks would make it perfect.
DNS 100 Your DNS is set up correctly and resilient.
SSL 93 Your certificate is valid, with 2 improvements worth making.
SPF 100 Your SPF record is valid and clearly lists who may send your email.
DKIM n/a Not needed: this domain doesn't send email.
DMARC 94 Forged email from your domain will be rejected. Excellent.
The three tweaks are a missing http→https redirect, no HSTS header and no DMARC reporting address.
Ranking the fixes#
The fix list is sorted by how much each one costs you:
impact = check.weight × category.weight × (fail ? 1 : 0.5) × (critical ? 2 : 1)
For expired.badssl.com that puts the untrusted certificate first (4 × 25 × 2 = 200), then the expiry and the missing DMARC record (100 each), the missing SPF record (60), DKIM (22.5) and HSTS (12.5). The sort is stable, so when two fixes tie, the order of the categories decides. That is why the expired certificate stays next to the untrusted certificate instead of after DMARC.
Fixes that would only improve a check that already passes, like moving DMARC from quarantine to reject or adding a CAA record, go into a separate “Nice to have” list so they do not get mixed in with real problems.
DKIM and the selector problem#
DKIM keys are published at <selector>._domainkey.<domain>, and the selector is whatever your email provider chose. DNS has no way to list the names under a domain, so there is no way to find a key without knowing its selector. DNS Doctor tries 47 names that common providers use (google, selector1 and selector2 for Microsoft 365, k1 for Mailchimp, s1 for SendGrid, Proton’s protonmail, Fastmail’s fm1 and so on) in parallel. That is why a missing key is a warning and not a failure: the tool can’t prove the key is not there.
Gmail is a good example of the limit:
$ dnsdoctor gmail.com
...
! DKIM keys
We only found retired (revoked) DKIM keys. Your current key is probably published under a
selector name we don’t know.
i Key "20230601"
The "20230601" key has been revoked (empty p=). That's normal for retired keys.
i Key "20221208"
The "20221208" key has been revoked (empty p=). That's normal for retired keys.
...
I thought this was a bug at first, so I looked up the record directly. 20230601._domainkey.gmail.com really is v=DKIM1; k=rsa; p=, an empty key, which is how you revoke one. Gmail has moved to a selector that is not on the list. If you know yours, pass it in. It is in the s= tag of the DKIM-Signature header of any email you have sent:
dnsdoctor mycompany.com --selector s1,mycorp2024
Key sizes come from parsing the public key with node:crypto, not from guessing based on the length of the base64. Keys under 1024 bits fail, keys under 2048 bits get a warning, and Ed25519 keys pass.
The SPF lookup limit#
SPF has a rule that is easy to break without noticing: checking a record may not take more than 10 DNS lookups (RFC 7208). Each include:, a, mx, ptr, exists and redirect= costs one, and the lookups inside each included record count too. Add one more email service and you can go over the limit. When that happens, receivers stop checking and treat SPF as failed, so your legitimate email starts landing in spam.
countSpfLookups() follows include: and redirect= recursively and adds them up. It keeps a set of names it has already visited so a loop of includes cannot run forever, and it skips targets that contain SPF macros (%{...}), because those can only be expanded with a real sender address. Eight or nine lookups gets a warning (“adding one or two more email services will break it”), and more than ten is a critical failure. An include that points to a domain with no SPF record is reported too.
Checking the certificate#
The SSL check opens a real TLS connection to port 443. It sets rejectUnauthorized: false so that a bad certificate is something to report, not an error that ends the check, and checks the hostname separately with tls.checkServerIdentity(). It also allows TLS 1.0 and lowers OpenSSL’s security level:
const socket = tls.connect({
host: ip,
port: 443,
servername,
rejectUnauthorized: false,
minVersion: 'TLSv1', // let old servers connect so we can tell the user they're old
ciphers: 'DEFAULT:@SECLEVEL=0',
timeout,
});
Node’s defaults would refuse to talk to a server that only offers TLS 1.0, and the result would be a vague connection error. Allowing it means the report can say “your server uses TLSv1, which is outdated and blocked by modern browsers” instead.
The redirect check makes plain HTTP requests and follows up to five redirects to see whether a visitor ends up on https://. A separate HTTPS request reads the Strict-Transport-Security header. A max-age of less than six months gets a warning. DNSSEC is the one check that does not use your resolver: it asks Cloudflare’s validating DNS-over-HTTPS resolver and reads the AD (authenticated data) flag, because Node’s dns module does not expose it.
Running the web app in public#
The web app is a single page that calls GET /api/check?domain=..., which returns the same JSON as dnsdoctor --json. Reports can be shared as links: /?d=example.com runs the check when the page opens.
A public page that connects to whatever address a domain points to can be used to probe someone’s internal network. You register a name, point it at 10.0.0.5 or 127.0.0.1, and let the server scan its own network for you. So the API always passes allowPrivate: false, and the SSL check refuses private, loopback, link-local, multicast and other reserved addresses, including at every redirect hop. localtest.me is a public name that points to 127.0.0.1, which makes it a handy test. Asking the API about it (/api/check?domain=localtest.me) gives an SSL category that is marked “not applicable” and never connects:
summary: Skipped: the website is on a private network address.
message: localtest.me points to a private or reserved address (127.0.0.1), which can't be
checked from the public internet.
Two details took longer than the rest. First, the probes connect to the IP that was checked, not the hostname. If they connected by name, the DNS answer could change between the check and the connection (DNS rebinding) and the server would end up on a private address anyway. The TLS connection sends the real name as SNI and the HTTP request sends it as the Host header, so the remote server still sees the right site.
Second, IPv4-mapped IPv6 addresses like ::ffff:10.0.0.1 are judged by hand by their IPv4 part. The obvious approach, a ::ffff:0:0/96 rule in Node’s net.BlockList, also matches every plain IPv4 address, which would block everything.
The rest is usual for a public endpoint: 20 checks per IP per minute (set by RATE_LIMIT), TRUST_PROXY=1 to read X-Forwarded-For behind a reverse proxy, results cached for 60 seconds, a strict Content-Security-Policy, and DNS data always shown as text, never as HTML, so a TXT record cannot inject markup. The server binds to 127.0.0.1 by default. You have to choose HOST=0.0.0.0 to expose it.
In CI and in code#
--fail-under turns the score into an exit code, so a pipeline can catch an expiring certificate or a broken SPF record before users do:
npx github:0xgitpushpray/dnsdoctor yourdomain.com --fail-under 80
It exits with 0 on success, 1 when the score is below the threshold and 2 on invalid input. As a library, diagnose() returns the full report:
import { diagnose } from 'dnsdoctor';
const report = await diagnose('example.com', { selectors: ['s1'] });
console.log(report.score, report.grade, report.summary);
for (const fix of report.fixes) console.log(`[${fix.categoryName}] ${fix.fix}`);
Tests#
40 tests with Node’s built-in test runner (node --test), and none of them use the network. diagnose() accepts a resolver and a pair of network probes as options, so the tests pass in an in-memory DNS zone and fake TLS and HTTP responses from test/helpers.js. A zone value that is an Error gets thrown, which is how SERVFAIL and timeouts are tested. The tests cover the scoring and the critical cap, a domain that does not exist, a parked domain that does not need DKIM, expired and missing certificates, the www. fallback, the private-address guard, the SPF lookup counter following includes and redirects, DMARC inheritance from the parent domain, and the server’s headers, rate limit and input checks.
npm test
GitHub Actions runs them on Node 18, 20 and 22, then runs the CLI once against example.com as a live smoke test.