Skip to content

fix: advertise the host's own address, and unwrap the Supervisor envelope - #20

Merged
cayossarian merged 3 commits into
mainfrom
fix/advertise-address-and-supervisor-envelope
Aug 29, 2026
Merged

fix: advertise the host's own address, and unwrap the Supervisor envelope#20
cayossarian merged 3 commits into
mainfrom
fix/advertise-address-and-supervisor-envelope

Conversation

@cayossarian

@cayossarian cayossarian commented Aug 29, 2026

Copy link
Copy Markdown
Member

Two independent defects, both of which made the add-on look healthy in its own
log while a consumer could not use what it published. Released as 2.5.1.

The advertised address was the upstream router

run.sh derived the address from ip route | awk '/default/ { print $3 }',
which is the gateway. That was correct for a bridge-networked container,
where the default gateway is the host, and became wrong when the add-on moved
to host_network: true: the container then shares the host's network
namespace and reads the host's routing table, so $3 names a neighbouring
device rather than us.

That address is what goes into the leaf certificate's SAN and into the mDNS
advertisement, so the effect was that no address a client could reach the
panel at was one it could verify the panel by. Observed on a real install:
both carried the VLAN gateway 192.168.65.1 while the host was
192.168.65.19.

Replaced with ip -4 route get 1.1.1.1, taking the token after src. That is
a routing-table lookup rather than a probe, so it sends no packets and needs
nothing at the far address to be reachable, and it answers the question that
actually matters -- which source address the kernel would put on a reply --
which also keeps it right on an interface holding several addresses. An
ADVERTISE_ADDRESS from the environment still wins, for run-local.sh and
for an operator on a multi-homed host.

Existing installs self-correct. A stored leaf that does not name the
advertised address is already unfit by certs.py::_leaf_is_fit, so
/data/certs is re-signed on the next start with no manual clearing.

The Supervisor response envelope was never unwrapped

Every Supervisor endpoint answers {"result": "ok", "data": {...}}, and both
readers in supervisor_discovery.py went straight to the top level. So
data.get("uuid") on a registration response was always None, which the
code correctly reported as invalid and refused to store:

register sim-40t-001 returned invalid uuid: {'result': 'ok', 'data': {'uuid': ...}}

With no uuid tracked, unregister_panel had nothing to delete on stop, and
cleanup_stale read data.get("discovery") the same way and saw an empty
list -- so both paths that could have removed a stale entry failed for the one
reason, and entries accumulated across restarts.

Added a single _payload helper used by both readers, so the envelope is
unwrapped in one place and a third reader cannot reintroduce the assumption.
It answers {} for a body that is not a dict or carries no dict data, and
the caller guards that discovery is a list, so a malformed response degrades
to doing nothing rather than raising into startup.

Release

2.5.1 is also the first release to ship the ebus-sdk ceiling fix merged
separately in #19, so the changelog entry lists it; this branch has been
rebased onto that merge and carries no copy of the commit.

Testing

tests/test_run_sh_advertise_address.py extracts detect_advertise_address
from the shipped run.sh and runs it against a stubbed ip on PATH, rather
than restating the pipeline -- a copy would keep passing after the original
drifted, and drift is precisely the failure being guarded. Six cases: the
host's address is taken, the gateway specifically is not, an on-link route
with no via still yields the source, control characters are stripped, a
failing ip leaves the address empty rather than aborting under set -euo pipefail, and an explicit ADVERTISE_ADDRESS is not overridden.

The Supervisor tests now serve enveloped bodies and fail without this change:
a top-level read of the enveloped fixture yields None for the uuid and []
for the entries, which is the production symptom exactly.

Full suite 390 passed after the rebase; pre-commit run --all-files clean.

@cayossarian
cayossarian force-pushed the fix/advertise-address-and-supervisor-envelope branch from c0ba1fb to 239cab6 Compare August 29, 2026 01:02
`ip route | awk '/default/ { print $3 }'` takes the gateway. That was right
for a bridge-networked container, where the default gateway is the host, and
became wrong when the add-on moved to `host_network: true`: the container then
shares the host's network namespace and reads the host's routing table, so the
gateway is a neighbouring device rather than us.

The address is what goes into the leaf certificate's SAN and into the mDNS
advertisement, so the effect was that no address a client can reach the panel
at was one it could verify the panel by. Observed on a real install: both
carried the VLAN gateway 192.168.65.1 while the host was 192.168.65.19.

`ip -4 route get 1.1.1.1` answers the question that actually matters --
which source address the kernel would put on a reply -- as a routing-table
lookup rather than a probe, so it sends no packets and needs nothing at the
far address to be reachable.

A stored leaf naming the wrong address is already unfit by `_leaf_is_fit`, so
an existing /data/certs install re-signs on its next start.

The test extracts the function from the shipped run.sh and runs it against a
stubbed `ip`, rather than restating the pipeline: a copy would keep passing
after the original drifted, and drift is the failure being guarded.
…s stored

Every Supervisor endpoint answers `{"result": "ok", "data": {...}}`, and both
readers went straight to the top level. `data.get("uuid")` on a registration
response was therefore always None, which the code correctly reported as an
invalid uuid and refused to store:

    register sim-40t-001 returned invalid uuid: {'result': 'ok', 'data': {'uuid': ...}}

With no uuid tracked, `unregister_panel` had nothing to delete on stop, so
discovery entries accumulated across restarts. `cleanup_stale` read
`data.get("discovery")` the same way and saw an empty list, so the entries the
previous run left behind were never collected either -- the two paths that
could have removed a stale entry failed for one reason.

One `_payload` helper for both, so the envelope is unwrapped in a single place
and a third reader cannot reintroduce the assumption. It answers {} for a body
that is not a dict or carries no dict `data`, and the caller guards that
`discovery` is a list, so a malformed response degrades to doing nothing
rather than raising into startup.

The existing tests now serve enveloped bodies. They fail without this change:
a top-level read of the enveloped fixture yields None for the uuid and [] for
the entries, which is exactly the production symptom.
Version synced across pyproject, config.yaml, Dockerfile and __init__.py by
scripts/sync-version.sh from pyproject as the source of truth, and uv.lock
regenerated so the recorded project version matches.
@cayossarian
cayossarian force-pushed the fix/advertise-address-and-supervisor-envelope branch from f911c62 to 21554e2 Compare August 29, 2026 01:14
@cayossarian
cayossarian merged commit 1429e57 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