v2.34.0 (CertMate watches the name, not only the certificate)
A minor release, and the largest surface change since 2.24: CertMate now
answers questions about a domain name — whether its registration is about
to lapse, whether its mail records are intact, whether its addresses are on a
blocklist, what its hosts serve in their headers, and whether they still speak
TLS versions deprecated five years ago. Alongside that, a served certificate's
revocation is finally verified rather than assumed, and several answers that
were reassuring without being earned now say what they actually know.
The API contract moves to 2.11, from 2.3. Read it from
X-CertMate-API-Version on any response, or from api_contract_version in
/api/health. Eight MINOR steps — 2.4 through 2.11 — each written down beside
the constant in modules/core/constants.py; nothing was removed or retyped.
A running theme, worth naming because it decided the shape of most of this
release: a check that cannot answer must say so. Several of the things
added here are easy to build so that they can never find anything, and an
answer of "clean" from a check that was never able to run is worse than no
check at all — it closes the question. Every new check here has unknown as a
first-class result, and unknown is never rendered as a pass.
Revocation is checked, not assumed
modules/core/revocation.py is new. The TLS probe can now ask whether the
certificate a host is serving has been revoked: OCSP first (RFC 6960), CRL
where OCSP is gone — which is most of the web now, since Let's Encrypt turned
OCSP off in 2025.
Only a verified answer counts. The OCSP response's signature is checked
against the issuer, or against a delegated responder carrying the
id-kp-OCSPSigning EKU; the CertID must match the certificate's serial and
the issuer's key hash; thisUpdate/nextUpdate must place the answer in the
present. A responder that does not reply, replies late, or replies about a
different certificate produces unavailable, not good.
Five statuses: good, revoked, unknown, unavailable, not_applicable.
revoked is final — revocation cannot be undone, so a later unavailable
never overwrites it in the inventory.
The inventory carries the last answer on every record, and its summary counts
them. A record that has never been checked reports null, which is not good.
A host can be asked what it is serving
POST /api/probe reads the certificate a host presents right now, with the
verified revocation answer. Until this, the inventory could only probe what
its own configuration named and deployment-status only a managed domain, so
anything that wanted CertMate to answer "what is at this address" had to
reimplement the probe — and at least one tool did, with a revocation status
that was assumed rather than checked. A scoped key probes only what its scope
covers.
CAA is read before the order, and explains the refusal after it
POST /api/certificates/check-caa says what a domain's CAA records permit,
so the create form can warn before an order instead of after the CA refuses
it. RFC 8659 tree climb, issuewild for wildcards, validationmethods (RFC
8657), and a critical unknown tag treated as forbidding rather than ignored.
It advises and never gates: the create endpoint does not consult it. A CAA
record CertMate cannot read is not a reason to refuse an issuance the CA might
well allow. When certbot does fail, the same reading is attached to the error,
so the answer arrives with the question.
When each domain's registration expires
GET /api/inventory/domains. One row per registrable domain — fifty hosts
under example.co.uk cost one lookup — from RDAP at the server IANA's
bootstrap names for the TLD, falling back to WHOIS on port 43 for the TLDs
that have no RDAP at all: .it, .eu, .de, .es, .ch, .at, .io,
.co.
not_published is an answer, not a gap: DENIC and EURid do not publish expiry
dates, and CertMate does not invent one. A WHOIS reply in a format it does not
recognise is unavailable, not a guess. A registry that timed out today has
not changed the date it published yesterday, so a known expiry survives a
failed lookup.
Daily at 06:00, at most 100 lookups a second apart, each domain asked again
only when its answer is due. Opt-in.
Domain health: the checks that are about the name
GET /api/inventory/health, and a panel on /inventory. Seven checks, daily,
over every name CertMate already tracks — absorbed from DomainMate, which is
now archived.
SPF, DMARC, MX on the registrable domain. Not on each host: DMARC falls
back to the organisational domain, so asking _dmarc.www.example.com alone
would report "no DMARC" for a domain that publishes one. Two v=spf1 records
are a finding, because every receiver treats that as permerror. DMARC's policy
is reported, not graded: p=none is where a careful rollout starts, and
calling it a failure would be an opinion about someone's deployment.
Blocklists, and this is the one that shaped the release. A DNSBL that will
not serve your query answers 127.255.255.x rather than an error — Spamhaus's
own documentation says these "must not be taken to imply that the object of
the query is listed" — and that is what every public resolver gets. Reading it
as "not listed" is how a tool reports a domain clean having learned nothing,
and it is exactly what the code this absorbed used to do.
Worse, the same refusal forwarded through a resolver often arrives as plain
NXDOMAIN, which at the DNS level is indistinguishable from "not listed".
Measured on one developer machine: the system resolver answered NXDOMAIN for
2.0.0.127.zen.spamhaus.org, an address Spamhaus keeps permanently listed,
while 8.8.8.8, 1.1.1.1, 9.9.9.9 and OpenDNS all answered the refusal
code for the same name.
So each list is asked about its own test point before it is trusted:
127.0.0.2 must come back listed, 127.0.0.1 must not. A list that cannot
answer that is not asked about your domains and is named in unanswered. If
none can, the check is unknown and says to point CertMate at a resolver of
its own.
Those test points are IPv4, so they prove a list answers about IPv4 and
nothing more. Most DNSBLs do not list IPv6, or use a separate zone for it, so
an empty answer about an AAAA address would be indistinguishable from "this
list does not cover IPv6" — the same false-clean one layer further down.
IPv6 addresses are therefore not asked about at all. They appear in
not_covered, deliberately separate from unanswered: a list that refused is
a hole in coverage and downgrades the result, an address family the lists do
not serve is a boundary and does not. Most domains are dual-stack, and a
warning that is always on is one nobody reads. A domain resolving only to IPv6
is unknown, because then nothing was asked.
HSTS, the protective headers, and disclosure — three answers from one
HEAD per host. Missing X-Frame-Options/nosniff/CSP is a warning; a
broken one is a finding, because it is worse: X-Frame-Options: ALLOW-FROM
is ignored by every modern browser and a report-only CSP with no enforcing
policy blocks nothing. Both answer "are we covered?" with a yes. CSP
frame-ancestors counts instead of X-Frame-Options, not as well as it.
Server is reported only when it carries a version: nginx/1.24.0 tells an
attacker which CVEs to try, cloudflare does not.
Redirects within the same registrable domain are followed — at most three,
https only, SSRF guard re-run on each hop — because most estates answer their
apex with a 301 to www and the protective headers live on the page. HSTS is
still read from the first response, including the 301, because that is what a
browser records.
Deprecated TLS versions, off by default. Both existing probes set
minimum_version = TLSv1_2, so they report what was negotiated and never what
the server would also have agreed to. This one offers TLS 1.0 and 1.1 and sees
whether the host says yes — the only check that opens a connection a host did
not invite, which is why it is opt-in.
It asks its own runtime first, in memory and against no server, whether it can
produce a ClientHello for those versions at all: a modern OpenSSL build often
refuses, every handshake then fails locally, and a probe reading that as "the
server said no" would report a clean estate having asked nothing. An
acceptance is recorded only when the handshake completed at the version
offered.
Expiry warnings are finally said
certificate_expiring was in the always-notify list and had no title, so it
could not be rendered and nothing was ever sent. It is said now, at 14/7/3/1/0
days, and domain_expiring at 60/30/14/7/1/0 for the registration itself.
Each threshold is announced once per expiry date, so a renewed certificate
starts again from the first mark and a repeated run says nothing.
certificate_deploy_incomplete had the same defect and is fixed with it.
Setup mode only bootstraps
An instance in setup mode serves every request as admin, because there is no
credential yet. It could also mint API keys, and those keys outlived setup —
created by whoever could reach the instance during it.
POST /api/keys, and any user after the first, now answer 409 SETUP_BOOTSTRAP_ONLY while in setup mode. Keys that already exist from a
previous setup are flagged rather than revoked: PATCH /api/keys/<id> is how
an operator confirms one, and a critical line is logged at startup while any
remain unreviewed. Revoking by default would lock out an operator whose only
credential is such a key.
"false" stops meaning true
auto_renew and its siblings were read with a plain truthiness test, so the
JSON string "false" — which several HTTP clients send for a boolean —
enabled the thing it was meant to disable. A malformed boolean is now a 400
that names the field, in the API, the Python SDK and the MCP server. Fixed as
a bug rather than a contract change: nothing could safely depend on "false"
meaning true.
Smaller, and the gates
dnspythonis pinned by us. It was arriving as a transitive dependency
of whichever certbot DNS plugin still wanted it, so the version CertMate ran
was decided by a plugin's requirements. It is a direct dependency now, and
the lockfiles were regenerated — a bump that is not regenerated never
reaches the image.- A doc placeholder covered a literal sibling route. The gate that checks
every public endpoint is documented had a placeholder matching more than it
stood for, and four endpoints were hiding behind it. Fixed, and the same
mistake inscripts/check_wiki_endpoints.pywith it. - The API reference is in one place.
README.mdanddocs/api.mdboth
described the API and disagreed in places; the reference now lives in
docs/api.mdalone. One endpoint,
GET /api/certificates/<domain>/deployment-status, was dropped rather than
moved in that consolidation and is documented again — it was invisible until
the placeholder fix above narrowed what the coverage gate accepts.
A Terraform example that described resources CertMate has never had went
with it. - The wiki is documentation too, and something reads it now.
boto31.43.93 → 1.43.98, with the locks regenerated, and floor bumps
forurllib3andSQLAlchemythat the locks already satisfied.SECURITY.md's reachability argument names functions, not modules. It
said nothing in the tree referencedpkcs7; the revocation check above
does, to read an AIAcaIssuersbundle. The conclusion is unchanged — the
advisory is about decryptingEnvelopedData, which nothing calls — but the
stated reason had quietly stopped being true, which is the worse failure.- The duplicate DNS accounts path is deprecated, not removed (#674): it
carriesDeprecation/Sunsetheaders instead of disappearing. - Per-module coverage floors:
modules/api/resources_inventory.pyraised
80 → 85 where its coverage climbed, and seven new core modules pinned at
90–100 from what each measures.
Upgrading
Nothing to do. The inventory database migrates itself from schema 1 to 5 on
first start; the migration is additive and an older CertMate would still read
the file. Every new check is opt-in and off by default, under domain_health
and domain_registration in the discovery configuration.
check_hsts in the domain_health config is now check_headers, because one
switch covers three checks over one request. The old key is still read on the
way in, so an instance configured before this does not silently start making a
request it had turned off.