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
226 changes: 108 additions & 118 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion span_panel_simulator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion span_panel_simulator/config.yaml
Original file line number Diff line number Diff line change
@@ -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}"
Expand Down
38 changes: 33 additions & 5 deletions span_panel_simulator/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/span_panel_simulator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Standalone eBus simulator for SPAN panels."""

__version__ = "1.1.0"
__version__ = "1.2.0"
94 changes: 63 additions & 31 deletions src/span_panel_simulator/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
66 changes: 58 additions & 8 deletions src/span_panel_simulator/bootstrap.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -16,6 +32,7 @@
import contextlib
import logging
import secrets
import ssl
import time
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/span_panel_simulator/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading