Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
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
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.

## [3.1.1]

A follow-up to 3.1.0's security work, with no API change and no adapter move required.

### Fixed

- **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.
- **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.
- **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.
- **`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.

### Added

- **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
always been.

## [3.1.0]

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 —
Expand Down
8 changes: 8 additions & 0 deletions packages/schema-0/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ rather than by this version number. A release here means this parser changed, ne

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.

## [1.1.1]

Still requires `span-panel-api` **3.1.0 or newer**, unchanged.

### Removed

- **`denormalize_circuit_id`, which nothing called** — it restored the dashes a circuit's UUID is stripped of on the way in, a form no snapshot, command topic or consumer has ever asked for.

## [1.1.0]

Requires `span-panel-api` **3.1.0 or newer**, and the two must be upgraded together in both directions: this wheel is rejected at discovery by a 3.0.x bootstrap, and a 1.0.0 wheel is rejected by 3.1.0.
Expand Down
2 changes: 1 addition & 1 deletion packages/schema-0/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api-schema-0"
version = "1.1.0"
version = "1.1.1"
description = "Flat-schema (data-model-version absent) parser for span-panel-api"
authors = [
{name = "SpanPanel"}
Expand Down
7 changes: 0 additions & 7 deletions packages/schema-0/src/span_panel_api_schema_0/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,3 @@
def normalize_circuit_id(node_id: str) -> str:
"""Strip dashes from Homie UUID for entity stability."""
return node_id.replace("-", "")


def denormalize_circuit_id(circuit_id: str) -> str:
"""Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format)."""
if len(circuit_id) == 32 and "-" not in circuit_id:
return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}"
return circuit_id
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "span-panel-api"
version = "3.1.0"
version = "3.1.1"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down
208 changes: 201 additions & 7 deletions src/span_panel_api/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,33 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
import logging
import ssl
from typing import Literal

import httpx

from .exceptions import SpanPanelValidationError
from .exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, SpanPanelValidationError

_LOGGER = logging.getLogger(__name__)

#: What a bootstrap URL resolves to when the caller names no port. HTTP without a
#: context, HTTPS with one -- so a caller that pins the panel CA and leaves the
#: port alone reaches the right place rather than the plaintext one.
DEFAULT_HTTP_PORT = 80
DEFAULT_HTTPS_PORT = 443

#: The one bootstrap path two modules request: the detector probes it to decide
#: whether the panel speaks v2 at all, and `get_v2_status` reads the same answer
#: for a caller that already knows it does. Named here rather than spelled out in
#: each, so the two cannot drift apart the way their parsers had.
V2_STATUS_PATH = "/api/v2/status"

#: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
#: through to `client.request()` so the dispatch below stays exhaustive and each
#: call still reaches the named httpx method.
type _Method = Literal["GET", "POST", "PUT", "DELETE"]


@dataclass
class _SSLCache:
Expand Down Expand Up @@ -76,15 +91,24 @@ async def _create_ssl_context() -> ssl.SSLContext:
performs blocking file I/O on the system CA bundle. The resulting context
is thread-safe and reusable, so we cache it for the lifetime of the process.
"""
if _ssl_cache.context is not None:
return _ssl_cache.context
cached = _ssl_cache.context
if cached is not None:
return cached
async with _ssl_cache.get_lock():
# Double-check after acquiring the lock.
if _ssl_cache.context is not None:
return _ssl_cache.context
cached = _ssl_cache.context
if cached is not None:
return cached
# Read back through a local rather than returning the field again. The
# field is `SSLContext | None` and another task may clear or replace it
# between the assignment and the return, so returning it a second time
# is a read this function cannot promise is non-None -- which is what a
# strict checker objects to, correctly. The value that was just built is
# the value to hand back.
loop = asyncio.get_running_loop()
_ssl_cache.context = await loop.run_in_executor(None, ssl.create_default_context)
return _ssl_cache.context
context = await loop.run_in_executor(None, ssl.create_default_context)
_ssl_cache.context = context
return context


@asynccontextmanager
Expand Down Expand Up @@ -122,3 +146,173 @@ async def _get_client(
ctx = await _create_ssl_context()
async with httpx.AsyncClient(timeout=timeout, verify=ctx) as client:
yield client


#: Panels already warned about over plaintext, so the warning is said once each.
#: Process-wide, once per panel host: wider than the MQTT bridge's unpinned-CA
#: warning, which is per bridge instance and so repeats when a config entry is
#: reloaded. See `_warn_plaintext_transport` for why the client object is the
#: wrong key.
_warned_plaintext_hosts: set[str] = set()


def _reset_plaintext_warnings() -> None:
"""Test hook. Not public API."""
_warned_plaintext_hosts.clear()


def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) -> None:
"""Say out loud, once per panel, that its bootstrap traffic is not encrypted.

In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
reason: a security property that is off by default is only a decision if the
operator can tell it is off. ``ssl_context=None`` puts the request on
plaintext ``http://``, and two of these calls carry credentials --
registration sends the panel passphrase and brings the broker password back,
and passphrase rotation sends a bearer token and brings the new broker
password back -- so anything on the path reads all of it.

**Called from the transport, not from the calls that bootstrap a client.**
Warning at the call sites meant each new call site had to remember to, and
passphrase rotation did not: the one call a consumer reaches for when
reauthenticating went out in the clear and said nothing. There is one
mechanism here so there is nothing to remember.

**Scoped to the panel, not to the request or the client object.** Per
request is a line somebody filters out. Per client object looks tighter and
is worse, because the CA download runs on every MQTT reconnect and builds a
fresh client each time -- so that key would produce a warning per reconnect,
which is precisely what the bridge's own once-per-bridge warning exists to
avoid. The panel is the thing the warning is actually about.

The credential itself is never named. This is a warning *about* a secret,
not a place to put one.
"""
if ssl_context is not None:
return
if host in _warned_plaintext_hosts:
return
_warned_plaintext_hosts.add(host)
_LOGGER.warning(
"Bootstrap traffic for %s is being sent over plaintext HTTP: no ssl_context was supplied, so "
"these requests and their responses -- including any credential they carry, such as the panel "
"passphrase and the broker password -- are readable by anything on the path between here and "
"the panel. Pin the panel's CA certificate and pass it as ssl_context.",
host,
)


@dataclass(frozen=True, slots=True)
class _Reply:
"""One panel answer, with the decoding every caller of it needs.

Status classification stays with the caller, because it is genuinely
per-endpoint: 412 means "no passphrase is set" on the rotation path and
nothing anywhere else, and 404 means "no FQDN configured" on one call and
"not a v2 panel" on another. The two steps *around* that classification are
the same everywhere and had been written out once per endpoint -- translating
a failed connection, and turning a body into an object with the fields the
caller is about to read. Both live here.
"""

host: str
endpoint: str
response: httpx.Response

@property
def status_code(self) -> int:
"""The status the panel answered with."""
return self.response.status_code

@property
def text(self) -> str:
"""The body as text, for the one endpoint that answers with a PEM."""
return self.response.text

@property
def headers(self) -> httpx.Headers:
"""The response headers, for ``Retry-After`` and content-type."""
return self.response.headers

def json_object(self, *required: str, on_malformed: type[SpanPanelAPIError] = SpanPanelAPIError) -> dict[str, object]:
"""Decode the body as a JSON object and confirm the fields about to be read.

A 200 is not a promise of a body. A panel part-way through starting
answers one with nothing in it; a proxy in front of one answers with an
HTML error page under a 200; and firmware is free to omit a field this
library treats as mandatory. Untranslated those surfaced as
``JSONDecodeError`` and ``KeyError`` -- neither of them a
``SpanPanelError``, so neither caught by a caller holding this library's
contract, and both escaping the retry clauses built on it.

``on_malformed`` exists for the one endpoint where an unreadable body is
"not ready yet" rather than "wrong": the schema fetch, whose caller
retries a booting panel. Every other endpoint here is asked once.
"""
try:
parsed = self.response.json()
except ValueError as exc:
raise on_malformed(
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} with a body that is not JSON",
status_code=self.status_code,
) from exc
if not isinstance(parsed, dict):
raise on_malformed(
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} "
f"with {type(parsed).__name__}, not a JSON object",
status_code=self.status_code,
)
body: dict[str, object] = parsed
missing = sorted(key for key in required if key not in body)
if missing:
raise on_malformed(
f"{self.host} answered HTTP {self.status_code} for {self.endpoint} "
f"without the required field(s) {', '.join(missing)}",
status_code=self.status_code,
)
return body


async def _request(
method: _Method,
host: str,
port: int | None,
path: str,
*,
timeout: float,
httpx_client: httpx.AsyncClient | None = None,
ssl_context: ssl.SSLContext | None = None,
json: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> _Reply:
"""Make one bootstrap request and translate everything that is not an answer.

The whole ``httpx.TransportError`` family, not just a refused connect:
``ReadError`` and ``WriteError`` when a rebooting panel resets mid-request,
and ``RemoteProtocolError`` when its proxy closes without answering, which is
what a proxy restarting under load produces. ``TimeoutException`` is itself a
``TransportError``, so it has to be caught first to keep its own class.

The verb is dispatched to the named httpx method rather than handed to
``client.request()``: the URL stays the first positional argument of a
recognisable call, which is what makes an injected client inspectable by the
caller that supplied it.
"""
url = _build_url(host, port, path, ssl_context)
_warn_plaintext_transport(host, ssl_context)
try:
async with _get_client(httpx_client, timeout, ssl_context) as client:
match method:
case "GET":
response = await client.get(url, headers=headers)
case "POST":
response = await client.post(url, json=json, headers=headers)
case "PUT":
response = await client.put(url, json=json, headers=headers)
case "DELETE":
response = await client.delete(url, headers=headers)
except httpx.TimeoutException as exc:
raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
except httpx.TransportError as exc:
raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
return _Reply(host=host, endpoint=path, response=response)
Loading
Loading