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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## 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
simulator and starting panelbench rehearses a firmware upgrade on one panel — the config carries the serial across, and the panel keeps its address and ports —
but every install minted its own authority, so the swap presented a new trust anchor and read as a panel substitution to anything pinned to the old one. A
firmware upgrade does not rotate a panel's certificate authority, and now neither does the swap.

Its SHA-256, the value Home Assistant pins and displays, is `3cf8c14a78900b8736870c95adcc931cdcb3a51bc3029c96efafd0a4cb790d97`.

**Upgrading raises one "SPAN Panel certificate authority changed" repair, once, and it is expected.** The generated authority this install pinned is being
replaced by the shipped one; open the repair, check the fingerprint against the value above, and accept it. Doing this now, at an ordinary add-on upgrade, is
why the later swap to panelbench needs no repair at all — so upgrade this simulator before moving across, not after.

The authority's private key is committed deliberately and is not a leaked secret. It signs nothing that chains to a real panel, which mints its own authority in
firmware and is pinned per config entry, and a real panel ever reporting the fingerprint above would be conclusive evidence of tampering.

### Fixed

- **A changed advertised address or container hostname now re-signs only the server certificate**, where it used to regenerate the certificate authority along
with it and present a pinned consumer with a trust anchor that had rotated for no reason.
- **A server certificate signed by a superseded authority is detected by signature rather than by issuer name**, which is the only way to tell two of these
authorities apart: every authority the simulator ever generated carries the same subject and none carries a key identifier.
- **An expired or nearly expired server certificate is re-signed at startup** instead of being served until a handshake fails against an anchor that never
changed — a failure a pinned consumer correctly reports as retryable and then retries forever.
- **A corrupt or unreadable server certificate is replaced rather than raised out of startup**, where it previously put the add-on into a restart loop.
- **An advertised address that is not an IP address is ignored with a warning** instead of aborting certificate generation.
- **Certificate files are written atomically**, so a container killed mid-write cannot leave a truncated certificate behind.
- **The authority's private key is no longer written into the certificate directory**, and one left there by an earlier build is removed; nothing reads it, and
it was world-readable in a directory that survives upgrades.

## 1.0.18 — 2026-08-28 — an unreachable Home Assistant no longer aborts startup

