span-panel-api 3.2.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/CHANGELOG.md +15 -0
  2. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/PKG-INFO +20 -4
  3. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/README.md +19 -3
  4. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/pyproject.toml +1 -1
  5. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/__init__.py +7 -1
  6. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/_ssl.py +156 -15
  7. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/exceptions.py +11 -3
  8. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/client.py +47 -0
  9. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/connection.py +153 -34
  10. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/const.py +8 -0
  11. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/protocol.py +19 -0
  12. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_ca_pinning.py +35 -6
  13. span_panel_api-3.3.0/tests/test_leaf_name_mismatch.py +465 -0
  14. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_public_api_unchanged.py +7 -0
  15. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_ssl_context.py +151 -177
  16. span_panel_api-3.3.0/tests/tls_fixtures.py +246 -0
  17. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/.gitignore +0 -0
  18. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/LICENSE +0 -0
  19. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/_http.py +0 -0
  20. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/adapters.py +0 -0
  21. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/auth.py +0 -0
  22. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/const.py +0 -0
  23. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/detection.py +0 -0
  24. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/dispatch.py +0 -0
  25. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/factory.py +0 -0
  26. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/models.py +0 -0
  27. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  28. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  29. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/control.py +0 -0
  30. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/mqtt/models.py +0 -0
  31. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/phase_validation.py +0 -0
  32. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/py.typed +0 -0
  33. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/src/span_panel_api/schema_drift.py +0 -0
  34. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/conftest.py +0 -0
  35. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  36. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  37. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  38. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/flat_wire.json +0 -0
  39. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  40. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/panelbench_wire.json +0 -0
  41. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/v2/README.md +0 -0
  42. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/fixtures/v2/status.json +0 -0
  43. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/reference_payloads/README.md +0 -0
  44. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/reference_payloads/__init__.py +0 -0
  45. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/reference_payloads/bootstrap.py +0 -0
  46. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/reference_payloads/schema_one.py +0 -0
  47. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  48. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  49. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  50. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/simulation_fixtures/status.response.txt +0 -0
  51. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  52. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_accumulator.py +0 -0
  53. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_adapters_discovery.py +0 -0
  54. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_adopted_control.py +0 -0
  55. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_adoption.py +0 -0
  56. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_async_mqtt_client.py +0 -0
  57. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_auth_and_homie_helpers.py +0 -0
  58. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_auth_redaction.py +0 -0
  59. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_catalog_divergence.py +0 -0
  60. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_control_interceptor.py +0 -0
  61. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_detection_auth.py +0 -0
  62. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_exceptions.py +0 -0
  63. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_factory_dispatch.py +0 -0
  64. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_field_metadata.py +0 -0
  65. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_https_transport.py +0 -0
  66. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_live_flat_differential.py +0 -0
  67. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_bridge.py +0 -0
  68. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_client_connection.py +0 -0
  69. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_connect_flow.py +0 -0
  70. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_debounce.py +0 -0
  71. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_mqtt_homie.py +0 -0
  72. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_packaging.py +0 -0
  73. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_phase_validation_configs.py +0 -0
  74. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_phase_validation_errors.py +0 -0
  75. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_plaintext_warning.py +0 -0
  76. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_protocol_conformance.py +0 -0
  77. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_protocol_models.py +0 -0
  78. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_publish_outcome.py +0 -0
  79. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_redispatch_on_reconnect.py +0 -0
  80. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_reference_tree_values.py +0 -0
  81. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_rest_transport_contract.py +0 -0
  82. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_generation_cross_check.py +0 -0
  83. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_migration_delta.py +0 -0
  84. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_adapter.py +0 -0
  85. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_charge_limit.py +0 -0
  86. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_circuits.py +0 -0
  87. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_conformance.py +0 -0
  88. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_connection_health.py +0 -0
  89. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_control_refusal.py +0 -0
  90. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_devices.py +0 -0
  91. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_discovery.py +0 -0
  92. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_extension.py +0 -0
  93. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_panel.py +0 -0
  94. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_pcs.py +0 -0
  95. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_service_entrance.py +0 -0
  96. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_shed_forecast.py +0 -0
  97. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_snapshot.py +0 -0
  98. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_one_transport.py +0 -0
  99. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_provenance.py +0 -0
  100. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_schema_zero_adapter.py +0 -0
  101. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_shared_http_client.py +0 -0
  102. {span_panel_api-3.2.0 → span_panel_api-3.3.0}/tests/test_v2_status_parser.py +0 -0
