diff --git a/CHANGELOG.md b/CHANGELOG.md index 04be4a4..c82d6b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.5.2 — discovery hands over an address, not an internal alias + +### Fixed + +- **Discovery now gives Home Assistant the panel's address rather than an internal add-on hostname**, which Home Assistant writes over the address on a panel + you already added and which no certificate names, so a panel added by IP stopped verifying. + ## 2.5.1 — the address a client can actually verify us by An existing install corrects itself on the next start: a stored certificate that names the wrong address is already treated as unfit and re-signed, so nothing diff --git a/panelbench/Dockerfile b/panelbench/Dockerfile index 2b0d241..a245350 100644 --- a/panelbench/Dockerfile +++ b/panelbench/Dockerfile @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080 LABEL io.hass.name="SPAN PanelBench" \ io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \ io.hass.type="addon" \ - io.hass.version="2.5.1" \ + io.hass.version="2.5.2" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/panelbench/config.yaml b/panelbench/config.yaml index 5cd3fb1..963e5b0 100644 --- a/panelbench/config.yaml +++ b/panelbench/config.yaml @@ -1,6 +1,6 @@ name: "SPAN PanelBench" description: "Simulates a SPAN electrical panel for testing and upgrade modeling" -version: "2.5.1" +version: "2.5.2" slug: "panelbench" url: "https://github.com/SpanPanel/panelbench" image: "ghcr.io/spanpanel/panelbench/{arch}" diff --git a/pyproject.toml b/pyproject.toml index 2997071..4e27067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "panelbench" -version = "2.5.1" +version = "2.5.2" description = "Standalone eBus simulator for SPAN panels" requires-python = ">=3.14" dependencies = [ diff --git a/src/panelbench/__init__.py b/src/panelbench/__init__.py index d70b9e1..f2bddff 100644 --- a/src/panelbench/__init__.py +++ b/src/panelbench/__init__.py @@ -1,3 +1,3 @@ """Standalone eBus simulator for SPAN panels.""" -__version__ = "2.5.1" +__version__ = "2.5.2" diff --git a/src/panelbench/app.py b/src/panelbench/app.py index b89ad1f..d5d8c99 100644 --- a/src/panelbench/app.py +++ b/src/panelbench/app.py @@ -797,7 +797,9 @@ async def run(self) -> None: # 3b. Initialise Supervisor Discovery (add-on mode). from panelbench.supervisor_discovery import SupervisorDiscovery - self._supervisor_discovery = SupervisorDiscovery() + self._supervisor_discovery = SupervisorDiscovery( + advertise_address=self._advertise_address, + ) if self._supervisor_discovery.is_available: await self._supervisor_discovery.cleanup_stale() _LOGGER.info("Supervisor Discovery: available (add-on mode)") diff --git a/src/panelbench/supervisor_discovery.py b/src/panelbench/supervisor_discovery.py index 345d996..3b832b6 100644 --- a/src/panelbench/supervisor_discovery.py +++ b/src/panelbench/supervisor_discovery.py @@ -47,9 +47,10 @@ def _payload(body: object) -> dict[str, object]: class SupervisorDiscovery: """Manages Supervisor Discovery entries for simulated panels.""" - def __init__(self) -> None: + def __init__(self, advertise_address: str | None = None) -> None: self._token = os.environ.get("SUPERVISOR_TOKEN") self._entries: dict[str, str] = {} # serial -> discovery UUID + self._advertise_address = advertise_address @property def is_available(self) -> bool: @@ -106,8 +107,20 @@ async def register_panel( ) -> None: """Register a panel with the Supervisor Discovery API. - The host is always the container hostname — HA Core resolves it - via Docker DNS. No-ops if not in add-on mode. + The host is the advertised address when one is configured, and the + container hostname only when none is. Both are reachable from HA + Core, but only the address is in the certificate this panel serves, + and the hostname is what a consumer cannot verify us by: under + ``host_network: true`` it is a per-add-on alias for the host rather + than a name the leaf was issued for. + + It also has to be the address because ``async_step_hassio`` rewrites + an existing entry's host to whatever is registered here. A panel + someone added by IP is silently re-pointed at this alias, which then + fails hostname verification -- and the alias changes when the panel + moves between add-ons, where the address does not. + + No-ops if not in add-on mode. ``https_port`` is published beside the bootstrap port because a simulated panel serves TLS on a port only this process knows. The @@ -118,7 +131,7 @@ async def register_panel( if not self._token: return - host = _container_hostname() + host = self._advertise_address or _container_hostname() payload = { "service": _SERVICE_NAME, "config": { diff --git a/tests/test_supervisor_discovery.py b/tests/test_supervisor_discovery.py index acab201..3444818 100644 --- a/tests/test_supervisor_discovery.py +++ b/tests/test_supervisor_discovery.py @@ -18,6 +18,14 @@ def discovery() -> SupervisorDiscovery: return d +@pytest.fixture +def discovery_with_address() -> SupervisorDiscovery: + """Add-on mode with an advertised address configured.""" + d = SupervisorDiscovery(advertise_address="192.168.65.19") + d._token = "test-token" + return d + + @pytest.fixture def discovery_no_token() -> SupervisorDiscovery: """Discovery client without token (standalone mode).""" @@ -81,6 +89,50 @@ async def test_register_panel_publishes_both_ports(discovery: SupervisorDiscover assert config["https_port"] == 9081 +async def test_the_advertised_address_is_registered_as_the_host( + discovery_with_address: SupervisorDiscovery, +): + """The host is the address the leaf names, not the container alias. + + `async_step_hassio` rewrites an existing entry's host to whatever is + registered here, so registering the alias silently re-points a panel that + someone added by IP at a name the certificate does not cover. + """ + mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}}) + with ( + patch("aiohttp.ClientSession", return_value=mock_session), + patch( + "panelbench.supervisor_discovery._container_hostname", + return_value="f8c38f2b-panelbench", + ), + ): + await discovery_with_address.register_panel("sim-001", 8081) + + config = mock_session.post.call_args.kwargs["json"]["config"] + assert config["host"] == "192.168.65.19" + assert config["host"] != "f8c38f2b-panelbench" + + +async def test_the_container_hostname_is_the_fallback(discovery: SupervisorDiscovery): + """With no address configured there is nothing else to publish. + + Docker DNS still resolves it, so a panel with no advertised address stays + discoverable rather than registering a host of ``None``. + """ + mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}}) + with ( + patch("aiohttp.ClientSession", return_value=mock_session), + patch( + "panelbench.supervisor_discovery._container_hostname", + return_value="f8c38f2b-panelbench", + ), + ): + await discovery.register_panel("sim-001", 8081) + + config = mock_session.post.call_args.kwargs["json"]["config"] + assert config["host"] == "f8c38f2b-panelbench" + + async def test_unregister_panel_deletes_from_supervisor(discovery: SupervisorDiscovery): """unregister_panel DELETEs /discovery/{uuid}.""" discovery._entries["sim-001"] = "disc-uuid-123" diff --git a/uv.lock b/uv.lock index 25f581f..ca4d139 100644 --- a/uv.lock +++ b/uv.lock @@ -677,7 +677,7 @@ wheels = [ [[package]] name = "panelbench" -version = "2.5.1" +version = "2.5.2" source = { editable = "." } dependencies = [ { name = "aiohttp" },