diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2384a..2936443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 1.2.0 — 2026-08-28 — the panel serves TLS, so Home Assistant can finish adding it + +**Each panel now serves its bootstrap API over HTTPS as well as HTTP**, on a second port allocated alongside the HTTP one. The certificate it presents is signed +by the same authority `/api/v2/certificate/ca` publishes, which is what a consumer that pins that authority is entitled to assume — and until now could not, +because nothing ever served the certificate this add-on has always minted. + +**This is what unblocks adding a simulated panel to Home Assistant.** The integration fetches the authority, checks it validates what the panel actually serves, +and only then sends the passphrase — so registration never crosses the wire in the clear. With no TLS listener that check could not pass at any port, and the +config flow stopped at "The certificate this panel serves is not signed by the authority it published" no matter what was entered. + +**The TLS port is published in discovery, so Home Assistant does not ask for it.** It is allocated per panel and reallocated across restarts, so this add-on is +the only party that knows it; a user left to answer would have to guess, and 443 — the sensible guess — is never right here. Panels found over mDNS carry it as +`httpsPort`, and panels registered through the Supervisor carry it as `https_port`. + +The plain HTTP port keeps serving the whole API. 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 be placed behind one. + +### Fixed + +- **A panel registered through the Supervisor now records the discovery entry it was given**, where the reply was read from the wrong level of the response and + logged as an invalid uuid despite the registration having succeeded. +- **Discovery entries are now removed when the add-on stops**, instead of accumulating in the Supervisor across every restart because the entry to delete was + never stored. +- **Stale discovery entries left by an earlier run are cleaned up at startup**, which silently did nothing for the same reason. +- **The address advertised to the network, and named in the certificate, is now this machine's own**, where it was the default route's gateway — a neighbouring + router — which left no address a consumer could verify the panel by. + ## 1.1.0 — 2026-08-28 — a fixed certificate authority, so a firmware upgrade looks like one **The certificate authority is now shipped with the package rather than generated at startup**, and is identical to the one panelbench ships. Stopping this @@ -84,151 +111,110 @@ firmware does. `clone` now carries the flag over from the source panel, so a clo ### Also fixed -- **The dashboard shows a locked circuit's priority as the panel publishes it.** Its entity view read the shed priority - off the circuit's template, so a never-backup circuit would have displayed the template's value while the wire carried - `OFF_GRID`. +- **The dashboard shows a locked circuit's priority as the panel publishes it.** Its entity view read the shed priority off the circuit's template, so a + never-backup circuit would have displayed the template's value while the wire carried `OFF_GRID`. - **`circuit/sheddable` no longer claims a relay that cannot open is sheddable.** The consumer rule the guide gives for the retired property is `priority != NEVER && relay-controllable`; the second conjunct was missing, so an always-on circuit at `OFF_GRID` or `SOC_THRESHOLD` priority published `sheddable: true` while the emitter correctly refused to shed it. ## 1.0.16 — 2026-08-11 — EVSE nodes carry the drive serial -**An EVSE's Homie node id is now its drive serial**, where it used to be a positional -slot — `evse`, `evse-2`. No panel publishes those. Firmware names the node after the -drive, the flat parser keys its snapshot on the node id verbatim, and the integration -builds the Home Assistant device identifier from that key — so against real firmware an -EVSE is already serial-identified. v1.0 names the same device `-` and -strips it back to the serial, reproducing the flat key exactly. - -That equality is what carries a drive's identity, history and automations across a -firmware upgrade. The positional slot broke it: upgrading re-keyed every EVSE, so Home -Assistant built new devices and stranded the old ones with their entities Unavailable — -two devices for one drive, same serial, one live and one dead. The defect was only ever -in this simulator, but this simulator is what the upgrade path is rehearsed against, so -it made a passing rehearsal out of a migration that does not survive. - -**The default serial is lower-case** (`sim-evse-`), because it is a topic level -now and not merely a property value: Homie 5 allows only `a`-`z`, `0`-`9` and `-` in a -topic-level id. A configured `evse.serial_number` outside that set is refused at build -time rather than sanitised — rewriting it would publish a node id that no longer matches -the `info/serial-number` beside it, quietly reintroducing the mismatch this release -removes. - -**Upgrade note.** Existing simulator users will see their EVSE devices re-key once, on -first run after this version: the old positional-slot devices are stranded and can be -deleted from the device registry. Panels running real firmware are unaffected, having -been serial-keyed all along. +**An EVSE's Homie node id is now its drive serial**, where it used to be a positional slot — `evse`, `evse-2`. No panel publishes those. Firmware names the node +after the drive, the flat parser keys its snapshot on the node id verbatim, and the integration builds the Home Assistant device identifier from that key — so +against real firmware an EVSE is already serial-identified. v1.0 names the same device `-` and strips it back to the serial, reproducing the flat +key exactly. + +That equality is what carries a drive's identity, history and automations across a firmware upgrade. The positional slot broke it: upgrading re-keyed every +EVSE, so Home Assistant built new devices and stranded the old ones with their entities Unavailable — two devices for one drive, same serial, one live and one +dead. The defect was only ever in this simulator, but this simulator is what the upgrade path is rehearsed against, so it made a passing rehearsal out of a +migration that does not survive. + +**The default serial is lower-case** (`sim-evse-`), because it is a topic level now and not merely a property value: Homie 5 allows only `a`-`z`, `0`-`9` +and `-` in a topic-level id. A configured `evse.serial_number` outside that set is refused at build time rather than sanitised — rewriting it would publish a +node id that no longer matches the `info/serial-number` beside it, quietly reintroducing the mismatch this release removes. + +**Upgrade note.** Existing simulator users will see their EVSE devices re-key once, on first run after this version: the old positional-slot devices are +stranded and can be deleted from the device registry. Panels running real firmware are unaffected, having been serial-keyed all along. ## 1.0.15 — 2026-08-06 — the flat schema release -The flat (`node-on-parent`) Homie data model, which SPAN firmware speaks today. Development -of the parent/child (v1.0) model continues separately and will take over `main`; the flat -lineage stays reachable at this tag and on the `flat` branch, which is where any further -flat-only fix belongs. +The flat (`node-on-parent`) Homie data model, which SPAN firmware speaks today. Development of the parent/child (v1.0) model continues separately and will take +over `main`; the flat lineage stays reachable at this tag and on the `flat` branch, which is where any further flat-only fix belongs. -Nothing in the published surface changed since 1.0.14 — no new entities, no topic moves, no -configuration changes. Both fixes below landed after 1.0.14 was cut, which is the reason to -re-cut rather than leave that tag as the flat reference. +Nothing in the published surface changed since 1.0.14 — no new entities, no topic moves, no configuration changes. Both fixes below landed after 1.0.14 was cut, +which is the reason to re-cut rather than leave that tag as the flat reference. ### Fixed -- **The Home Assistant long-lived token no longer appears in the process command line.** It - was passed to the simulator as `--ha-token`, which put it in `argv`, readable by any - process listing on the machine. It is now exported to the child process instead, and - `.env` is the documented place to set it. If you ran a previous version with a token on - the command line, rotate it. -- **`--stop` waits for the process to actually exit before returning.** It sent SIGTERM and - returned immediately, which is not the same as being stopped: graceful shutdown clears - retained topics and closes the broker connection before releasing the dashboard and HTTP - ports. Anything stopping and immediately restarting on the same ports raced the old - process and failed with "address already in use", which made `--restart` intermittently - fail. Waits up to 10s per process, then forces. +- **The Home Assistant long-lived token no longer appears in the process command line.** It was passed to the simulator as `--ha-token`, which put it in `argv`, + readable by any process listing on the machine. It is now exported to the child process instead, and `.env` is the documented place to set it. If you ran a + previous version with a token on the command line, rotate it. +- **`--stop` waits for the process to actually exit before returning.** It sent SIGTERM and returned immediately, which is not the same as being stopped: + graceful shutdown clears retained topics and closes the broker connection before releasing the dashboard and HTTP ports. Anything stopping and immediately + restarting on the same ports raced the old process and failed with "address already in use", which made `--restart` intermittently fail. Waits up to 10s per + process, then forces. ## 1.0.14 — 2026-07-31 — vendor the flat emitter ### Fixed -- **The HA add-on image could never start.** `ebus_emitter` is a hard, unconditional import - (`app.py` → `emitter_adapter/runtime.py`), but the Dockerfile installs only - `pip install --no-cache-dir .`, and the package was not a declared dependency — it is not - on PyPI and was installed editable from `EBUS_EMITTER_PATH` by `scripts/dev-setup.sh`. - The image therefore built successfully and failed at container start with - `ModuleNotFoundError: No module named 'ebus_emitter'`. The same applied to anyone who - cloned this repo and ran `uv sync` without `dev-setup.sh`. Vendoring removes the external - dependency entirely, so both paths now work. +- **The HA add-on image could never start.** `ebus_emitter` is a hard, unconditional import (`app.py` → `emitter_adapter/runtime.py`), but the Dockerfile + installs only `pip install --no-cache-dir .`, and the package was not a declared dependency — it is not on PyPI and was installed editable from + `EBUS_EMITTER_PATH` by `scripts/dev-setup.sh`. The image therefore built successfully and failed at container start with + `ModuleNotFoundError: No module named 'ebus_emitter'`. The same applied to anyone who cloned this repo and ran `uv sync` without `dev-setup.sh`. Vendoring + removes the external dependency entirely, so both paths now work. ### Changed -- **The flat emitter is vendored at `src/span_panel_simulator/flat_emitter`**, copied from - `ebus-emitter` 0.2.1 (commit `5b84de8`) — MIT, same copyright holders. The upstream repo - has permanently diverged onto the parent/child (v1.0) Homie data model while this - simulator continues to publish the flat schema, so the dependency delivered no upstream - changes while costing path configuration, stale editable metadata, and an unsolvable - distribution problem for the add-on. See the package docstring for full provenance. - - It also closes a correctness hazard: `clone.py` seeds energy accumulators against what - this code publishes, and while the two lived in separate repos each side could look - locally correct while jointly inverting circuit energy — which is exactly what happened. - Both ends now sit in one repo under one test run. - -- **The emitter's test suite came with it** (`tests/flat_emitter/`, 154 tests), including - the circuit energy reference-frame regression tests. Total suite is now 395 tests. - -- **`scripts/dev-setup.sh` is now a thin `uv sync` wrapper** and `.env.example` no longer - defines `EBUS_EMITTER_PATH`; every dependency resolves from PyPI. - -- **`ebus-sdk` is pinned to `==0.1.5`** rather than the range upstream declared, so that - vendoring is behaviour-neutral: 0.1.5 is what the emitter's lockfile resolved and what - this code was tested against. Letting it float within `<0.2` resolves 0.1.10, which drops - the module-level `setLevel(INFO)` on the `homie` logger that `tests/test_main_logging.py` - guards. Raising it is a deliberate follow-up, not a side effect of moving code. - -- **`[tool.ruff.lint]` now declares `ignore = ["TC001", "TC002", "TC003"]`.** The existing - comment already described this ignore, but the key was never present — none of the - simulator's own modules happened to trigger the rules, so the omission was invisible. - The vendored code was authored under an identical select list plus this ignore. - -- **`ChargeMode` is exported from the vendored package** and used to annotate the - `charge_mode` derivation in `engine.py` and `emitter_adapter/runtime.py`. Both sites - already produced only valid values; mypy could not see it while `ebus_emitter` was an - `ignore_missing_imports` module and `BESSConfig` was therefore `Any`. - -> **Version note.** This work merged as 1.0.13, the same version the circuit-energy fix -> below had already published. The add-on image tag is derived from `config.yaml`, so the -> second merge overwrote the first's image without changing the version — leaving anyone -> who had already pulled 1.0.13 on the earlier build with no update signal. Re-cut as -> 1.0.14 so Supervisor sees a change. +- **The flat emitter is vendored at `src/span_panel_simulator/flat_emitter`**, copied from `ebus-emitter` 0.2.1 (commit `5b84de8`) — MIT, same copyright + holders. The upstream repo has permanently diverged onto the parent/child (v1.0) Homie data model while this simulator continues to publish the flat schema, + so the dependency delivered no upstream changes while costing path configuration, stale editable metadata, and an unsolvable distribution problem for the + add-on. See the package docstring for full provenance. + + It also closes a correctness hazard: `clone.py` seeds energy accumulators against what this code publishes, and while the two lived in separate repos each + side could look locally correct while jointly inverting circuit energy — which is exactly what happened. Both ends now sit in one repo under one test run. + +- **The emitter's test suite came with it** (`tests/flat_emitter/`, 154 tests), including the circuit energy reference-frame regression tests. Total suite is + now 395 tests. + +- **`scripts/dev-setup.sh` is now a thin `uv sync` wrapper** and `.env.example` no longer defines `EBUS_EMITTER_PATH`; every dependency resolves from PyPI. + +- **`ebus-sdk` is pinned to `==0.1.5`** rather than the range upstream declared, so that vendoring is behaviour-neutral: 0.1.5 is what the emitter's lockfile + resolved and what this code was tested against. Letting it float within `<0.2` resolves 0.1.10, which drops the module-level `setLevel(INFO)` on the `homie` + logger that `tests/test_main_logging.py` guards. Raising it is a deliberate follow-up, not a side effect of moving code. + +- **`[tool.ruff.lint]` now declares `ignore = ["TC001", "TC002", "TC003"]`.** The existing comment already described this ignore, but the key was never present + — none of the simulator's own modules happened to trigger the rules, so the omission was invisible. The vendored code was authored under an identical select + list plus this ignore. + +- **`ChargeMode` is exported from the vendored package** and used to annotate the `charge_mode` derivation in `engine.py` and `emitter_adapter/runtime.py`. Both + sites already produced only valid values; mypy could not see it while `ebus_emitter` was an `ignore_missing_imports` module and `BESSConfig` was therefore + `Any`. + +> **Version note.** This work merged as 1.0.13, the same version the circuit-energy fix below had already published. The add-on image tag is derived from +> `config.yaml`, so the second merge overwrote the first's image without changing the version — leaving anyone who had already pulled 1.0.13 on the earlier +> build with no update signal. Re-cut as 1.0.14 so Supervisor sees a change. ## 1.0.13 — 2026-07-31 — circuit energy reference frame ### Fixed -- **Clone energy seeds were read in the wrong reference frame.** `clone.py` seeded - `initial_consumed_energy_wh` from a scraped panel's `imported-energy` and - `initial_produced_energy_wh` from its `exported-energy`. The wire is enclosure-framed: - `exported-energy` is energy the enclosure exported *to* a circuit (normal load - consumption) and `imported-energy` is energy it imported *from* a circuit (backfeed). - The two are now read the correct way round, in both the initial-translation path - (`_translate_circuit`) and the refresh path (`update_config_from_scrape`). - - This mirrors the fix in `ebus-emitter` 0.2.1, which corrected the same inversion on the - publish side. The two were previously wrong in a mutually cancelling way — clone read - `imported-energy` into "consumed" and the emitter published "consumed" back out as - `imported-energy` — so a cloned panel round-tripped its wire values faithfully while - every value carried the wrong meaning. Correcting only one side would have broken the - round-trip, so they move together. - -- **Test fixtures encoded the same inversion.** `test_clone.py` gave a load circuit a - rising `imported-energy` and a backfeeding solar circuit a rising `exported-energy`, - which is the reverse of what a real panel publishes, and one fixture comment described - positive `active-power` as "export" when on the wire it means the enclosure is importing - from the circuit. Fixtures and the two energy-seeding test names now describe the - enclosure frame. +- **Clone energy seeds were read in the wrong reference frame.** `clone.py` seeded `initial_consumed_energy_wh` from a scraped panel's `imported-energy` and + `initial_produced_energy_wh` from its `exported-energy`. The wire is enclosure-framed: `exported-energy` is energy the enclosure exported _to_ a circuit + (normal load consumption) and `imported-energy` is energy it imported _from_ a circuit (backfeed). The two are now read the correct way round, in both the + initial-translation path (`_translate_circuit`) and the refresh path (`update_config_from_scrape`). + + This mirrors the fix in `ebus-emitter` 0.2.1, which corrected the same inversion on the publish side. The two were previously wrong in a mutually cancelling + way — clone read `imported-energy` into "consumed" and the emitter published "consumed" back out as `imported-energy` — so a cloned panel round-tripped its + wire values faithfully while every value carried the wrong meaning. Correcting only one side would have broken the round-trip, so they move together. + +- **Test fixtures encoded the same inversion.** `test_clone.py` gave a load circuit a rising `imported-energy` and a backfeeding solar circuit a rising + `exported-energy`, which is the reverse of what a real panel publishes, and one fixture comment described positive `active-power` as "export" when on the wire + it means the enclosure is importing from the circuit. Fixtures and the two energy-seeding test names now describe the enclosure frame. ### Requires -- **ebus-emitter >= 0.2.1**, which carries the matching publish-side fix. Pairing this - release with an older emitter reinstates the inversion. +- **ebus-emitter >= 0.2.1**, which carries the matching publish-side fix. Pairing this release with an older emitter reinstates the inversion. ## 1.0.12 — 2026-07-30 — emitter live-schema alignment and abstraction @@ -237,9 +223,13 @@ re-cut rather than leave that tag as the flat reference. - **Emitter schema alignment**: Adapter updated to work with emitter's live SPAN panel Homie 5 schema (flat node layout, accurate topology and properties). - **Lugs IDs**: Updated to match emitter convention (`lugs-upstream`, `lugs-downstream`). - **BESS/PV feeds**: Updated spec_generator to derive device feeds and metadata from circuit templates (stable circuit UUID linkage). -- **Simulator adapter**: Updated `spec_generator.py` and `runtime.py` to pass EVSE powers to emitter, set `clear_retained=True` on clone stop for graceful shutdown. +- **Simulator adapter**: Updated `spec_generator.py` and `runtime.py` to pass EVSE powers to emitter, set `clear_retained=True` on clone stop for graceful + shutdown. ### Fixed -- **Dev bootstrap dependency drift**: `scripts/dev-setup.sh` installed `ebus-emitter` with a bare `uv pip install --editable`, which re-resolves the emitter's dependency constraints against PyPI and ignores its `uv.lock`. A fresh bootstrap pulled `ebus-sdk` 0.12.0 — whose `Device` constructor is incompatible with the 0.1.x API the emitter targets — and panel startup died with `AttributeError: 'NoneType' object has no attribute 'get'` in `connect_broker()`. The script now installs the emitter's locked runtime dependencies first, then the emitter itself with `--no-deps`, so the venv matches what the emitter pins. +- **Dev bootstrap dependency drift**: `scripts/dev-setup.sh` installed `ebus-emitter` with a bare `uv pip install --editable`, which re-resolves the emitter's + dependency constraints against PyPI and ignores its `uv.lock`. A fresh bootstrap pulled `ebus-sdk` 0.12.0 — whose `Device` constructor is incompatible with + the 0.1.x API the emitter targets — and panel startup died with `AttributeError: 'NoneType' object has no attribute 'get'` in `connect_broker()`. The script + now installs the emitter's locked runtime dependencies first, then the emitter itself with `--no-deps`, so the venv matches what the emitter pins. - **Type safety**: Fixed mypy error in simulator runtime (`_first_feed_for_device_type`) where template_name could be None. diff --git a/pyproject.toml b/pyproject.toml index 36a4725..b111600 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "span-panel-simulator" -version = "1.1.0" +version = "1.2.0" description = "Standalone eBus simulator for SPAN panels" requires-python = ">=3.14" dependencies = [ diff --git a/span_panel_simulator/Dockerfile b/span_panel_simulator/Dockerfile index 4dc3553..c4ee0ae 100644 --- a/span_panel_simulator/Dockerfile +++ b/span_panel_simulator/Dockerfile @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080 LABEL io.hass.name="SPAN Panel Simulator" \ io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \ io.hass.type="addon" \ - io.hass.version="1.1.0" \ + io.hass.version="1.2.0" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml index 546c0d1..e2d638d 100644 --- a/span_panel_simulator/config.yaml +++ b/span_panel_simulator/config.yaml @@ -1,6 +1,6 @@ name: "SPAN Panel Simulator" description: "Simulates a SPAN electrical panel for testing and upgrade modeling" -version: "1.1.0" +version: "1.2.0" slug: "span_panel_simulator" url: "https://github.com/SpanPanel/simulator" image: "ghcr.io/spanpanel/simulator/{arch}" diff --git a/span_panel_simulator/run.sh b/span_panel_simulator/run.sh index 2bf5a3a..9feca5f 100755 --- a/span_panel_simulator/run.sh +++ b/span_panel_simulator/run.sh @@ -19,11 +19,39 @@ LOG_LEVEL=$(jq -r '.log_level' "${OPTIONS_FILE}") DASHBOARD_ENABLED=$(jq -r '.dashboard_enabled' "${OPTIONS_FILE}") BASE_HTTP_PORT=$(jq -r '.base_http_port // 8081' "${OPTIONS_FILE}") -# Auto-detect host IP for TLS cert SAN. -# Inside a bridge-networked container the default gateway is the host. -# Strip control characters — some container ip implementations emit trailing -# non-printables that would break Python string literals or cert generation. -ADVERTISE_ADDRESS=$(ip route | awk '/default/ { print $3 }' | tr -d '[:cntrl:]' || true) +# Auto-detect the address a client on the LAN reaches this add-on at. It goes +# into the leaf certificate's SAN and into the mDNS advertisement, so getting it +# wrong leaves no address a client can verify us by. +# +# This ran `ip route | awk '/default/ { print $3 }'` and took the *gateway*. The +# reasoning held for a bridge-networked container, where the default gateway is +# the host -- but this add-on sets `host_network: true` (config.yaml), so the +# container shares the host's network namespace and reads the host's routing +# table. `$3` of `default via 192.168.65.1 dev eth0` is then the upstream +# router: a neighbouring device, named in our certificate, that is not us. +# +# `ip route get` is a routing-table lookup rather than a probe -- it sends no +# packets and needs nothing at the far address to be reachable. It answers the +# question that actually matters, which source address the kernel would put on a +# reply, and stays right on an interface holding several addresses where taking +# the first one listed would not. +# +# $ ip -4 route get 1.1.1.1 +# 1.1.1.1 via 192.168.65.1 dev eth0 src 192.168.65.19 uid 0 +# ^^^^^^^^^^^^^ what we want +# +# Control characters are stripped because some container `ip` implementations +# emit trailing non-printables, which would break cert generation downstream. +detect_advertise_address() { + ip -4 route get 1.1.1.1 2>/dev/null \ + | awk '{ for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit } }' \ + | tr -d '[:cntrl:]' || true +} + +# An address supplied by the environment wins over detection: scripts/run-local.sh +# sets one, and an operator on a multi-homed host may need to pick which of its +# addresses the panel is known by. +ADVERTISE_ADDRESS="${ADVERTISE_ADDRESS:-$(detect_advertise_address)}" export ADVERTISE_ADDRESS export CERT_DIR="/data/certs" export BROKER_USERNAME="span" diff --git a/src/span_panel_simulator/__init__.py b/src/span_panel_simulator/__init__.py index f4bcd18..626065e 100644 --- a/src/span_panel_simulator/__init__.py +++ b/src/span_panel_simulator/__init__.py @@ -1,3 +1,3 @@ """Standalone eBus simulator for SPAN panels.""" -__version__ = "1.1.0" +__version__ = "1.2.0" diff --git a/src/span_panel_simulator/app.py b/src/span_panel_simulator/app.py index f0cdd24..3420168 100644 --- a/src/span_panel_simulator/app.py +++ b/src/span_panel_simulator/app.py @@ -25,6 +25,7 @@ from span_panel_simulator.const import ( DASHBOARD_PORT, DEFAULT_BASE_HTTP_PORT, + DEFAULT_BASE_HTTPS_PORT, DEFAULT_BROKER_PASSWORD, DEFAULT_BROKER_USERNAME, DEFAULT_FIRMWARE_VERSION, @@ -111,6 +112,7 @@ def __init__( broker_host: str = "localhost", broker_port: int = MQTTS_PORT, base_http_port: int = DEFAULT_BASE_HTTP_PORT, + base_https_port: int = DEFAULT_BASE_HTTPS_PORT, cert_dir: Path | None = None, homie_schema_path: Path | None = None, dashboard_port: int = DASHBOARD_PORT, @@ -126,6 +128,7 @@ def __init__( self._broker_host = broker_host self._broker_port = broker_port self._base_http_port = base_http_port + self._base_https_port = base_https_port self._cert_dir = cert_dir or Path("/tmp/span-sim-certs") self._homie_schema_path = homie_schema_path self._dashboard_port = dashboard_port @@ -139,6 +142,9 @@ def __init__( self._panel_start_errors: dict[str, str] = {} # filename -> last error message self._panel_servers: dict[str, BootstrapHttpServer] = {} self._panel_ports: dict[str, int] = {} + self._panel_https_ports: dict[str, int] = {} + # One pool across both bases so an HTTP port can never be handed out + # as an HTTPS one, however the two ranges are configured to overlap. self._used_ports: set[int] = set() self._dashboard_runner: web.AppRunner | None = None self._advertiser: PanelAdvertiser | None = None @@ -155,9 +161,9 @@ def __init__( # Port allocation # ------------------------------------------------------------------ - def _allocate_port(self) -> int: - """Return the lowest available port from the base.""" - port = self._base_http_port + def _allocate_port(self, base: int | None = None) -> int: + """Return the lowest available port at or above ``base``.""" + port = self._base_http_port if base is None else base while port in self._used_ports: port += 1 self._used_ports.add(port) @@ -179,6 +185,10 @@ def _get_panel_ports(self) -> dict[str, int]: """Return a mapping of serial number to HTTP port for running panels.""" return dict(self._panel_ports) + def _get_panel_https_ports(self) -> dict[str, int]: + """Return a mapping of serial number to HTTPS port for running panels.""" + return dict(self._panel_https_ports) + def _get_panel_start_errors(self) -> dict[str, str]: """Return the most recent per-filename start/reload errors.""" return dict(self._panel_start_errors) @@ -343,59 +353,78 @@ async def _start_panel(self, config_path: Path) -> PanelInstance: self._panels[config_path] = panel self._serial_to_panel[serial] = panel - # Create per-panel bootstrap HTTP server with port allocation - port = self._allocate_port() - server = BootstrapHttpServer( - serial, - self._firmware, - self._certs, - panel_schema, - broker_username=self._broker_username, - broker_password=self._broker_password, - broker_host=self._broker_host, - port=port, - ) + # Create the per-panel bootstrap server on a freshly allocated port + # pair. Both are retried together: the server binds HTTP and HTTPS as + # one unit, so a collision on either means this pair is unusable and + # the panel needs another. + # Bound outside the closure: `self._certs` is optional and mutable, so + # the narrowing asserted at the top of this method does not reach into + # a nested function. + certs = self._certs + + def _build(http_port: int, https_port: int) -> BootstrapHttpServer: + return BootstrapHttpServer( + serial, + self._firmware, + certs, + panel_schema, + broker_username=self._broker_username, + broker_password=self._broker_password, + broker_host=self._broker_host, + port=http_port, + https_port=https_port, + ) + max_port_retries = 20 + port = self._allocate_port() + https_port = self._allocate_port(self._base_https_port) for _attempt in range(max_port_retries): + server = _build(port, https_port) try: await server.start() break except OSError as exc: if exc.errno != errno.EADDRINUSE: raise - _LOGGER.warning("Port %d in use for panel %s, trying next port", port, serial) - self._release_port(port) - port = self._allocate_port() - server = BootstrapHttpServer( + _LOGGER.warning( + "Ports %d/%d in use for panel %s, trying the next pair", + port, + https_port, serial, - self._firmware, - self._certs, - panel_schema, - broker_username=self._broker_username, - broker_password=self._broker_password, - broker_host=self._broker_host, - port=port, ) + self._release_port(port) + self._release_port(https_port) + port = self._allocate_port() + https_port = self._allocate_port(self._base_https_port) else: + self._release_port(port) + self._release_port(https_port) raise OSError( - f"Could not find an available port for panel {serial} " + f"Could not find an available port pair for panel {serial} " f"after {max_port_retries} attempts" ) self._panel_servers[serial] = server self._panel_ports[serial] = port + self._panel_https_ports[serial] = https_port # Register with mDNS advertiser if self._advertiser is not None: await self._advertiser.register_panel( - serial, self._firmware, model=panel_model, port=port + serial, self._firmware, model=panel_model, port=port, https_port=https_port ) # Register with Supervisor Discovery if self._supervisor_discovery is not None and self._supervisor_discovery.is_available: - await self._supervisor_discovery.register_panel(serial, port) + await self._supervisor_discovery.register_panel(serial, port, https_port) - _LOGGER.info("Registered panel %s from %s on port %d", serial, config_path.name, port) + _LOGGER.info( + "Registered panel %s from %s on ports %d/%d (http/https)", + serial, + config_path.name, + port, + https_port, + ) return panel async def _load_recorder_data(self, config_path: Path) -> RecorderDataSource | None: @@ -543,10 +572,13 @@ async def _stop_panel(self, config_path: Path) -> None: if server is not None: await server.stop() - # Release allocated port + # Release the allocated port pair port = self._panel_ports.pop(serial, None) if port is not None: self._release_port(port) + https_port = self._panel_https_ports.pop(serial, None) + if https_port is not None: + self._release_port(https_port) # Unregister from mDNS if self._advertiser is not None: diff --git a/src/span_panel_simulator/bootstrap.py b/src/span_panel_simulator/bootstrap.py index 90e1cd2..b73c195 100644 --- a/src/span_panel_simulator/bootstrap.py +++ b/src/span_panel_simulator/bootstrap.py @@ -1,8 +1,24 @@ """Bootstrap HTTP server — single-panel per instance. Each simulated panel gets its own ``BootstrapHttpServer`` bound to a -unique port, matching real SPAN hardware where each panel is a separate -device on a different IP. +unique port pair, matching real SPAN hardware where each panel is a +separate device on a different IP. + +Two listeners serve the same routes, mirroring a real panel's 80/443: + + * HTTP -- how a consumer that holds no anchor yet reaches the panel. It + probes ``/api/v2/status`` to decide whether this is a SPAN panel at all + and fetches ``/api/v2/certificate/ca`` to obtain one. Both necessarily + predate the anchor, so neither can be behind it. + * HTTPS -- the same routes under the leaf this panel's published authority + signed. A consumer pins that authority and then does everything else + here, so registration -- the exchange carrying the passphrase and + returning the broker password -- never crosses the wire in the clear. + +The route table is deliberately not split between them. Which endpoints a +consumer chooses to reach over which listener is the consumer's decision, +and hard-coding one client's current split into the panel would make the +simulator lie about hardware the moment that client changed its mind. Endpoints: GET /api/v2/status -> panel identity (serialNumber, firmwareVersion) @@ -16,6 +32,7 @@ import contextlib import logging import secrets +import ssl import time from typing import TYPE_CHECKING @@ -54,7 +71,8 @@ def __init__( broker_password: str = DEFAULT_BROKER_PASSWORD, broker_host: str = "localhost", host: str = "0.0.0.0", - port: int = 443, + port: int = 80, + https_port: int = 443, ) -> None: self._serial = serial self._firmware = firmware @@ -64,6 +82,7 @@ def __init__( self._broker_host = broker_host self._host = host self._port = port + self._https_port = https_port self._homie_schema = schema.raw_json self._app = web.Application() @@ -148,21 +167,52 @@ async def _handle_schema(self, _request: web.Request) -> web.Response: # Lifecycle # ------------------------------------------------------------------ + def _ssl_context(self) -> ssl.SSLContext: + """Build the TLS context from this panel's leaf. + + The leaf is signed by the same authority ``/api/v2/certificate/ca`` + publishes, which is the whole point: a consumer that pins what the + panel hands out must find the pin validates what the panel serves. + """ + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain( + certfile=str(self._certs.server_cert_path), + keyfile=str(self._certs.server_key_path), + ) + return context + async def start(self) -> None: - """Start the HTTP server.""" + """Start the HTTP and HTTPS listeners. + + Both are bound before either is reported started, so a caller that + retries on ``EADDRINUSE`` never inherits a half-bound server: the + runner cleanup in the failure path takes down whichever site did come + up. Started HTTPS-first so the noisier failure surfaces first. + """ self._runner = web.AppRunner(self._app) await self._runner.setup() - site = web.TCPSite(self._runner, self._host, self._port) - await site.start() + try: + https_site = web.TCPSite( + self._runner, self._host, self._https_port, ssl_context=self._ssl_context() + ) + await https_site.start() + http_site = web.TCPSite(self._runner, self._host, self._port) + await http_site.start() + except BaseException: + await self._runner.cleanup() + self._runner = None + raise _LOGGER.info( - "Bootstrap HTTP server for %s listening on %s:%d", + "Bootstrap server for %s listening on http://%s:%d and https://%s:%d", self._serial, self._host, self._port, + self._host, + self._https_port, ) async def stop(self) -> None: - """Stop the HTTP server.""" + """Stop both listeners.""" if self._runner is not None: await self._runner.cleanup() self._runner = None diff --git a/src/span_panel_simulator/const.py b/src/span_panel_simulator/const.py index 751b872..a827897 100644 --- a/src/span_panel_simulator/const.py +++ b/src/span_panel_simulator/const.py @@ -9,6 +9,10 @@ WS_PORT = 19001 WSS_PORT = 19002 DEFAULT_BASE_HTTP_PORT = 8081 +# Real panels serve the bootstrap API over plain HTTP on 80 and over TLS on 443. +# The simulator keeps that split -- consumers pin the published authority and then +# talk to the panel over it -- but on offset ports, one pair per panel. +DEFAULT_BASE_HTTPS_PORT = 8443 DASHBOARD_PORT = 18080 # Default simulation parameters diff --git a/src/span_panel_simulator/discovery.py b/src/span_panel_simulator/discovery.py index 4d9c7fd..4c5c88d 100644 --- a/src/span_panel_simulator/discovery.py +++ b/src/span_panel_simulator/discovery.py @@ -83,7 +83,13 @@ async def stop(self) -> None: _LOGGER.info("mDNS advertiser stopped") async def register_panel( - self, serial: str, firmware: str, *, model: str = "MAIN_32", port: int = 80 + self, + serial: str, + firmware: str, + *, + model: str = "MAIN_32", + port: int = 80, + https_port: int = 443, ) -> None: """Advertise a panel on the local network. @@ -114,6 +120,14 @@ async def register_panel( if port != 80: ebus_properties["httpPort"] = str(port) + # And httpsPort likewise. A consumer that pins the published authority + # has to know where the leaf it validates is served, and only this + # process knows: the port is allocated per panel and reallocated across + # restarts, so a consumer left to guess would guess 443 and find + # nothing. Publishing it is what keeps that question off the user. + if https_port != 443: + ebus_properties["httpsPort"] = str(https_port) + # _span._tcp properties span_properties: dict[str, str] = { "serialNumber": serial, @@ -151,10 +165,11 @@ async def register_panel( self._services[serial] = services _LOGGER.info( - "Advertised panel %s on %s (ebus SRV port 0, HTTP port %d)", + "Advertised panel %s on %s (ebus SRV port 0, HTTP port %d, HTTPS port %d)", serial, ", ".join(addresses), port, + https_port, ) async def unregister_panel(self, serial: str) -> None: diff --git a/src/span_panel_simulator/supervisor_discovery.py b/src/span_panel_simulator/supervisor_discovery.py index 0743e9a..d7332ee 100644 --- a/src/span_panel_simulator/supervisor_discovery.py +++ b/src/span_panel_simulator/supervisor_discovery.py @@ -30,6 +30,18 @@ def _container_hostname() -> str: _SERVICE_NAME = "span_panel" +def _payload(body: object) -> dict[str, object]: + """Unwrap a Supervisor API response envelope. + + Every Supervisor endpoint answers ``{"result": ..., "data": {...}}``; + the fields we want live under ``data``, never at the top level. + """ + if not isinstance(body, dict): + return {} + data = body.get("data") + return data if isinstance(data, dict) else {} + + class SupervisorDiscovery: """Manages Supervisor Discovery entries for simulated panels.""" @@ -59,9 +71,11 @@ async def cleanup_stale(self) -> None: async with session.get(_SUPERVISOR_DISCOVERY_URL, headers=self._headers()) as resp: if resp.status != 200: return - data = await resp.json() + body = await resp.json() - entries = data.get("discovery", []) + entries = _payload(body).get("discovery", []) + if not isinstance(entries, list): + return for entry in entries: if entry.get("service") != _SERVICE_NAME: continue @@ -85,11 +99,17 @@ async def cleanup_stale(self) -> None: finally: await session.close() - async def register_panel(self, serial: str, port: int) -> None: + async def register_panel(self, serial: str, port: int, https_port: int = 443) -> 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. + + ``https_port`` is published alongside the HTTP one because a consumer + that pins the authority this panel serves then has to reach the leaf + that authority signed, and this process is the only party that knows + where: the port is allocated per panel and reallocated across restarts. + Omitting it leaves the consumer to assume 443 and find nothing there. """ if not self._token: return @@ -100,6 +120,7 @@ async def register_panel(self, serial: str, port: int) -> None: "config": { "host": host, "port": port, + "https_port": https_port, "serial": serial, }, } @@ -112,8 +133,8 @@ async def register_panel(self, serial: str, port: int) -> None: headers=self._headers(), ) as resp: if resp.status == 200: - data = await resp.json() - uuid = data.get("uuid") + body = await resp.json() + uuid = _payload(body).get("uuid") if isinstance(uuid, str) and uuid: self._entries[serial] = uuid _LOGGER.info( @@ -125,7 +146,7 @@ async def register_panel(self, serial: str, port: int) -> None: _LOGGER.warning( "Supervisor discovery: register %s returned invalid uuid: %s", serial, - data, + body, ) else: text = await resp.text() diff --git a/tests/test_bootstrap_tls.py b/tests/test_bootstrap_tls.py new file mode 100644 index 0000000..67d42c7 --- /dev/null +++ b/tests/test_bootstrap_tls.py @@ -0,0 +1,156 @@ +"""Tests for the bootstrap server's TLS listener. + +These bind real sockets with real certificates rather than driving the app +through ``TestServer``, because what is under test is precisely the part +``TestServer`` bypasses: that the panel actually serves TLS, and that the leaf +it serves is signed by the authority it publishes over plain HTTP. + +That pairing is the whole contract a pinning consumer depends on. It fetches +the authority unpinned, checks it validates what the panel serves, and only +then sends the passphrase -- so a panel that publishes an authority it cannot +back with a live leaf fails that consumer at the last step before +registration, which is exactly where the failure is most expensive. +""" + +from __future__ import annotations + +import socket +import ssl +from pathlib import Path +from unittest.mock import MagicMock + +import aiohttp +import pytest + +from span_panel_simulator.bootstrap import BootstrapHttpServer +from span_panel_simulator.certs import generate_certificates +from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION + +_SERIAL = "sim-tls-001" + + +def _free_port() -> int: + """Return a port that was free a moment ago.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +@pytest.fixture +async def running_server(tmp_path: Path): + """A started panel server on real ports with real certificates.""" + certs = generate_certificates(tmp_path, advertise_address="127.0.0.1") + + schema = MagicMock() + schema.raw_json = '{"test": true}' + + server = BootstrapHttpServer( + serial=_SERIAL, + firmware=DEFAULT_FIRMWARE_VERSION, + certs=certs, + schema=schema, + host="127.0.0.1", + port=_free_port(), + https_port=_free_port(), + ) + await server.start() + try: + yield server + finally: + await server.stop() + + +async def _published_ca(server: BootstrapHttpServer) -> str: + """Fetch the authority the way an unpinned consumer does — over plain HTTP.""" + url = f"http://127.0.0.1:{server._port}/api/v2/certificate/ca" + async with aiohttp.ClientSession() as session, session.get(url) as resp: + assert resp.status == 200 + return (await resp.read()).decode() + + +def _consumer_context(ca_pem: str) -> ssl.SSLContext: + """Build the context a pinning consumer actually uses. + + Mirrors ``span_panel_api.build_panel_ssl_context`` rather than calling it — + the library is not a dependency of the simulator and should not become one + to run a test. What must be copied exactly is the cleared + ``VERIFY_X509_STRICT``: real SPAN panels ship a minimal authority with no + Authority Key Identifier, Python 3.13 turned that flag on by default, and + the library clears it so those panels keep working. + + Verifying here with the flag left on would test a client that does not + exist, and would push this simulator towards a strictly RFC-correct + authority that real hardware does not have — retiring the coverage that + keeps the library's workaround honest. Everything that does matter stays + on: the panel's own CA is the only anchor, and the hostname is checked. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.verify_mode = ssl.CERT_REQUIRED + context.check_hostname = True + context.verify_flags &= ~ssl.VERIFY_X509_STRICT + context.load_verify_locations(cadata=ca_pem) + return context + + +async def test_tls_leaf_is_signed_by_the_published_authority( + running_server: BootstrapHttpServer, +) -> None: + """The HTTPS listener serves a leaf that the published CA validates. + + Verification is left fully on — hostname included — because a consumer + that relaxed it would not be testing the pin it relies on. + """ + context = _consumer_context(await _published_ca(running_server)) + + url = f"https://127.0.0.1:{running_server._https_port}/api/v2/status" + async with aiohttp.ClientSession() as session, session.get(url, ssl=context) as resp: + assert resp.status == 200 + assert (await resp.json())["serialNumber"] == _SERIAL + + +async def test_tls_listener_is_rejected_without_the_published_authority( + running_server: BootstrapHttpServer, +) -> None: + """A client that does not hold the panel's CA cannot verify the panel. + + Guards the inverse of the test above: it would still pass if the listener + served something publicly trusted, or if verification were quietly off. + """ + url = f"https://127.0.0.1:{running_server._https_port}/api/v2/status" + with pytest.raises(aiohttp.ClientConnectorCertificateError): + async with aiohttp.ClientSession() as session, session.get(url): + pass + + +async def test_http_listener_still_serves_the_pre_anchor_probes( + running_server: BootstrapHttpServer, +) -> None: + """Status and the CA stay reachable over plain HTTP. + + Both necessarily happen before a consumer holds an anchor — it probes + status to decide this is a SPAN panel at all, and fetches the CA to get + the anchor — so moving either behind TLS would strand it. + """ + base = f"http://127.0.0.1:{running_server._port}" + async with aiohttp.ClientSession() as session: + async with session.get(f"{base}/api/v2/status") as resp: + assert resp.status == 200 + assert (await resp.json())["serialNumber"] == _SERIAL + async with session.get(f"{base}/api/v2/certificate/ca") as resp: + assert resp.status == 200 + assert "BEGIN CERTIFICATE" in (await resp.text()) + + +async def test_register_is_available_over_tls(running_server: BootstrapHttpServer) -> None: + """Registration — the exchange carrying the passphrase — is served over TLS.""" + context = _consumer_context(await _published_ca(running_server)) + + url = f"https://127.0.0.1:{running_server._https_port}/api/v2/auth/register" + async with ( + aiohttp.ClientSession() as session, + session.post(url, json={"hopPassphrase": "sim-passphrase"}, ssl=context) as resp, + ): + assert resp.status == 200 + body = await resp.json() + assert body["serialNumber"] == _SERIAL + assert body["ebusBrokerPassword"] == "sim-password" diff --git a/tests/test_discovery_advertiser.py b/tests/test_discovery_advertiser.py new file mode 100644 index 0000000..6c19669 --- /dev/null +++ b/tests/test_discovery_advertiser.py @@ -0,0 +1,59 @@ +"""Tests for the mDNS TXT records a panel advertises. + +The ports are the point. A consumer that finds this panel over mDNS has to +learn where to reach it from the record alone, and both ports move: they are +allocated per panel and reallocated across restarts, so anything the consumer +assumes will be wrong as soon as a second panel exists. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from span_panel_simulator.const import DEFAULT_FIRMWARE_VERSION +from span_panel_simulator.discovery import SERVICE_TYPE_EBUS, PanelAdvertiser + + +@pytest.fixture +def advertiser() -> PanelAdvertiser: + """An advertiser with a stubbed zeroconf, so nothing touches the network.""" + adv = PanelAdvertiser(advertise_address="127.0.0.1") + adv._zeroconf = AsyncMock() + return adv + + +async def _ebus_properties(advertiser: PanelAdvertiser, **kwargs: int) -> dict[bytes, bytes]: + """Register a panel and return the TXT properties of its _ebus._tcp record.""" + with patch( + "span_panel_simulator.discovery._get_host_addresses", + return_value=["127.0.0.1"], + ): + await advertiser.register_panel("sim-001", DEFAULT_FIRMWARE_VERSION, **kwargs) + + for info in advertiser._services["sim-001"]: + if info.type == SERVICE_TYPE_EBUS: + return info.properties + raise AssertionError("no _ebus._tcp record was registered") + + +async def test_non_standard_ports_are_both_published(advertiser: PanelAdvertiser) -> None: + """A panel on offset ports advertises both of them.""" + props = await _ebus_properties(advertiser, port=8081, https_port=8443) + + assert props[b"httpPort"] == b"8081" + assert props[b"httpsPort"] == b"8443" + + +async def test_standard_ports_are_left_unsaid(advertiser: PanelAdvertiser) -> None: + """A panel on 80/443 publishes neither port. + + Silence is how a consumer is told the panel is where it would have looked + anyway, and it is what real hardware does. Emitting the defaults would put + values in the record that mean nothing. + """ + props = await _ebus_properties(advertiser, port=80, https_port=443) + + assert b"httpPort" not in props + assert b"httpsPort" not in props diff --git a/tests/test_run_sh_advertise_address.py b/tests/test_run_sh_advertise_address.py new file mode 100644 index 0000000..54fdc61 --- /dev/null +++ b/tests/test_run_sh_advertise_address.py @@ -0,0 +1,102 @@ +"""Tests for the host-address detection in the add-on's run.sh. + +The function under test is extracted from the shipped ``run.sh`` and executed, +rather than restated here. A copy of the pipeline would keep passing after the +original drifted, and drift is precisely the failure this guards: the previous +implementation was correct for a bridge-networked container and silently became +wrong when the add-on moved to ``host_network: true``. + +``ip`` is replaced with a stub on PATH so the sample routing output is fixed and +the test says nothing about the machine it runs on. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +_RUN_SH = Path(__file__).parent.parent / "span_panel_simulator" / "run.sh" + +# `host_network: true`, so this is the *host's* table: .19 is this machine and +# .1 is the upstream router. Taking the gateway is the bug being guarded. +_ROUTE_GET = "1.1.1.1 via 192.168.65.1 dev eth0 src 192.168.65.19 uid 0\n" + + +def _detect( + ip_stdout: str, + tmp_path: Path, + *, + ip_exit: int = 0, + env_value: str | None = None, +) -> str: + """Run run.sh's detection with a stubbed ``ip`` and return what it printed.""" + source = _RUN_SH.read_text() + match = re.search(r"^detect_advertise_address\(\) \{.*?^\}", source, re.MULTILINE | re.DOTALL) + assert match, "run.sh no longer defines detect_advertise_address()" + + stub_dir = tmp_path / "bin" + stub_dir.mkdir() + stub = stub_dir / "ip" + stub.write_text(f"#!/usr/bin/env bash\ncat <<'OUT'\n{ip_stdout}OUT\nexit {ip_exit}\n") + stub.chmod(0o755) + + # Mirrors run.sh: the same `set` flags, the function, then the assignment it + # actually feeds -- so the env-override precedence is covered too. + script = ( + "set -euo pipefail\n" + f"{match.group(0)}\n" + 'ADVERTISE_ADDRESS="${ADVERTISE_ADDRESS:-$(detect_advertise_address)}"\n' + 'printf "%s" "$ADVERTISE_ADDRESS"\n' + ) + env = {"PATH": f"{stub_dir}:/usr/bin:/bin"} + if env_value is not None: + env["ADVERTISE_ADDRESS"] = env_value + + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, env=env, check=True + ) + return result.stdout + + +def test_the_hosts_own_address_is_detected(tmp_path: Path) -> None: + """The source address is taken, not the gateway it routes via.""" + assert _detect(_ROUTE_GET, tmp_path) == "192.168.65.19" + + +def test_the_gateway_is_not_mistaken_for_the_host(tmp_path: Path) -> None: + """The original defect, stated directly. + + Named separately from the test above because this is the assertion that + would have failed before the fix, and the one worth reading in a report. + """ + assert _detect(_ROUTE_GET, tmp_path) != "192.168.65.1" + + +def test_an_on_link_route_with_no_gateway_still_yields_the_source(tmp_path: Path) -> None: + """A destination on the local segment has no `via`, so `src` shifts position. + + Guards against a parser that counted fields instead of finding the keyword. + """ + on_link = "10.0.0.7 dev eth0 src 10.0.0.3 uid 0\n" + assert _detect(on_link, tmp_path) == "10.0.0.3" + + +def test_control_characters_are_stripped(tmp_path: Path) -> None: + """Some container `ip` builds emit trailing non-printables.""" + noisy = "1.1.1.1 via 192.168.65.1 dev eth0 src 192.168.65.19\r uid 0\n" + assert _detect(noisy, tmp_path) == "192.168.65.19" + + +def test_no_route_leaves_the_address_empty(tmp_path: Path) -> None: + """A failing `ip` must not abort the script under `set -euo pipefail`. + + An empty address costs the leaf its IP SAN. Failing to boot costs the panel + entirely, so the empty answer is the right one. + """ + assert _detect("", tmp_path, ip_exit=2) == "" + + +def test_an_explicit_address_is_not_overridden(tmp_path: Path) -> None: + """run-local.sh sets this, and a multi-homed host may need to choose.""" + assert _detect(_ROUTE_GET, tmp_path, env_value="10.9.9.9") == "10.9.9.9" diff --git a/tests/test_supervisor_discovery.py b/tests/test_supervisor_discovery.py index 69d1fe4..7ca0368 100644 --- a/tests/test_supervisor_discovery.py +++ b/tests/test_supervisor_discovery.py @@ -45,7 +45,7 @@ def _mock_session(response_status: int, response_json: dict | None = None) -> Ma async def test_register_panel_posts_to_supervisor(discovery: SupervisorDiscovery): """register_panel POSTs to /discovery and tracks the UUID.""" - mock_session = _mock_session(200, {"uuid": "disc-uuid-123"}) + mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}}) with ( patch("aiohttp.ClientSession", return_value=mock_session), patch( @@ -59,6 +59,29 @@ async def test_register_panel_posts_to_supervisor(discovery: SupervisorDiscovery mock_session.post.assert_called_once() +async def test_register_panel_publishes_both_ports(discovery: SupervisorDiscovery): + """The registration carries the TLS port as well as the HTTP one. + + A consumer that pins the authority this panel publishes then has to reach + the leaf that authority signed, and only the simulator knows where: the + port is allocated per panel and reallocated across restarts. Publishing it + is what stops the consumer assuming 443 and finding nothing there. + """ + mock_session = _mock_session(200, {"result": "ok", "data": {"uuid": "disc-uuid-123"}}) + with ( + patch("aiohttp.ClientSession", return_value=mock_session), + patch( + "span_panel_simulator.supervisor_discovery._container_hostname", + return_value="f8c38f2b-span-panel-simulator", + ), + ): + await discovery.register_panel("sim-001", 8081, 8443) + + config = mock_session.post.call_args.kwargs["json"]["config"] + assert config["port"] == 8081 + assert config["https_port"] == 8443 + + async def test_unregister_panel_deletes_from_supervisor(discovery: SupervisorDiscovery): """unregister_panel DELETEs /discovery/{uuid}.""" discovery._entries["sim-001"] = "disc-uuid-123" @@ -100,7 +123,9 @@ async def test_cleanup_stale_on_startup(discovery: SupervisorDiscovery): ] get_resp = AsyncMock() get_resp.status = 200 - get_resp.json = AsyncMock(return_value={"discovery": existing_entries}) + get_resp.json = AsyncMock( + return_value={"result": "ok", "data": {"discovery": existing_entries}} + ) del_resp = AsyncMock() del_resp.status = 200