span-panel-api 3.1.0__tar.gz → 3.1.1__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.1.0 → span_panel_api-3.1.1}/CHANGELOG.md +16 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/PKG-INFO +1 -1
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/pyproject.toml +1 -1
- span_panel_api-3.1.1/src/span_panel_api/_http.py +318 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/auth.py +235 -174
- span_panel_api-3.1.1/src/span_panel_api/detection.py +96 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/models.py +29 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/conftest.py +6 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_auth_redaction.py +86 -1
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_homie.py +0 -14
- span_panel_api-3.1.1/tests/test_plaintext_warning.py +241 -0
- span_panel_api-3.1.1/tests/test_rest_transport_contract.py +188 -0
- span_panel_api-3.1.1/tests/test_v2_status_parser.py +74 -0
- span_panel_api-3.1.0/src/span_panel_api/_http.py +0 -124
- span_panel_api-3.1.0/src/span_panel_api/detection.py +0 -85
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/.gitignore +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/LICENSE +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/README.md +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/__init__.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/client.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/homie_schema.json +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/parent_child_tree.json +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_adoption.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_packaging.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_public_api_unchanged.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_against_simulator.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_control_refusal.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_ssl_context.py +0 -0
|
@@ -7,6 +7,22 @@ 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.1.1]
|
|
11
|
+
|
|
12
|
+
A follow-up to 3.1.0's security work, with no API change and no adapter move required.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **A rejected passphrase no longer reaches the debug log**, closing the shape of validation response that reports the field that failed in one place and the value it rejected in another, under a key that says nothing about what it holds.
|
|
17
|
+
- **A panel that fails part-way through answering is reported as unreachable**, rather than as an error from the HTTP layer that a consumer catching this library's own errors would not catch.
|
|
18
|
+
- **A response this library cannot read is reported as an API error naming the endpoint and the missing field**, instead of a raw parsing error raised out of the call.
|
|
19
|
+
- **`get_v2_status` reports whether the panel proved proximity**, which until now only the detection path had read, so the same panel answered differently depending on which call had asked.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **A warning when a panel's bootstrap traffic is unencrypted**, raised by the transport so that every call carrying a credential is covered, logged once per panel, never repeating the credential it warns about, and leaving plaintext the default it has
|
|
24
|
+
always been.
|
|
25
|
+
|
|
10
26
|
## [3.1.0]
|
|
11
27
|
|
|
12
28
|
A security release. Three things a caller could not previously find out — whether a control command was delivered, whether the panel's bootstrap traffic was encrypted, and whether the CA behind the MQTT broker is still the one that was there yesterday —
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.1
|
|
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
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Shared HTTP helpers for SPAN Panel bootstrap REST calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
import logging
|
|
10
|
+
import ssl
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, SpanPanelValidationError
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
#: What a bootstrap URL resolves to when the caller names no port. HTTP without a
|
|
20
|
+
#: context, HTTPS with one -- so a caller that pins the panel CA and leaves the
|
|
21
|
+
#: port alone reaches the right place rather than the plaintext one.
|
|
22
|
+
DEFAULT_HTTP_PORT = 80
|
|
23
|
+
DEFAULT_HTTPS_PORT = 443
|
|
24
|
+
|
|
25
|
+
#: The one bootstrap path two modules request: the detector probes it to decide
|
|
26
|
+
#: whether the panel speaks v2 at all, and `get_v2_status` reads the same answer
|
|
27
|
+
#: for a caller that already knows it does. Named here rather than spelled out in
|
|
28
|
+
#: each, so the two cannot drift apart the way their parsers had.
|
|
29
|
+
V2_STATUS_PATH = "/api/v2/status"
|
|
30
|
+
|
|
31
|
+
#: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
|
|
32
|
+
#: through to `client.request()` so the dispatch below stays exhaustive and each
|
|
33
|
+
#: call still reaches the named httpx method.
|
|
34
|
+
type _Method = Literal["GET", "POST", "PUT", "DELETE"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _SSLCache:
|
|
39
|
+
"""Mutable container for the cached SSLContext and its async lock."""
|
|
40
|
+
|
|
41
|
+
context: ssl.SSLContext | None = None
|
|
42
|
+
lock: asyncio.Lock | None = field(default=None, repr=False)
|
|
43
|
+
|
|
44
|
+
def get_lock(self) -> asyncio.Lock:
|
|
45
|
+
"""Return the async lock, creating it lazily."""
|
|
46
|
+
if self.lock is None:
|
|
47
|
+
self.lock = asyncio.Lock()
|
|
48
|
+
return self.lock
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_ssl_cache = _SSLCache()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_url(host: str, port: int | None, path: str, ssl_context: ssl.SSLContext | None = None) -> str:
|
|
55
|
+
"""Build a bootstrap URL, choosing the scheme from whether a CA was supplied.
|
|
56
|
+
|
|
57
|
+
``port`` is ``None`` for "the caller did not say", which is the whole reason
|
|
58
|
+
it is nullable: with a plain ``int = 80`` there is no way to tell an omitted
|
|
59
|
+
port from one the caller deliberately set to 80, and the two need opposite
|
|
60
|
+
answers here.
|
|
61
|
+
|
|
62
|
+
An explicit ``port=80`` alongside an ``ssl_context`` is refused rather than
|
|
63
|
+
reinterpreted. It is not a hypothetical combination -- a consumer that
|
|
64
|
+
persisted a port before it pinned a CA produces exactly this on its first
|
|
65
|
+
HTTPS call -- and both readings are defensible: the caller may mean "HTTPS on
|
|
66
|
+
the unusual port 80" or may simply not have migrated the stored value.
|
|
67
|
+
Guessing either way is a security control that silently does something other
|
|
68
|
+
than what it was asked to.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
SpanPanelValidationError: ``ssl_context`` supplied with an explicit port 80.
|
|
72
|
+
"""
|
|
73
|
+
if ssl_context is None:
|
|
74
|
+
resolved = DEFAULT_HTTP_PORT if port is None else port
|
|
75
|
+
return f"http://{host}{path}" if resolved == DEFAULT_HTTP_PORT else f"http://{host}:{resolved}{path}"
|
|
76
|
+
|
|
77
|
+
if port == DEFAULT_HTTP_PORT:
|
|
78
|
+
raise SpanPanelValidationError(
|
|
79
|
+
f"port={DEFAULT_HTTP_PORT} was passed together with an ssl_context for {host}. "
|
|
80
|
+
f"Port {DEFAULT_HTTP_PORT} is the plaintext default and {DEFAULT_HTTPS_PORT} is the TLS one; "
|
|
81
|
+
"pass the panel's HTTPS port explicitly, or omit port to take the default."
|
|
82
|
+
)
|
|
83
|
+
resolved = DEFAULT_HTTPS_PORT if port is None else port
|
|
84
|
+
return f"https://{host}{path}" if resolved == DEFAULT_HTTPS_PORT else f"https://{host}:{resolved}{path}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _create_ssl_context() -> ssl.SSLContext:
|
|
88
|
+
"""Return a cached default SSL context, creating it in an executor on first call.
|
|
89
|
+
|
|
90
|
+
``ssl.create_default_context()`` calls ``load_verify_locations`` which
|
|
91
|
+
performs blocking file I/O on the system CA bundle. The resulting context
|
|
92
|
+
is thread-safe and reusable, so we cache it for the lifetime of the process.
|
|
93
|
+
"""
|
|
94
|
+
cached = _ssl_cache.context
|
|
95
|
+
if cached is not None:
|
|
96
|
+
return cached
|
|
97
|
+
async with _ssl_cache.get_lock():
|
|
98
|
+
# Double-check after acquiring the lock.
|
|
99
|
+
cached = _ssl_cache.context
|
|
100
|
+
if cached is not None:
|
|
101
|
+
return cached
|
|
102
|
+
# Read back through a local rather than returning the field again. The
|
|
103
|
+
# field is `SSLContext | None` and another task may clear or replace it
|
|
104
|
+
# between the assignment and the return, so returning it a second time
|
|
105
|
+
# is a read this function cannot promise is non-None -- which is what a
|
|
106
|
+
# strict checker objects to, correctly. The value that was just built is
|
|
107
|
+
# the value to hand back.
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
context = await loop.run_in_executor(None, ssl.create_default_context)
|
|
110
|
+
_ssl_cache.context = context
|
|
111
|
+
return context
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@asynccontextmanager
|
|
115
|
+
async def _get_client(
|
|
116
|
+
httpx_client: httpx.AsyncClient | None,
|
|
117
|
+
timeout: float,
|
|
118
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
119
|
+
) -> AsyncIterator[httpx.AsyncClient]:
|
|
120
|
+
"""Yield the client this call should use, honouring both arguments truthfully.
|
|
121
|
+
|
|
122
|
+
**A supplied ``ssl_context`` wins over an injected client, deliberately.**
|
|
123
|
+
httpx fixes ``verify=`` at construction, so a context cannot be applied to a
|
|
124
|
+
client somebody else built. This function used to yield an injected client
|
|
125
|
+
untouched, which meant a caller passing both would have got a plaintext-or-
|
|
126
|
+
system-trust connection while believing it had pinned the panel CA -- a
|
|
127
|
+
security control that appears to be on and is off. The only two honest
|
|
128
|
+
options are to refuse the combination or to build a dedicated client, and
|
|
129
|
+
building one keeps the pin working for the consumer that motivated it: Home
|
|
130
|
+
Assistant injects its shared client at every bootstrap call site.
|
|
131
|
+
|
|
132
|
+
The cost is named rather than hidden: these calls lose the shared connection
|
|
133
|
+
pool and the injected client's timeout and header policy, and take this
|
|
134
|
+
function's ``timeout`` instead. Acceptable because every caller here is
|
|
135
|
+
bootstrap -- registration, detection, schema, FQDN, status -- made a handful
|
|
136
|
+
of times per config entry, not a hot path. The injected client is never
|
|
137
|
+
closed by this function on any path; the dedicated one always is.
|
|
138
|
+
"""
|
|
139
|
+
if ssl_context is not None:
|
|
140
|
+
async with httpx.AsyncClient(timeout=timeout, verify=ssl_context) as client:
|
|
141
|
+
yield client
|
|
142
|
+
return
|
|
143
|
+
if httpx_client is not None:
|
|
144
|
+
yield httpx_client
|
|
145
|
+
return
|
|
146
|
+
ctx = await _create_ssl_context()
|
|
147
|
+
async with httpx.AsyncClient(timeout=timeout, verify=ctx) as client:
|
|
148
|
+
yield client
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#: Panels already warned about over plaintext, so the warning is said once each.
|
|
152
|
+
#: Process-wide, once per panel host: wider than the MQTT bridge's unpinned-CA
|
|
153
|
+
#: warning, which is per bridge instance and so repeats when a config entry is
|
|
154
|
+
#: reloaded. See `_warn_plaintext_transport` for why the client object is the
|
|
155
|
+
#: wrong key.
|
|
156
|
+
_warned_plaintext_hosts: set[str] = set()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _reset_plaintext_warnings() -> None:
|
|
160
|
+
"""Test hook. Not public API."""
|
|
161
|
+
_warned_plaintext_hosts.clear()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) -> None:
|
|
165
|
+
"""Say out loud, once per panel, that its bootstrap traffic is not encrypted.
|
|
166
|
+
|
|
167
|
+
In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
|
|
168
|
+
reason: a security property that is off by default is only a decision if the
|
|
169
|
+
operator can tell it is off. ``ssl_context=None`` puts the request on
|
|
170
|
+
plaintext ``http://``, and two of these calls carry credentials --
|
|
171
|
+
registration sends the panel passphrase and brings the broker password back,
|
|
172
|
+
and passphrase rotation sends a bearer token and brings the new broker
|
|
173
|
+
password back -- so anything on the path reads all of it.
|
|
174
|
+
|
|
175
|
+
**Called from the transport, not from the calls that bootstrap a client.**
|
|
176
|
+
Warning at the call sites meant each new call site had to remember to, and
|
|
177
|
+
passphrase rotation did not: the one call a consumer reaches for when
|
|
178
|
+
reauthenticating went out in the clear and said nothing. There is one
|
|
179
|
+
mechanism here so there is nothing to remember.
|
|
180
|
+
|
|
181
|
+
**Scoped to the panel, not to the request or the client object.** Per
|
|
182
|
+
request is a line somebody filters out. Per client object looks tighter and
|
|
183
|
+
is worse, because the CA download runs on every MQTT reconnect and builds a
|
|
184
|
+
fresh client each time -- so that key would produce a warning per reconnect,
|
|
185
|
+
which is precisely what the bridge's own once-per-bridge warning exists to
|
|
186
|
+
avoid. The panel is the thing the warning is actually about.
|
|
187
|
+
|
|
188
|
+
The credential itself is never named. This is a warning *about* a secret,
|
|
189
|
+
not a place to put one.
|
|
190
|
+
"""
|
|
191
|
+
if ssl_context is not None:
|
|
192
|
+
return
|
|
193
|
+
if host in _warned_plaintext_hosts:
|
|
194
|
+
return
|
|
195
|
+
_warned_plaintext_hosts.add(host)
|
|
196
|
+
_LOGGER.warning(
|
|
197
|
+
"Bootstrap traffic for %s is being sent over plaintext HTTP: no ssl_context was supplied, so "
|
|
198
|
+
"these requests and their responses -- including any credential they carry, such as the panel "
|
|
199
|
+
"passphrase and the broker password -- are readable by anything on the path between here and "
|
|
200
|
+
"the panel. Pin the panel's CA certificate and pass it as ssl_context.",
|
|
201
|
+
host,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class _Reply:
|
|
207
|
+
"""One panel answer, with the decoding every caller of it needs.
|
|
208
|
+
|
|
209
|
+
Status classification stays with the caller, because it is genuinely
|
|
210
|
+
per-endpoint: 412 means "no passphrase is set" on the rotation path and
|
|
211
|
+
nothing anywhere else, and 404 means "no FQDN configured" on one call and
|
|
212
|
+
"not a v2 panel" on another. The two steps *around* that classification are
|
|
213
|
+
the same everywhere and had been written out once per endpoint -- translating
|
|
214
|
+
a failed connection, and turning a body into an object with the fields the
|
|
215
|
+
caller is about to read. Both live here.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
host: str
|
|
219
|
+
endpoint: str
|
|
220
|
+
response: httpx.Response
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def status_code(self) -> int:
|
|
224
|
+
"""The status the panel answered with."""
|
|
225
|
+
return self.response.status_code
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def text(self) -> str:
|
|
229
|
+
"""The body as text, for the one endpoint that answers with a PEM."""
|
|
230
|
+
return self.response.text
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def headers(self) -> httpx.Headers:
|
|
234
|
+
"""The response headers, for ``Retry-After`` and content-type."""
|
|
235
|
+
return self.response.headers
|
|
236
|
+
|
|
237
|
+
def json_object(self, *required: str, on_malformed: type[SpanPanelAPIError] = SpanPanelAPIError) -> dict[str, object]:
|
|
238
|
+
"""Decode the body as a JSON object and confirm the fields about to be read.
|
|
239
|
+
|
|
240
|
+
A 200 is not a promise of a body. A panel part-way through starting
|
|
241
|
+
answers one with nothing in it; a proxy in front of one answers with an
|
|
242
|
+
HTML error page under a 200; and firmware is free to omit a field this
|
|
243
|
+
library treats as mandatory. Untranslated those surfaced as
|
|
244
|
+
``JSONDecodeError`` and ``KeyError`` -- neither of them a
|
|
245
|
+
``SpanPanelError``, so neither caught by a caller holding this library's
|
|
246
|
+
contract, and both escaping the retry clauses built on it.
|
|
247
|
+
|
|
248
|
+
``on_malformed`` exists for the one endpoint where an unreadable body is
|
|
249
|
+
"not ready yet" rather than "wrong": the schema fetch, whose caller
|
|
250
|
+
retries a booting panel. Every other endpoint here is asked once.
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
parsed = self.response.json()
|
|
254
|
+
except ValueError as exc:
|
|
255
|
+
raise on_malformed(
|
|
256
|
+
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} with a body that is not JSON",
|
|
257
|
+
status_code=self.status_code,
|
|
258
|
+
) from exc
|
|
259
|
+
if not isinstance(parsed, dict):
|
|
260
|
+
raise on_malformed(
|
|
261
|
+
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} "
|
|
262
|
+
f"with {type(parsed).__name__}, not a JSON object",
|
|
263
|
+
status_code=self.status_code,
|
|
264
|
+
)
|
|
265
|
+
body: dict[str, object] = parsed
|
|
266
|
+
missing = sorted(key for key in required if key not in body)
|
|
267
|
+
if missing:
|
|
268
|
+
raise on_malformed(
|
|
269
|
+
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} "
|
|
270
|
+
f"without the required field(s) {', '.join(missing)}",
|
|
271
|
+
status_code=self.status_code,
|
|
272
|
+
)
|
|
273
|
+
return body
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _request(
|
|
277
|
+
method: _Method,
|
|
278
|
+
host: str,
|
|
279
|
+
port: int | None,
|
|
280
|
+
path: str,
|
|
281
|
+
*,
|
|
282
|
+
timeout: float,
|
|
283
|
+
httpx_client: httpx.AsyncClient | None = None,
|
|
284
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
285
|
+
json: dict[str, str] | None = None,
|
|
286
|
+
headers: dict[str, str] | None = None,
|
|
287
|
+
) -> _Reply:
|
|
288
|
+
"""Make one bootstrap request and translate everything that is not an answer.
|
|
289
|
+
|
|
290
|
+
The whole ``httpx.TransportError`` family, not just a refused connect:
|
|
291
|
+
``ReadError`` and ``WriteError`` when a rebooting panel resets mid-request,
|
|
292
|
+
and ``RemoteProtocolError`` when its proxy closes without answering, which is
|
|
293
|
+
what a proxy restarting under load produces. ``TimeoutException`` is itself a
|
|
294
|
+
``TransportError``, so it has to be caught first to keep its own class.
|
|
295
|
+
|
|
296
|
+
The verb is dispatched to the named httpx method rather than handed to
|
|
297
|
+
``client.request()``: the URL stays the first positional argument of a
|
|
298
|
+
recognisable call, which is what makes an injected client inspectable by the
|
|
299
|
+
caller that supplied it.
|
|
300
|
+
"""
|
|
301
|
+
url = _build_url(host, port, path, ssl_context)
|
|
302
|
+
_warn_plaintext_transport(host, ssl_context)
|
|
303
|
+
try:
|
|
304
|
+
async with _get_client(httpx_client, timeout, ssl_context) as client:
|
|
305
|
+
match method:
|
|
306
|
+
case "GET":
|
|
307
|
+
response = await client.get(url, headers=headers)
|
|
308
|
+
case "POST":
|
|
309
|
+
response = await client.post(url, json=json, headers=headers)
|
|
310
|
+
case "PUT":
|
|
311
|
+
response = await client.put(url, json=json, headers=headers)
|
|
312
|
+
case "DELETE":
|
|
313
|
+
response = await client.delete(url, headers=headers)
|
|
314
|
+
except httpx.TimeoutException as exc:
|
|
315
|
+
raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
|
|
316
|
+
except httpx.TransportError as exc:
|
|
317
|
+
raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
|
|
318
|
+
return _Reply(host=host, endpoint=path, response=response)
|