Skip to content

fix: serve the panel API over TLS, and advertise the right address - #48

Merged
cayossarian merged 4 commits into
mainfrom
fix/tls-listener-and-advertise-address
Aug 29, 2026
Merged

fix: serve the panel API over TLS, and advertise the right address#48
cayossarian merged 4 commits into
mainfrom
fix/tls-listener-and-advertise-address

Conversation

@cayossarian

@cayossarian cayossarian commented Aug 29, 2026

Copy link
Copy Markdown
Member

Three separate defects, each of which on its own stopped a simulated panel being added to Home Assistant. Split into one commit per concern.

The panel never served TLS

BootstrapHttpServer bound plain HTTP only. The panel minted a server certificate and published the authority that signed it at /api/v2/certificate/ca, but nothing ever served that certificate — the leaf only ever fed mosquitto.

The integration pins the published authority before it sends the panel passphrase, and checks the anchor validates what the panel actually serves. With no TLS listener that check cannot pass at any port, so the config flow ended at "The certificate this panel serves is not signed by the authority it published" whatever was entered. The message is misleading: async_panel_leaf_host returns the same answer for a refused connection as for a genuine mismatch, and the truth here was that nothing answered.

Each panel now binds HTTPS alongside HTTP off one runner, using the leaf already being generated. Both listeners serve the whole route table — deciding a panel is a SPAN panel at all, and fetching the authority, both necessarily happen before a consumer holds an anchor, so neither can sit behind one.

Home Assistant had to ask for the TLS port, and could not be told

The port is allocated per panel and reallocated across restarts, so this add-on is the only party that knows it. Neither discovery path published it, so the integration fell back to prompting with a 443 default that is right for real hardware and never right here.

It is now published both ways: https_port through the Supervisor, httpsPort in the mDNS TXT.

Supervisor replies were read from the wrong level

Every Supervisor endpoint answers {"result": ..., "data": {...}}; both calls read from the top level of the body.

Registration never found the uuid and logged the whole successful reply as an invalid one. The entry was created — the simulator just did not record what it was called, so unregister_panel had nothing to delete and every entry leaked; cleanup_stale read discovery off the same wrong level, always saw an empty list, and could not reap them on the next start either. Entries accumulated in the Supervisor across every restart.

The advertised address was the default gateway

run.sh took $3 of the default route — the next hop. The comment above it explained why that was the host, which holds for a bridge-networked container; this add-on sets host_network: true, so the container reads the host's own routing table and the value picked up was the upstream router.

That address went into the certificate SAN and the mDNS advertisement, so a neighbouring device was named as the panel and no address a client could use passed verification. It also broke the simulator-to-panelbench upgrade rehearsal, leaving only a Supervisor hostname that differs between the two.

Now taken from ip -4 route get, a routing-table lookup rather than a probe — it sends no packets and needs nothing reachable — which yields the source address the kernel would put on a reply, and stays correct on an interface holding several addresses.

Existing installs need no intervention: the leaf-reuse check already rejects a certificate whose SAN omits the advertised address, so the next start re-signs it and leaves the authority alone.

Testing

486 passed, up from 473 on this branch point. Three failures remain (test_graph_builder_topology ×2, test_main_logging) and are pre-existing on main, unrelated to this change: the pin is ebus-sdk==0.23.1 while the vendored emitter targets the 0.1.x Device API.

New coverage, 13 tests:

  • TLS — binds real sockets with real certificates, because what is under test is exactly what an in-process test client bypasses. Verifies the leaf served on the TLS port validates against the CA fetched over HTTP with hostname checking on; the inverse test proves a client without that CA is rejected, so it would fail if verification were quietly off. The context is built the way the consumer builds it, with VERIFY_X509_STRICT cleared — real panels ship a minimal authority with no key identifier, and verifying more strictly would test a client that does not exist.
  • Discovery — both publishers carry the TLS port, and neither emits a port that is already the default.
  • run.sh — the function is extracted from the shipped script and executed against a stub ip, rather than restating the pipeline, since a copy would keep passing after the original drifted. That is precisely how this broke. Covers the gateway case, an on-link route where src shifts position, control-character stripping, a failing ip not aborting under set -euo pipefail, and the environment override.

