v2.36.0 (the reasons, checked)
A minor release whose theme is a method rather than a feature. CertMate has
spent the last few versions writing down why each piece of defensive code is
shaped the way it is — every broad exception handler now states its reason, and
the renewal and certificate-info paths were decomposed into units with names.
This release is largely what happened when those written reasons were read back
against the code: several of them were not true, and two of the untrue ones
were defects.
Alongside that: renewal timing the CA can move (RFC 9773), probes that work
from behind a proxy, a new notification channel contributed from outside, and
the dashboard's header band folded into the topbar.
The API contract moves to 2.19, from 2.14. Read it from
X-CertMate-API-Version on any response, or api_contract_version in
/api/health. Five MINOR steps, each written down beside the constant in
modules/core/constants.py; nothing was removed or retyped.
| step | what grew |
|---|---|
| 2.15 | POST /api/web/update-check, and enabled on its GET response
|
| 2.16 | GET /api/client-certs/ca; csr/country/state on client-cert create; alias_dns_provider on certificate create; client_ca_subject on settings; chain_available on probe/discovery
|
| 2.17 | POST /api/inventory/scan answers 409 when a scan is already running
|
| 2.18 | {{cert}} and {{fullchain}} webhook template placeholders
|
| 2.19 | a fourth zombie-scan status, unverifiable, and its count
|
Read this before upgrading
A renewal can now start earlier than your threshold
CertMate renewed at days_left <= renewal_threshold_days — the same opinion
for every certificate and every CA. RFC 9773 lets the CA publish its own window
per certificate, and the TLS renewal sweep now asks for it.
It can only bring a renewal forward. The configured threshold remains the
backstop: ARI is consulted after the threshold declines, never before, so a CA
that is slow, wrong or unreachable cannot delay anything. Let's Encrypt serves
a window in production; so does step-ca.
If you have tuned renewal_threshold_days upward to renew early, nothing
changes. If you tuned it downward to renew late, a CA asking for an earlier
replacement will now be honoured.
Probes behind an HTTP proxy answer instead of failing
Only the deployment probe honoured HTTPS_PROXY. Four other things that dial a
host opened raw sockets, which do not read those variables at all: the deep TLS
probe (the inventory sweep and POST /api/probe), the OCSP/CRL fetch behind
every revocation answer, the HSTS/header check, and the TLS 1.0/1.1 check.
On a host whose only route out is a proxy, an inventory scan reported every
endpoint unreachable. Those four now tunnel with HTTP CONNECT like the
deployment probe. If you run behind a proxy, expect scans that used to be
uniformly empty to start returning findings — including expiry warnings for
certificates that were never being examined.
Certificate pinning is not available through a tunnel: the proxy resolves the
name. The transport order is direct for a non-global address, proxy where one
is configured, pinned otherwise, and each answer records which leg it took.
A wildcard with no probe host is no longer reported as a zombie
ZombieScanner turned *.example.com into example.com and probed that. A
wildcard does not cover its own apex (RFC 6125), so the apex is frequently a
name that resolves to nothing while every host the certificate protects is up —
and the scanner said zombie, which is its word for "delete this".
It reads deployment_host now, the field the deployment-status probe already
uses. A wildcard with a probe host is checked there; one without gets the new
unverifiable status rather than a verdict. A scan that crashed was also
reported as zombie — the scanner's own failure stated as a fact about your
estate — and is unverifiable too.
If you automate against this endpoint, treat any status you do not recognise as
"not a finding". Deleting on zombie alone was already risky; it is now
narrower and more truthful.
/api/activity filters by API-key scope
The activity feed takes the viewer role and returned audit entries with no
scope filter, while every neighbouring route that returns domain-bearing data
has one. A scoped key could read domains outside its scope. It cannot now, so
a scoped key will see fewer entries than before — which is the point.
Azure Key Vault both mode no longer serves a replaced certificate
both mode keeps two copies and picks the fresher by comparing the Certificate
object's timestamp with the Secrets copy's. A failed read of that timestamp
was indistinguishable from "there is no Certificate object", and that branch
returns the Secrets copy without comparing anything — so after a renewal had
written the Certificate object, a throttled read or a 403 could hand back the
certificate the renewal had replaced.
The failure is now distinguished and retried once; if it still cannot be read,
the Secrets copy is served with a warning rather than in silence.
modules/core/factory.py is now modules/factory.py
The composition root imported the layers above it — the one upward edge in the
system. It has moved out of core/, which now imports neither api nor web.
A stock deployment needs no change: the container runs gunicorn ... app:app,
and app.py imports from the new location. If you have a custom WSGI entry
point, a wrapper, or any script that imports modules.core.factory directly,
update the import — that module no longer exists.
The login no longer tells you whether a username exists
Both early returns in authenticate_user answered in microseconds while a real
username paid for bcrypt: measured on this project's bench at 180.47 ms against
0.29 ms, a ratio of 628 with no overlap. One request was enough to learn
whether a username exists, a second whether it is disabled.
New
Google Chat notifications
Contributed by @QuentinBtd (#919). A Google
Chat incoming webhook joins the channel types: one Cards v2 message per event
carrying the status, the summary and the escaped certificate details, and —
when a CertMate URL is configured — buttons that open the dashboard and that
certificate's detail panel.
The destination is validated when you save, not when an event fires, so a
mistyped URL is a message on the form rather than a delivery that never
happens. The URL carries key and token, so it is treated as the credential
it is: masked on read, preserved across an unrelated save, and reduced to
https://chat.googleapis.com in the delivery log.
Webhooks can deliver the certificate itself
{{cert}} and {{fullchain}} join the payload-template placeholders (#218).
Both are read from disk at delivery time, and only when the template being
rendered names them — so a webhook that does not use them causes no read at
all. {{privkey}} is deliberately absent.
The dashboard header band is in the topbar
The view toggle and the certificate counters were a row at the top of the page
content; the dashboard now starts at the certificate list. The wordmark leaves
the bar (the logo carries the brand, the credit moved to the footer), and the
heading remains for screen readers.
On an unconfigured instance the setup screen no longer shows certificate
controls it has no code to run.
An update check you can turn on
2.13 shipped the update check with no way to enable it: the save method
existed and nothing called it. POST /api/web/update-check is that way. It
remains off by default, and an instance that never enables it contacts nothing.
Fixed
A backup route said "Backup created" with no file. create_unified_backup
returns None on failure — an unwritable directory, a full disk — and the route
answered 200 with filename: null regardless.
A cleared Timeout box was a 500. The shipped UI binds x-model.number,
which emits null for an empty numeric input, and the validator called int()
on it unguarded.
A viewer could read deploy-hook commands. Secrets are masked by field
name; command is not one of those names, so a hook came back intact from
two endpoints that take the viewer role.
A restore dropped secrets nested in a list. The share-safe backup masks on
the way out and the restore merges against what is on disk; that worked for a
secret nested in dicts and not for one in a list.
The login redirect guard parsed instead of guessing. safeNextUrl()
decided same-origin from the first two characters of next, which cannot see
what the URL parser strips before those characters matter.
Two CI gates had stopped measuring what they claim. storage-live could
not pull its image at all — minio/minio now refuses anonymous pulls on Docker
Hub and on quay.io — and was reporting green for a suite that never ran.
warn_if_bearer_token_hash_is_stale read the token file itself and
returned in silence when it could not, a second spelling of a function a
previous pass had already fixed. An operator who rotated the token in that file
got 401 on every request with nothing in the log.
Inside
Not operator-visible, but they are why the list above exists.
Every broad exception handler now records the failure or says why not.
UNACCOUNTED_LIMIT is 0 — a rule, like the bare and silent counts, not a
ratchet. Total broad handlers 429 → 421. Making 421 handlers state their
reason is what made this release's two defects findable: of the 421, 28 make a
claim that can be false, and reading them back is how the Azure Key Vault
staleness above was found.
renew_certificate: 393 lines at complexity 36 → 174 at 16, in four named
units. get_certificate_info: 163 lines at 28 → 28 lines and under 10, in
four. Both are pure extractions, with a control test each for the cheat of
moving complexity into a helper rather than dissolving it — a control that
caught exactly that during the second one.
A defect that had a test guarding it. The wildcard-zombie assertion checked
the mechanism (assert mock_dns.called_with('wildcard.com')) rather than the
meaning, and the mechanism was the defect.