feat: report a pinned broker whose certificate names somewhere else (3.3.0) - #174
Merged
Conversation
…else
A pinned handshake that fails carries no evidence about why. When the panel still
advertises the pinned CA, the transport now asks one further question -- a second
handshake to the broker with hostname checking relaxed, chain, signature and
expiry still verified against the pin -- and can therefore say which of the two
remaining failures it is. An expired leaf and an unreachable broker keep their
existing shape and their existing silence; a certificate that chains to the pin
and does not name the address being dialled becomes a typed report.
Deliberately not terminal. A DHCP lease that comes back, or a panel that finishes
registering its name, fixes this without anyone's help, so the reconnect loop is
unchanged and the report exists to put the other remedy in front of a user who
would otherwise see nothing but an outage. It fires at most once per outage and
is re-armed by the next successful connect, so a mismatch lasting a week is one
notification rather than one per backoff tick.
Neither fetch can re-anchor anything. `probe_leaf_name` takes the pin as an
argument and returns a verdict, so there is no path by which a certificate it
observed can become a trust anchor, and the transport's own context builder is
never reached from this path. Anything the probe raises is caught before it can
escape into the reconnect loop's exception handler, where it would kill the task
silently and leave a bridge that looks merely disconnected forever.
The per-retry warning stays and its text changes from saying it could be either
to naming which. `_diagnose_ca_change` becomes `_diagnose_verification_failure`,
because it now diagnoses more than the CA.
Public API:
LeafNameMismatch(host: str, leaf_names: tuple[str, ...])
SpanMqttClient.register_leaf_mismatch_callback(cb) -> Callable[[], None]
The register method is declared on `SpanPanelClientProtocol` beside the fatal
one, for the reason that one is: the consumer depends on it and codes against
protocols. Additive for callers, breaking for implementers.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Library half of the leaf-name-mismatch repair design (
SpanPanel_Docs/span/2026-08-28-leaf-name-mismatch-repair-design.md), both decision points resolved to Option A: the library classifies, and the condition is reported rather than made terminal. The integration half is a separate change and depends on this release.The gap
When a pinned MQTT connection fails TLS verification and the panel still advertises the pinned CA, three things could be true and the library said so: "an expired certificate or a changed hostname would both look like this". One of those is transient and the panel fixes it. The other is an address that will never come back on its own, and the consumer sat in it for five minutes with every entity unavailable, one warning per minute, and nothing in the UI.
What changed
When the advertised CA matches the pin, the transport asks one further question: a second handshake to the broker with
check_hostname=False— chain, signature and expiry still verified against the pin — and reads the names off the certificate that comes back.LeafNameMismatchthrough a callbackNot terminal. A returning DHCP lease or a panel that finishes
register_fqdnfixes this with nobody's help, so the reconnect loop is untouched; the report exists so a consumer can put the other remedy in front of a user. Fires at most once per outage, re-armed by the next successful connect, so a mismatch lasting a week is one notification rather than one per backoff tick.Public API added
LeafNameMismatchis exported from the package root. The register method is declared onSpanPanelClientProtocolbesideregister_fatal_error_callback, for the reason that one is: the consumer depends on it and codes against protocols. Additive for callers, breaking for implementers.Internal:
probe_leaf_name/LeafProbein_ssl.py, not exported (it blocks; the bridge runs it in an executor)._diagnose_ca_changebecomes_diagnose_verification_failure.Nothing can re-anchor
The discipline the CA re-read is built on, extended to the second handshake, and asserted at both levels:
probe_leaf_nametakes the anchor as an argument and returns a verdict. There is no path by which a certificate it observed travels back out.Robustness
Anything the probe raises is caught before it can escape. The diagnostic is awaited from inside
_reconnect_loop's own exception handler, where an escaping exception kills the task with no traceback and leaves a bridge that looks merely disconnected forever — the one outcome the fatal-error channel exists to prevent. Covered by a test.Tests
New
tests/test_leaf_name_mismatch.py(18) and aTestProbeLeafNameclass intest_ssl_context.py(9). Every case runs a real handshake against a real TLS server — a mock would happily agree with a wrong implementation, and the value of this function is that it reaches the point of holding a validated certificate.The cert-minting and TLS-server helpers move to
tests/tls_fixtures.pyso both suites use the same chains rather than growing a second, subtly different set. The server there accepts repeatedly, because a reconnect loop probing on every backoff tick needs it to.Verification
1200 passed, 7 skipped. Full pre-commit green: ruff format/check, black, mypy--strict, pylint 10.00/10, bandit, vulture, prettier, markdownlint, uv lock check, coverage 96.09% (gate 85%).No
Any, notype: ignore, nocast.Release
Version
3.2.0→3.3.0(new public API),uv.lockrefreshed, CHANGELOG and the README's pinning section updated — that section previously said both failures "produce the identical error" and the library "keeps retrying", which this makes only half true. Not published to PyPI.