**`HAClient.async_validate` now degrades to `False` on a connection timeout** instead of letting the exception escape and take startup with it. The client sets
Expand Down
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.0.18"
version = "1.1.0"
description = "Standalone eBus simulator for SPAN panels"
requires-python = ">=3.14"
dependencies = [
Expand Down
5 changes: 4 additions & 1 deletion scripts/sync-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ DIRTY=0
sync_file() {
local file="$1" pattern="$2" replacement="$3"
if ! grep -qF "$replacement" "$file"; then
sed -i '' "s|${pattern}|${replacement}|" "$file"
# No `-i`: BSD wants `-i ''` and GNU wants `-i` alone, and getting it
# wrong fails only on the platform you are not developing on. A temp
# file and a move behave the same everywhere.
sed "s|${pattern}|${replacement}|" "$file" > "$file.tmp" && mv "$file.tmp" "$file"
git add "$file"
echo "sync-version: updated $file -> $VERSION"
DIRTY=1
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.0.18" \
io.hass.version="1.1.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.0.18"
version: "1.1.0"
slug: "span_panel_simulator"
url: "https://github.com/SpanPanel/simulator"
image: "ghcr.io/spanpanel/simulator/{arch}"
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.0.18"
__version__ = "1.1.0"
49 changes: 49 additions & 0 deletions src/span_panel_simulator/_ca/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# The emulator's certificate authority

`ca.crt` and `ca.key` are a fixed, deliberately public certificate authority, committed on
purpose. **This is not a leaked secret.** It is test material, in the tradition of Debian's
`ssl-cert-snakeoil` key, and secret scanners flagging it should be allowlisted rather than
obeyed.

SHA-256 of the certificate's DER bytes — the value the Home Assistant integration pins,
reports under `panel_ca` in diagnostics, and displays in a certificate-authority-changed
repair:

```
3cf8c14a78900b8736870c95adcc931cdcb3a51bc3029c96efafd0a4cb790d97
```

## Why it is fixed rather than generated

The simulator emulates SPAN firmware before r202633 and panelbench emulates r202633 and
later, so stopping one and starting the other rehearses a firmware upgrade on a single
panel. A firmware upgrade does not rotate a panel's certificate authority, and a consumer
that pins the authority is right to treat a change as worth stopping for. When each
emulator minted its own CA the swap looked like a panel substitution, which is the one
thing the rehearsal must not simulate.

Both repositories ship these bytes identically. They are the only shared state between two
otherwise decoupled projects, and nothing but this directory couples them: neither imports
the other, and the simulator's eventual archival leaves panelbench unaffected.

## What the public key does and does not cost

Anyone holding `ca.key` can mint a certificate that an integration entry pinned to *this*
authority will trust, and so can impersonate an emulated panel or read its traffic. What
that buys is control of synthetic circuits on an entry someone created for testing, and
the broker credentials it would expose are already public constants in `run.sh`.

It buys nothing at all against a real panel. A real panel mints its own authority in
firmware, the integration pins per config entry with no shared trust store, and this key
signs nothing that chains to it. A real panel's entry reporting the fingerprint above
would be conclusive evidence of tampering — a check the generated-CA arrangement could
never offer.

## Validity

Valid from 2026-01-01 to 2126-01-01. A static CA's expiry is a same-day, fleet-wide event
for every install at once, and no replacement can ship once the simulator is archived, so
the window is set far past the point where either emulator could still be in use.

The server certificate signed by it is *not* static: each install mints its own leaf and
key, because a leaf has to name that install's own address and hostname in its SAN.
20 changes: 20 additions & 0 deletions src/span_panel_simulator/_ca/ca.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDMDCCAhigAwIBAgIUad8iDgDJTr6QoYDp9lM+1tuwzCswDQYJKoZIhvcNAQEL
BQAwRTEdMBsGA1UECgwUU1BBTiBQYW5lbCBFbXVsYXRvcnMxJDAiBgNVBAMMG1NQ
QU4gUGFuZWwgRW11bGF0b3IgVGVzdCBDQTAgFw0yNjAxMDEwMDAwMDBaGA8yMTI2
MDEwMTAwMDAwMFowRTEdMBsGA1UECgwUU1BBTiBQYW5lbCBFbXVsYXRvcnMxJDAi
BgNVBAMMG1NQQU4gUGFuZWwgRW11bGF0b3IgVGVzdCBDQTCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBALGBGL8YmpvmTdeChNiIJCSkxMa6AijXdouLXanJ
QyfhKDKsmmSTCgtVAdlFfqFoMz/E9QxPDg6Ej6rXerKuaOdcZax8EU1TvhHXuuYA
TTjlGuLWcEOSh7pbReFLxU7Wr38M2yiRcW1NJznv4IGaSIboqOYtD+F23Y4RtuT3
j4xi4b9UAYY+psCHNF4jiw4MR/XAi+5wDA7RVujyrzRnfYd0XVcs69g7zGQrDiKQ
hGcu8daSixXKbLGDcq8p/rSB3liDxK7Ji38vDB5oNyzvEE0IIXWi9dqqzPBRp25g
IsQjq8ufoZ6953BmM5prnBdyFB6i5XJ+tbPEvXOKFjm9W9UCAwEAAaMWMBQwEgYD
VR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAqgENM+PcNvMcXqBf
vsWlysA5Fm9gGZrTgpV0iZI6ei+YoL9hQE0mLMO3/YOtoJjQbGFeW3w5vrBpQRCS
kPZ8Id1f6Ern5zyaNLF5Kz9ZGp9/VAz5NMsB0KOmRhYBLzJejLpnnazOAFiMbCxt
CetLHKMbyPSla5OoEkEhK2oPdHYcC4qvZrcNo+WMB2UHZYQejixQdQagexZgGXyE
7liPP+KngYcUV0g3XXD45rjQ0czCkxzuJ8kLsqFRMMIW/zF/R9zXG0+8WcF/qhLG
7mj4o5WXYu67qnHVJ0MQ7QZbbvNeqY/KkqgtGvLZ72YgkWy0EWU1atj1KDLSAS1e
5Kr66g==
-----END CERTIFICATE-----
27 changes: 27 additions & 0 deletions src/span_panel_simulator/_ca/ca.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAsYEYvxiam+ZN14KE2IgkJKTExroCKNd2i4tdqclDJ+EoMqya
ZJMKC1UB2UV+oWgzP8T1DE8ODoSPqtd6sq5o51xlrHwRTVO+Ede65gBNOOUa4tZw
Q5KHultF4UvFTtavfwzbKJFxbU0nOe/ggZpIhuio5i0P4XbdjhG25PePjGLhv1QB
hj6mwIc0XiOLDgxH9cCL7nAMDtFW6PKvNGd9h3RdVyzr2DvMZCsOIpCEZy7x1pKL
FcpssYNyryn+tIHeWIPErsmLfy8MHmg3LO8QTQghdaL12qrM8FGnbmAixCOry5+h
nr3ncGYzmmucF3IUHqLlcn61s8S9c4oWOb1b1QIDAQABAoIBAASNbpCMICmqb578
3SJej4AUbRWPCg95GIm+And1zs/0MYnnLQiHpY9319XLjKgKxPG4P/QCXQvDRkSt
RRuAZ0wJGHR4NCC7FUSzjJAUkZEe0yCJO7+H6mI9sAj2CV1FxUzUDBTkx2IfGJ1J
noTvFjgbjyVkIiRNXk5OpwBarGb/IUSAmz2+pGidzReiNQNRaxoXNXrAwKMBwBde
JMXcg5jsC5x/5E8J1sR982tsW4SEpu9C+JsPE+QRecl//gcmRKbEcU/UDnAzt8nE
74l8VYvFIGPN8ensiVePF6AOeD+sNurJr7+4bB6dp+oho+mwlx+lnaQFnNE3fyNP
BeZIH6sCgYEA5vckI0gxdHqbdIjKwDSa3UrvLYn6uNMERYf9xqOPqRamVpwvI/8b
2u7gsU2WVctzLOGki62BtLJ/LxWbMjHfC0P462W0rGLxM5RWW/I8Eg8YTbZ6TnI7
eKJ6Kwp6XOLctrMkdvG8Uhb1xmqJhhcXuUm/5eAFGQRj0wscSE4N6fcCgYEAxL6C
Z+x8ACaQlpGM/ETHLULscWk4eHVJxAPWP46KSYw/sRpCBGzwqBKdNHB9WfVJ4KOC
6/vdjdyKMBvDAo6fgQ7xipQWLSCieG/DUlgWnxYBrFxjdhs5Pup/c2Gstqc0Ik35
rRa7FyIVv9hZlpKxTIEOkIvCqr+goHfJiWM3VZMCgYEA3UU0B+EoEBFOUUykAZPk
MdGSZqxj2SHAW+S3CdC01ydt4FftCa1jwFFOTk6okjVBeGQMQgzN0JGZK0Cl2GbS
6nYKSWq8ttJrhiC8w4XEawCvJfSGJBVoSGBQbl+xbr+g+B+T9agN5Q8+EwMKEb6Z
6ddv0wjVmWwNPqjMRbERv20CgYAGtmlEmnDwoGA2gujIgqA5Dxt6Exj2IypNIcRh
jr9xGvPhWv/9nqnnUhtC+sOMGi5XF70TyBgbCvc8BPZ2U99lS9k53SuUhpSEf2uu
l4rOCrYr50sNcIhXeTC4AxEdej7xHK/0E952P5d2YC9OT9r0vUJ99AbERJ16XBvo
MBZnOQKBgAaV1JHmRveHj2WSKv4QAj5j7GhmZ5x7kbCBsupvOgEWl/WWIbZ0FQc4
cASSqXrSOagLa1DeuvuPNgcrgTtECDZL5tufT3WODLqOt0KnsT4v/705S2C+qbbe
SUi0YAzIQB9rDewY9dY6UP6lJfIbX+GmaUmgkKF++tYyNwBSWS2C
-----END RSA PRIVATE KEY-----
Loading