Skip to content
Open
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
70 changes: 57 additions & 13 deletions src/span_panel_api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import asyncio
import hashlib
import json
import uuid
Expand All @@ -33,6 +34,30 @@ def _int(val: object) -> int:
return int(str(val))


HTTP_TOO_MANY_REQUESTS = 429

#: Default attempts and base backoff used when the panel rate-limits a request.
CA_CERT_MAX_ATTEMPTS = 5
CA_CERT_BACKOFF_S = 1.5


def _retry_delay(retry_after: str | None, attempt: int, backoff_s: float) -> float:
"""Return how long to wait before retrying a rate-limited request.

Prefers the panel's ``Retry-After`` header (delta-seconds form) and falls
back to exponential backoff. Malformed or negative values fall back too,
so a bad header can never stall or skip the retry.
"""
fallback = backoff_s * (2.0 ** (attempt - 1))
if retry_after is None:
return fallback
try:
parsed = float(retry_after)
except (TypeError, ValueError):
return fallback
return parsed if parsed >= 0 else fallback


async def register_v2(
host: str,
name: str,
Expand Down Expand Up @@ -116,14 +141,24 @@ async def download_ca_cert(
timeout: float = 10.0,
port: int = 80,
httpx_client: httpx.AsyncClient | None = None,
max_attempts: int = CA_CERT_MAX_ATTEMPTS,
backoff_s: float = CA_CERT_BACKOFF_S,
) -> str:
"""Download the PEM CA certificate from the SPAN Panel.

The panel rate-limits this endpoint and replies with HTTP 429 once the
limit is hit. A single reconnect storm — or another client polling the
same panel — is enough to trigger it, and a one-shot request would turn
that transient condition into a hard setup failure. Retry with
exponential backoff, honouring ``Retry-After`` when the panel sends it.

Args:
host: IP address or hostname of the SPAN Panel
timeout: Request timeout in seconds when ``httpx_client`` is None; ignored when injected.
port: HTTP port of the panel bootstrap API
httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function.
max_attempts: Total attempts made when the panel replies HTTP 429.
backoff_s: Base delay for exponential backoff between 429 retries.

Returns:
PEM-encoded CA certificate as a string
Expand All @@ -134,23 +169,32 @@ async def download_ca_cert(
SpanPanelAPIError: Unexpected response or invalid PEM
"""
url = _build_url(host, port, "/api/v2/certificate/ca")
last_status: int | None = None

try:
async with _get_client(httpx_client, timeout) as client:
response = await client.get(url)
except httpx.ConnectError as exc:
raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc
except httpx.TimeoutException as exc:
raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
for attempt in range(1, max_attempts + 1):
try:
async with _get_client(httpx_client, timeout) as client:
response = await client.get(url)
except httpx.ConnectError as exc:
raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc
except httpx.TimeoutException as exc:
raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc

if response.status_code != 200:
raise SpanPanelAPIError(f"Failed to download CA cert: HTTP {response.status_code}")
if response.status_code == 200:
pem = response.text
if not pem.startswith("-----BEGIN"):
raise SpanPanelAPIError("Response is not a valid PEM certificate")
return pem

last_status = response.status_code

if response.status_code == HTTP_TOO_MANY_REQUESTS and attempt < max_attempts:
await asyncio.sleep(_retry_delay(response.headers.get("retry-after"), attempt, backoff_s))
continue

pem = response.text
if not pem.startswith("-----BEGIN"):
raise SpanPanelAPIError("Response is not a valid PEM certificate")
break

return pem
raise SpanPanelAPIError(f"Failed to download CA cert: HTTP {last_status}")


async def get_homie_schema(
Expand Down
13 changes: 13 additions & 0 deletions src/span_panel_api/mqtt/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,23 @@ def _build_ssl_context(ca_pem: str) -> ssl.SSLContext:
The panel issues a private CA and a server cert signed by it. We do
not want to trust system CAs for this connection, so the context is
built fresh rather than via ``ssl.create_default_context()``.

The panel's CA is a minimal self-signed certificate that omits the
Authority Key Identifier (AKI) X.509v3 extension. Python 3.13 enabled
``VERIFY_X509_STRICT`` by default, and that flag rejects such a
certificate with "Missing Authority Key Identifier", which makes the
MQTTS handshake fail on otherwise healthy panels. The flag is cleared
here so the library keeps working across Python versions.

This does not weaken the parts of verification that matter for this
connection: the trust anchor is still only the panel's own CA (fetched
over the local network immediately before use), hostname checking stays
enabled, and signature/expiry validation is unchanged.
"""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.check_hostname = True
ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT
ctx.load_verify_locations(cadata=ca_pem)
return ctx

Expand Down
105 changes: 105 additions & 0 deletions tests/test_detection_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from span_panel_api.auth import (
delete_fqdn,
_retry_delay,
download_ca_cert,
get_fqdn,
get_homie_schema,
Expand Down Expand Up @@ -392,6 +393,110 @@ async def test_download_http_error(self):
with pytest.raises(SpanPanelAPIError, match="500"):
await download_ca_cert("192.168.65.70")

@pytest.mark.asyncio
async def test_download_retries_after_429_then_succeeds(self):
"""A transient rate-limit must not fail setup — it should be retried."""
responses = [_mock_response(429), _mock_response(429), _mock_response(200, text=PEM_CERT)]
sleeps: list[float] = []

async def _fake_sleep(delay: float) -> None:
sleeps.append(delay)

with (
patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls,
patch("span_panel_api.auth.asyncio.sleep", _fake_sleep),
):
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=responses)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client

result = await download_ca_cert("192.168.65.70", backoff_s=1.5)

assert result.startswith("-----BEGIN")
assert mock_client.get.await_count == 3
# exponential backoff between the two retries
assert sleeps == [1.5, 3.0]

@pytest.mark.asyncio
async def test_download_gives_up_after_max_attempts(self):
"""Persistent 429s surface as an API error rather than retrying forever."""
sleeps: list[float] = []

async def _fake_sleep(delay: float) -> None:
sleeps.append(delay)

with (
patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls,
patch("span_panel_api.auth.asyncio.sleep", _fake_sleep),
):
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=_mock_response(429))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client

with pytest.raises(SpanPanelAPIError, match="429"):
await download_ca_cert("192.168.65.70", max_attempts=3)

assert mock_client.get.await_count == 3
assert len(sleeps) == 2

@pytest.mark.asyncio
async def test_download_honours_retry_after_header(self):
"""When the panel sends Retry-After, obey it instead of the backoff curve."""
rate_limited = _mock_response(429)
rate_limited.headers["retry-after"] = "7"
sleeps: list[float] = []

async def _fake_sleep(delay: float) -> None:
sleeps.append(delay)

with (
patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls,
patch("span_panel_api.auth.asyncio.sleep", _fake_sleep),
):
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[rate_limited, _mock_response(200, text=PEM_CERT)])
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client

result = await download_ca_cert("192.168.65.70")

assert result.startswith("-----BEGIN")
assert sleeps == [7.0]

@pytest.mark.asyncio
async def test_download_does_not_retry_non_429(self):
"""Only rate limiting is retried; other failures fail fast."""
with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=_mock_response(500))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = mock_client

with pytest.raises(SpanPanelAPIError, match="500"):
await download_ca_cert("192.168.65.70")

assert mock_client.get.await_count == 1


class TestRetryDelay:
def test_malformed_retry_after_falls_back_to_backoff(self):
assert _retry_delay("soon", attempt=1, backoff_s=1.5) == 1.5

def test_negative_retry_after_falls_back_to_backoff(self):
assert _retry_delay("-5", attempt=2, backoff_s=1.5) == 3.0

def test_missing_retry_after_uses_exponential_backoff(self):
assert _retry_delay(None, attempt=3, backoff_s=1.5) == 6.0

def test_zero_retry_after_is_respected(self):
assert _retry_delay("0", attempt=4, backoff_s=1.5) == 0.0


# ===================================================================
# get_homie_schema
Expand Down
Loading