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.
Files changed (104) hide show
  1. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/CHANGELOG.md +16 -0
  2. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/PKG-INFO +1 -1
  3. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/pyproject.toml +1 -1
  4. span_panel_api-3.1.1/src/span_panel_api/_http.py +318 -0
  5. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/auth.py +235 -174
  6. span_panel_api-3.1.1/src/span_panel_api/detection.py +96 -0
  7. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/models.py +29 -0
  8. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/conftest.py +6 -0
  9. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_auth_redaction.py +86 -1
  10. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_homie.py +0 -14
  11. span_panel_api-3.1.1/tests/test_plaintext_warning.py +241 -0
  12. span_panel_api-3.1.1/tests/test_rest_transport_contract.py +188 -0
  13. span_panel_api-3.1.1/tests/test_v2_status_parser.py +74 -0
  14. span_panel_api-3.1.0/src/span_panel_api/_http.py +0 -124
  15. span_panel_api-3.1.0/src/span_panel_api/detection.py +0 -85
  16. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/.gitignore +0 -0
  17. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/LICENSE +0 -0
  18. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/README.md +0 -0
  19. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/__init__.py +0 -0
  20. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/_ssl.py +0 -0
  21. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/adapters.py +0 -0
  22. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/const.py +0 -0
  23. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/dispatch.py +0 -0
  24. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/exceptions.py +0 -0
  25. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/factory.py +0 -0
  26. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/__init__.py +0 -0
  27. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/async_client.py +0 -0
  28. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/client.py +0 -0
  29. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/connection.py +0 -0
  30. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/const.py +0 -0
  31. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/control.py +0 -0
  32. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/mqtt/models.py +0 -0
  33. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/phase_validation.py +0 -0
  34. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/protocol.py +0 -0
  35. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/py.typed +0 -0
  36. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/src/span_panel_api/schema_drift.py +0 -0
  37. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  38. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  39. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  40. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/flat_wire.json +0 -0
  41. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  42. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/v2/README.md +0 -0
  43. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/fixtures/v2/status.json +0 -0
  44. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/README.md +0 -0
  45. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/__init__.py +0 -0
  46. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/bootstrap.py +0 -0
  47. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/homie_schema.json +0 -0
  48. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/parent_child_tree.json +0 -0
  49. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/reference_payloads/schema_one.py +0 -0
  50. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  51. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/simulation_fixtures/panel.response.txt +0 -0
  52. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/simulation_fixtures/soe.response.txt +0 -0
  53. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/simulation_fixtures/status.response.txt +0 -0
  54. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_absent_readings_are_not_zero.py +0 -0
  55. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_accumulator.py +0 -0
  56. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_adapters_discovery.py +0 -0
  57. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_adopted_control.py +0 -0
  58. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_adoption.py +0 -0
  59. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_async_mqtt_client.py +0 -0
  60. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_auth_and_homie_helpers.py +0 -0
  61. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_ca_pinning.py +0 -0
  62. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_catalog_divergence.py +0 -0
  63. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_control_interceptor.py +0 -0
  64. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_detection_auth.py +0 -0
  65. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_exceptions.py +0 -0
  66. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_factory_dispatch.py +0 -0
  67. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_field_metadata.py +0 -0
  68. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_https_transport.py +0 -0
  69. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_live_flat_differential.py +0 -0
  70. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_bridge.py +0 -0
  71. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_client_connection.py +0 -0
  72. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_connect_flow.py +0 -0
  73. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_mqtt_debounce.py +0 -0
  74. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_packaging.py +0 -0
  75. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_phase_validation_configs.py +0 -0
  76. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_phase_validation_errors.py +0 -0
  77. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_protocol_conformance.py +0 -0
  78. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_protocol_models.py +0 -0
  79. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_public_api_unchanged.py +0 -0
  80. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_publish_outcome.py +0 -0
  81. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_redispatch_on_reconnect.py +0 -0
  82. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_reference_tree_values.py +0 -0
  83. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_generation_cross_check.py +0 -0
  84. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_migration_delta.py +0 -0
  85. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_adapter.py +0 -0
  86. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_against_simulator.py +0 -0
  87. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_charge_limit.py +0 -0
  88. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_circuits.py +0 -0
  89. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_conformance.py +0 -0
  90. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_connection_health.py +0 -0
  91. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_control_refusal.py +0 -0
  92. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_devices.py +0 -0
  93. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_discovery.py +0 -0
  94. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_extension.py +0 -0
  95. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_panel.py +0 -0
  96. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_pcs.py +0 -0
  97. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_service_entrance.py +0 -0
  98. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_shed_forecast.py +0 -0
  99. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_snapshot.py +0 -0
  100. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_one_transport.py +0 -0
  101. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_provenance.py +0 -0
  102. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_schema_zero_adapter.py +0 -0
  103. {span_panel_api-3.1.0 → span_panel_api-3.1.1}/tests/test_shared_http_client.py +0 -0
  104. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.1.0"
3
+ version = "3.1.1"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -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)