span-panel-api 3.3.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.3.0 → span_panel_api-3.4.0}/CHANGELOG.md +21 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/PKG-INFO +1 -1
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/pyproject.toml +1 -1
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/__init__.py +6 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/_http.py +55 -3
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/auth.py +2 -2
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/exceptions.py +19 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/factory.py +15 -2
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/client.py +57 -1
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_plaintext_warning.py +41 -7
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_public_api_unchanged.py +5 -0
- span_panel_api-3.4.0/tests/test_schema_fetch_transport_split.py +276 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/.gitignore +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/LICENSE +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/README.md +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/conftest.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_adoption.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_packaging.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/tls_fixtures.py +0 -0
|
@@ -7,6 +7,27 @@ 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
|
+
|
|
10
31
|
## [3.3.0]
|
|
11
32
|
|
|
12
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.
|
|
@@ -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
|
|
@@ -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
|
|
@@ -200,6 +201,11 @@ __all__ = [ # noqa: RUF022
|
|
|
200
201
|
"SpanPanelError",
|
|
201
202
|
"SpanPanelServerError",
|
|
202
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",
|
|
203
209
|
"SpanPanelTimeoutError",
|
|
204
210
|
"SpanPanelValidationError",
|
|
205
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
|
|
@@ -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
|
|
|
@@ -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,
|
|
@@ -33,6 +33,8 @@ from ..exceptions import (
|
|
|
33
33
|
SpanPanelServerError,
|
|
34
34
|
SpanPanelStaleDataError,
|
|
35
35
|
SpanPanelTimeoutError,
|
|
36
|
+
SpanPanelTLSVerificationError,
|
|
37
|
+
SpanPanelValidationError,
|
|
36
38
|
)
|
|
37
39
|
from ..models import AdoptedProperty, ControlTarget, FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema
|
|
38
40
|
from ..protocol import PanelCapability, SchemaAdapter
|
|
@@ -131,6 +133,7 @@ class SpanMqttClient:
|
|
|
131
133
|
broker_config: MqttClientConfig,
|
|
132
134
|
snapshot_interval: float = 1.0,
|
|
133
135
|
panel_http_port: int | None = None,
|
|
136
|
+
panel_https_port: int | None = None,
|
|
134
137
|
adapter_factory: Callable[[str, V2HomieSchema], SchemaAdapter] | None = None,
|
|
135
138
|
data_model_version: str | None = None,
|
|
136
139
|
schema_dispatch_reason: str | None = None,
|
|
@@ -139,11 +142,29 @@ class SpanMqttClient:
|
|
|
139
142
|
ssl_context: ssl.SSLContext | None = None,
|
|
140
143
|
control_deadlines: ControlDeadlines | None = None,
|
|
141
144
|
) -> None:
|
|
145
|
+
if panel_https_port is not None and ssl_context is None:
|
|
146
|
+
# A TLS port with nothing to verify against is a decision nobody
|
|
147
|
+
# made: accepted silently, the schema fetch would run plaintext HTTP
|
|
148
|
+
# against a port the caller believes is TLS. The same misreading
|
|
149
|
+
# `_build_url` refuses for port 80 with a context, from the other
|
|
150
|
+
# direction.
|
|
151
|
+
raise SpanPanelValidationError(
|
|
152
|
+
f"panel_https_port={panel_https_port} was passed without an ssl_context for {host}. "
|
|
153
|
+
"Supply the pinned CA as ssl_context, or omit panel_https_port to stay on plaintext HTTP."
|
|
154
|
+
)
|
|
142
155
|
self._host = host
|
|
143
156
|
self._serial_number = serial_number
|
|
144
157
|
self._broker_config = broker_config
|
|
145
158
|
self._snapshot_interval = snapshot_interval
|
|
159
|
+
# Two ports because they serve transports with opposite security
|
|
160
|
+
# properties. `panel_http_port` is the plaintext one, and it belongs to
|
|
161
|
+
# the bridge: the CA download is unauthenticated by construction — it
|
|
162
|
+
# fetches the very anchor everything else is checked against — so it
|
|
163
|
+
# never follows the pin. `panel_https_port` carries this client's own
|
|
164
|
+
# schema fetches once an `ssl_context` anchors them; `None` with a
|
|
165
|
+
# context means `_build_url`'s TLS default, 443.
|
|
146
166
|
self._panel_http_port = panel_http_port
|
|
167
|
+
self._panel_https_port = panel_https_port
|
|
147
168
|
self._adapter_factory = adapter_factory
|
|
148
169
|
# Shared by the caller, owned by the caller: never closed here, and its
|
|
149
170
|
# policy -- timeouts, limits, headers -- is whatever the caller set. That
|
|
@@ -222,10 +243,18 @@ class SpanMqttClient:
|
|
|
222
243
|
four arguments spelled out separately, and adding the trust anchor to one
|
|
223
244
|
and not the other is exactly how a session ends up bootstrapping over
|
|
224
245
|
HTTPS and refetching over HTTP for the rest of its life. One call site.
|
|
246
|
+
|
|
247
|
+
The port follows the transport. With an anchor the fetch is HTTPS and
|
|
248
|
+
takes ``panel_https_port``; without one it is plaintext and takes
|
|
249
|
+
``panel_http_port``, exactly as it always did. Handing the HTTP port to
|
|
250
|
+
a TLS call is the combination ``_build_url`` refuses, and handing the
|
|
251
|
+
TLS port to the plaintext one is the constructor refusal -- so by the
|
|
252
|
+
time this runs, the pairing is already known good.
|
|
225
253
|
"""
|
|
254
|
+
port = self._panel_https_port if self._ssl_context is not None else self._panel_http_port
|
|
226
255
|
return await get_homie_schema(
|
|
227
256
|
self._host,
|
|
228
|
-
port=
|
|
257
|
+
port=port,
|
|
229
258
|
httpx_client=self._httpx_client,
|
|
230
259
|
ssl_context=self._ssl_context,
|
|
231
260
|
)
|
|
@@ -1465,6 +1494,16 @@ class SpanMqttClient:
|
|
|
1465
1494
|
while True:
|
|
1466
1495
|
try:
|
|
1467
1496
|
return await self._fetch_schema()
|
|
1497
|
+
except SpanPanelTLSVerificationError:
|
|
1498
|
+
# Before its parent, which the next clause would retry forever.
|
|
1499
|
+
# A verification failure cannot succeed on a later attempt --
|
|
1500
|
+
# the anchor is fixed for the session -- so it is precisely the
|
|
1501
|
+
# "error that is not the panel still coming up" this loop's
|
|
1502
|
+
# contract leaves to raise. The redispatch wrapper logs it once
|
|
1503
|
+
# per trigger, and escalation belongs to the MQTT side: a
|
|
1504
|
+
# rotated CA surfaces through the bridge's own diagnosis and
|
|
1505
|
+
# fatal-error channel, which reconnects share with this trigger.
|
|
1506
|
+
raise
|
|
1468
1507
|
except (
|
|
1469
1508
|
SpanPanelConnectionError,
|
|
1470
1509
|
SpanPanelTimeoutError,
|
|
@@ -1516,6 +1555,23 @@ class SpanMqttClient:
|
|
|
1516
1555
|
"""
|
|
1517
1556
|
try:
|
|
1518
1557
|
await self._redispatch_once()
|
|
1558
|
+
except SpanPanelTLSVerificationError:
|
|
1559
|
+
# Before the catch-all, whose text blames a slow boot. This is the
|
|
1560
|
+
# one refetch failure that is not one: the schema endpoint answered
|
|
1561
|
+
# with a certificate the session's anchor rejects, which cannot fix
|
|
1562
|
+
# itself on a later attempt and must not steer a user investigating
|
|
1563
|
+
# an interception toward waiting. Still non-fatal here for the
|
|
1564
|
+
# catch-all's reason -- nothing may escape a fire-and-forget task --
|
|
1565
|
+
# and the next reconnect edge re-arms the attempt.
|
|
1566
|
+
_LOGGER.error(
|
|
1567
|
+
"Could not follow the panel's schema-generation change: the schema refetch "
|
|
1568
|
+
"failed certificate verification against the pinned CA, so the %r parser is "
|
|
1569
|
+
"unchanged. If the panel's CA rotated with the firmware, the broker "
|
|
1570
|
+
"connection will surface it; otherwise check what answers the panel's "
|
|
1571
|
+
"HTTPS port.",
|
|
1572
|
+
self._data_model_version,
|
|
1573
|
+
exc_info=True,
|
|
1574
|
+
)
|
|
1519
1575
|
except Exception: # pylint: disable=broad-exception-caught
|
|
1520
1576
|
# Nothing may escape here. This runs as a fire-and-forget task, so an
|
|
1521
1577
|
# escaping exception becomes "Task exception was never retrieved" in
|
|
@@ -135,13 +135,23 @@ class TestItIsSaidOnce:
|
|
|
135
135
|
assert len(_warnings(caplog)) == 1
|
|
136
136
|
|
|
137
137
|
@pytest.mark.asyncio
|
|
138
|
-
async def
|
|
139
|
-
"""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
async def test_the_ca_download_never_warns(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
139
|
+
"""Reversed in 3.4.0, deliberately: this endpoint is unverifiable by construction.
|
|
140
|
+
|
|
141
|
+
The warning exists so an operator can tell a security property is *off*
|
|
142
|
+
when it could be on, and for the first fetch of the anchor itself there
|
|
143
|
+
is no "on": any verification would require the anchor being fetched, an
|
|
144
|
+
unverified-TLS wrapping would be readable and forgeable by the same
|
|
145
|
+
active on-path attacker, and the payload is a public certificate with no
|
|
146
|
+
credential in either direction — the authenticity control is the leaf
|
|
147
|
+
check its callers run *after* the fetch. Every caller also already says
|
|
148
|
+
so in its own voice: the bridge's once-per-bridge unpinned warning, the
|
|
149
|
+
config flow's fingerprint confirmation, and the deferred pin's
|
|
150
|
+
trust-on-first-use log line. Until 3.3.x this endpoint warned like the
|
|
151
|
+
rest, naming credentials it never carries; that line is what issue
|
|
152
|
+
span#264 reported. A caller that already holds the anchor and wants a
|
|
153
|
+
verified second copy passes ``ssl_context``, and no warning was ever in
|
|
154
|
+
question there.
|
|
145
155
|
"""
|
|
146
156
|
with patch("span_panel_api._http.httpx.AsyncClient") as mock_cls:
|
|
147
157
|
dedicated = AsyncMock()
|
|
@@ -154,6 +164,30 @@ class TestItIsSaidOnce:
|
|
|
154
164
|
for _ in range(5):
|
|
155
165
|
await download_ca_cert(HOST)
|
|
156
166
|
|
|
167
|
+
assert len(_warnings(caplog)) == 0
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_the_ca_download_does_not_swallow_a_later_warning(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
171
|
+
"""Skipping the warning must not mark the host as already warned.
|
|
172
|
+
|
|
173
|
+
The diagnostic CA re-read runs on a *pinned* entry whose other calls are
|
|
174
|
+
HTTPS; if it claimed the once-per-host slot, a genuinely plaintext call
|
|
175
|
+
made later — a reauth on an entry that lost its pin — would say nothing.
|
|
176
|
+
"""
|
|
177
|
+
answer = _json_response({"ebusBrokerPassword": "new-pass"}, method="PUT")
|
|
178
|
+
# Built before the patch below replaces `httpx.AsyncClient`; a spec
|
|
179
|
+
# against the patched class is a spec against a Mock, which mock refuses.
|
|
180
|
+
injected = _client("put", answer)
|
|
181
|
+
with patch("span_panel_api._http.httpx.AsyncClient") as mock_cls:
|
|
182
|
+
dedicated = AsyncMock()
|
|
183
|
+
dedicated.__aenter__ = AsyncMock(return_value=dedicated)
|
|
184
|
+
dedicated.__aexit__ = AsyncMock(return_value=False)
|
|
185
|
+
dedicated.get = AsyncMock(return_value=_text_response(PEM))
|
|
186
|
+
mock_cls.return_value = dedicated
|
|
187
|
+
with caplog.at_level(logging.WARNING):
|
|
188
|
+
await download_ca_cert(HOST)
|
|
189
|
+
await regenerate_passphrase(HOST, "jwt", httpx_client=injected)
|
|
190
|
+
|
|
157
191
|
assert len(_warnings(caplog)) == 1
|
|
158
192
|
|
|
159
193
|
@pytest.mark.asyncio
|
|
@@ -155,6 +155,11 @@ EXPECTED_PUBLIC_API = {
|
|
|
155
155
|
"SpanPanelError",
|
|
156
156
|
"SpanPanelServerError",
|
|
157
157
|
"SpanPanelStaleDataError",
|
|
158
|
+
# Added 2026-08-31 (3.4.0): a bootstrap REST call that failed *verification*
|
|
159
|
+
# rather than connection. A subclass of SpanPanelConnectionError, so every
|
|
160
|
+
# existing except clause keeps its meaning; a consumer that wants to fail
|
|
161
|
+
# closed on an untrusted certificate catches this one first.
|
|
162
|
+
"SpanPanelTLSVerificationError",
|
|
158
163
|
"SpanPanelTimeoutError",
|
|
159
164
|
"SpanPanelValidationError",
|
|
160
165
|
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""The schema fetch and the CA download need different ports, and had one.
|
|
2
|
+
|
|
3
|
+
`panel_http_port` serves two transports with opposite security properties: the
|
|
4
|
+
bridge's CA download, which is plaintext by design because it fetches the very
|
|
5
|
+
anchor everything else is checked against, and the client's schema fetch, which
|
|
6
|
+
should ride the pinned HTTPS transport whenever the caller holds one. One
|
|
7
|
+
parameter fed both, so a consumer that pinned a CA could not move its schema
|
|
8
|
+
fetch to HTTPS without simultaneously pointing the CA download at a TLS port it
|
|
9
|
+
speaks plaintext to.
|
|
10
|
+
|
|
11
|
+
The split: `panel_https_port` carries the schema fetch when an `ssl_context` is
|
|
12
|
+
supplied, and `panel_http_port` keeps the bridge's deliberately-plaintext CA
|
|
13
|
+
fetches exactly where they were.
|
|
14
|
+
|
|
15
|
+
Alongside it, a TLS verification failure on a bootstrap REST call gets its own
|
|
16
|
+
exception class. A consumer that fails closed on an untrusted certificate needs
|
|
17
|
+
to tell "something answered with a certificate the pin does not sign" apart from
|
|
18
|
+
"nothing answered" — the first is terminal and needs a person, the second clears
|
|
19
|
+
itself when the panel finishes rebooting.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import contextlib
|
|
26
|
+
import logging
|
|
27
|
+
import ssl
|
|
28
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
from span_panel_api._http import _reset_plaintext_warnings
|
|
34
|
+
from span_panel_api.auth import get_homie_schema
|
|
35
|
+
from span_panel_api.exceptions import (
|
|
36
|
+
SpanPanelConnectionError,
|
|
37
|
+
SpanPanelTLSVerificationError,
|
|
38
|
+
SpanPanelValidationError,
|
|
39
|
+
)
|
|
40
|
+
from span_panel_api.mqtt import MqttClientConfig
|
|
41
|
+
from span_panel_api.mqtt.client import SpanMqttClient
|
|
42
|
+
|
|
43
|
+
from conftest import flat_schema
|
|
44
|
+
|
|
45
|
+
HOST = "192.168.1.1"
|
|
46
|
+
SERIAL = "sp3-242424-001"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture(autouse=True)
|
|
50
|
+
def _fresh_warning_state() -> None:
|
|
51
|
+
"""Keep the once-per-host warning set from leaking between tests."""
|
|
52
|
+
_reset_plaintext_warnings()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def context() -> ssl.SSLContext:
|
|
57
|
+
"""Any context object will do — nothing here completes a handshake."""
|
|
58
|
+
return ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Schema:
|
|
62
|
+
def __init__(self, version: str | None) -> None:
|
|
63
|
+
self.data_model_version = version
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _client(**kwargs: object) -> SpanMqttClient:
|
|
67
|
+
return SpanMqttClient(
|
|
68
|
+
host=HOST,
|
|
69
|
+
serial_number=SERIAL,
|
|
70
|
+
broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"),
|
|
71
|
+
**kwargs, # type: ignore[arg-type]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _run_connect_fetch(client: SpanMqttClient, fetch: AsyncMock) -> None:
|
|
76
|
+
"""Drive connect() far enough to make its schema fetch, and no further."""
|
|
77
|
+
with (
|
|
78
|
+
patch("span_panel_api.mqtt.client.get_homie_schema", fetch),
|
|
79
|
+
patch.object(client, "_preload_adapter", AsyncMock()),
|
|
80
|
+
patch.object(client, "_build_adapter", MagicMock()),
|
|
81
|
+
):
|
|
82
|
+
with contextlib.suppress(Exception):
|
|
83
|
+
await client.connect()
|
|
84
|
+
assert fetch.await_count >= 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestSchemaFetchPortSelection:
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_a_pinned_client_fetches_the_schema_on_the_https_port(self, context: ssl.SSLContext) -> None:
|
|
90
|
+
client = _client(panel_http_port=80, panel_https_port=8443, ssl_context=context)
|
|
91
|
+
fetch = AsyncMock(return_value=_Schema("1.0"))
|
|
92
|
+
await _run_connect_fetch(client, fetch)
|
|
93
|
+
assert fetch.await_args.kwargs["port"] == 8443
|
|
94
|
+
assert fetch.await_args.kwargs["ssl_context"] is context
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_a_pinned_client_without_a_named_port_takes_the_scheme_default(self, context: ssl.SSLContext) -> None:
|
|
98
|
+
"""`None` reaches `_build_url`, whose default for a TLS call is 443."""
|
|
99
|
+
client = _client(panel_http_port=80, ssl_context=context)
|
|
100
|
+
fetch = AsyncMock(return_value=_Schema("1.0"))
|
|
101
|
+
await _run_connect_fetch(client, fetch)
|
|
102
|
+
assert fetch.await_args.kwargs["port"] is None
|
|
103
|
+
assert fetch.await_args.kwargs["ssl_context"] is context
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_an_unpinned_client_keeps_the_plaintext_port(self) -> None:
|
|
107
|
+
client = _client(panel_http_port=8080)
|
|
108
|
+
fetch = AsyncMock(return_value=_Schema("1.0"))
|
|
109
|
+
await _run_connect_fetch(client, fetch)
|
|
110
|
+
assert fetch.await_args.kwargs["port"] == 8080
|
|
111
|
+
assert fetch.await_args.kwargs["ssl_context"] is None
|
|
112
|
+
|
|
113
|
+
@pytest.mark.asyncio
|
|
114
|
+
async def test_the_upgrade_refetch_uses_the_same_transport(self, context: ssl.SSLContext) -> None:
|
|
115
|
+
"""One call site for both fetches is the point; prove the retry loop kept it."""
|
|
116
|
+
client = _client(panel_http_port=80, panel_https_port=8443, ssl_context=context)
|
|
117
|
+
client._loop = asyncio.get_running_loop()
|
|
118
|
+
fetch = AsyncMock(return_value=_Schema("1.0"))
|
|
119
|
+
with patch("span_panel_api.mqtt.client.get_homie_schema", fetch):
|
|
120
|
+
assert await client._fetch_schema_with_retry() is not None
|
|
121
|
+
assert fetch.await_args.kwargs["port"] == 8443
|
|
122
|
+
assert fetch.await_args.kwargs["ssl_context"] is context
|
|
123
|
+
|
|
124
|
+
def test_an_https_port_without_an_anchor_is_refused(self) -> None:
|
|
125
|
+
"""A TLS port with nothing to verify against is a decision nobody made.
|
|
126
|
+
|
|
127
|
+
Accepting it silently would put the schema fetch on plaintext HTTP at a
|
|
128
|
+
port the caller believes is TLS — the same misreading `_build_url`
|
|
129
|
+
refuses for port 80 with a context, from the other direction.
|
|
130
|
+
"""
|
|
131
|
+
with pytest.raises(SpanPanelValidationError):
|
|
132
|
+
_client(panel_https_port=8443)
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_the_bridge_keeps_the_plaintext_port_when_the_schema_fetch_moves(self, context: ssl.SSLContext) -> None:
|
|
136
|
+
"""The CA download is plaintext by design and must not follow the pin."""
|
|
137
|
+
client = _client(panel_http_port=8080, panel_https_port=8443, ssl_context=context)
|
|
138
|
+
# A real schema, because this test needs connect() to get all the way
|
|
139
|
+
# to bridge construction rather than stopping at the fetch.
|
|
140
|
+
fetch = AsyncMock(return_value=flat_schema(32))
|
|
141
|
+
bridge_cls = MagicMock()
|
|
142
|
+
with (
|
|
143
|
+
patch("span_panel_api.mqtt.client.get_homie_schema", fetch),
|
|
144
|
+
patch("span_panel_api.mqtt.client.AsyncMqttBridge", bridge_cls),
|
|
145
|
+
patch.object(client, "_preload_adapter", AsyncMock()),
|
|
146
|
+
patch.object(client, "_build_adapter", MagicMock()),
|
|
147
|
+
):
|
|
148
|
+
with contextlib.suppress(Exception):
|
|
149
|
+
await client.connect()
|
|
150
|
+
assert bridge_cls.call_args is not None
|
|
151
|
+
assert bridge_cls.call_args.kwargs["panel_http_port"] == 8080
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TestFactoryPortRouting:
|
|
155
|
+
"""`create_span_client`'s one `port` lands in the slot its transport needs.
|
|
156
|
+
|
|
157
|
+
The factory's REST calls already read `port` as "the HTTPS port" when an
|
|
158
|
+
`ssl_context` rides along — `_build_url` refuses anything else — but it then
|
|
159
|
+
handed the same number to `panel_http_port`, pointing the bridge's
|
|
160
|
+
deliberately-plaintext CA download at a TLS port.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
@pytest.mark.asyncio
|
|
164
|
+
async def test_a_pinned_factory_call_routes_its_port_to_the_https_slot(self, context: ssl.SSLContext) -> None:
|
|
165
|
+
from span_panel_api.adapters import _reset_adapter_cache
|
|
166
|
+
from span_panel_api.factory import create_span_client
|
|
167
|
+
|
|
168
|
+
_reset_adapter_cache()
|
|
169
|
+
config = MqttClientConfig(broker_host="broker.local", username="u", password="p")
|
|
170
|
+
with (
|
|
171
|
+
patch("span_panel_api.factory.SpanMqttClient") as mock_cls,
|
|
172
|
+
patch("span_panel_api.factory.get_homie_schema", return_value=_Schema(None)),
|
|
173
|
+
):
|
|
174
|
+
mock_cls.return_value.connect = AsyncMock()
|
|
175
|
+
await create_span_client(
|
|
176
|
+
HOST,
|
|
177
|
+
mqtt_config=config,
|
|
178
|
+
serial_number=SERIAL,
|
|
179
|
+
port=8443,
|
|
180
|
+
ssl_context=context,
|
|
181
|
+
)
|
|
182
|
+
kwargs = mock_cls.call_args.kwargs
|
|
183
|
+
assert kwargs["panel_https_port"] == 8443
|
|
184
|
+
assert kwargs["ssl_context"] is context
|
|
185
|
+
# The CA download takes the plaintext default rather than the TLS port.
|
|
186
|
+
assert kwargs["panel_http_port"] is None
|
|
187
|
+
|
|
188
|
+
@pytest.mark.asyncio
|
|
189
|
+
async def test_an_unpinned_factory_call_keeps_its_port_on_the_plaintext_slot(self) -> None:
|
|
190
|
+
from span_panel_api.adapters import _reset_adapter_cache
|
|
191
|
+
from span_panel_api.factory import create_span_client
|
|
192
|
+
|
|
193
|
+
_reset_adapter_cache()
|
|
194
|
+
config = MqttClientConfig(broker_host="broker.local", username="u", password="p")
|
|
195
|
+
with (
|
|
196
|
+
patch("span_panel_api.factory.SpanMqttClient") as mock_cls,
|
|
197
|
+
patch("span_panel_api.factory.get_homie_schema", return_value=_Schema(None)),
|
|
198
|
+
):
|
|
199
|
+
mock_cls.return_value.connect = AsyncMock()
|
|
200
|
+
await create_span_client(HOST, mqtt_config=config, serial_number=SERIAL, port=8080)
|
|
201
|
+
kwargs = mock_cls.call_args.kwargs
|
|
202
|
+
assert kwargs["panel_http_port"] == 8080
|
|
203
|
+
assert kwargs.get("panel_https_port") is None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestRedispatchRetryDoesNotRetryVerificationFailures:
|
|
207
|
+
"""The retry loop's contract is 'the panel still coming up', and this is not that.
|
|
208
|
+
|
|
209
|
+
A verification failure cannot succeed on a later attempt under the same
|
|
210
|
+
context — the anchor is fixed for the session — so retrying it is a
|
|
211
|
+
background task GETting the panel every thirty seconds forever while the
|
|
212
|
+
log blames a slow boot. Left to raise, the redispatch wrapper logs the
|
|
213
|
+
failure once per trigger, and the MQTT side owns the escalation: a rotated
|
|
214
|
+
CA surfaces through the bridge's own diagnosis and fatal-error channel.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
@pytest.mark.asyncio
|
|
218
|
+
async def test_a_verification_failure_raises_out_of_the_retry_loop(self, context: ssl.SSLContext) -> None:
|
|
219
|
+
client = _client(panel_https_port=8443, ssl_context=context)
|
|
220
|
+
client._loop = asyncio.get_running_loop()
|
|
221
|
+
fetch = AsyncMock(side_effect=SpanPanelTLSVerificationError("cert rejected by the pin"))
|
|
222
|
+
with patch("span_panel_api.mqtt.client.get_homie_schema", fetch):
|
|
223
|
+
# wait_for, because the defect this guards against is an infinite
|
|
224
|
+
# retry loop with real backoff sleeps: without it, a regression
|
|
225
|
+
# hangs the suite instead of failing it.
|
|
226
|
+
with pytest.raises(SpanPanelTLSVerificationError):
|
|
227
|
+
await asyncio.wait_for(client._fetch_schema_with_retry(), timeout=5)
|
|
228
|
+
# One attempt, no backoff loop: the failure is not retried at all.
|
|
229
|
+
assert fetch.await_count == 1
|
|
230
|
+
|
|
231
|
+
@pytest.mark.asyncio
|
|
232
|
+
async def test_the_escaping_failure_is_logged_as_what_it_is(
|
|
233
|
+
self, context: ssl.SSLContext, caplog: pytest.LogCaptureFixture
|
|
234
|
+
) -> None:
|
|
235
|
+
"""The wrapper's catch-all blamed a slow boot; verification is not that.
|
|
236
|
+
|
|
237
|
+
'Reload once the panel is fully back up' is accidentally workable advice
|
|
238
|
+
— a reload does surface the repair — but the sentence points at the
|
|
239
|
+
wrong cause, and the wrong cause is the one a user investigating an
|
|
240
|
+
interception must not be steered away from.
|
|
241
|
+
"""
|
|
242
|
+
client = _client(panel_https_port=8443, ssl_context=context)
|
|
243
|
+
client._loop = asyncio.get_running_loop()
|
|
244
|
+
failure = SpanPanelTLSVerificationError("cert rejected by the pin")
|
|
245
|
+
with patch.object(client, "_redispatch_once", AsyncMock(side_effect=failure)):
|
|
246
|
+
with caplog.at_level(logging.ERROR):
|
|
247
|
+
await client._redispatch_if_generation_changed()
|
|
248
|
+
assert "certificate verification" in caplog.text
|
|
249
|
+
assert "fully back up" not in caplog.text
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class TestTLSVerificationFailureIsNamed:
|
|
253
|
+
@pytest.mark.asyncio
|
|
254
|
+
async def test_a_certificate_verification_failure_is_its_own_error(self) -> None:
|
|
255
|
+
"""The one transport failure that must not be retried into submission."""
|
|
256
|
+
verify_failure = ssl.SSLCertVerificationError("certificate verify failed: unable to get local issuer certificate")
|
|
257
|
+
wrapped = httpx.ConnectError("[SSL: CERTIFICATE_VERIFY_FAILED]")
|
|
258
|
+
wrapped.__cause__ = verify_failure
|
|
259
|
+
injected = MagicMock()
|
|
260
|
+
injected.get = AsyncMock(side_effect=wrapped)
|
|
261
|
+
|
|
262
|
+
with pytest.raises(SpanPanelTLSVerificationError) as excinfo:
|
|
263
|
+
await get_homie_schema(HOST, httpx_client=injected)
|
|
264
|
+
# Still a connection error, so a consumer holding the 3.x contract —
|
|
265
|
+
# catch SpanPanelConnectionError, retry — keeps working unchanged.
|
|
266
|
+
assert isinstance(excinfo.value, SpanPanelConnectionError)
|
|
267
|
+
|
|
268
|
+
@pytest.mark.asyncio
|
|
269
|
+
async def test_an_ordinary_connect_failure_stays_a_connection_error(self) -> None:
|
|
270
|
+
"""A refused socket is 'not up yet', and must not look terminal."""
|
|
271
|
+
injected = MagicMock()
|
|
272
|
+
injected.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
|
|
273
|
+
|
|
274
|
+
with pytest.raises(SpanPanelConnectionError) as excinfo:
|
|
275
|
+
await get_homie_schema(HOST, httpx_client=injected)
|
|
276
|
+
assert not isinstance(excinfo.value, SpanPanelTLSVerificationError)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|