@@ -7,6 +7,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
7
7
  Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
8
8
  beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
9
9
 
10
+ ## [3.3.0]
11
+
12
+ A pinned panel that has moved is no longer reported the same way as a panel whose clock reset, so a consumer can put the remedy in front of a user instead of retrying in silence.
13
+
14
+ ### Added
15
+
16
+ - **`LeafNameMismatch` reports a broker whose certificate the pinned CA validates and which names somewhere other than the configured address**, carrying that address and the names the certificate does carry.
17
+ - **`register_leaf_mismatch_callback` delivers that report**, at most once per outage and re-armed by the next successful connect, returning an unregister function like the other callback channels.
18
+
19
+ ### Changed
20
+
21
+ - **The warning logged when a pinned handshake fails against an unchanged CA now names which failure it is** — an expired or otherwise rejected certificate, an unreachable broker, or a certificate that names somewhere else — instead of saying it could be
22
+ either.
23
+ - **A moved panel is still retried and never terminal**, because the address can come back on its own and the report exists to make the alternative remedy visible rather than to stop the transport.
24
+
10
25
  ## [3.2.0]
11
26
 
12
27
  A consumer pinned to a panel's CA cannot currently tell a panel that has moved from something impersonating one, because the two produce the same verification failure. This release splits the question.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: A client library for SPAN Panel API
5
5
  Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
6
  Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
@@ -420,9 +420,25 @@ context = build_panel_ssl_context(stored_pem)
420
420
  fingerprint = ca_fingerprint(stored_pem)
421
421
  ```
422
422
 
423
- Leaving `ca_pem` unset keeps the previous behaviour, with one `WARNING` per bridge recording that the anchor was obtained unauthenticated. With it set, a certificate-verification failure is diagnosed rather than assumed: an expired leaf after a panel's
424
- clock reset and a hostname mismatch after the panel moved both produce the identical error, so the library refetches the advertised CA for comparison only and keeps retrying unless the fingerprint has actually changed — at which point it raises
425
- `SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
423
+ Leaving `ca_pem` unset keeps the previous behaviour, with one `WARNING` per bridge recording that the anchor was obtained unauthenticated. With it set, a certificate-verification failure is diagnosed rather than assumed, in two steps — a rotated CA, an
424
+ expired leaf and a panel that has moved all raise the identical error, and the failed handshake carries no evidence about which.
425
+
426
+ First the library refetches the advertised CA, for comparison only. If the fingerprint has changed it raises `SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers
427
+ nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
428
+
429
+ If the fingerprint matches, the panel is still the panel and the library asks one further question: a second handshake to the broker with hostname checking relaxed — the chain, the signature and the expiry still verified against the pin — to see whether
430
+ the certificate names the address being dialled.
431
+
432
+ ```python
433
+ def moved(mismatch: LeafNameMismatch) -> None:
434
+ print(f"configured as {mismatch.host}, certificate names {', '.join(mismatch.leaf_names)}")
435
+
436
+ unregister = client.register_leaf_mismatch_callback(moved)
437
+ ```
438
+
439
+ **This is not fatal and the transport keeps retrying**, because a returning DHCP lease fixes it without anyone's help; what the callback is for is putting the other remedy — re-point the configuration at one of the names reported — in front of a user who
440
+ would otherwise see only an outage. It fires at most once per outage and is re-armed by the next successful connect. An expired leaf reports nothing, because nothing anyone does helps and the panel recovers on its own once it has the time again. Neither
441
+ handshake can re-anchor anything: both are diagnostic, and the pin is the pin whatever the panel served.
426
442
 
427
443
  The bootstrap REST calls take an `ssl_context` for the same purpose. `download_ca_cert` is the one exception and stays on plain HTTP — it fetches the anchor everything else is checked against, so it has nothing to check itself against, and its result must
428
444
  be fingerprint-confirmed out of band before it is trusted.
@@ -393,9 +393,25 @@ context = build_panel_ssl_context(stored_pem)
393
393
  fingerprint = ca_fingerprint(stored_pem)
394
394
  ```