Verified by falsification rather than reading: the old pipeline yields 192.168.65.1 and the new one 192.168.65.19 against identical stubbed routing output.

Note

Version is bumped minor, not patch: the TLS listener and the new discovery field are added capability. span_panel_simulator/CHANGELOG.md is left alone — it has been stale since 1.0.12 and was not updated for 1.1.0 either, so the root changelog is the maintained one. Worth deciding separately whether the add-on changelog should be retired or backfilled.

Follow-on: #49 carries two further fixes found by repeating the upgrade rehearsal against this build — the Supervisor discovery host, and matching panelbench's TLS port convention.

Every Supervisor endpoint answers `{"result": ..., "data": {...}}`, but both
calls here read the fields they wanted from the top level of the body.

Registering a panel therefore never found the uuid, and logged the whole
successful reply as an invalid one. The entry was created — the simulator just
did not record what it was called, so `unregister_panel` had nothing to delete
and every entry leaked. `cleanup_stale` read `discovery` off the same wrong
level, always saw an empty list, and could not reap them on the next start
either, so they accumulated in the Supervisor across restarts.

Unwrapped once in a helper rather than at each call site, so the shape is
stated in one place. The tests mocked a flat body the Supervisor never returns,
which is why this shipped; they now mock the real envelope.
The panel minted a server certificate and published the authority that signed
it, but nothing ever served that certificate: the per-panel bootstrap server
bound plain HTTP only. A consumer that pins the published authority — as the
Home Assistant integration does before it will send the panel passphrase —
checks that the anchor validates what the panel actually serves, and that check
could not pass at any port. The config flow failed at the certificate step no
matter what was entered, reporting a leaf mismatch when the truth was that
nothing answered.

Each panel now binds HTTPS alongside HTTP, off one runner, using the leaf that
was already being generated. Both listeners serve the whole route table rather
than splitting it: deciding a panel is a SPAN panel at all, and fetching the
authority, both happen before a consumer holds an anchor, so neither can sit
behind one — and which endpoints a consumer reaches over which listener is its
decision, not the panel's to hard-code.

The TLS port is allocated per panel from a pool shared with the HTTP one, so
one can never be handed out as the other, and it is published by both discovery
paths — `https_port` through the Supervisor, `httpsPort` in the mDNS TXT — so a
consumer is not left to assume 443, which is never right here.

The new tests bind real sockets with real certificates, because what is under
test is exactly what an in-process test client bypasses. They verify against a
context built the way the consumer builds it, with VERIFY_X509_STRICT cleared:
real panels ship a minimal authority with no key identifier, and verifying more
strictly here would test a client that does not exist and push this simulator
away from the hardware it stands in for.
The detection took `$3` of the default route, which is the next hop. The
comment above it explained why that was the host — true of a bridge-networked
container, and this add-on sets `host_network: true`, so the container shares
the host's network namespace and reads the host's own routing table. The value
picked up was the upstream router.

That address went into the leaf certificate's SAN and into the mDNS
advertisement, so a neighbouring device was named as the panel and there was no
address a client could reach the panel by that passed verification. It also
broke the simulator-to-panelbench upgrade rehearsal, since the only remaining
way in was a Supervisor hostname that differs between the two.

Now taken from `ip -4 route get`, which is a routing-table lookup rather than a
probe — it sends no packets and needs nothing reachable — and answers the
question that matters: the source address the kernel would put on a reply. That
also stays correct on an interface holding several addresses, where taking the
first one listed would be arbitrary. An address supplied by the environment now
wins over detection, which the previous unconditional assignment clobbered.

Existing installs need no intervention: the leaf-reuse check already rejects a
certificate whose SAN omits the advertised address, so the next start re-signs
it and leaves the authority alone.

The test executes the function out of the shipped run.sh against a stub `ip`
rather than restating the pipeline, since a copy would keep passing after the
original drifted — which is precisely how this broke.
Minor rather than patch: the panel gains a TLS listener and a new discovery
field, which is added capability, not only repair.
@cayossarian
cayossarian merged commit 076bac6 into main Aug 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant