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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

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** — 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

### In short
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
220 changes: 204 additions & 16 deletions custom_components/span_panel/__init__.py

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions custom_components/span_panel/ca_repairs.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,25 @@
_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:
"""Issue id for one entry's CA change."""
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,
Expand Down Expand Up @@ -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))
Loading