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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ 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.4.0]

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

### Added

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

### Changed

- **`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
default, instead of the TLS port being handed to a plaintext fetch.
- **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
seconds forever while the log blames a slow boot.
- **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
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.

## [3.3.0]

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.
Expand Down
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.3.0"
version = "3.4.0"
description = "A client library for SPAN Panel API"
authors = [
{name = "SpanPanel"}
Expand Down
6 changes: 6 additions & 0 deletions src/span_panel_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
SpanPanelServerError,
SpanPanelStaleDataError,
SpanPanelTimeoutError,
SpanPanelTLSVerificationError,
SpanPanelValidationError,
)
from .factory import create_span_client
Expand Down Expand Up @@ -200,6 +201,11 @@
"SpanPanelError",
"SpanPanelServerError",
"SpanPanelStaleDataError",
# Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
# rather than connection. A subclass of SpanPanelConnectionError, so every
# existing except clause keeps its meaning; a consumer that fails closed on
# an untrusted certificate catches this one before the parent.
"SpanPanelTLSVerificationError",
"SpanPanelTimeoutError",
"SpanPanelValidationError",
]
Expand Down
58 changes: 55 additions & 3 deletions src/span_panel_api/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@

import httpx

from .exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, SpanPanelValidationError
from .exceptions import (
SpanPanelAPIError,
SpanPanelConnectionError,
SpanPanelTimeoutError,
SpanPanelTLSVerificationError,
SpanPanelValidationError,
)

_LOGGER = logging.getLogger(__name__)

Expand All @@ -28,6 +34,10 @@
#: each, so the two cannot drift apart the way their parsers had.
V2_STATUS_PATH = "/api/v2/status"

#: The one bootstrap path exempt from the plaintext warning, named here because
#: the transport is what grants the exemption. See `_warn_plaintext_transport`.
CA_CERT_PATH = "/api/v2/certificate/ca"

#: 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.
Expand Down Expand Up @@ -161,9 +171,23 @@ def _reset_plaintext_warnings() -> None:
_warned_plaintext_hosts.clear()


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

**The CA download is exempt, and does not claim the once-per-host slot.**
The warning exists so an operator can tell a security property is off when
it could be on, and for that endpoint there is no "on": verifying the fetch
of the anchor would require the anchor being fetched, an unverified-TLS
wrapping is readable and forgeable by the same active on-path attacker, and
the payload is a public certificate carrying no credential in either
direction — its authenticity control is the leaf check callers run *after*
the fetch. Each caller also states its own trust posture in its own voice:
the bridge's unpinned warning, a config flow's fingerprint confirmation, a
consumer's trust-on-first-use log. Warning here anyway named credentials the
call never carries, which is the line issue span#264 reported. Not marking
the host matters as much as not warning: a pinned consumer's diagnostic
re-read must not spend the slot a genuinely plaintext call needs later.

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
Expand All @@ -190,6 +214,8 @@ def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) ->
"""
if ssl_context is not None:
return
if path == CA_CERT_PATH:
return
if host in _warned_plaintext_hosts:
return
_warned_plaintext_hosts.add(host)
Expand Down Expand Up @@ -299,7 +325,7 @@ async def _request(
caller that supplied it.
"""
url = _build_url(host, port, path, ssl_context)
_warn_plaintext_transport(host, ssl_context)
_warn_plaintext_transport(host, path, ssl_context)
try:
async with _get_client(httpx_client, timeout, ssl_context) as client:
match method:
Expand All @@ -314,5 +340,31 @@ async def _request(
except httpx.TimeoutException as exc:
raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
except httpx.TransportError as exc:
if _is_certificate_verification_failure(exc):
raise SpanPanelTLSVerificationError(
f"{host} answered {path} with a certificate the supplied trust anchor rejects: {exc}"
) from exc
raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
return _Reply(host=host, endpoint=path, response=response)


def _is_certificate_verification_failure(exc: BaseException) -> bool:
"""Whether this transport failure is demonstrably about certificate verification.

httpx wraps the underlying ``ssl.SSLCertVerificationError`` rather than
exposing it, so the evidence lives in the cause chain. Only that exact class
counts: a handshake that dies any other way -- a reset, a protocol mismatch,
an alert from a peer that is not TLS at all -- is indistinguishable from a
panel mid-reboot, and calling ambiguous evidence "verification failed" would
let a transient outage masquerade as the one failure consumers treat as
terminal. The walk is capped because ``__context__`` chains are
caller-assembled and nothing here should trust one to be finite.
"""
seen = 0
current: BaseException | None = exc
while current is not None and seen < 10:
if isinstance(current, ssl.SSLCertVerificationError):
return True
current = current.__cause__ if current.__cause__ is not None else current.__context__
seen += 1
return False
4 changes: 2 additions & 2 deletions src/span_panel_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import httpx

from ._http import V2_STATUS_PATH, _request
from ._http import CA_CERT_PATH, V2_STATUS_PATH, _request
from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelServerError
from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo

Expand Down Expand Up @@ -373,7 +373,7 @@ async def download_ca_cert(
"GET",
host,
port,
"/api/v2/certificate/ca",
CA_CERT_PATH,
timeout=timeout,
httpx_client=httpx_client,
ssl_context=ssl_context,
Expand Down
19 changes: 19 additions & 0 deletions src/span_panel_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ class SpanPanelConnectionError(SpanPanelError):
"""Connection to SPAN panel failed."""


class SpanPanelTLSVerificationError(SpanPanelConnectionError):
"""Something answered a bootstrap REST call with a certificate the supplied anchor rejects.

A subclass of `SpanPanelConnectionError` on purpose: every consumer that
catches the parent and retries keeps doing exactly what it did, because
nothing raised this before an `ssl_context` reached the bootstrap calls. The
subclass exists for the consumer that wants the opposite of a retry — a
verification failure is not "the panel is not up yet", it is "whatever is up
does not hold a key the pin signs", and retrying that is waiting to succeed
against whatever is answering. Catch this before the parent to fail closed.

Raised only when the failure is demonstrably about verification — an
`ssl.SSLCertVerificationError` in the cause chain. Every other transport
failure, TLS handshakes that die for other reasons included, stays a plain
`SpanPanelConnectionError`, because ambiguous evidence must not look
terminal.
"""


class SpanPanelTimeoutError(SpanPanelError):
"""Request timed out."""

Expand Down
17 changes: 15 additions & 2 deletions src/span_panel_api/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ async def create_span_client(
serial_number: Panel serial number (extracted from detection/registration if omitted).
port: Port of the panel bootstrap API used for registration, detection and the
schema fetch. ``None`` takes the scheme default -- 80 plaintext, 443 with a
context.
context. It reaches the constructed client in the slot matching its
transport: ``panel_https_port`` with a context, ``panel_http_port`` without
-- so a pinned client's plaintext CA fetches never dial the TLS port.
The corollary is stated rather than hidden: under a context the bridge's
diagnostic CA re-read takes the plaintext default, port 80. A pinned
caller whose panel serves plaintext on a nonstandard port has no way to
say so through this factory; construct ``SpanMqttClient`` directly and
pass both ports.
httpx_client: Optional shared ``httpx.AsyncClient``, used for every request this
makes and handed to the client it builds. Not closed here; its timeouts and
limits are the caller's, which is why the per-call ``timeout`` defaults are
Expand Down Expand Up @@ -115,11 +122,17 @@ async def create_span_client(
# `adapters` — none of it is safe to run on an event loop.
adapter_cls = await asyncio.to_thread(resolve_adapter, adapter_key, dispatch_reason)

# `port` follows the transport the factory's own REST calls just used it
# for: with an ssl_context it was the HTTPS port (`_build_url` accepts no
# other reading), so it lands in the HTTPS slot and the bridge's
# deliberately-plaintext CA download keeps its own default. Without one it
# is the plaintext port, exactly as before.
client = SpanMqttClient(
host,
serial_number,
mqtt_config,
panel_http_port=port,
panel_http_port=None if ssl_context is not None else port,
panel_https_port=port if ssl_context is not None else None,
adapter_factory=adapter_cls,
data_model_version=schema.data_model_version,
schema_dispatch_reason=dispatch_reason,
Expand Down
58 changes: 57 additions & 1 deletion src/span_panel_api/mqtt/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
SpanPanelServerError,
SpanPanelStaleDataError,
SpanPanelTimeoutError,
SpanPanelTLSVerificationError,
SpanPanelValidationError,
)
from ..models import AdoptedProperty, ControlTarget, FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema
from ..protocol import PanelCapability, SchemaAdapter
Expand Down Expand Up @@ -131,6 +133,7 @@ def __init__(
broker_config: MqttClientConfig,
snapshot_interval: float = 1.0,
panel_http_port: int | None = None,
panel_https_port: int | None = None,
adapter_factory: Callable[[str, V2HomieSchema], SchemaAdapter] | None = None,
data_model_version: str | None = None,
schema_dispatch_reason: str | None = None,
Expand All @@ -139,11 +142,29 @@ def __init__(
ssl_context: ssl.SSLContext | None = None,
control_deadlines: ControlDeadlines | None = None,
) -> None:
if panel_https_port is not None and ssl_context is None:
# A TLS port with nothing to verify against is a decision nobody
# made: accepted silently, the schema fetch would run plaintext HTTP
# against a port the caller believes is TLS. The same misreading
# `_build_url` refuses for port 80 with a context, from the other
# direction.
raise SpanPanelValidationError(
f"panel_https_port={panel_https_port} was passed without an ssl_context for {host}. "
"Supply the pinned CA as ssl_context, or omit panel_https_port to stay on plaintext HTTP."
)
self._host = host
self._serial_number = serial_number
self._broker_config = broker_config
self._snapshot_interval = snapshot_interval
# Two ports because they serve transports with opposite security
# properties. `panel_http_port` is the plaintext one, and it belongs to
# the bridge: the CA download is unauthenticated by construction — it
# fetches the very anchor everything else is checked against — so it
# never follows the pin. `panel_https_port` carries this client's own
# schema fetches once an `ssl_context` anchors them; `None` with a
# context means `_build_url`'s TLS default, 443.
self._panel_http_port = panel_http_port
self._panel_https_port = panel_https_port
self._adapter_factory = adapter_factory
# Shared by the caller, owned by the caller: never closed here, and its
# policy -- timeouts, limits, headers -- is whatever the caller set. That
Expand Down Expand Up @@ -222,10 +243,18 @@ async def _fetch_schema(self) -> V2HomieSchema:
four arguments spelled out separately, and adding the trust anchor to one
and not the other is exactly how a session ends up bootstrapping over
HTTPS and refetching over HTTP for the rest of its life. One call site.

The port follows the transport. With an anchor the fetch is HTTPS and
takes ``panel_https_port``; without one it is plaintext and takes
``panel_http_port``, exactly as it always did. Handing the HTTP port to
a TLS call is the combination ``_build_url`` refuses, and handing the
TLS port to the plaintext one is the constructor refusal -- so by the
time this runs, the pairing is already known good.
"""
port = self._panel_https_port if self._ssl_context is not None else self._panel_http_port
return await get_homie_schema(
self._host,
port=self._panel_http_port,
port=port,
httpx_client=self._httpx_client,
ssl_context=self._ssl_context,
)
Expand Down Expand Up @@ -1465,6 +1494,16 @@ async def _fetch_schema_with_retry(self) -> V2HomieSchema | None:
while True:
try:
return await self._fetch_schema()
except SpanPanelTLSVerificationError:
# Before its parent, which the next clause would retry forever.
# A verification failure cannot succeed on a later attempt --
# the anchor is fixed for the session -- so it is precisely the
# "error that is not the panel still coming up" this loop's
# contract leaves to raise. The redispatch wrapper logs it once
# per trigger, and escalation belongs to the MQTT side: a
# rotated CA surfaces through the bridge's own diagnosis and
# fatal-error channel, which reconnects share with this trigger.
raise
except (
SpanPanelConnectionError,
SpanPanelTimeoutError,
Expand Down Expand Up @@ -1516,6 +1555,23 @@ async def _redispatch_if_generation_changed(self) -> None:
"""
try:
await self._redispatch_once()
except SpanPanelTLSVerificationError:
# Before the catch-all, whose text blames a slow boot. This is the
# one refetch failure that is not one: the schema endpoint answered
# with a certificate the session's anchor rejects, which cannot fix
# itself on a later attempt and must not steer a user investigating
# an interception toward waiting. Still non-fatal here for the
# catch-all's reason -- nothing may escape a fire-and-forget task --
# and the next reconnect edge re-arms the attempt.
_LOGGER.error(
"Could not follow the panel's schema-generation change: the schema refetch "
"failed certificate verification against the pinned CA, so the %r parser is "
"unchanged. If the panel's CA rotated with the firmware, the broker "
"connection will surface it; otherwise check what answers the panel's "
"HTTPS port.",
self._data_model_version,
exc_info=True,
)
except Exception: # pylint: disable=broad-exception-caught
# Nothing may escape here. This runs as a fire-and-forget task, so an
# escaping exception becomes "Task exception was never retrieved" in
Expand Down
Loading
Loading