Skip to content

feat: report a pinned broker whose certificate names somewhere else (3.3.0) - #174

Merged
cayossarian merged 1 commit into
mainfrom
feat/leaf-name-mismatch-signal
Aug 29, 2026
Merged

feat: report a pinned broker whose certificate names somewhere else (3.3.0)#174
cayossarian merged 1 commit into
mainfrom
feat/leaf-name-mismatch-signal

Conversation

@cayossarian

Copy link
Copy Markdown
Member

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.

Relaxed handshake Library action
Rejected under the pin (expired, or anything else) Unchanged: transient, retry
Nothing answers Unchanged: transient, retry
Succeeds, names the host Cannot follow a failed strict handshake; treated as transient
Succeeds, does not name the host New: LeafNameMismatch through a callback

Not terminal. A returning DHCP lease or a panel that finishes register_fqdn fixes 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

@dataclass(frozen=True, slots=True)
class LeafNameMismatch:
    host: str                    # the address configured and dialled
    leaf_names: tuple[str, ...]  # SAN DNS names and IP addresses, certificate order

# on SpanMqttClient, and declared on SpanPanelClientProtocol
def register_leaf_mismatch_callback(
    self, callback: Callable[[LeafNameMismatch], None]
) -> Callable[[], None]: ...

LeafNameMismatch is exported from the package root. The register method is declared on SpanPanelClientProtocol beside register_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 / LeafProbe in _ssl.py, not exported (it blocks; the bridge runs it in an executor). _diagnose_ca_change becomes _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_name takes the anchor as an argument and returns a verdict. There is no path by which a certificate it observed travels back out.
  • An untrusted chain is rejected by the relaxed context, so the mismatch signal cannot become a way for something the pin does not trust to publish its own names to a user as addresses to move to.
  • The transport's own context builder is never reached from this path; a rebuild after a mismatch is still handed the pinned PEM.
  • The CA question is asked first and short-circuits: a rotated anchor stays terminal, with no "you have probably moved" notice softening it.

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 a TestProbeLeafName class in test_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.py so 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, no type: ignore, no cast.

Release

Version 3.2.03.3.0 (new public API), uv.lock refreshed, 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.

…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.
@cayossarian
cayossarian merged commit 5f25dd1 into main Aug 29, 2026
6 checks passed
@cayossarian
cayossarian deleted the feat/leaf-name-mismatch-signal branch August 29, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant