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.
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/CHANGELOG.md +36 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/PKG-INFO +20 -4
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/README.md +19 -3
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/pyproject.toml +1 -1
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/__init__.py +13 -1
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/_http.py +55 -3
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/_ssl.py +156 -15
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/auth.py +2 -2
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/exceptions.py +30 -3
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/factory.py +15 -2
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/client.py +104 -1
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/connection.py +153 -34
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/const.py +8 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/protocol.py +19 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_ca_pinning.py +35 -6
- span_panel_api-3.4.0/tests/test_leaf_name_mismatch.py +465 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_plaintext_warning.py +41 -7
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_public_api_unchanged.py +12 -0
- span_panel_api-3.4.0/tests/test_schema_fetch_transport_split.py +276 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_ssl_context.py +151 -177
- span_panel_api-3.4.0/tests/tls_fixtures.py +246 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/.gitignore +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/LICENSE +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/conftest.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_adoption.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_packaging.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.2.0 → span_panel_api-3.4.0}/tests/test_shared_http_client.py +0 -0
- {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.
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
the
|
|
6
|
-
lives in ``auth.download_ca_cert``,
|
|
7
|
-
trusted lives with the caller.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
a power outage) and a hostname mismatch (a panel whose address moved)
|
|
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,
|