span-panel-api 3.2.0__tar.gz → 3.4.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 (103) hide show
  1. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/CHANGELOG.md +36 -0
  2. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/PKG-INFO +20 -4
  3. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/README.md +19 -3
  4. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/pyproject.toml +1 -1
  5. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/__init__.py +13 -1
  6. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/_http.py +55 -3
  7. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/_ssl.py +156 -15
  8. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/auth.py +2 -2
  9. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/exceptions.py +30 -3
  10. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/factory.py +15 -2
  11. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/client.py +104 -1
  12. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/connection.py +153 -34
  13. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/const.py +8 -0
  14. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/protocol.py +19 -0
  15. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_ca_pinning.py +35 -6
  16. span_panel_api-3.4.0/tests/test_leaf_name_mismatch.py +465 -0
  17. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_plaintext_warning.py +41 -7
  18. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_public_api_unchanged.py +12 -0
  19. span_panel_api-3.4.0/tests/test_schema_fetch_transport_split.py +276 -0
  20. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_ssl_context.py +151 -177
  21. span_panel_api-3.4.0/tests/tls_fixtures.py +246 -0
  22. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/.gitignore +0 -0
  23. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/LICENSE +0 -0
  24. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/adapters.py +0 -0
  25. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/const.py +0 -0
  26. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/detection.py +0 -0
  27. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/dispatch.py +0 -0
  28. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/models.py +0 -0
  29. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  30. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  31. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/control.py +0 -0
  32. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/models.py +0 -0
  33. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/phase_validation.py +0 -0
  34. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/py.typed +0 -0
  35. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/schema_drift.py +0 -0
  36. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/conftest.py +0 -0
  37. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  38. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  39. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  40. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/flat_wire.json +0 -0
  41. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  42. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_wire.json +0 -0
  43. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/v2/README.md +0 -0
  44. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/v2/status.json +0 -0
  45. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/reference_payloads/README.md +0 -0
  46. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/reference_payloads/__init__.py +0 -0
  47. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/reference_payloads/bootstrap.py +0 -0
  48. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/reference_payloads/schema_one.py +0 -0
  49. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  50. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  51. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  52. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/status.response.txt +0 -0
  53. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  54. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_accumulator.py +0 -0
  55. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_adapters_discovery.py +0 -0
  56. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_adopted_control.py +0 -0
  57. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_adoption.py +0 -0
  58. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_async_mqtt_client.py +0 -0
  59. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_auth_and_homie_helpers.py +0 -0
  60. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_auth_redaction.py +0 -0
  61. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_catalog_divergence.py +0 -0
  62. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_control_interceptor.py +0 -0
  63. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_detection_auth.py +0 -0
  64. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_exceptions.py +0 -0
  65. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_factory_dispatch.py +0 -0
  66. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_field_metadata.py +0 -0
  67. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_https_transport.py +0 -0
  68. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_live_flat_differential.py +0 -0
  69. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_bridge.py +0 -0
  70. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_client_connection.py +0 -0
  71. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_connect_flow.py +0 -0
  72. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_debounce.py +0 -0
  73. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_homie.py +0 -0
  74. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_packaging.py +0 -0
  75. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_phase_validation_configs.py +0 -0
  76. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_phase_validation_errors.py +0 -0
  77. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_protocol_conformance.py +0 -0
  78. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_protocol_models.py +0 -0
  79. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_publish_outcome.py +0 -0
  80. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_redispatch_on_reconnect.py +0 -0
  81. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_reference_tree_values.py +0 -0
  82. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_rest_transport_contract.py +0 -0
  83. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_generation_cross_check.py +0 -0
  84. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_migration_delta.py +0 -0
  85. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_adapter.py +0 -0
  86. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_charge_limit.py +0 -0
  87. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_circuits.py +0 -0
  88. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_conformance.py +0 -0
  89. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_connection_health.py +0 -0
  90. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_control_refusal.py +0 -0
  91. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_devices.py +0 -0
  92. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_discovery.py +0 -0
  93. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_extension.py +0 -0
  94. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_panel.py +0 -0
  95. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_pcs.py +0 -0
  96. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_service_entrance.py +0 -0
  97. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_shed_forecast.py +0 -0
  98. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_snapshot.py +0 -0
  99. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_transport.py +0 -0
  100. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_provenance.py +0 -0
  101. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_zero_adapter.py +0 -0
  102. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_shared_http_client.py +0 -0
  103. {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_v2_status_parser.py +0 -0
@@ -7,6 +7,42 @@ 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.4.0]
11
+
12
+ A consumer that pinned the panel's CA could not put its schema fetches behind that pin, because the one port `SpanMqttClient` took served two transports with opposite security properties — the schema fetch, which should ride the pinned HTTPS transport, and
13
+ the bridge's CA download, which is plaintext by design because it fetches the very anchor everything else is checked against. This release splits them.
14
+
15
+ ### Added
16
+
17
+ - **`SpanMqttClient` takes `panel_https_port`**, and its schema fetches — the one at connect and every redispatch refetch — move to HTTPS on that port whenever an `ssl_context` is supplied, leaving the bridge's deliberately-plaintext CA fetches on
18
+ `panel_http_port` exactly where they were. Naming the HTTPS port without an anchor is refused rather than left silently plaintext, for the same reason `_build_url` refuses port 80 with a context.
19
+ - **`SpanPanelTLSVerificationError` names a bootstrap REST call that failed certificate verification**, as a subclass of `SpanPanelConnectionError` so every existing except clause keeps its meaning — raised only when an `ssl.SSLCertVerificationError` is in
20
+ the cause chain, because ambiguous evidence must not look terminal, and exported so a consumer that fails closed on an untrusted certificate can catch it before the parent.
21
+
22
+ ### Changed
23
+
24
+ - **`create_span_client`'s `port` lands in the slot its transport needs**: with an `ssl_context` it was already read as the HTTPS port by every REST call the factory makes, so it now reaches the client's HTTPS slot and the CA download takes the plaintext
25
+ default, instead of the TLS port being handed to a plaintext fetch.
26
+ - **The redispatch schema refetch no longer retries a certificate-verification failure**, which cannot succeed on a later attempt under the same anchor; it is left to raise and logged once per trigger, instead of a background task fetching every thirty
27
+ seconds forever while the log blames a slow boot.
28
+ - **The CA download no longer emits the plaintext-transport warning**, because the fetch of the anchor itself is unverifiable by construction and carries no credential — its trust posture is stated by each caller in its own voice, and the warning as it
29
+ stood named credentials that call never carries. Every other bootstrap call still warns, and the CA download no longer spends the once-per-host slot a genuinely plaintext call needs later.
30
+
31
+ ## [3.3.0]
32
+
33
+ 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.
34
+
35
+ ### Added
36
+
37
+ - **`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.
38
+ - **`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.
39
+
40
+ ### Changed
41
+
42
+ - **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
43
+ either.
44
+ - **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.
45
+
10
46
  ## [3.2.0]
