From 566bf1c192210246da8c4249c03ad4708df6fa85 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:06:04 -0700 Subject: [PATCH 01/10] security(rest): ride the pinned CA at runtime, and fail closed with repairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #264. Every REST call site adopted `panel_rest_transport` when the pin landed — config flow, reauth, repairs, rotate_credentials — except the one that runs on every startup: the schema fetch inside connect() stayed on plaintext HTTP with the pin sitting unused in entry.data, which is the condition the library's transport warning named. The runtime client now takes the pinned context and the HTTPS port (span-panel-api 3.4.0), so a pinned entry makes zero plaintext bootstrap calls in steady state. Failing closed follows the library's own escalation rule: a matching fingerprint never convicts. A rotated CA — diagnosed by re-reading the advertised CA, plaintext deliberately — is the one terminal outcome, taking the existing guided re-pin repair. Under an unchanged CA the leaf probe splits the rest: a moved panel raises the existing address repair and keeps its promise to retry; a leaf the pin does not validate — a middlebox, or a certificate a clock reset pushed outside its window — raises its own non-persistent repair and retries, so the clock case heals itself and plaintext is never fallen back to; missing evidence just retries. The two same-fingerprint repairs supersede each other, since a panel cannot be both moved and intercepted. A stored anchor that cannot be read stops setup with a fixable repair that reuses the CA-change flow, instead of quietly downgrading the one call that runs unattended on every boot. A stored HTTPS port of 80 — which the library refuses under a context — ends in a clear error naming Reconfigure, and both port forms now refuse it at input. For the population the deferred pin serves — TLS behind NAT, a port forward, a proxy, never asked for a port by the gated CA step — Reconfigure now offers the HTTPS port on any pinned entry, probing against and persisting the submitted value, and the retry message for an unreachable pinned panel names the expected port and the remedy. Entry removal clears the whole CA repair family, including two pre-existing leakers. --- CHANGELOG.md | 18 + README.md | 7 +- custom_components/span_panel/__init__.py | 222 ++++++- custom_components/span_panel/ca_repairs.py | 121 ++++ custom_components/span_panel/config_flow.py | 168 ++++-- .../span_panel/config_flow_validation.py | 48 +- custom_components/span_panel/manifest.json | 2 +- custom_components/span_panel/repairs.py | 10 +- custom_components/span_panel/strings.json | 27 +- .../span_panel/translations/en.json | 27 +- .../span_panel/translations/es.json | 27 +- .../span_panel/translations/fr.json | 27 +- .../span_panel/translations/ja.json | 27 +- .../span_panel/translations/pt.json | 27 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- tests/test_runtime_rest_transport.py | 571 ++++++++++++++++++ tests/test_v2_config_flow.py | 194 ++++++ 18 files changed, 1420 insertions(+), 107 deletions(-) create mode 100644 tests/test_runtime_rest_transport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a8951c..83686417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [2.1.1] + +### Fixed + +- **The startup warning that panel traffic is unencrypted is resolved, not silenced** — a pinned entry now reads the panel's schema over HTTPS verified against its pinned certificate authority, instead of plaintext HTTP (#264). + +### Added + +- **A repair when the HTTPS port serves a certificate the pin does not validate** — a wrong HTTPS port, a proxy terminating TLS in front of the panel, or a panel whose clock reset in an outage; the integration keeps retrying without connecting and never falls back to plaintext, so the clock case heals on its own. +- **A repair when the stored certificate authority can no longer be read**, guiding you through re-acquiring and confirming the panel's certificate authority. +- **Reconfigure now offers the HTTPS port on any panel with a pinned certificate authority**, for installs whose TLS lives behind a proxy or port forward and were never asked. + +### Changed + +- **A panel whose certificate authority rotated is now also detected at startup's first read**, raising the same guided re-pin repair it already had. +- **A moved panel detected at startup's first read raises the existing address repair and keeps retrying**, exactly as it promises. +- **When a pinned panel cannot be reached at all, the retry message names the HTTPS port and the Reconfigure remedy** instead of only reporting the panel as not ready. + ## [2.1.0] - 8/2026 ### In short diff --git a/README.md b/README.md index 99a45103..6350117a 100644 --- a/README.md +++ b/README.md @@ -679,9 +679,10 @@ re-fetched over plaintext on every connection and whatever answers is trusted. I refusing to start would remove the integration without making the credential any safer. **Proxies cut both ways.** One terminating the broker port with a certificate of its own leaves the entry unpinned, warning at every start with no repair -raised. One terminating only port 443 lets the entry pin, but **Reauthenticate**, **Reconfigure** and **Rotate credentials** then refuse rather than send your -access token unencrypted, until port 443 serves the panel's own certificate too. If your panel serves TLS on another port the setup flow asks — but only once -you have moved the HTTP port off 80. +raised. One terminating only port 443 lets the entry pin, but startup then refuses to connect and keeps retrying, with a repair naming the port — every REST +call a pinned entry makes, the schema read at startup included, verifies against the pin, and a certificate the pin does not validate is refused rather than +downgraded to plaintext — until the port serves the panel's own certificate. If your panel serves TLS on another port, the setup flow asks once you have moved +the HTTP port off 80, and **Reconfigure** offers the HTTPS port to any pinned entry. ### Restricting who can operate the panel diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index a1085e86..bc8a96a0 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -35,6 +35,7 @@ SpanPanelError, SpanPanelServerError, SpanPanelTimeoutError, + SpanPanelTLSVerificationError, SpanPanelValidationError, ) from span_panel_api.mqtt.models import MqttClientConfig @@ -43,8 +44,23 @@ from . import config_flow # noqa: F401 from .additions import async_announce_new_entities, async_forget_announcements from .adoption import async_register_adopted_devices -from .ca_repairs import async_clear_ca_changed, async_raise_ca_changed -from .config_flow_validation import as_port, async_ca_signs_panel_leaf, async_fetch_panel_ca +from .ca_repairs import ( + async_clear_ca_changed, + async_clear_ca_unusable, + async_clear_rest_tls_untrusted, + async_raise_ca_changed, + async_raise_ca_unusable, + async_raise_rest_tls_untrusted, +) +from .config_flow_validation import ( + LeafVerdict, + PanelCaUnusableError, + as_port, + async_ca_signs_panel_leaf, + async_fetch_panel_ca, + async_leaf_probe, + panel_rest_transport, +) from .const import ( CONF_API_VERSION, CONF_EBUS_BROKER_HOST, @@ -213,8 +229,14 @@ async def _async_pinned_ca( # Verifying one port and using the anchor on two has a cost, and it is # stated rather than hidden: a CA that signs the broker's leaf but not # whatever answers on 443 -- a proxy terminating only the HTTPS port -- - # pins here and then fails every REST path, which the callers that carry a - # secret refuse rather than downgrade. README and the changelog say so. + # pins here and then fails every REST path. Since the runtime schema fetch + # moved onto the pin (issue #264), that failure is no longer quiet: setup + # retries under `ConfigEntryNotReady` with the `panel_rest_tls_untrusted` + # Repair naming the port when something answers it badly, and with the + # retry message naming the port and Reconfigure when nothing answers it at + # all -- Reconfigure offers the HTTPS port to a pinned entry for exactly + # this population. Never plaintext either way. README and the changelog + # say so. # # One asymmetry to know about: `async_ca_signs_panel_leaf` accepts a leaf # reached at the address `host` resolves to, while the bridge dials `host` @@ -249,6 +271,88 @@ async def _async_pinned_ca( return ca_pem +async def _async_rest_tls_verdict( + hass: HomeAssistant, + entry: SpanPanelConfigEntry, + host: str, + http_port: int, + https_port: int, + pinned_pem: str, + err: SpanPanelTLSVerificationError, +) -> ConfigEntryError | ConfigEntryNotReady: + """Say what a REST certificate-verification failure was, and how setup ends. + + The same question the library asks when the MQTT handshake fails under a + pin, answered the same way, because the failure itself carries no evidence: + re-read the CA the panel advertises — plaintext deliberately, since a fetch + verified against the old pin would fail and tell us nothing — and compare + fingerprints. The fetch is diagnostic only and never re-anchors anything. + + A different fingerprint is the CA-changed Repair, with its guided re-pin — + a firmware reset rotating the CA is the common legitimate cause of this + failure, and it already has a flow. It is also the only terminal outcome, + which mirrors the library's own `_diagnose_verification_failure`: a + matching fingerprint never escalates, because `SSLCertVerificationError` + under an unchanged CA is three different conditions and two of them are + transient. The leaf probe splits them. A leaf that names somewhere else is + a moved panel — the leaf-mismatch Repair, which promises retrying, and + setup keeps that promise. A leaf the pin does not validate at all is either + something terminating TLS in front of the panel or a certificate a clock + reset pushed outside its validity window — one Repair naming both, retried + so the clock case heals itself and the proxy case stands visibly refused, + never downgraded to plaintext. And missing evidence — the CA unreadable, + the TLS port unreachable — retries with no verdict at all, because a panel + mid-reboot must not be convicted of anything. + """ + expected = ca_fingerprint(pinned_pem) + try: + advertised = await async_fetch_panel_ca(hass, host, http_port=http_port) + observed = ca_fingerprint(advertised) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, + ) as fetch_err: + _LOGGER.warning( + "TLS verification failed for SPAN panel %s's REST API and the panel's CA " + "could not be re-read to say why (%s). Treating this as transient and retrying", + entry.title, + fetch_err, + ) + return ConfigEntryNotReady(f"SPAN panel REST TLS failure is undiagnosed yet: {err}") + if observed != expected: + async_raise_ca_changed(hass, entry, expected, observed) + return ConfigEntryError( + f"SPAN panel {entry.title} is advertising CA {observed} where {expected} was pinned" + ) + probe = await async_leaf_probe(host, https_port, pinned_pem) + if probe.verdict is LeafVerdict.NAME_MISMATCH: + # The other half of the mutual supersede: `async_raise_rest_tls_untrusted` + # clears the leaf repair itself, but `leaf_repairs` cannot import + # `ca_repairs` back without a cycle, so this direction lives with the + # one caller that can produce both verdicts. A panel cannot be both + # merely moved and untrusted; only the current verdict may stand. + async_clear_rest_tls_untrusted(hass, entry) + async_raise_leaf_name_mismatch( + hass, entry, LeafNameMismatch(host=host, leaf_names=probe.leaf_names) + ) + return ConfigEntryNotReady( + f"SPAN panel {entry.title}'s certificate does not name {host}; " + "see the Repair, and retrying meanwhile" + ) + if probe.verdict is LeafVerdict.UNTRUSTED: + async_raise_rest_tls_untrusted(hass, entry, host, https_port, expected) + return ConfigEntryNotReady( + f"{host}:{https_port} answered with a certificate SPAN panel {entry.title}'s " + "pinned CA does not currently validate; see the Repair, and retrying meanwhile" + ) + return ConfigEntryNotReady( + f"SPAN panel {entry.title}'s REST TLS failure did not reproduce under diagnosis " + f"({probe.verdict}); retrying: {err}" + ) + + async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> bool: """Set up Span Panel from a config entry.""" _LOGGER.debug("Setting up entry %s (version %s)", entry.entry_id, entry.version) @@ -325,6 +429,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # plaintext HTTP on every connect and trusting whatever answers. ca_pem = await _async_pinned_ca(hass, entry, host, panel_http_port) + # From `entry.data` rather than `config`, because the deferred pin + # above may have just written the anchor there. Fail closed on a + # stored PEM that cannot be read: the fallback the other callers get + # would put this entry's schema fetch — the call that runs + # unattended on every boot — back on plaintext, which is the + # downgrade the pin exists to prevent (issue #264). The transport's + # own client is not used here; see the constructor comment below. + try: + transport = panel_rest_transport( + hass, entry.data, allow_plaintext_fallback=False + ) + except PanelCaUnusableError as err: + async_raise_ca_unusable(hass, entry, str(err)) + raise ConfigEntryError( # noqa: TRY301 + f"The stored CA for SPAN panel {entry.title} cannot be read; " + "re-acquire it in Settings > Repairs" + ) from err + broker_config = MqttClientConfig( broker_host=host, username=config[CONF_EBUS_BROKER_USERNAME], @@ -341,19 +463,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> serial_number, broker_config, snapshot_interval=snapshot_interval, + # Both ports, because the client runs two transports with + # opposite security properties. The plaintext port carries the + # bridge's CA fetches, which never follow the pin — they read + # the very anchor everything else is checked against. The + # schema fetch rides the pinned transport on the HTTPS port + # whenever the entry holds an anchor, which is every entry the + # config flow has created since pinning landed; passing the + # HTTPS port without a context is refused by the library, hence + # the pairing (issue #264). panel_http_port=panel_http_port, + panel_https_port=transport.port if transport.ssl_context is not None else None, + ssl_context=transport.ssl_context, # Home Assistant's shared client, which it owns and closes at - # shutdown. Without this the library built one per schema read -- - # once at connect, and once per retry while a panel finishes - # rebooting after a firmware upgrade. `quality_scale.yaml` claims - # `inject-websession: done`, and until now that was true of the - # config flow and of nothing that ran afterwards. - # - # Verification is not the reason for the default client rather - # than the config flow's `verify_ssl=False` one: the library's - # bootstrap URLs are plain `http://`, so TLS never happens on - # this path either way. It is the default client because that is - # the one every other integration already shares. + # shutdown, and which serves only the unpinned plaintext path -- + # under a context the library builds a dedicated client per + # call, because httpx fixes its trust store at construction. + # `transport.httpx_client` is deliberately not used: it is the + # config flow's `verify_ssl=False` variant, and asking for that + # here would stand up a second connection pool for a flag that + # is inert on plaintext. The default client is the one every + # other integration already shares. httpx_client=get_async_client(hass), ) @@ -388,6 +518,42 @@ def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: except SpanPanelAuthError as err: await client.close() raise ConfigEntryAuthFailed(f"MQTT authentication failed: {err}") from err + except SpanPanelValidationError as err: + # The library refusing a stored combination it will not guess + # about — an HTTPS port of 80 under a pin is the one that + # reaches here, written by a config flow that used to accept + # it. A stored value does not fix itself, so no retry; the + # message names the remedy. + await client.close() + raise ConfigEntryError( # noqa: TRY301 + f"SPAN panel {entry.title}'s stored HTTPS port is not usable under " + f"its pinned CA ({err}); correct it with Reconfigure" + ) from err + except SpanPanelTLSVerificationError as err: + # Before its parent `SpanPanelConnectionError`, which would + # retry it with no diagnosis. Something answered the REST HTTPS + # port with a certificate the pin rejects; which Repair that + # raises — and whether it is terminal — is decided by the + # diagnosis below, the only party holding the evidence. + await client.close() + pinned_pem = transport.ca_pem + if pinned_pem is None: + # Not reachable: the library raises this only under an + # ssl_context, and the transport carries a PEM exactly when + # it carries a context. Stated as a guard rather than + # laundered through `str()`, so if the pairing is ever + # broken the failure names itself instead of fingerprinting + # the string "None". + raise + raise await _async_rest_tls_verdict( + hass, + entry, + host, + panel_http_port, + transport.port, + pinned_pem, + err, + ) from err except ( SpanPanelConnectionError, SpanPanelTimeoutError, @@ -402,7 +568,19 @@ def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: SpanPanelServerError, ) as err: await client.close() - raise ConfigEntryNotReady(f"SPAN panel is not ready yet: {err}") from err + message = f"SPAN panel is not ready yet: {err}" + if transport.ssl_context is not None: + # For the population the deferred pin serves — TLS behind + # NAT, a port forward, a proxy — nothing may answer the + # default HTTPS port at all, and that failure is a plain + # refused connection indistinguishable from a reboot. The + # retry message is the one channel that reaches them, so it + # carries the remedy instead of blaming the panel. + message += ( + f" (its REST API is expected on HTTPS port {transport.port}; if the " + "panel's TLS is served elsewhere, set the HTTPS port via Reconfigure)" + ) + raise ConfigEntryNotReady(message) from err # The connection got as far as a handshake under the current pin, so # any standing Repair describes a state that no longer holds. That @@ -411,6 +589,11 @@ def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: # event the library re-arms its own signal on. async_clear_ca_changed(hass, entry) async_clear_leaf_name_mismatch(hass, entry) + # Same reconciliation: the REST half of the handshake succeeded + # under the current pin too — the schema fetch inside connect() is + # what these two describe failing. + async_clear_rest_tls_untrusted(hass, entry) + async_clear_ca_unusable(hass, entry) # The other half of the same condition: the reconnect loop runs # fire-and-forget, so a CA that changes mid-session cannot surface as @@ -571,6 +754,13 @@ async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) - it recreates, because every one of them is already recorded as announced. """ async_clear_schema_issues(hass, entry) + # The CA family too, for the same reason — and the two persistent ones most + # of all, since nothing else can ever clear an issue whose entry is gone: + # the fixable flow aborts with `entry_gone`, and the rest have no flow. + async_clear_ca_changed(hass, entry) + async_clear_ca_unusable(hass, entry) + async_clear_rest_tls_untrusted(hass, entry) + async_clear_leaf_name_mismatch(hass, entry) await async_forget_announcements(hass, entry) await async_forget(hass, entry) diff --git a/custom_components/span_panel/ca_repairs.py b/custom_components/span_panel/ca_repairs.py index 7a8c37c6..c2f98055 100644 --- a/custom_components/span_panel/ca_repairs.py +++ b/custom_components/span_panel/ca_repairs.py @@ -30,6 +30,8 @@ _LOGGER = logging.getLogger(__name__) CA_CHANGED_ISSUE_PREFIX = "panel_ca_changed_" +CA_UNUSABLE_ISSUE_PREFIX = "panel_ca_unusable_" +REST_TLS_UNTRUSTED_ISSUE_PREFIX = "panel_rest_tls_untrusted_" def ca_changed_issue_id(entry_id: str) -> str: @@ -37,6 +39,16 @@ def ca_changed_issue_id(entry_id: str) -> str: return f"{CA_CHANGED_ISSUE_PREFIX}{entry_id}" +def ca_unusable_issue_id(entry_id: str) -> str: + """Issue id for one entry's unreadable stored CA.""" + return f"{CA_UNUSABLE_ISSUE_PREFIX}{entry_id}" + + +def rest_tls_untrusted_issue_id(entry_id: str) -> str: + """Issue id for one entry's REST TLS port serving something the pin rejects.""" + return f"{REST_TLS_UNTRUSTED_ISSUE_PREFIX}{entry_id}" + + @callback def async_raise_ca_changed( hass: HomeAssistant, @@ -94,3 +106,112 @@ def async_raise_ca_changed( def async_clear_ca_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: """Drop the Repair, on a setup that reached the panel under the current pin.""" ir.async_delete_issue(hass, DOMAIN, ca_changed_issue_id(entry.entry_id)) + + +@callback +def async_raise_ca_unusable(hass: HomeAssistant, entry: ConfigEntry, reason: str) -> None: + """Raise the Repair for a stored anchor this system cannot read. + + Setup fails closed on this instead of downgrading to plaintext, because the + downgrade would run unattended on every boot at exactly the call the pin + exists to protect. The other REST callers that refuse this state — a + credential rotation — could point at reauth; setup cannot fix a stored + value, so the fix is the same flow the CA-change uses: fetch what the panel + advertises, verify it signs the panel's own certificate, and ask a person to + accept the fingerprint. + + Fixable and persistent for the CA-changed Repair's reasons: the fix *is* the + resolution, and there is no live transport left to re-derive the finding + from. + """ + _LOGGER.error( + "The stored certificate authority for SPAN panel %s cannot be read (%s), so its " + "REST calls cannot be verified and setup has stopped rather than falling back to " + "plaintext. Re-acquire the panel's certificate authority in Settings > Repairs", + entry.title, + reason, + ) + ir.async_create_issue( + hass, + DOMAIN, + ca_unusable_issue_id(entry.entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_unusable", + translation_placeholders={"panel": entry.title}, + data={"entry_id": entry.entry_id}, + ) + + +@callback +def async_clear_ca_unusable(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair, on a setup whose stored anchor built a working transport.""" + ir.async_delete_issue(hass, DOMAIN, ca_unusable_issue_id(entry.entry_id)) + + +@callback +def async_raise_rest_tls_untrusted( + hass: HomeAssistant, entry: ConfigEntry, host: str, https_port: int, fingerprint: str +) -> None: + """Raise the Repair for a REST TLS port answering with a certificate the pin rejects. + + Raised only after the diagnosis has ruled the alternatives out: the panel + still advertises the pinned CA (a rotated CA takes the CA-changed Repair + and its guided re-pin), and the leaf probe found a certificate the pin does + not currently validate rather than one that merely names somewhere else (a + moved panel takes the leaf-mismatch Repair). What is left is two conditions + the probe cannot tell apart, and the text names both: something terminating + TLS in front of the panel with a certificate of its own, or the panel's own + certificate outside its validity window after a clock reset. + + Not fixable, because none of the remedies are this integration's to apply: + the port is corrected by Reconfigure, the middlebox by whoever put it + there, and the clock by the panel getting time. Retried rather than + terminal — the clock case clears itself, plaintext is never fallen back + to either way, and a matching fingerprint never escalates, which is the + same stance the library's own diagnosis takes. `is_persistent=False` for + the leaf-mismatch Repair's reason: re-derived on every retry, so a restart + that resolves it must not resurrect it. + + Supersedes a standing leaf-name mismatch on the same entry, for the + CA-changed Repair's reason: the two verdicts come out of the same probe and + contradict each other — one promises recovery at a returning address, this + one says what answers there is not trusted — so only the current one may + stand. The reverse supersede lives at the verdict call site in `__init__`, + because `leaf_repairs` cannot import this module back without a cycle. + """ + async_clear_leaf_name_mismatch(hass, entry) + _LOGGER.error( + "Something at %s:%s is answering SPAN panel %s's HTTPS port with a certificate " + "its pinned CA (SHA-256 %s) does not currently validate, while the panel still " + "advertises that same CA. Retrying without connecting: check the entry's HTTPS " + "port and whether anything terminates TLS between Home Assistant and the panel; " + "a panel whose clock reset clears this on its own once it has time again", + host, + https_port, + entry.title, + fingerprint, + ) + ir.async_create_issue( + hass, + DOMAIN, + rest_tls_untrusted_issue_id(entry.entry_id), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_rest_tls_untrusted", + translation_placeholders={ + "panel": entry.title, + "host": host, + "https_port": str(https_port), + "fingerprint": fingerprint, + }, + data={"entry_id": entry.entry_id}, + ) + + +@callback +def async_clear_rest_tls_untrusted(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair, on a setup that reached the panel under the current pin.""" + ir.async_delete_issue(hass, DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 5a30a6af..75c76ec0 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Mapping +from dataclasses import replace import enum import logging import ssl @@ -184,6 +185,10 @@ def __init__(self) -> None: self._v2_panel_serial: str | None = None self._http_port: int = 80 self._https_port: int = DEFAULT_HTTPS_PORT + # Reconfigure's own copy, kept apart from `_https_port` because that one + # belongs to the CA-acquisition steps and reauth. None until the user + # submits the reconfigure form of a pinned entry. + self._reconfigure_https_port: int | None = None # Whether something authoritative already told this flow where the panel # serves TLS, rather than the value above being a default. Discovery is # one such source: a panel that publishes the port knows where it @@ -1053,17 +1058,30 @@ async def async_step_panel_ca_start( async def async_step_panel_https_port( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Collect the port the panel serves TLS on.""" + """Collect the port the panel serves TLS on. + + 80 is refused here, at the form, because the library refuses it at + every call it would later anchor — a stored plaintext port under a pin + fails each setup with nothing the user can do but reconfigure. Refuse + it while the person who typed it is present. + """ + schema = vol.Schema( + { + vol.Required(CONF_HTTPS_PORT, default=DEFAULT_HTTPS_PORT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=65535) + ), + } + ) if user_input is None: + return self.async_show_form(step_id="panel_https_port", data_schema=schema) + port = int(user_input[CONF_HTTPS_PORT]) + if port == 80: return self.async_show_form( step_id="panel_https_port", - data_schema=vol.Schema( - { - vol.Required(CONF_HTTPS_PORT, default=DEFAULT_HTTPS_PORT): int, - } - ), + data_schema=schema, + errors={"base": "https_port_is_plaintext"}, ) - self._https_port = int(user_input[CONF_HTTPS_PORT]) + self._https_port = port return await self.async_step_panel_ca() async def async_step_panel_ca( @@ -1352,6 +1370,40 @@ async def async_step_choose_entity_naming_initial( raise ConfigFlowError("Missing required parameters during entry creation") return self.create_new_entry(self.host, self.serial_number, self.access_token) + def _reconfigure_form( + self, entry: config_entries.ConfigEntry, host: str, error: str | None = None + ) -> ConfigFlowResult: + """Show the reconfigure form, with the HTTPS port on it exactly when the entry is pinned. + + A pinned entry's REST calls ride the HTTPS port, and one population was + never asked for it: TLS behind a port forward with the HTTP port still + on 80, which the CA step's own gating skips. Reconfigure is the remedy + every refusal points at, so this is where the port has to be settable. + An unpinned entry keeps the host-only form — it has no TLS transport + for the answer to configure. + """ + schema: dict[Any, Any] = {vol.Required(CONF_HOST, default=host): str} + if entry.data.get(CONF_PANEL_CA_PEM): + default = ( + self._reconfigure_https_port + if self._reconfigure_https_port is not None + else as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + ) + schema[vol.Required(CONF_HTTPS_PORT, default=default)] = vol.All( + vol.Coerce(int), vol.Range(min=1, max=65535) + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema(schema), + errors={"base": error} if error else None, + ) + + def _reconfigured_transport(self, transport: PanelRestTransport) -> PanelRestTransport: + """Apply the submitted HTTPS port to a transport built from stored data.""" + if self._reconfigure_https_port is None or transport.ssl_context is None: + return transport + return replace(transport, port=self._reconfigure_https_port) + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -1360,18 +1412,23 @@ async def async_step_reconfigure( if user_input is None: current_host = reconfigure_entry.data.get(CONF_HOST, "") - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=current_host): str}), - ) + return self._reconfigure_form(reconfigure_entry, str(current_host)) host = user_input[CONF_HOST].strip() if not host: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=""): str}), - errors={"base": "host_required"}, - ) + return self._reconfigure_form(reconfigure_entry, "", "host_required") + + if reconfigure_entry.data.get(CONF_PANEL_CA_PEM): + submitted = port_or_none(user_input.get(CONF_HTTPS_PORT)) + if submitted is None: + submitted = as_port(reconfigure_entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + if submitted == 80: + # The plaintext default. The library refuses this combination at + # every call, so storing it would fail every later setup; + # refused here instead, where the person who typed it is + # present. See `_build_url` in span-panel-api. + return self._reconfigure_form(reconfigure_entry, host, "https_port_is_plaintext") + self._reconfigure_https_port = submitted # Validate the host is reachable and is a v2 panel http_port = as_port(reconfigure_entry.data.get(CONF_HTTP_PORT), 80) @@ -1394,11 +1451,8 @@ async def async_step_reconfigure( reconfigure_entry.title, err, ) - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_unusable"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "ca_unusable") + self._rest_transport = self._reconfigured_transport(self._rest_transport) # The new host may be one the panel's certificate does not name -- a new # DHCP lease, or an FQDN it has not been told about yet -- and this entry # is pinned. Ask what is actually true of it before deciding, because @@ -1420,11 +1474,7 @@ async def async_step_reconfigure( # one of them is the panel being absent. verdict = await async_leaf_verdict(host, self._rest_transport.port, pinned_pem) if verdict is LeafVerdict.UNREACHABLE: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") if verdict is not LeafVerdict.NAME_MISMATCH: _LOGGER.warning( "Refusing to reconfigure panel %s to %s: the certificate served there on " @@ -1435,11 +1485,7 @@ async def async_step_reconfigure( self._rest_transport.port, pem_fingerprint_or_reason(pinned_pem), ) - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_leaf_mismatch"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "ca_leaf_mismatch") # The panel is there -- only it holds a key the pinned anchor # signed -- but its certificate names neither this address nor # anything this flow could reach it by. Every call over the @@ -1449,11 +1495,13 @@ async def async_step_reconfigure( # regenerate the certificate that fixes this. The relaxed # transport never leaves this flow and is never stored. self._leaf_names_host = False - self._rest_transport = panel_rest_transport( - self.hass, - reconfigure_entry.data, - allow_plaintext_fallback=False, - verify_hostname=False, + self._rest_transport = self._reconfigured_transport( + panel_rest_transport( + self.hass, + reconfigure_entry.data, + allow_plaintext_fallback=False, + verify_hostname=False, + ) ) try: detection = await detect_api_version( @@ -1468,25 +1516,13 @@ async def async_step_reconfigure( SpanPanelTimeoutError, SpanPanelAPIError, ): - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") if detection.probe_failed: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") if detection.api_version != "v2" or detection.status_info is None: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") # Ensure the serial number matches — prevent switching to a different panel await self.async_set_unique_id(detection.status_info.serial_number) @@ -1520,13 +1556,11 @@ async def async_step_reconfigure( reconfigure_entry.title, host, ) - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "ca_name_mismatch"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "ca_name_mismatch") data_updates: dict[str, Any] = {CONF_HOST: host} + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port old_fqdn = str(reconfigure_entry.data.get(CONF_REGISTERED_FQDN, "")) if old_fqdn: # Switching from FQDN to IP — clean up old registration @@ -1585,12 +1619,15 @@ async def async_step_reconfigure_fqdn_done( ) -> ConfigFlowResult: """Complete reconfiguration after successful FQDN registration.""" reconfigure_entry = self._get_reconfigure_entry() + data_updates: dict[str, Any] = { + CONF_HOST: self.host or "", + CONF_REGISTERED_FQDN: self.host or "", + } + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port return self.async_update_reload_and_abort( reconfigure_entry, - data_updates={ - CONF_HOST: self.host or "", - CONF_REGISTERED_FQDN: self.host or "", - }, + data_updates=data_updates, ) async def async_step_reconfigure_fqdn_failed( @@ -1620,17 +1657,18 @@ async def async_step_reconfigure_fqdn_failed( self._get_reconfigure_entry().title, self.host, ) - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=self.host or ""): str}), - errors={"base": "ca_name_mismatch"}, + return self._reconfigure_form( + self._get_reconfigure_entry(), self.host or "", "ca_name_mismatch" ) # User chose to continue anyway — update host without FQDN registration self._fall_back_to_the_bootstrap_address() reconfigure_entry = self._get_reconfigure_entry() + data_updates: dict[str, Any] = {CONF_HOST: self.host or ""} + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port return self.async_update_reload_and_abort( reconfigure_entry, - data_updates={CONF_HOST: self.host or ""}, + data_updates=data_updates, ) return self.async_show_form( step_id="reconfigure_fqdn_failed", diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index b421cee2..0fa9053b 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -242,9 +242,35 @@ class LeafVerdict(StrEnum): UNREACHABLE = "unreachable" +@dataclass(frozen=True, slots=True) +class LeafProbeResult: + """One leaf probe's verdict, with the names the certificate actually carries. + + `leaf_names` exists for exactly one verdict: a `NAME_MISMATCH` is only + actionable when the user can be told which names *would* work, and the + probe is the one place that has the validated certificate in hand. SAN + ``DNS`` and ``IP Address`` entries in certificate order — the same reading + the library's `LeafNameMismatch` documents — and empty for every other + verdict, where no validated certificate came back or the names answered + nothing. + """ + + verdict: LeafVerdict + leaf_names: tuple[str, ...] + + async def async_leaf_verdict(host: str, tls_port: int, ca_pem: str) -> LeafVerdict: """Classify the certificate served on `tls_port` against `ca_pem`. + The verdict half of `async_leaf_probe`, kept as its own name for the + callers that act only on the classification. + """ + return (await async_leaf_probe(host, tls_port, ca_pem)).verdict + + +async def async_leaf_probe(host: str, tls_port: int, ca_pem: str) -> LeafProbeResult: + """Classify the certificate served on `tls_port` against `ca_pem`. + One handshake, with hostname checking off, which establishes the chain; the name binding is then evaluated separately from the certificate the handshake returned. Both halves come from the library rather than being @@ -261,7 +287,7 @@ async def async_leaf_verdict(host: str, tls_port: int, ca_pem: str) -> LeafVerdi """ loop = asyncio.get_running_loop() - def _check() -> LeafVerdict: + def _check() -> LeafProbeResult: try: # The library's builder, not a hand-rolled context: the panel's CA # omits the Authority Key Identifier extension, which Python's @@ -271,7 +297,7 @@ def _check() -> LeafVerdict: except (ssl.SSLError, ValueError): # An anchor that will not load cannot validate anything. Nothing was # reached, so this is not an accusation against the host. - return LeafVerdict.UNTRUSTED + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) try: with ( socket.create_connection((host, tls_port), timeout=5) as sock, @@ -279,7 +305,7 @@ def _check() -> LeafVerdict: ): peer = tls.getpeercert() except ssl.SSLCertVerificationError: - return LeafVerdict.UNTRUSTED + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) except (OSError, TimeoutError, UnicodeError): # `UnicodeError` for the same reason as in `async_resolve_host`: the # connect resolves the name, and an over-long label raises out of @@ -287,7 +313,7 @@ def _check() -> LeafVerdict: # subclass of `OSError` and lands here too -- a handshake that broke # without a verification failure says nothing about the peer's # certificate, so it is a failure to reach, not a failure to trust. - return LeafVerdict.UNREACHABLE + return LeafProbeResult(LeafVerdict.UNREACHABLE, ()) # The handshake completed under the pinned anchor, so the peer holds a # key that anchor signed. Only the name is left in question. @@ -297,10 +323,18 @@ def _check() -> LeafVerdict: # the verdict that unlocks the relaxed transport, and "the handshake # completed but no validated certificate came back" is not evidence # that anything holds a key the anchor signed. - return LeafVerdict.UNTRUSTED + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) if leaf_names_host(peer, host): - return LeafVerdict.TRUSTED - return LeafVerdict.NAME_MISMATCH + return LeafProbeResult(LeafVerdict.TRUSTED, ()) + names: list[str] = [] + for san_entry in peer.get("subjectAltName", ()): + # Typeshed leaves SAN entries loosely typed; only the two-string + # shape carries an address, which is all the repair can point at. + if isinstance(san_entry, tuple) and len(san_entry) == 2: + kind, value = san_entry + if kind in ("DNS", "IP Address") and isinstance(value, str): + names.append(value) + return LeafProbeResult(LeafVerdict.NAME_MISMATCH, tuple(names)) return await loop.run_in_executor(None, _check) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 49c0c6fa..5b834a34 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,7 +23,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.3.0", + "span-panel-api==3.4.0", "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], diff --git a/custom_components/span_panel/repairs.py b/custom_components/span_panel/repairs.py index 06467be7..1693d9af 100644 --- a/custom_components/span_panel/repairs.py +++ b/custom_components/span_panel/repairs.py @@ -22,7 +22,7 @@ ) import voluptuous as vol -from .ca_repairs import CA_CHANGED_ISSUE_PREFIX +from .ca_repairs import CA_CHANGED_ISSUE_PREFIX, CA_UNUSABLE_ISSUE_PREFIX from .config_flow_validation import ( as_port, async_ca_signs_panel_leaf, @@ -150,4 +150,12 @@ async def async_create_fix_flow( if issue_id.startswith(CA_CHANGED_ISSUE_PREFIX): entry_id = issue_id.removeprefix(CA_CHANGED_ISSUE_PREFIX) return PanelCAChangedRepairFlow(entry_id) + if issue_id.startswith(CA_UNUSABLE_ISSUE_PREFIX): + # The same flow on purpose: an unreadable stored anchor and a rotated CA + # end in the same place — fetch what the panel advertises, check it + # signs the panel's own certificate, and pin it only on a person's + # explicit acceptance of the fingerprint. Only the story the issue tells + # differs, and that lives in its own translation. + entry_id = issue_id.removeprefix(CA_UNUSABLE_ISSUE_PREFIX) + return PanelCAChangedRepairFlow(entry_id) raise ValueError(f"{DOMAIN} raised no fixable issue with id {issue_id}") diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index 645df518..da6fe33e 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -19,6 +19,7 @@ "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", + "https_port_is_plaintext": "Port 80 is the plaintext HTTP port. Enter the port the panel serves TLS on — 443 unless something moved it.", "host_required": "Host is required", "invalid_auth": "Invalid authentication", "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "HTTPS port" }, "data_description": { - "host": "IP address or hostname of the SPAN Panel" + "host": "IP address or hostname of the SPAN Panel", + "https_port": "Shown for a panel with a pinned certificate authority. Leave at 443 unless a proxy or a port mapping moved the panel's TLS." }, "description": "Update the host address for this SPAN Panel. The panel serial number must match.", "title": "Reconfigure SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "SPAN Panel stored certificate authority is unreadable", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the panel's certificate authority?", + "description": "The certificate authority stored for {panel} can no longer be read, so its connection cannot be verified and the integration has stopped rather than continue unencrypted.\n\nThe panel is advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Compare the fingerprint against another install or the panel itself if you can — a device on your network standing in for your panel would present a fingerprint of its own." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel HTTPS port is serving an untrusted certificate", + "description": "Whatever answers at **{host}:{https_port}** presented a certificate that {panel}'s pinned certificate authority (SHA-256 {fingerprint}) does not currently validate — while the panel itself still advertises that same authority, so the authority has not changed.\n\nTwo things produce this. Something between Home Assistant and the panel — a reverse proxy, a port forward, a wrong HTTPS port on the entry — is terminating TLS with a certificate of its own. Or the panel's clock reset in an outage and its certificate is outside its validity window, which clears on its own once the panel has time again.\n\nThe integration keeps retrying without connecting, and never falls back to unencrypted HTTP. Check the HTTPS port with *Reconfigure* on the SPAN Panel entry, or remove whatever is intercepting the connection; if the panel just lost power, give it a little while." + }, "panel_leaf_name_mismatch": { "title": "SPAN Panel is not at the configured address", "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 645df518..da6fe33e 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -19,6 +19,7 @@ "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", + "https_port_is_plaintext": "Port 80 is the plaintext HTTP port. Enter the port the panel serves TLS on — 443 unless something moved it.", "host_required": "Host is required", "invalid_auth": "Invalid authentication", "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "HTTPS port" }, "data_description": { - "host": "IP address or hostname of the SPAN Panel" + "host": "IP address or hostname of the SPAN Panel", + "https_port": "Shown for a panel with a pinned certificate authority. Leave at 443 unless a proxy or a port mapping moved the panel's TLS." }, "description": "Update the host address for this SPAN Panel. The panel serial number must match.", "title": "Reconfigure SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "SPAN Panel stored certificate authority is unreadable", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the panel's certificate authority?", + "description": "The certificate authority stored for {panel} can no longer be read, so its connection cannot be verified and the integration has stopped rather than continue unencrypted.\n\nThe panel is advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Compare the fingerprint against another install or the panel itself if you can — a device on your network standing in for your panel would present a fingerprint of its own." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel HTTPS port is serving an untrusted certificate", + "description": "Whatever answers at **{host}:{https_port}** presented a certificate that {panel}'s pinned certificate authority (SHA-256 {fingerprint}) does not currently validate — while the panel itself still advertises that same authority, so the authority has not changed.\n\nTwo things produce this. Something between Home Assistant and the panel — a reverse proxy, a port forward, a wrong HTTPS port on the entry — is terminating TLS with a certificate of its own. Or the panel's clock reset in an outage and its certificate is outside its validity window, which clears on its own once the panel has time again.\n\nThe integration keeps retrying without connecting, and never falls back to unencrypted HTTP. Check the HTTPS port with *Reconfigure* on the SPAN Panel entry, or remove whatever is intercepting the connection; if the panel just lost power, give it a little while." + }, "panel_leaf_name_mismatch": { "title": "SPAN Panel is not at the configured address", "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 90d5e826..9307040f 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -20,6 +20,7 @@ "cannot_connect": "No se logró establecer conexión con Span Panel", "fqdn_registration_failed": "No se pudo registrar el nombre de dominio en el panel o el certificado TLS no se actualizó a tiempo.", "host_required": "Se requiere host", + "https_port_is_plaintext": "El puerto 80 es el puerto HTTP sin cifrar. Introduzca el puerto en el que el panel sirve TLS — 443 salvo que algo lo haya movido.", "invalid_auth": "Autenticación invalida", "proximity_failed": "Proximidad no comprobada. Por favor abra y cierre la puerta del panel 3 veces e intente de nuevo.", "unknown": "Error inesperado" @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "Puerto HTTPS" }, "data_description": { - "host": "Dirección IP o nombre de host del SPAN Panel" + "host": "Dirección IP o nombre de host del SPAN Panel", + "https_port": "Se muestra para un panel con autoridad de certificación fijada. Deje 443 salvo que un proxy o una redirección de puertos haya movido el TLS del panel." }, "description": "Actualice la dirección de host para este SPAN Panel. El número de serie del panel debe coincidir.", "title": "Reconfigurar SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "La autoridad de certificación almacenada del SPAN Panel es ilegible", + "fix_flow": { + "step": { + "confirm": { + "title": "¿Confiar en la autoridad de certificación del panel?", + "description": "La autoridad de certificación almacenada para {panel} ya no se puede leer, por lo que su conexión no puede verificarse y la integración se ha detenido en lugar de continuar sin cifrado.\n\nEl panel está anunciando esta autoridad de certificación:\n\n**{fingerprint}**\n\nAceptar la fija y reconecta. Compare la huella con otra instalación o con el propio panel si puede — un dispositivo de su red que suplante al panel presentaría una huella propia." + } + }, + "abort": { + "entry_gone": "Ese SPAN Panel ya no está configurado.", + "ca_unreadable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible e inténtelo de nuevo.", + "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que publicó, por lo que esa autoridad no se le ha ofrecido para aceptar y no se ha cambiado nada. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, e inténtelo de nuevo." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "El puerto HTTPS del SPAN Panel sirve un certificado no fiable", + "description": "Lo que responde en **{host}:{https_port}** presentó un certificado que la autoridad de certificación fijada de {panel} (SHA-256 {fingerprint}) no valida actualmente — mientras el propio panel sigue anunciando esa misma autoridad, así que la autoridad no ha cambiado.\n\nDos cosas producen esto. Algo entre Home Assistant y el panel — un proxy inverso, una redirección de puertos, un puerto HTTPS incorrecto en la entrada — está terminando TLS con un certificado propio. O el reloj del panel se reinició en un apagón y su certificado está fuera de su período de validez, lo que se resuelve solo cuando el panel vuelve a tener hora.\n\nLa integración sigue reintentando sin conectarse, y nunca recurre a HTTP sin cifrar. Compruebe el puerto HTTPS con *Reconfigurar* en la entrada del SPAN Panel, o retire lo que esté interceptando la conexión; si el panel acaba de perder la alimentación, dele un tiempo." + }, "panel_leaf_name_mismatch": { "title": "El SPAN Panel no está en la dirección configurada", "description": "{panel} está configurado como **{host}**, pero el certificado que sirve solo nombra: **{leaf_names}**. Es probable que el panel se haya movido, o que la dirección se guardara con un nombre por el que el panel no se conoce a sí mismo.\n\nUse *Reconfigurar* en la entrada del SPAN Panel para apuntarla a uno de los nombres anteriores. Mientras tanto, la integración sigue reintentando y se recuperará por sí sola si el panel vuelve a {host}." diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 488f01ff..35b2b8bf 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -20,6 +20,7 @@ "cannot_connect": "Échec de la connexion au Span Panel", "fqdn_registration_failed": "Impossible d'enregistrer le nom de domaine auprès du panneau ou le certificat TLS n'a pas été mis à jour à temps.", "host_required": "L'hôte est requis", + "https_port_is_plaintext": "Le port 80 est le port HTTP en clair. Saisissez le port sur lequel le panneau sert TLS — 443 sauf si quelque chose l'a déplacé.", "invalid_auth": "Authentification invalide", "proximity_failed": "Proximité non prouvée. Veuillez ouvrir et fermer la porte du panneau 3 fois et réessayer.", "unknown": "Erreur inattendue" @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Hôte" + "host": "Hôte", + "https_port": "Port HTTPS" }, "data_description": { - "host": "Adresse IP ou nom d'hôte du SPAN Panel" + "host": "Adresse IP ou nom d'hôte du SPAN Panel", + "https_port": "Affiché pour un panneau dont l'autorité de certification est épinglée. Laissez 443 sauf si un proxy ou une redirection de port a déplacé le TLS du panneau." }, "description": "Mettez à jour l'adresse de l'hôte pour ce SPAN Panel. Le numéro de série du panneau doit correspondre.", "title": "Reconfigurer le SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "L'autorité de certification enregistrée du SPAN Panel est illisible", + "fix_flow": { + "step": { + "confirm": { + "title": "Faire confiance à l'autorité de certification du panneau ?", + "description": "L'autorité de certification enregistrée pour {panel} ne peut plus être lue, sa connexion ne peut donc plus être vérifiée et l'intégration s'est arrêtée plutôt que de continuer sans chiffrement.\n\nLe panneau annonce cette autorité de certification :\n\n**{fingerprint}**\n\nAccepter l'épingle et reconnecte. Comparez l'empreinte avec une autre installation ou avec le panneau lui-même si possible — un appareil de votre réseau se faisant passer pour le panneau présenterait sa propre empreinte." + } + }, + "abort": { + "entry_gone": "Ce SPAN Panel n'est plus configuré.", + "ca_unreadable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et réessayez.", + "ca_leaf_mismatch": "Le certificat servi par ce panneau n'est pas signé par l'autorité qu'il a publiée ; cette autorité ne vous a donc pas été proposée et rien n'a été modifié. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "Le port HTTPS du SPAN Panel sert un certificat non fiable", + "description": "Ce qui répond sur **{host}:{https_port}** a présenté un certificat que l'autorité de certification épinglée de {panel} (SHA-256 {fingerprint}) ne valide pas actuellement — alors que le panneau lui-même annonce toujours cette même autorité, qui n'a donc pas changé.\n\nDeux choses produisent cela. Quelque chose entre Home Assistant et le panneau — un proxy inverse, une redirection de port, un port HTTPS erroné sur l'entrée — termine TLS avec son propre certificat. Ou l'horloge du panneau s'est réinitialisée pendant une coupure et son certificat est hors de sa période de validité, ce qui se résout de lui-même dès que le panneau retrouve l'heure.\n\nL'intégration continue de réessayer sans se connecter, et ne revient jamais au HTTP non chiffré. Vérifiez le port HTTPS via *Reconfigurer* sur l'entrée SPAN Panel, ou retirez ce qui intercepte la connexion ; si le panneau vient de perdre l'alimentation, laissez-lui un peu de temps." + }, "panel_leaf_name_mismatch": { "title": "Le SPAN Panel n'est pas à l'adresse configurée", "description": "{panel} est configuré comme **{host}**, mais le certificat qu'il présente ne nomme que : **{leaf_names}**. Le panneau a probablement changé d'adresse, ou l'adresse a été enregistrée sous un nom que le panneau ne se connaît pas.\n\nUtilisez *Reconfigurer* sur l'entrée SPAN Panel pour la pointer vers l'un des noms ci-dessus. L'intégration continue de réessayer entre-temps et se rétablira d'elle-même si le panneau revient à {host}." diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 670fbf55..29eee67d 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -20,6 +20,7 @@ "cannot_connect": "スパンパネルへの接続に失敗しました", "fqdn_registration_failed": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。", "host_required": "ホストが必要です", + "https_port_is_plaintext": "ポート 80 は平文 HTTP のポートです。パネルが TLS を提供しているポートを入力してください — 移動されていなければ 443 です。", "invalid_auth": "認証が無効です", "proximity_failed": "近接が証明されませんでした。パネルのドアを3回開閉してから、もう一度お試しください。", "unknown": "予期しないエラー" @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "ホスト" + "host": "ホスト", + "https_port": "HTTPS ポート" }, "data_description": { - "host": "SPAN PanelのIPアドレスまたはホスト名" + "host": "SPAN PanelのIPアドレスまたはホスト名", + "https_port": "認証局が固定されているパネルで表示されます。プロキシやポート転送でパネルの TLS が移動していない限り、443 のままにしてください。" }, "description": "このSPAN Panelのホストアドレスを更新します。パネルのシリアル番号が一致する必要があります。", "title": "SPAN Panelの再設定" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "SPAN Panel の保存済み認証局が読み取れません", + "fix_flow": { + "step": { + "confirm": { + "title": "パネルの認証局を信頼しますか?", + "description": "{panel} に保存されている認証局が読み取れなくなったため、接続を検証できず、暗号化なしで続行する代わりに統合を停止しました。\n\nパネルは次の認証局を公開しています:\n\n**{fingerprint}**\n\n承認するとこの認証局が固定され、再接続します。可能であれば、フィンガープリントを別のインストールまたはパネル本体と比較してください — ネットワーク上でパネルになりすます機器は、独自のフィンガープリントを提示します。" + } + }, + "abort": { + "entry_gone": "その SPAN Panel はもう設定されていません。", + "ca_unreadable": "パネルの認証局を読み取れませんでした。パネルに到達できることを確認して、もう一度お試しください。", + "ca_leaf_mismatch": "このパネルが提示する証明書は、パネルが公開した認証局によって署名されていないため、その認証局は承認対象として提示されず、何も変更されていません。HTTPS ポートが正しいこと、接続を傍受するものがないことを確認して、もう一度お試しください。" + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel の HTTPS ポートが信頼できない証明書を提示しています", + "description": "**{host}:{https_port}** で応答しているものは、{panel} の固定された認証局(SHA-256 {fingerprint})が現在検証できない証明書を提示しました — 一方でパネル本体は同じ認証局を公開し続けているため、認証局は変更されていません。\n\n原因は二つ考えられます。Home Assistant とパネルの間の何か — リバースプロキシ、ポートフォワード、エントリの誤った HTTPS ポート — が独自の証明書で TLS を終端している場合。または、停電でパネルの時計がリセットされ、証明書が有効期間外になっている場合で、こちらはパネルが時刻を取得し直せば自然に解消します。\n\n統合は接続せずに再試行を続け、暗号化されていない HTTP には決して戻りません。SPAN Panel エントリの *再設定* で HTTPS ポートを確認するか、接続を傍受しているものを取り除いてください。パネルが停電直後の場合は、しばらく待ってください。" + }, "panel_leaf_name_mismatch": { "title": "SPAN Panel が設定されたアドレスにありません", "description": "{panel} は **{host}** として設定されていますが、パネルが提供する証明書は **{leaf_names}** しか名前として持っていません。パネルのアドレスが変わったか、パネル自身が認識していない名前でアドレスが保存された可能性があります。\n\nSPAN Panel のエントリで *再構成* を使い、上記のいずれかの名前を指すように変更してください。その間も統合は再試行を続け、パネルが {host} に戻れば自動的に復旧します。" diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index d97042ab..bfe126c5 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -20,6 +20,7 @@ "cannot_connect": "Falha ao ligar ao Painel Span", "fqdn_registration_failed": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo.", "host_required": "O anfitrião é necessário", + "https_port_is_plaintext": "A porta 80 é a porta HTTP sem cifra. Introduza a porta em que o painel serve TLS — 443 a menos que algo a tenha movido.", "invalid_auth": "Autenticação inválida", "proximity_failed": "Proximidade não comprovada. Por favor, abra e feche a porta do painel 3 vezes e tente novamente.", "unknown": "Erro inesperado" @@ -86,10 +87,12 @@ }, "reconfigure": { "data": { - "host": "Anfitrião" + "host": "Anfitrião", + "https_port": "Porta HTTPS" }, "data_description": { - "host": "Endereço IP ou nome do host do SPAN Panel" + "host": "Endereço IP ou nome do host do SPAN Panel", + "https_port": "Mostrado para um painel com autoridade de certificação fixada. Deixe 443 a menos que um proxy ou um reencaminhamento de porta tenha movido o TLS do painel." }, "description": "Atualize o endereço do anfitrião para este SPAN Panel. O número de série do painel deve coincidir.", "title": "Reconfigurar SPAN Panel" @@ -901,6 +904,26 @@ } } }, + "panel_ca_unusable": { + "title": "A autoridade de certificação armazenada do SPAN Panel está ilegível", + "fix_flow": { + "step": { + "confirm": { + "title": "Confiar na autoridade de certificação do painel?", + "description": "A autoridade de certificação armazenada para {panel} já não pode ser lida, pelo que a sua ligação não pode ser verificada e a integração parou em vez de continuar sem cifragem.\n\nO painel está a anunciar esta autoridade de certificação:\n\n**{fingerprint}**\n\nAceitar fixa-a e reconecta. Compare a impressão digital com outra instalação ou com o próprio painel se puder — um dispositivo na sua rede a fazer-se passar pelo painel apresentaria uma impressão digital própria." + } + }, + "abort": { + "entry_gone": "Esse SPAN Panel já não está configurado.", + "ca_unreadable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e tente novamente.", + "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou, pelo que essa autoridade não lhe foi oferecida para aceitar e nada foi alterado. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "A porta HTTPS do SPAN Panel está a servir um certificado não confiável", + "description": "O que responde em **{host}:{https_port}** apresentou um certificado que a autoridade de certificação fixada de {panel} (SHA-256 {fingerprint}) não valida atualmente — enquanto o próprio painel continua a anunciar essa mesma autoridade, pelo que a autoridade não mudou.\n\nDuas coisas produzem isto. Algo entre o Home Assistant e o painel — um proxy inverso, um reencaminhamento de porta, uma porta HTTPS errada na entrada — está a terminar TLS com um certificado próprio. Ou o relógio do painel foi reposto numa falha de energia e o seu certificado está fora do período de validade, o que se resolve sozinho assim que o painel voltar a ter horas.\n\nA integração continua a tentar sem se ligar, e nunca recorre a HTTP sem cifra. Verifique a porta HTTPS com *Reconfigurar* na entrada do SPAN Panel, ou remova o que está a intercetar a ligação; se o painel acabou de perder energia, dê-lhe algum tempo." + }, "panel_leaf_name_mismatch": { "title": "O SPAN Panel não está no endereço configurado", "description": "{panel} está configurado como **{host}**, mas o certificado que serve nomeia apenas: **{leaf_names}**. É provável que o painel tenha mudado de endereço, ou que o endereço tenha sido guardado com um nome pelo qual o painel não se conhece a si próprio.\n\nUse *Reconfigurar* na entrada do SPAN Panel para a apontar para um dos nomes acima. Entretanto, a integração continua a tentar e recupera sozinha se o painel voltar a {host}." diff --git a/pyproject.toml b/pyproject.toml index 71b70784..e51bc710 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.3", - "span-panel-api==3.3.0", + "span-panel-api==3.4.0", # Separate distributions, not dependencies of the bootstrap: adapters are found # through the `span_panel_api.schema_adapters` entry-point group, so each one has # to be installed for discovery to see it. manifest.json carries the same three, diff --git a/requirements_test.txt b/requirements_test.txt index 6d01ab3f..b6b87133 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.358 homeassistant==2026.8.3 -span-panel-api==3.3.0 +span-panel-api==3.4.0 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 diff --git a/tests/test_runtime_rest_transport.py b/tests/test_runtime_rest_transport.py new file mode 100644 index 00000000..3eeb889d --- /dev/null +++ b/tests/test_runtime_rest_transport.py @@ -0,0 +1,571 @@ +"""The runtime client rides the pinned transport, and refuses to ride without it. + +Issue #264: every other REST call site adopted `panel_rest_transport` when the +pin landed — config flow, reauth, repairs, `rotate_credentials` — and the one +that runs on every startup did not. The schema fetch inside `connect()` stayed +on plaintext HTTP with the pin sitting unused in `entry.data`, which is exactly +the condition the library's transport warning names. + +Fail closed, both ways it can fail. A stored anchor that cannot be read is not +downgraded to plaintext at the one call that runs unattended on every boot; a +certificate the pin rejects is not retried into submission. Each gets a Repair, +because both need a person: the first needs a new anchor accepted, the second is +either a legitimately rotated CA (accept it) or something standing in front of +the panel's TLS port (investigate it) — and which of those it is gets diagnosed +the same way the library diagnoses the MQTT side, by re-reading the advertised +CA and comparing fingerprints. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.config_entries import ConfigEntryError, ConfigEntryNotReady +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.httpx_client import get_async_client +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api.exceptions import ( + SpanPanelConnectionError, + SpanPanelTLSVerificationError, + SpanPanelValidationError, +) + +from custom_components.span_panel import async_remove_entry, async_setup_entry +from custom_components.span_panel.ca_repairs import ( + ca_changed_issue_id, + ca_unusable_issue_id, + rest_tls_untrusted_issue_id, +) +from custom_components.span_panel.config_flow_validation import LeafProbeResult, LeafVerdict +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, +) +from custom_components.span_panel.leaf_repairs import leaf_name_mismatch_issue_id + +from .factories import SpanPanelSnapshotFactory + +PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" +OTHER_PEM = "-----BEGIN CERTIFICATE-----\nb3RoZXI=\n-----END CERTIFICATE-----\n" +SERIAL = "sp3-rest-001" + + +def _entry(hass: HomeAssistant, **data_overrides: object) -> MockConfigEntry: + data: dict[str, object] = { + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.50", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-pass", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + } + data.update(data_overrides) + entry = MockConfigEntry( + domain=DOMAIN, data=data, entry_id="entry-rest", title=SERIAL, unique_id=SERIAL + ) + entry.add_to_hass(hass) + return entry + + +def _happy_client() -> MagicMock: + client = MagicMock() + client.connect = AsyncMock() + client.close = AsyncMock() + return client + + +def _happy_coordinator() -> MagicMock: + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number=SERIAL) + return coordinator + + +@contextmanager +def _full_setup(client: MagicMock, hass: HomeAssistant) -> Iterator[MagicMock]: + """Everything a successful setup needs mocked, yielding the client class.""" + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ) as mock_client_cls, + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=_happy_coordinator(), + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + yield mock_client_cls + + +class TestThePinReachesTheRuntimeClient: + """The transport decision follows the entry's pin, port by port.""" + + async def test_a_pinned_entry_hands_its_anchor_to_the_runtime_client( + self, hass: HomeAssistant + ) -> None: + """The pin stored on the entry anchors the schema fetch at connect.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + context = MagicMock(name="pinned-context") + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + _full_setup(client, hass) as mock_client_cls, + ): + assert await async_setup_entry(hass, entry) is True + + kwargs = mock_client_cls.call_args.kwargs + assert kwargs["ssl_context"] is context + # 443 by default — the stored plaintext port is the bridge's, not TLS's. + assert kwargs["panel_https_port"] == 443 + assert kwargs["panel_http_port"] == 80 + # The shared client stays: the library ignores it under a context, and + # it is what the unpinned plaintext path uses. + assert kwargs["httpx_client"] is get_async_client(hass) + + async def test_a_pinned_entry_honours_a_configured_https_port( + self, hass: HomeAssistant + ) -> None: + """A stored HTTPS port names where the panel's TLS actually lives.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTPS_PORT: 8443}) + client = _happy_client() + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + _full_setup(client, hass) as mock_client_cls, + ): + assert await async_setup_entry(hass, entry) is True + + assert mock_client_cls.call_args.kwargs["panel_https_port"] == 8443 + + async def test_an_unpinned_entry_keeps_the_plaintext_transport( + self, hass: HomeAssistant + ) -> None: + """No pin is not a failure — it is the TOFU path, plaintext as designed.""" + entry = _entry(hass) + client = _happy_client() + + with _full_setup(client, hass) as mock_client_cls: + assert await async_setup_entry(hass, entry) is True + + kwargs = mock_client_cls.call_args.kwargs + assert kwargs["ssl_context"] is None + assert kwargs["panel_https_port"] is None + assert kwargs["panel_http_port"] == 80 + + +class TestFailingClosed: + """Both ways the pinned transport can fail end with a person, not a downgrade.""" + + async def test_an_unusable_stored_anchor_fails_setup_with_a_repair( + self, hass: HomeAssistant + ) -> None: + """A pin that cannot be read must not quietly become no pin at all.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: "not a certificate"}) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient") as mock_client_cls, + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + mock_client_cls.assert_not_called() + issue = ir.async_get(hass).async_get_issue(DOMAIN, ca_unusable_issue_id(entry.entry_id)) + assert issue is not None + assert issue.is_fixable + + async def test_a_rotated_ca_behind_a_rest_failure_raises_the_ca_changed_repair( + self, hass: HomeAssistant + ) -> None: + """The common legitimate cause, given the guided re-pin it already has.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=OTHER_PEM), + ), + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) + assert issue is not None + + async def test_an_untrusted_leaf_behind_an_unchanged_ca_retries_with_a_repair( + self, hass: HomeAssistant + ) -> None: + """The CA did not rotate and the leaf does not chain to it. + + A proxy terminating the TLS port with its own certificate, or a leaf an + outage's clock reset left outside its validity window — the probe cannot + tell them apart, so the repair names both and the entry retries: the + clock case heals itself, and never plaintext either way. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=LeafProbeResult(LeafVerdict.UNTRUSTED, ())), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue( + DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id) + ) + assert issue is not None + assert not issue.is_fixable + # Re-derived on every retry, so it must not outlive a restart that fixes it. + assert not issue.is_persistent + + async def test_a_moved_panel_behind_an_unchanged_ca_raises_the_leaf_repair( + self, hass: HomeAssistant + ) -> None: + """The panel is who it says it is, and not where the entry says it is. + + The leaf-mismatch repair already promises "the integration keeps + retrying", and setup keeps that promise with NotReady rather than + contradicting it with a terminal accusation of interception. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock( + return_value=LeafProbeResult( + LeafVerdict.NAME_MISMATCH, ("span-panel.local", "10.0.0.7") + ) + ), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue( + DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id) + ) + assert issue is not None + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) + is None + ) + + async def test_the_two_same_fingerprint_repairs_supersede_each_other( + self, hass: HomeAssistant + ) -> None: + """A panel cannot be both intercepted and merely moved; only the current verdict stands. + + Without the supersede, a panel that first probed UNTRUSTED and then + moved (or the reverse) showed both repairs at once — one saying "keep + retrying, it will recover", the other implying interception — for the + rest of the session. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + registry = ir.async_get(hass) + + async def _setup_with_verdict(result: LeafProbeResult) -> None: + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=result), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + await _setup_with_verdict(LeafProbeResult(LeafVerdict.UNTRUSTED, ())) + await _setup_with_verdict( + LeafProbeResult(LeafVerdict.NAME_MISMATCH, ("span-panel.local",)) + ) + assert registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + + await _setup_with_verdict(LeafProbeResult(LeafVerdict.UNTRUSTED, ())) + assert registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) + assert ( + registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) is None + ) + + async def test_an_unreachable_tls_port_behind_an_unchanged_ca_just_retries( + self, hass: HomeAssistant + ) -> None: + """The probe reached nothing, which is a panel mid-reboot, not a verdict.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=LeafProbeResult(LeafVerdict.UNREACHABLE, ())), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + assert registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) is None + + async def test_a_pinned_entry_that_cannot_connect_names_the_https_port( + self, hass: HomeAssistant + ) -> None: + """Nothing answering the TLS port must not read as 'panel is down'. + + The deferred-pin population can have TLS living somewhere 443 is not — + behind NAT, a port forward, a proxy — and was never asked for the port. + Their failure is a plain refused connection, indistinguishable from a + reboot, so the retry message is where the remedy has to travel. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock(side_effect=SpanPanelConnectionError("connection refused")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady, match="HTTPS port 443"), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_a_stored_https_port_of_80_fails_setup_cleanly( + self, hass: HomeAssistant + ) -> None: + """The library refuses the plaintext port under a context; setup must not crash. + + `SpanPanelValidationError` out of connect() used to escape as a raw + traceback with the client left open. It is a stored-configuration + problem, so it ends as a clear terminal error naming the remedy. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTPS_PORT: 80}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelValidationError("port=80 was passed together with an ssl_context") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_missing_evidence_retries_rather_than_escalating( + self, hass: HomeAssistant + ) -> None: + """A panel unreachable on its HTTP port is a panel mid-reboot, not a verdict.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) is None + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + + async def test_a_clean_connect_clears_the_standing_repairs( + self, hass: HomeAssistant + ) -> None: + """A handshake under the current pin refutes what the issues describe.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + registry = ir.async_get(hass) + registry.async_get_or_create( + DOMAIN, + rest_tls_untrusted_issue_id(entry.entry_id), + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_rest_tls_untrusted", + ) + registry.async_get_or_create( + DOMAIN, + ca_unusable_issue_id(entry.entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_unusable", + ) + client = _happy_client() + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + _full_setup(client, hass), + ): + assert await async_setup_entry(hass, entry) is True + + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + assert registry.async_get_issue(DOMAIN, ca_unusable_issue_id(entry.entry_id)) is None + + +class TestRemovalLeavesNothingBehind: + """Core deletes no issues when an entry is removed; the CA family goes here.""" + + async def test_removing_the_entry_clears_its_ca_repairs(self, hass: HomeAssistant) -> None: + """Persistent issues especially: nothing else can clear one whose entry is gone.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + registry = ir.async_get(hass) + for issue_id, fixable in ( + (ca_changed_issue_id(entry.entry_id), True), + (ca_unusable_issue_id(entry.entry_id), True), + (rest_tls_untrusted_issue_id(entry.entry_id), False), + (leaf_name_mismatch_issue_id(entry.entry_id), False), + ): + registry.async_get_or_create( + DOMAIN, + issue_id, + is_fixable=fixable, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_changed", + ) + + with ( + patch("custom_components.span_panel.async_forget_announcements", AsyncMock()), + patch("custom_components.span_panel.async_forget", AsyncMock()), + ): + await async_remove_entry(hass, entry) + + for issue_id in ( + ca_changed_issue_id(entry.entry_id), + ca_unusable_issue_id(entry.entry_id), + rest_tls_untrusted_issue_id(entry.entry_id), + leaf_name_mismatch_issue_id(entry.entry_id), + ): + assert registry.async_get_issue(DOMAIN, issue_id) is None, issue_id diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index a1d9ddff..570ab7ab 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -3156,3 +3156,197 @@ async def test_reconfigure_refuses_to_downgrade_an_unreadable_pin( assert entry.data[CONF_HOST] == "panel.example.com" assert entry.data[CONF_REGISTERED_FQDN] == "panel.example.com" assert entry.data[CONF_PANEL_CA_PEM] == UNREADABLE_CA_PEM + + +@pytest.mark.asyncio +async def test_reconfigure_offers_the_https_port_to_a_pinned_entry( + hass: HomeAssistant, +) -> None: + """A pinned entry's REST rides the HTTPS port, so reconfigure must let it be set. + + The population this exists for was never asked: TLS behind a port forward + with the HTTP port still on 80, which the CA step's gating skips. Before + this field, such an entry was enforced against port 443 with no in-product + way to say otherwise. + """ + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert CONF_HTTPS_PORT in result["data_schema"].schema + + +@pytest.mark.asyncio +async def test_reconfigure_keeps_the_host_only_form_for_an_unpinned_entry( + hass: HomeAssistant, +) -> None: + """No pin, no HTTPS transport — a TLS-port question would answer nothing.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert CONF_HTTPS_PORT not in result["data_schema"].schema + + +@pytest.mark.asyncio +async def test_reconfigure_rejects_the_plaintext_port_as_https( + hass: HomeAssistant, +) -> None: + """80 is the plaintext default; storing it as the TLS port bricks every setup.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + detect = AsyncMock(return_value=MOCK_V2_DETECTION) + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.config_flow.detect_api_version", new=detect), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_HTTPS_PORT: 80}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "https_port_is_plaintext"} + detect.assert_not_awaited() + assert CONF_HTTPS_PORT not in entry.data + + +@pytest.mark.asyncio +async def test_reconfigure_stores_a_corrected_https_port(hass: HomeAssistant) -> None: + """The submitted port is both probed against and persisted.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_HTTPS_PORT: 8443}, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert entry.data[CONF_HTTPS_PORT] == 8443 + # The probe ran against the port the user submitted, not the stored default. + assert leaf_host.await_args.args[2] == 8443 + + +@pytest.mark.asyncio +async def test_the_https_port_step_refuses_the_plaintext_port( + hass: HomeAssistant, +) -> None: + """80 stored as the TLS port fails every later setup; refuse it at the form.""" + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8080"}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert port_step["step_id"] == "panel_https_port" + + refused = await hass.config_entries.flow.async_configure( + port_step["flow_id"], {CONF_HTTPS_PORT: 80} + ) + + assert refused["type"] == FlowResultType.FORM + assert refused["step_id"] == "panel_https_port" + assert refused["errors"] == {"base": "https_port_is_plaintext"} From 235632392d69ae3f63c3995879da3ffbe9a4ebbf Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:12:52 -0700 Subject: [PATCH 02/10] fix(deps): pair pytest-homeassistant-custom-component with the pinned Home Assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each release of the test harness pins one exact Home Assistant, so its pin and the homeassistant pin are a single choice — the pyproject says so. The bump to 0.13.358 paired with 2026.9.0b0 against the pinned 2026.8.3, an unresolvable pair that CI's re-lock from PyPI reports as a sqlalchemy split (2.0.52 vs 2.0.51) while a stale local venv resolves nothing and notices nothing. 0.13.357 is the release that pins 2026.8.3. --- pyproject.toml | 7 ++++--- requirements_test.txt | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e51bc710..5c9fbfc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,9 +34,10 @@ dev = [ "pylint==4.0.6", # Pinned, not floored: each release pins one exact Home Assistant, so this # and the `homeassistant` pin above are a single choice rather than two. - # 0.13.354 is the release that pins 2026.8.0, the version this integration - # declares as its floor and therefore the one worth testing against. - "pytest-homeassistant-custom-component==0.13.358", + # 0.13.357 is the release that pins 2026.8.3 — the version pinned above; a + # bump to 0.13.358 pinned 2026.9.0b0 and made the pair unresolvable, which + # CI's re-lock from PyPI catches and a stale local venv does not. + "pytest-homeassistant-custom-component==0.13.357", "isort", "vulture>=2.14", ] diff --git a/requirements_test.txt b/requirements_test.txt index b6b87133..136aa655 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pytest>=9.0.3 pytest-asyncio>=1.4.0 setuptools>=65.7.0 -pytest-homeassistant-custom-component==0.13.358 +pytest-homeassistant-custom-component==0.13.357 homeassistant==2026.8.3 span-panel-api==3.4.0 span-panel-api-schema-0==1.1.2 From ce9711dc1401812b12d13048a41001258fd15de7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:15:20 -0700 Subject: [PATCH 03/10] style: fold in the formatter fix the first commit left unstaged --- custom_components/span_panel/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index bc8a96a0..cf4a03c7 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -437,9 +437,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> # downgrade the pin exists to prevent (issue #264). The transport's # own client is not used here; see the constructor comment below. try: - transport = panel_rest_transport( - hass, entry.data, allow_plaintext_fallback=False - ) + transport = panel_rest_transport(hass, entry.data, allow_plaintext_fallback=False) except PanelCaUnusableError as err: async_raise_ca_unusable(hass, entry, str(err)) raise ConfigEntryError( # noqa: TRY301 From fc5ddf1ef59d2ff99e7416241760caa11dfc901a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:18:23 -0700 Subject: [PATCH 04/10] docs: wrap the 2.1.1 changelog to the markdown line limit --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83686417..b5f83664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,19 +6,23 @@ All notable changes to this project will be documented in this file. ### Fixed -- **The startup warning that panel traffic is unencrypted is resolved, not silenced** — a pinned entry now reads the panel's schema over HTTPS verified against its pinned certificate authority, instead of plaintext HTTP (#264). +- **The startup warning that panel traffic is unencrypted is resolved, not silenced** — a pinned entry now reads the panel's schema over HTTPS verified against + its pinned certificate authority, instead of plaintext HTTP (#264). ### Added -- **A repair when the HTTPS port serves a certificate the pin does not validate** — a wrong HTTPS port, a proxy terminating TLS in front of the panel, or a panel whose clock reset in an outage; the integration keeps retrying without connecting and never falls back to plaintext, so the clock case heals on its own. +- **A repair when the HTTPS port serves a certificate the pin does not validate** — a wrong HTTPS port, a proxy terminating TLS in front of the panel, or a + panel whose clock reset in an outage; the integration keeps retrying without connecting and never falls back to plaintext, so the clock case heals on its own. - **A repair when the stored certificate authority can no longer be read**, guiding you through re-acquiring and confirming the panel's certificate authority. -- **Reconfigure now offers the HTTPS port on any panel with a pinned certificate authority**, for installs whose TLS lives behind a proxy or port forward and were never asked. +- **Reconfigure now offers the HTTPS port on any panel with a pinned certificate authority**, for installs whose TLS lives behind a proxy or port forward and + were never asked. ### Changed - **A panel whose certificate authority rotated is now also detected at startup's first read**, raising the same guided re-pin repair it already had. - **A moved panel detected at startup's first read raises the existing address repair and keeps retrying**, exactly as it promises. -- **When a pinned panel cannot be reached at all, the retry message names the HTTPS port and the Reconfigure remedy** instead of only reporting the panel as not ready. +- **When a pinned panel cannot be reached at all, the retry message names the HTTPS port and the Reconfigure remedy** instead of only reporting the panel as not + ready. ## [2.1.0] - 8/2026 From 5f01ad02bb028fa8fa79b3db1848e6ffc65892a8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:37:34 -0700 Subject: [PATCH 05/10] chore(release): 2.1.1b1 on span-panel-api 3.4.0 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 5b834a34..47cb63d3 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.0", + "version": "2.1.1b1", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 172fc1cd..363c8e6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.0" +version = "2.1.1b1" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} From 444e3accf0ba60cf8d726028d0bad6f140172190 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:26:28 -0700 Subject: [PATCH 06/10] security(discovery): probe a pinned entry's configured host over its pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Supervisor-discovery host check reads the serial that decides whether the entry moves, and it read it over plaintext with the entry's anchor sitting unused — the one probe of a configured host that did not ride the pin. Since span-panel-api 3.4.1 exempts the status endpoint from the plaintext warning, nothing in the log would say so either; the probe now takes the entry's own transport, on the add-on's newly published TLS port when it published one, because a reallocated port is the very case this probe exists for. Unpinned entries keep the plaintext probe they always had. (Direct checks run in lieu of the worktree-incompatible uv hooks: pytest 1676 passed, ruff, mypy all clean.) --- custom_components/span_panel/config_flow.py | 24 +++++++-- tests/test_v2_config_flow.py | 58 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 75c76ec0..e5edbe22 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -408,9 +408,27 @@ async def _async_hassio_host_update(self, host: str, port: int, serial: str) -> if not configured or configured == host: return {CONF_HOST: host} - detection = await detect_api_version( - configured, port=port, httpx_client=get_async_client(self.hass, verify_ssl=False) - ) + # The identity read below decides whether the entry moves, and a pinned + # entry owes that read its own transport — since 3.4.1 the library's + # plaintext warning is silent for this endpoint, so nothing else would + # say the serial arrived unverified. The TLS port follows the same rule + # as the plaintext one: the add-on's newly published port when it + # published one, because a reallocated port is the very case this probe + # exists for, else the transport's own. 80 is the one value the library + # refuses under a context, so it falls back to the TLS default rather + # than raising out of a background discovery. + transport = panel_rest_transport(self.hass, entry.data) + if transport.ssl_context is not None: + probe_port = self._https_port if self._https_port_known else transport.port + if probe_port == 80: + probe_port = DEFAULT_HTTPS_PORT + detection = await detect_api_version( + configured, port=probe_port, ssl_context=transport.ssl_context + ) + else: + detection = await detect_api_version( + configured, port=port, httpx_client=get_async_client(self.hass, verify_ssl=False) + ) reached = ( detection.api_version == "v2" and detection.status_info is not None diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 570ab7ab..659843a3 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -3350,3 +3350,61 @@ async def test_the_https_port_step_refuses_the_plaintext_port( assert refused["type"] == FlowResultType.FORM assert refused["step_id"] == "panel_https_port" assert refused["errors"] == {"base": "https_port_is_plaintext"} + + +@pytest.mark.asyncio +async def test_hassio_probes_a_pinned_entry_over_its_pin(hass: HomeAssistant) -> None: + """The identity read that decides a host move must not travel plaintext under a pin. + + The probe reads the serial out of the status answer, and 3.4.1's warning + exemption means nothing in the log says it went unverified — so the probe + owes a pinned entry its own transport. On the add-on's newly published TLS + port, because a reallocated port is the very case this probe exists for. + """ + _hassio_configured_entry(hass, **{CONF_PANEL_CA_PEM: FAKE_CA_PEM}) + context = MagicMock(name="pinned-context") + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + patch("custom_components.span_panel.config_flow.detect_api_version", probe), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({**MOCK_HASSIO_CONFIG, "https_port": 10090}), + ) + + call = next(c for c in probe.call_args_list if c.args[0] == "192.168.1.40") + assert call.kwargs["ssl_context"] is context + assert call.kwargs["port"] == 10090 + + +@pytest.mark.asyncio +async def test_hassio_pinned_probe_takes_the_stored_tls_port_when_none_is_published( + hass: HomeAssistant, +) -> None: + """No published TLS port means the entry's own is the only address for the pin.""" + _hassio_configured_entry(hass, **{CONF_PANEL_CA_PEM: FAKE_CA_PEM, CONF_HTTPS_PORT: 8443}) + context = MagicMock(name="pinned-context") + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + patch("custom_components.span_panel.config_flow.detect_api_version", probe), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + call = next(c for c in probe.call_args_list if c.args[0] == "192.168.1.40") + assert call.kwargs["ssl_context"] is context + assert call.kwargs["port"] == 8443 From 738d09c9c1b7265e60cb98a40ad22905c11f84f1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:33:48 -0700 Subject: [PATCH 07/10] chore(deps): span-panel-api 3.4.1 The release that stops a merely advertising, unconfigured panel from producing the plaintext-transport warning at every boot when discovery probes it. Pairs with the Supervisor-discovery probe moving onto the pin in the previous commit, which that release's warning exemption motivated. --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- requirements_test.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 47cb63d3..93111afe 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -23,7 +23,7 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==3.4.0", + "span-panel-api==3.4.1", "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], diff --git a/pyproject.toml b/pyproject.toml index 363c8e6a..a13cfc1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.8.3", - "span-panel-api==3.4.0", + "span-panel-api==3.4.1", # Separate distributions, not dependencies of the bootstrap: adapters are found # through the `span_panel_api.schema_adapters` entry-point group, so each one has # to be installed for discovery to see it. manifest.json carries the same three, diff --git a/requirements_test.txt b/requirements_test.txt index f18d9bdb..a934f73e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,6 @@ pytest-asyncio>=1.4.0 setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.357 homeassistant==2026.8.3 -span-panel-api==3.4.0 +span-panel-api==3.4.1 span-panel-api-schema-0==1.1.2 span-panel-api-schema-1==1.1.3 From 7f7c84025a51d103368567d312dfd9fe34c37f30 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:37:37 -0700 Subject: [PATCH 08/10] chore(release): 2.1.1b2 on span-panel-api 3.4.1 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 93111afe..15decb8c 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.1b1", + "version": "2.1.1b2", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index a13cfc1a..48efad8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.1b1" +version = "2.1.1b2" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} From aff2e30e51ab0a16b0a3af82047e0eec86e190ee Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:15:51 -0700 Subject: [PATCH 09/10] docs: one changelog sentence for #264, as a user reads it --- CHANGELOG.md | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f83664..a0f0cf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,8 @@ All notable changes to this project will be documented in this file. ### Fixed -- **The startup warning that panel traffic is unencrypted is resolved, not silenced** — a pinned entry now reads the panel's schema over HTTPS verified against - its pinned certificate authority, instead of plaintext HTTP (#264). - -### Added - -- **A repair when the HTTPS port serves a certificate the pin does not validate** — a wrong HTTPS port, a proxy terminating TLS in front of the panel, or a - panel whose clock reset in an outage; the integration keeps retrying without connecting and never falls back to plaintext, so the clock case heals on its own. -- **A repair when the stored certificate authority can no longer be read**, guiding you through re-acquiring and confirming the panel's certificate authority. -- **Reconfigure now offers the HTTPS port on any panel with a pinned certificate authority**, for installs whose TLS lives behind a proxy or port forward and - were never asked. - -### Changed - -- **A panel whose certificate authority rotated is now also detected at startup's first read**, raising the same guided re-pin repair it already had. -- **A moved panel detected at startup's first read raises the existing address repair and keeps retrying**, exactly as it promises. -- **When a pinned panel cannot be reached at all, the retry message names the HTTPS port and the Reconfigure remedy** instead of only reporting the panel as not - ready. +- **The startup warning that panel traffic is unencrypted is resolved** — a panel with a pinned certificate authority is now read over verified HTTPS, with + repairs and a Reconfigure HTTPS-port option covering the rare setups where the certificate or the port needs attention (#264). ## [2.1.0] - 8/2026 From 95c89974060a0584fcf5cd5b5c6cdfc03d67392c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:21:38 -0700 Subject: [PATCH 10/10] chore(release): 2.1.1 --- custom_components/span_panel/manifest.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 15decb8c..8978f814 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -27,7 +27,7 @@ "span-panel-api-schema-0==1.1.2", "span-panel-api-schema-1==1.1.3" ], - "version": "2.1.1b2", + "version": "2.1.1", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 48efad8d..fd661c3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.1.1b2" +version = "2.1.1" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"}