v2.37.0 (what a walk through it turned up)
A minor release that adds almost nothing and corrects a good deal. Six of its
seven changes came from two places: an end-to-end walk through a running
instance, looking at the screen rather than at the tests, and three issues
opened by @tuxpowered, who was using CertMate
and wrote down what did not add up. The seventh is the contract number, which
should have moved with one of the six and did not.
All three of his turned out to be about the same thing from different angles —
the product knowing something and not saying it. A renewal that succeeded and
reported a network error. A notification channel that was working and silently
excluded the one event worth hearing. A saved webhook whose destination could
not be seen at all.
The API contract moves to 2.20, from 2.19. Read it from
X-CertMate-API-Version on any response, or api_contract_version in
/api/health. One MINOR step, written down beside the constant in
modules/core/constants.py; nothing was removed or retyped.
| step | what grew |
|---|---|
| 2.20 | url_hint on each webhook in GET /api/notifications/config
|
Read this before upgrading
A failed renewal can no longer be filtered away
This changes what you receive. Notifications have an event filter — the
Notify on Events chips, and a per-webhook list under each one — and an
empty filter means everything. Ticking any chip turns it into a list, and a
list that did not name certificate_failed silenced the event a renewal that
did not happen publishes.
So an operator who ticked renewed to hear about successful renewals had, in
the same gesture, stopped being told about failures. The filter was doing
exactly what it was designed to do.
certificate_failed now bypasses the filter, alongside deploy_hook_failed
and certificate_deploy_incomplete, which were exempted for this reason in an
earlier release. Its checkbox is gone, because an event that ignores the
filter must not be offered as a choice, and both chip rows now say that
failures are always sent.
If you had a filter set, you will start receiving renewal-failure notifications
you were not receiving before. That is the fix, not a side effect. Nothing needs
changing on your side: a saved filter that still names certificate_failed
keeps working and means the same thing it did.
Renew and Reissue return immediately
The dashboard's Renew and Reissue buttons now ask for the asynchronous
path the API already had: the request comes back in milliseconds with a job id,
and the row updates as the job progresses, instead of holding the connection
open for the whole issuance.
Against a local CA a renewal takes about three seconds and the difference is
invisible. Against a real CA over DNS-01 it is minutes — long enough for a
reverse proxy or a worker timeout to close the connection first and hand the
browser an HTML error page, which is where NETWORK_ERROR and
Unexpected token '<' came from. The renewal itself was succeeding
throughout; only the answer was being lost.
The API is unchanged — ?async= and the async body flag already existed on
these routes. This release is the dashboard starting to use them.
GET /api/notifications/config carries a new field
Each webhook now has url_hint: the origin of its saved URL, beside the
url that is still masked in full. It is derived on read and ignored on write,
so a client that echoes the response back is unaffected and one that does not
know the field can ignore it.
GET /api/settings, which the viewer role may read, carries no url_hint and
still masks webhook URLs whole. Neither does the share-safe backup.
New
A saved webhook says where it points
A webhook's URL is the credential — for Slack, Discord, ntfy and Gotify the
incoming-webhook URL embeds the bearer secret — so it is masked when the
settings page reads it back. That left a name and ********, with no way to
tell which workspace or which receiver a webhook pointed at, or to spot a wrong
host or port, without re-typing the whole URL from memory.
The settings page now shows the origin beside the masked field:
ops-slack ******** Saved: https://hooks.slack.com/…
pager ******** Saved: https://gotify.example.com:8443/…?…
topic ******** Saved: https://ntfy.sh/…
Scheme, host and port, plus markers that a path and/or a query exist — with
none of their content, and any user:password@ dropped. Enough to recognise
the destination; not enough to use it.
No path content at all, deliberately: the secret sits somewhere different in
each receiver. The last path segment for Slack and Discord. The topic in the
first segment for ntfy, where knowing the topic is the authorisation. A
query parameter for Gotify and Google Chat. Showing "just the first path
segment" would have been safe for Slack and would have handed over the ntfy
topic.
Changing a URL still means entering all of it. The thing being edited is the
credential.
Fixed
"0 days left" for a certificate with hours to live, and for one already
expired. timedelta.days truncates toward minus infinity, so anything inside
the last 24 hours reads 0 and anything that expired inside the last 24 hours
reads -1 — never 0. The dashboard said "0 days" for both and left an
operator to guess which. Every warning now carries the seconds and renders one
sentence: less than a day left, 3 days left, 2 days ago.
Inventory counters were coloured before they were counted. Eleven of them
carried their verdict unconditionally — five red (Expired, Revoked, Weak,
Failing, registration Expired), four amber, one orange — so Expired 0 and
Weak 0, the readings you want, were the same colour as the ones you do not.
The eleventh, Modern, was permanently green, so Modern 0 read as good news.
A zero is now neutral and the colour is earned by a non-zero count, in both
directions.
Dates were written two ways on the same screen. Some panels used the
viewer's locale and some a fixed format, so 09/03/2026 meant March in one
place and September in another, on the same page, with no way to tell which.
All of them now go through one formatter.
NETWORK_ERROR on a renewal that succeeded. See Renew and Reissue return
immediately above.
A notification webhook that did not fire on failures. See A failed renewal
can no longer be filtered away above.
Two things docs/api.md said about POST /api/notifications/config. It
said the endpoint "replaces rather than merges" — it deep-merges, and has since
an earlier audit fix that the sentence outlived; a body of only
{"enabled": false} leaves your channels standing. And it said nothing about
the channels.webhooks list, which is replaced wholesale when a body
carries one. Both are now measured by tests that POST through the real route.
Known, and not fixed here
Renaming a notification webhook deletes its saved URL, and the save answers
200 (#950). Masked
secrets are matched back to their stored values by (type, name) — the two
fields an operator edits — so changing either in a save that left the URL
masked drops it. The webhook stays enabled and stops delivering, and because
the URL is not displayed there is nothing on screen to notice.
Until it is fixed: after renaming a webhook or changing its type, re-enter its
URL in the same save. The url_hint above at least makes the loss visible —
the line under the field disappears.
Inside
Not operator-visible, but they are why the list above exists.
A contract gate that sees two of six clauses. The rule beside
API_CONTRACT_VERSION names six things that move the number.
tests/test_the_contract_moves_with_the_surface.py compares a snapshot of 109
METHOD /path strings, so it catches an endpoint appearing or going away and
is blind to the four clauses about what a response contains — which is how
url_hint shipped with the number unmoved. The limit is now written under the
rule rather than left to be rediscovered.
A gate that banned a string could not tell right from wrong. A test
asserted 'six events' not in docs/webhooks.md, because six was the wrong
number the day it was written. Moving certificate_failed out of the
filterable set made six correct, and the gate failed the corrected page for
saying so. It derives the count from the code now.
Verified by executing, not by reading. The failure-notification fix was
proved on a private-CA bench by stopping the challenge server so a renewal
genuinely failed: before, two real failures delivered nothing; after, the
message arrives. The successful-renewal run that preceded it existed only so
that "nothing arrived" could mean something — and it earned its place, because
the receiver was broken the first time.