11
47
 
12
48
  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.4.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.4.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,
@@ -30,6 +30,7 @@ from .exceptions import (
30
30
  SpanPanelServerError,
31
31
  SpanPanelStaleDataError,
32
32
  SpanPanelTimeoutError,
33
+ SpanPanelTLSVerificationError,
33
34
  SpanPanelValidationError,
34
35
  )
35
36
  from .factory import create_span_client
@@ -157,6 +158,12 @@ __all__ = [ # noqa: RUF022
157
158
  # Added 2026-08-28: the hostname half of verification, split out so a
158
159
  # caller using a relaxed context can still establish the name binding.
159
160
  "leaf_names_host",
161
+ # Added 2026-08-28 (3.3.0): what the transport reports when the pinned CA
162
+ # validates the broker's certificate and that certificate names somewhere
163
+ # else. Purely additive -- a consumer that registers no leaf-mismatch
164
+ # callback never receives one, and the reconnect behaviour it accompanies is
165
+ # unchanged.
166
+ "LeafNameMismatch",
160
167
  "delete_fqdn",
161
168
  "download_ca_cert",
162
169
  "get_fqdn",
@@ -194,6 +201,11 @@ __all__ = [ # noqa: RUF022
194
201
  "SpanPanelError",
195
202
  "SpanPanelServerError",
196
203
  "SpanPanelStaleDataError",
204
+ # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
205
+ # rather than connection. A subclass of SpanPanelConnectionError, so every
206
+ # existing except clause keeps its meaning; a consumer that fails closed on
207
+ # an untrusted certificate catches this one before the parent.
208
+ "SpanPanelTLSVerificationError",
197
209
  "SpanPanelTimeoutError",
198
210
  "SpanPanelValidationError",
199
211
  ]
@@ -12,7 +12,13 @@ from typing import Literal
12
12
 
13
13
  import httpx
14
14
 
15
- from .exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, SpanPanelValidationError
15
+ from .exceptions import (
16
+ SpanPanelAPIError,
17
+ SpanPanelConnectionError,
18
+ SpanPanelTimeoutError,
19
+ SpanPanelTLSVerificationError,
20
+ SpanPanelValidationError,
21
+ )
16
22
 
17
23
  _LOGGER = logging.getLogger(__name__)
18
24
 
@@ -28,6 +34,10 @@ DEFAULT_HTTPS_PORT = 443
28
34
  #: each, so the two cannot drift apart the way their parsers had.
29
35
  V2_STATUS_PATH = "/api/v2/status"
30
36
 
37
+ #: The one bootstrap path exempt from the plaintext warning, named here because
38
+ #: the transport is what grants the exemption. See `_warn_plaintext_transport`.
39
+ CA_CERT_PATH = "/api/v2/certificate/ca"
40
+
31
41
  #: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
32
42
  #: through to `client.request()` so the dispatch below stays exhaustive and each
33
43
  #: call still reaches the named httpx method.
@@ -161,9 +171,23 @@ def _reset_plaintext_warnings() -> None:
161
171
  _warned_plaintext_hosts.clear()
162
172
 
163
173
 
164
- def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) -> None:
174
+ def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext | None) -> None:
165
175
  """Say out loud, once per panel, that its bootstrap traffic is not encrypted.
166
176
 
177
+ **The CA download is exempt, and does not claim the once-per-host slot.**
178
+ The warning exists so an operator can tell a security property is off when
179
+ it could be on, and for that endpoint there is no "on": verifying the fetch
180
+ of the anchor would require the anchor being fetched, an unverified-TLS
181
+ wrapping is readable and forgeable by the same active on-path attacker, and
182
+ the payload is a public certificate carrying no credential in either
183
+ direction — its authenticity control is the leaf check callers run *after*
184
+ the fetch. Each caller also states its own trust posture in its own voice:
185
+ the bridge's unpinned warning, a config flow's fingerprint confirmation, a
186
+ consumer's trust-on-first-use log. Warning here anyway named credentials the
187
+ call never carries, which is the line issue span#264 reported. Not marking
188
+ the host matters as much as not warning: a pinned consumer's diagnostic
189
+ re-read must not spend the slot a genuinely plaintext call needs later.
190
+
167
191
  In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
168
192
  reason: a security property that is off by default is only a decision if the
169
193
  operator can tell it is off. ``ssl_context=None`` puts the request on
@@ -190,6 +214,8 @@ def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) ->
190
214
  """