395
395
 
396
- Leaving `ca_pem` unset keeps the previous behaviour, with one `WARNING` per bridge recording that the anchor was obtained unauthenticated. With it set, a certificate-verification failure is diagnosed rather than assumed: an expired leaf after a panel's
397
- clock reset and a hostname mismatch after the panel moved both produce the identical error, so the library refetches the advertised CA for comparison only and keeps retrying unless the fingerprint has actually changed — at which point it raises
398
- `SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
396
+ Leaving `ca_pem` unset keeps the previous behaviour, with one `WARNING` per bridge recording that the anchor was obtained unauthenticated. With it set, a certificate-verification failure is diagnosed rather than assumed, in two steps — a rotated CA, an
397
+ expired leaf and a panel that has moved all raise the identical error, and the failed handshake carries no evidence about which.
398
+
399
+ First the library refetches the advertised CA, for comparison only. If the fingerprint has changed it raises `SpanPanelCAChangedError` carrying both fingerprints and stops. Register `register_fatal_error_callback` to be told; a consumer that registers
400
+ nothing still cannot mistake a dead bridge for a healthy one, because `ping()` and `get_snapshot()` re-raise.
401
+
402
+ If the fingerprint matches, the panel is still the panel and the library asks one further question: a second handshake to the broker with hostname checking relaxed — the chain, the signature and the expiry still verified against the pin — to see whether
403
+ the certificate names the address being dialled.
404
+
405
+ ```python
406
+ def moved(mismatch: LeafNameMismatch) -> None:
407
+ print(f"configured as {mismatch.host}, certificate names {', '.join(mismatch.leaf_names)}")
408
+
409
+ unregister = client.register_leaf_mismatch_callback(moved)
410
+ ```
411
+
412
+ **This is not fatal and the transport keeps retrying**, because a returning DHCP lease fixes it without anyone's help; what the callback is for is putting the other remedy — re-point the configuration at one of the names reported — in front of a user who
413
+ would otherwise see only an outage. It fires at most once per outage and is re-armed by the next successful connect. An expired leaf reports nothing, because nothing anyone does helps and the panel recovers on its own once it has the time again. Neither
414
+ handshake can re-anchor anything: both are diagnostic, and the pin is the pin whatever the panel served.
399
415
 
400
416
  The bootstrap REST calls take an `ssl_context` for the same purpose. `download_ca_cert` is the one exception and stays on plain HTTP — it fetches the anchor everything else is checked against, so it has nothing to check itself against, and its result must
401
417
  be fingerprint-confirmed out of band before it is trusted.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.2.0"
3
+ version = "3.3.0"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -6,7 +6,7 @@ supporting MQTT/Homie (v2) transport.
6
6
 
7
7
  from importlib.metadata import version as _pkg_version
8
8
 
9
- from ._ssl import build_panel_ssl_context, ca_fingerprint, leaf_names_host
9
+ from ._ssl import LeafNameMismatch, build_panel_ssl_context, ca_fingerprint, leaf_names_host
10
10
  from .auth import (
11
11
  delete_fqdn,
12
12
  download_ca_cert,
@@ -157,6 +157,12 @@ __all__ = [ # noqa: RUF022
157
157
  # Added 2026-08-28: the hostname half of verification, split out so a
158
158
  # caller using a relaxed context can still establish the name binding.
159
159
  "leaf_names_host",
160
+ # Added 2026-08-28 (3.3.0): what the transport reports when the pinned CA
161
+ # validates the broker's certificate and that certificate names somewhere
162
+ # else. Purely additive -- a consumer that registers no leaf-mismatch
163
+ # callback never receives one, and the reconnect behaviour it accompanies is
164
+ # unchanged.
165
+ "LeafNameMismatch",
160
166
  "delete_fqdn",
161
167
  "download_ca_cert",
162
168
  "get_fqdn",
@@ -1,20 +1,30 @@
1
1
  """The panel's trust anchor: building a context from it, and naming it.
2
2
 
3
- Both functions here take a CA in PEM form and nothing else. They make no network
4
- call and hold no state, which is the point -- a trust anchor that is fetched at
5
- the moment it is used is not an anchor, it is whatever answered. The fetching
6
- lives in ``auth.download_ca_cert``, and deciding whether a fetched PEM may be
7
- trusted lives with the caller.
8
-
9
- Public rather than private (``_ssl`` is a module-name convention here, and every
10
- name is re-exported from the package root) because the consumer needs all three:
11
- it builds the same context for its own HTTPS calls, it prints and compares the
12
- same fingerprint string, and it applies the same hostname rules when it has to
13
- judge a name binding for itself. Two implementations of a fingerprint that must
14
- agree byte-for-byte is a defect waiting for a firmware upgrade to find it, and
15
- the same is true of a hand-written hostname matcher -- more so, since that one
16
- is security-relevant and has no standard-library implementation left to defer
17
- to since ``ssl.match_hostname`` was removed in Python 3.12.
3
+ ``build_panel_ssl_context``, ``leaf_names_host`` and ``ca_fingerprint`` take a CA
4
+ in PEM form and nothing else. They make no network call and hold no state, which
5
+ is the point -- a trust anchor that is fetched at the moment it is used is not an
6
+ anchor, it is whatever answered. The fetching lives in ``auth.download_ca_cert``,
7
+ and deciding whether a fetched PEM may be trusted lives with the caller.
8
+
9
+ Those three are public (``_ssl`` is a module-name convention here, and all three
10
+ are re-exported from the package root) because the consumer needs them: it builds
11
+ the same context for its own HTTPS calls, it prints and compares the same
12
+ fingerprint string, and it applies the same hostname rules when it has to judge a
13
+ name binding for itself. Two implementations of a fingerprint that must agree
14
+ byte-for-byte is a defect waiting for a firmware upgrade to find it, and the same
15
+ is true of a hand-written hostname matcher -- more so, since that one is
16
+ security-relevant and has no standard-library implementation left to defer to
17
+ since ``ssl.match_hostname`` was removed in Python 3.12.
18
+
19
+ ``probe_leaf_name`` is the one thing here that does open a socket, and it is the
20
+ same argument carried one step further. A failed pinned handshake carries no
21
+ evidence about *why* it failed, so somebody has to ask the peer a second, narrower
22
+ question -- and that question is a composition of the anchor, the relaxed context
23
+ and the SAN matcher, all of which live in this module. Written once here rather
24
+ than at each caller for exactly the reason the matcher is: a second implementation
25
+ of "does this certificate name this host" is the drift the module exists to
26
+ prevent. It anchors on the CA it is handed and returns a verdict, never a
27
+ certificate to trust -- nothing it sees can become an anchor.
18
28
  """
19
29
 
20
30
  from __future__ import annotations
@@ -22,8 +32,10 @@ from __future__ import annotations
22
32
  import base64
23
33
  import binascii
24
34
  from collections.abc import Iterator, Mapping
35
+ from dataclasses import dataclass
25
36
  import hashlib
26
37
  import ipaddress
38
+ import socket
27
39
  import ssl
28
40
 
29
41
  from .exceptions import SpanPanelValidationError
@@ -31,6 +43,12 @@ from .exceptions import SpanPanelValidationError
31
43
  _PEM_HEADER = "-----BEGIN CERTIFICATE-----"
32
44
  _PEM_FOOTER = "-----END CERTIFICATE-----"
33
45
 
46
+ #: The SAN entry kinds this library reads. A panel names literal addresses, so
47
+ #: these are the two that can carry one; anything else in a SAN (``email``, a
48
+ #: ``URI``) names something that is not a host and would only mislead a user
49
+ #: reading the list back.
50
+ _ADDRESSING_SAN_KINDS = ("DNS", "IP Address")
51
+
34
52
 
35
53
  def build_panel_ssl_context(ca_pem: str, *, check_hostname: bool = True) -> ssl.SSLContext:
36
54
  """Build an SSLContext that trusts only the provided panel CA.
@@ -127,6 +145,117 @@ def leaf_names_host(peer_cert: Mapping[str, object], host: str) -> bool:
127
145
  return _names_address(entries, wanted)
128
146
 
129
147
 
148
+ @dataclass(frozen=True, slots=True)
149
+ class LeafNameMismatch:
150
+ """A peer whose certificate the pinned CA validates, and which does not name ``host``.
151
+
152
+ The one thing that can be established about a failed pinned handshake beyond
153
+ "something is wrong": the panel is who it says it is, and it is not where the
154
+ configuration says it is. Not an exception, because it is not fatal and
155
+ nothing is being refused -- the transport keeps retrying, and a DHCP lease
156
+ that comes back or a panel that finishes registering its name fixes this with
157
+ nobody's help. It is a fact reported to whoever asked to be told, so that a
158
+ consumer can put the remedy in front of a person instead of leaving them to
159
+ read a log.
160
+
161
+ ``leaf_names`` is what the certificate actually carries -- its SAN ``DNS`` and
162
+ ``IP Address`` entries, in certificate order -- because the remedy is to
163
+ re-point the configuration at one of them, and a message that says only "the
164
+ name is wrong" does not tell anyone what the right one is. Empty is possible
165
+ and means the certificate names no address at all, which is a panel problem
166
+ rather than an addressing one.
167
+ """
168
+
169
+ host: str
170
+ leaf_names: tuple[str, ...]
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class LeafProbe:
175
+ """The result of one relaxed diagnostic handshake.
176
+
177
+ ``mismatch`` is set for the single outcome that is actionable and is ``None``
178
+ for every other, because every other one is transient and the caller's
179
+ response to all of them is the same: keep retrying. ``detail`` says which,
180
+ as a phrase fit to drop into a log line, so that a caller can be specific
181
+ about a verdict it must not act on differently.
182
+ """
183
+
184
+ mismatch: LeafNameMismatch | None
185
+ detail: str
186
+
187
+
188
+ def probe_leaf_name(ca_pem: str, host: str, port: int, *, timeout: float) -> LeafProbe:
189
+ """Ask ``host`` directly whether the certificate it serves names ``host``.
190
+
191
+ **Diagnostic only.** One handshake, under the CA it is handed, with hostname
192
+ checking relaxed. Nothing it observes is stored, no context is built from it
193
+ for any other use, and the anchor it verifies against is the caller's pin
194
+ unchanged -- a peer cannot become trusted by answering this call. The chain,
195
+ the signature and the expiry are all still verified, which is what makes the
196
+ remaining question meaningful: a peer that gets as far as being *named
197
+ wrongly* has already proved it holds a key the pin signed.
198
+
199
+ Blocking, and deliberately so -- ``ssl`` offers no non-blocking handshake
200
+ worth the machinery here, and the one caller has an executor. It is not
201
+ exported from the package root for that reason: a blocking call on an async
202
+ library's public surface is a footgun, and the consumer's own decisions about
203
+ which host to talk to are made in a config flow that already composes
204
+ :func:`build_panel_ssl_context` and :func:`leaf_names_host` for itself.
205
+
206
+ Four outcomes, and only the last is not a shrug:
207
+
208
+ - the peer rejects under the pin -- an expired leaf, most often a panel whose
209
+ clock reset after a power cut, and nothing anyone can act on;
210
+ - nothing answers -- a panel mid-reboot;
211
+ - the certificate names ``host`` -- which cannot follow a strict handshake
212
+ that failed, and is reported as transient rather than reasoned about,
213
+ because a contradiction is not evidence of anything;
214
+ - the certificate does not name ``host`` -- the mismatch.
215
+
216
+ Args:
217
+ ca_pem: The pinned CA, verified against and never replaced.
218
+ host: The name to dial and the name to look for. Both, deliberately:
219
+ the question is whether the peer reached *by this name* carries it.
220
+ port: The port to dial.
221
+ timeout: Seconds allowed for the connection and the handshake together.
222
+
223
+ Raises:
224
+ ssl.SSLError: ``ca_pem`` is not a certificate the ssl module accepts.
225
+ ValueError: ``ca_pem`` is malformed in a way ``ssl`` reports as such.
226
+ """
227
+ context = build_panel_ssl_context(ca_pem, check_hostname=False)
228
+ try:
229
+ with (
230
+ socket.create_connection((host, port), timeout=timeout) as raw,
231
+ context.wrap_socket(raw, server_hostname=host) as tls,
232
+ ):
233
+ peer = tls.getpeercert()
234
+ except ssl.SSLCertVerificationError as exc:
235
+ # Ahead of OSError because it is one: SSLCertVerificationError derives
236
+ # from SSLError derives from OSError, and this is the branch that means
237
+ # "the peer answered and the pin rejected it" rather than "nothing
238
+ # answered".
239
+ return LeafProbe(None, f"a second look with the hostname check relaxed was rejected too ({exc.verify_message})")
240
+ except (OSError, ValueError) as exc:
241
+ # Every remaining transport failure, including the non-verification TLS
242
+ # errors: refused, unresolvable, timed out, a handshake that went wrong
243
+ # for a reason the pin has no opinion about. ValueError because an empty
244
+ # `host` is one, and an unusable configuration is still not evidence.
245
+ return LeafProbe(None, f"a second look with the hostname check relaxed could not reach it ({exc})")
246
+ if peer is None:
247
+ # Only reachable with verification off, which this context never has.
248
+ # Kept because the alternative is reading a mismatch out of an empty
249
+ # certificate and naming no addresses in the report.
250
+ return LeafProbe(None, "a second look with the hostname check relaxed produced no certificate to read")
251
+ if leaf_names_host(peer, host):
252
+ return LeafProbe(None, f"the certificate it serves does name {host}, so the failure was something else")
253
+ return LeafProbe(
254
+ LeafNameMismatch(host=host, leaf_names=_san_names(peer)),
255
+ f"the certificate it serves does not name {host}",
256
+ )
257
+
258
+
130
259
  def _without_root_dot(name: str) -> str:
131
260
  """Strip surrounding space and a single root dot, which is not significant."""
132
261
  stripped = name.strip()
@@ -150,6 +279,18 @@ def _san_entries(peer_cert: Mapping[str, object]) -> Iterator[tuple[str, str]]:
150
279
  yield kind, value
151
280
 
152
281
 
282
+ def _san_names(peer_cert: Mapping[str, object]) -> tuple[str, ...]:
283
+ """The addresses a certificate names, in certificate order.
284
+
285
+ Verbatim, without normalisation: a user is going to read these back and type
286
+ one of them into a configuration field, so what is reported has to be what
287
+ the certificate says rather than a casefolded or dot-stripped rendering of
288
+ it. Order is the certificate's because the first entry is conventionally the
289
+ primary name, and re-sorting would lose that for nothing.
290
+ """
291
+ return tuple(value for kind, value in _san_entries(peer_cert) if kind in _ADDRESSING_SAN_KINDS)
292
+
293
+
153
294
  def _names_address(entries: list[tuple[str, str]], wanted: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
154
295
  """Whether an ``IP Address`` entry denotes ``wanted``, compared as addresses."""
155
296
  for kind, value in entries:
@@ -49,9 +49,9 @@ class SpanPanelCAChangedError(SpanPanelError):
49
49
  a client waiting to succeed against whatever is answering, which is the
50
50
  outcome pinning exists to prevent.
51
51
 
52
- It is also not a conclusion drawn from a handshake failure, because that
53
- conclusion cannot be drawn: an expired leaf (a panel whose clock reset after
54
- a power outage) and a hostname mismatch (a panel whose address moved) both
52
+ It is also not a conclusion drawn from the failed handshake, because that
53
+ handshake cannot support one: an expired leaf (a panel whose clock reset
54
+ after a power outage) and a hostname mismatch (a panel whose address moved)
55
55
  raise the same verification error against a perfectly valid pinned CA, and
56
56
  the ``ssl`` module exposes no peer chain when verification fails. This is
57
57
  raised only after a separate fetch of the panel's advertised CA returned a
@@ -59,6 +59,14 @@ class SpanPanelCAChangedError(SpanPanelError):
59
59
  ``observed_fingerprint`` is what the panel says its anchor is now, not what
60
60
  it presented on the connection that failed.
61
61
 
62
+ The other two are told apart afterwards and elsewhere, by a *second*
63
+ handshake with hostname checking relaxed (``_ssl.probe_leaf_name``), which
64
+ reaches the point of holding a validated certificate and can therefore read
65
+ its names. That path never produces this error: a leaf that chains to the pin
66
+ has proved the panel is the panel, so the worst it can report is
67
+ ``LeafNameMismatch``, which is not fatal and is retried like any other
68
+ address problem.
69
+
62
70
  The two remedies are opposite and only the user can choose between them, so
63
71
  both fingerprints are carried: re-pin, if the panel's CA was legitimately
64
72
  rotated by a firmware upgrade or a factory reset, or investigate, if it was
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, NoReturn
20
20
 
21
21
  from span_panel_api.schema_drift import log_schema_drift
22
22
 
23
+ from .._ssl import LeafNameMismatch
23
24
  from ..adapters import installed_adapter_keys, resolve_adapter
24
25
  from ..auth import get_homie_schema
25
26
  from ..dispatch import select_adapter_key
@@ -167,6 +168,7 @@ class SpanMqttClient:
167
168
  self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = []
168
169
  self._connection_callbacks: list[Callable[[bool], None]] = []
169
170
  self._fatal_error_callbacks: list[Callable[[SpanPanelError], None]] = []
171
+ self._leaf_mismatch_callbacks: list[Callable[[LeafNameMismatch], None]] = []
170
172
  self._schema_change_callbacks: list[Callable[[str | None, str | None], None]] = []
171
173
  self._live = False
172
174
  self._ready_event: asyncio.Event | None = None
@@ -456,6 +458,7 @@ class SpanMqttClient:
456
458
  self._bridge.set_message_callback(self._on_message)
457
459
  self._bridge.set_connection_callback(self._on_connection_change)
458
460
  self._bridge.set_fatal_error_callback(self._on_fatal_error)
461
+ self._bridge.set_leaf_mismatch_callback(self._on_leaf_mismatch)
459
462
  # Pre-rebuild hook: reset Homie accumulator before the bridge swaps
460
463
  # paho clients, so retained messages on the new subscription start
461
464
  # from a clean slate (no stale `$state=disconnected` cached from
@@ -631,6 +634,50 @@ class SpanMqttClient:
631
634
  except Exception: # pylint: disable=broad-exception-caught
632
635
  _LOGGER.warning("Fatal-error callback raised", exc_info=True)
633
636
 
637
+ def register_leaf_mismatch_callback(self, callback: Callable[[LeafNameMismatch], None]) -> Callable[[], None]:
638
+ """Subscribe to the broker's certificate naming somewhere other than here.
639
+
640
+ Fires with the address this client dials and the addresses the broker's
641
+ certificate actually carries, once the pinned CA has been confirmed as
642
+ still the panel's own. So it says something quite narrow and quite
643
+ useful: this *is* the panel, and it is not where the configuration says
644
+ it is -- most often a panel that took a new DHCP lease.
645
+
646
+ Not a fatal error and deliberately not on that channel. The transport
647
+ keeps retrying and recovers by itself if the panel comes back to the
648
+ configured address, so a consumer should surface the remedy -- re-point
649
+ the configuration at one of the names reported -- rather than tear
650
+ anything down. Nothing else re-raises it, because there is nothing to
651
+ raise: `ping()` and `get_snapshot()` go on reporting an ordinary outage,
652
+ which is what this is until somebody decides otherwise.
653
+
654
+ Fires at most once per outage: the next successful connect re-arms it, so
655
+ a mismatch that lasts a week is one notification and a mismatch that
656
+ recurs after a recovery is a second one.
657
+
658
+ Returns an unregister function. Calling it twice is safe.
659
+ """
660
+ self._leaf_mismatch_callbacks.append(callback)
661
+
662
+ def unregister() -> None:
663
+ with contextlib.suppress(ValueError):
664
+ self._leaf_mismatch_callbacks.remove(callback)
665
+
666
+ return unregister
667
+
668
+ def _on_leaf_mismatch(self, mismatch: LeafNameMismatch) -> None:
669
+ """Fan the bridge's name-mismatch report out to subscribers.
670
+
671
+ Iterates a copy for the same reason the other two fan-outs do: a
672
+ subscriber unregistering from inside its own callback must not mutate
673
+ the list being walked.
674
+ """
675
+ for cb in list(self._leaf_mismatch_callbacks):
676
+ try:
677
+ cb(mismatch)
678
+ except Exception: # pylint: disable=broad-exception-caught
679
+ _LOGGER.warning("Leaf-mismatch callback raised", exc_info=True)
680
+
634
681
  def register_schema_change_callback(self, callback: Callable[[str | None, str | None], None]) -> Callable[[], None]:
635
682
  """Subscribe to the panel changing schema generation mid-session.
636
683