191
215
  if ssl_context is not None:
192
216
  return
217
+ if path == CA_CERT_PATH:
218
+ return
193
219
  if host in _warned_plaintext_hosts:
194
220
  return
195
221
  _warned_plaintext_hosts.add(host)
@@ -299,7 +325,7 @@ async def _request(
299
325
  caller that supplied it.
300
326
  """
301
327
  url = _build_url(host, port, path, ssl_context)
302
- _warn_plaintext_transport(host, ssl_context)
328
+ _warn_plaintext_transport(host, path, ssl_context)
303
329
  try:
304
330
  async with _get_client(httpx_client, timeout, ssl_context) as client:
305
331
  match method:
@@ -314,5 +340,31 @@ async def _request(
314
340
  except httpx.TimeoutException as exc:
315
341
  raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
316
342
  except httpx.TransportError as exc:
343
+ if _is_certificate_verification_failure(exc):
344
+ raise SpanPanelTLSVerificationError(
345
+ f"{host} answered {path} with a certificate the supplied trust anchor rejects: {exc}"
346
+ ) from exc
317
347
  raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
318
348
  return _Reply(host=host, endpoint=path, response=response)
349
+
350
+
351
+ def _is_certificate_verification_failure(exc: BaseException) -> bool:
352
+ """Whether this transport failure is demonstrably about certificate verification.
353
+
354
+ httpx wraps the underlying ``ssl.SSLCertVerificationError`` rather than
355
+ exposing it, so the evidence lives in the cause chain. Only that exact class
356
+ counts: a handshake that dies any other way -- a reset, a protocol mismatch,
357
+ an alert from a peer that is not TLS at all -- is indistinguishable from a
358
+ panel mid-reboot, and calling ambiguous evidence "verification failed" would
359
+ let a transient outage masquerade as the one failure consumers treat as
360
+ terminal. The walk is capped because ``__context__`` chains are
361
+ caller-assembled and nothing here should trust one to be finite.
362
+ """
363
+ seen = 0
364
+ current: BaseException | None = exc
365
+ while current is not None and seen < 10:
366
+ if isinstance(current, ssl.SSLCertVerificationError):
367
+ return True
368
+ current = current.__cause__ if current.__cause__ is not None else current.__context__
369
+ seen += 1
370
+ return False
@@ -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:
@@ -18,7 +18,7 @@ import uuid
18
18
 
19
19
  import httpx
20
20
 
21
- from ._http import V2_STATUS_PATH, _request
21
+ from ._http import CA_CERT_PATH, V2_STATUS_PATH, _request
22
22
  from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelServerError
23
23
  from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo
24
24
 
@@ -373,7 +373,7 @@ async def download_ca_cert(
373
373
  "GET",
374
374
  host,
375
375
  port,
376
- "/api/v2/certificate/ca",
376
+ CA_CERT_PATH,
377
377
  timeout=timeout,
378
378
  httpx_client=httpx_client,
379
379
  ssl_context=ssl_context,
@@ -13,6 +13,25 @@ class SpanPanelConnectionError(SpanPanelError):
13
13
  """Connection to SPAN panel failed."""
14
14
 
15
15
 
16
+ class SpanPanelTLSVerificationError(SpanPanelConnectionError):
17
+ """Something answered a bootstrap REST call with a certificate the supplied anchor rejects.
18
+
19
+ A subclass of `SpanPanelConnectionError` on purpose: every consumer that
20
+ catches the parent and retries keeps doing exactly what it did, because
21
+ nothing raised this before an `ssl_context` reached the bootstrap calls. The
22
+ subclass exists for the consumer that wants the opposite of a retry — a
23
+ verification failure is not "the panel is not up yet", it is "whatever is up
24
+ does not hold a key the pin signs", and retrying that is waiting to succeed
25
+ against whatever is answering. Catch this before the parent to fail closed.
26
+
27
+ Raised only when the failure is demonstrably about verification — an
28
+ `ssl.SSLCertVerificationError` in the cause chain. Every other transport
29
+ failure, TLS handshakes that die for other reasons included, stays a plain
30
+ `SpanPanelConnectionError`, because ambiguous evidence must not look
31
+ terminal.
32
+ """
33
+
34
+
16
35
  class SpanPanelTimeoutError(SpanPanelError):
17
36
  """Request timed out."""
18
37
 
@@ -49,9 +68,9 @@ class SpanPanelCAChangedError(SpanPanelError):
49
68
  a client waiting to succeed against whatever is answering, which is the
50
69
  outcome pinning exists to prevent.
51
70
 
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
71
+ It is also not a conclusion drawn from the failed handshake, because that
72
+ handshake cannot support one: an expired leaf (a panel whose clock reset
73
+ after a power outage) and a hostname mismatch (a panel whose address moved)
55
74
  raise the same verification error against a perfectly valid pinned CA, and
56
75
  the ``ssl`` module exposes no peer chain when verification fails. This is
57
76
  raised only after a separate fetch of the panel's advertised CA returned a
@@ -59,6 +78,14 @@ class SpanPanelCAChangedError(SpanPanelError):
59
78
  ``observed_fingerprint`` is what the panel says its anchor is now, not what
60
79
  it presented on the connection that failed.
61
80
 
81
+ The other two are told apart afterwards and elsewhere, by a *second*
82
+ handshake with hostname checking relaxed (``_ssl.probe_leaf_name``), which
83
+ reaches the point of holding a validated certificate and can therefore read
84
+ its names. That path never produces this error: a leaf that chains to the pin
85
+ has proved the panel is the panel, so the worst it can report is
86
+ ``LeafNameMismatch``, which is not fatal and is retried like any other
87
+ address problem.
88
+
62
89
  The two remedies are opposite and only the user can choose between them, so
63
90
  both fingerprints are carried: re-pin, if the panel's CA was legitimately
64
91
  rotated by a firmware upgrade or a factory reset, or investigate, if it was
@@ -45,7 +45,14 @@ async def create_span_client(
45
45
  serial_number: Panel serial number (extracted from detection/registration if omitted).
46
46
  port: Port of the panel bootstrap API used for registration, detection and the
47
47
  schema fetch. ``None`` takes the scheme default -- 80 plaintext, 443 with a
48
- context.
48
+ context. It reaches the constructed client in the slot matching its
49
+ transport: ``panel_https_port`` with a context, ``panel_http_port`` without
50
+ -- so a pinned client's plaintext CA fetches never dial the TLS port.
51
+ The corollary is stated rather than hidden: under a context the bridge's
52
+ diagnostic CA re-read takes the plaintext default, port 80. A pinned
53
+ caller whose panel serves plaintext on a nonstandard port has no way to
54
+ say so through this factory; construct ``SpanMqttClient`` directly and
55
+ pass both ports.
49
56
  httpx_client: Optional shared ``httpx.AsyncClient``, used for every request this
50
57
  makes and handed to the client it builds. Not closed here; its timeouts and
51
58
  limits are the caller's, which is why the per-call ``timeout`` defaults are
@@ -115,11 +122,17 @@ async def create_span_client(
115
122
  # `adapters` — none of it is safe to run on an event loop.
116
123
  adapter_cls = await asyncio.to_thread(resolve_adapter, adapter_key, dispatch_reason)
117
124
 
125
+ # `port` follows the transport the factory's own REST calls just used it
126
+ # for: with an ssl_context it was the HTTPS port (`_build_url` accepts no
127
+ # other reading), so it lands in the HTTPS slot and the bridge's
128
+ # deliberately-plaintext CA download keeps its own default. Without one it
129
+ # is the plaintext port, exactly as before.
118
130
  client = SpanMqttClient(
119
131
  host,
120
132
  serial_number,
121
133
  mqtt_config,
122
- panel_http_port=port,
134
+ panel_http_port=None if ssl_context is not None else port,
135
+ panel_https_port=port if ssl_context is not None else None,
123
136
  adapter_factory=adapter_cls,
124
137
  data_model_version=schema.data_model_version,
125
138
  schema_dispatch_reason=dispatch_reason,