span-panel-api 3.1.1__tar.gz → 3.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/.gitignore +0 -5
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/CHANGELOG.md +10 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/PKG-INFO +24 -8
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/README.md +23 -7
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/pyproject.toml +36 -4
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/__init__.py +4 -1
- span_panel_api-3.2.0/src/span_panel_api/_ssl.py +223 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/protocol.py +6 -5
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/conftest.py +7 -7
- span_panel_api-3.2.0/tests/fixtures/panelbench_wire.json +684 -0
- span_panel_api-3.2.0/tests/fixtures/v2/README.md +14 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/reference_payloads/README.md +27 -19
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/reference_payloads/bootstrap.py +9 -4
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/reference_payloads/schema_one.py +10 -5
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_adoption.py +25 -1
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_catalog_divergence.py +16 -38
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_packaging.py +34 -32
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_public_api_unchanged.py +6 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_reference_tree_values.py +7 -7
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_migration_delta.py +11 -3
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_adapter.py +1 -1
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_circuits.py +43 -5
- span_panel_api-3.2.0/tests/test_schema_one_conformance.py +629 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_connection_health.py +2 -2
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_control_refusal.py +137 -41
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_discovery.py +1 -1
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_panel.py +11 -11
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_snapshot.py +4 -4
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_ssl_context.py +119 -1
- span_panel_api-3.1.1/src/span_panel_api/_ssl.py +0 -104
- span_panel_api-3.1.1/tests/fixtures/v2/README.md +0 -14
- span_panel_api-3.1.1/tests/reference_payloads/homie_schema.json +0 -420
- span_panel_api-3.1.1/tests/reference_payloads/parent_child_tree.json +0 -234
- span_panel_api-3.1.1/tests/test_schema_one_against_simulator.py +0 -260
- span_panel_api-3.1.1/tests/test_schema_one_conformance.py +0 -828
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/LICENSE +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/models.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/client.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.1.1 → span_panel_api-3.2.0}/tests/test_v2_status_parser.py +0 -0
|
@@ -40,8 +40,3 @@ coverage_output.log
|
|
|
40
40
|
# of which belongs in a repository. The differential that reads them commits its
|
|
41
41
|
# *verdict* only, never the capture, and skips when the file is absent.
|
|
42
42
|
tests/fixtures/live_*.json
|
|
43
|
-
|
|
44
|
-
# Peer checkouts. CI clones the eBus specification and SpanPanel/panelbench here so
|
|
45
|
-
# the provenance checks have something to compare vendored bytes against; the same
|
|
46
|
-
# layout works locally if you would rather not point .env at siblings.
|
|
47
|
-
/peers/
|
|
@@ -7,6 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
7
7
|
Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
|
|
8
8
|
beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
|
|
9
9
|
|
|
10
|
+
## [3.2.0]
|
|
11
|
+
|
|
12
|
+
A consumer pinned to a panel's CA cannot currently tell a panel that has moved from something impersonating one, because the two produce the same verification failure. This release splits the question.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`build_panel_ssl_context` takes `check_hostname`**, so a caller can verify that a peer holds a key the pinned CA signed without also asserting that the certificate names the address it was dialled by.
|
|
17
|
+
- **`leaf_names_host` decides the name binding on its own**, hand-written against `getpeercert()` because `ssl.match_hostname` was removed in Python 3.12, and stricter than that function was: no wildcards, no `commonName` fallback, and DNS and IP entries
|
|
18
|
+
that never stand in for one another.
|
|
19
|
+
|
|
10
20
|
## [3.1.1]
|
|
11
21
|
|
|
12
22
|
A follow-up to 3.1.0's security work, with no API change and no adapter move required.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: A client library for SPAN Panel API
|
|
5
5
|
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
6
|
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
@@ -554,11 +554,25 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
554
554
|
|
|
555
555
|
## Reference Payloads
|
|
556
556
|
|
|
557
|
-
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree —
|
|
557
|
+
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — ship as package data of the adapter that parses each, and their provenance is documented in
|
|
558
|
+
[`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them.
|
|
558
559
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
560
|
+
| Capture | Read from |
|
|
561
|
+
| ------------------------ | ---------------------------------------------------------- |
|
|
562
|
+
| `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` |
|
|
563
|
+
| `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` |
|
|
564
|
+
|
|
565
|
+
**Test-support data, and no runtime path reads either.** They ship so a downstream test suite pinned to a version of an adapter reads the same bytes that version was tested against, out of its own site-packages:
|
|
566
|
+
|
|
567
|
+
```python
|
|
568
|
+
from importlib.resources import files
|
|
569
|
+
import json
|
|
570
|
+
|
|
571
|
+
schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8"))
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
The bootstrap distribution ships neither: it registers no adapter and parses nothing. `span_panel_api.reference_payloads` and `span_panel_api_schema_1.reference_payloads` — the importable modules that existed until 3.1.0 — are still gone; these are data
|
|
575
|
+
files read through `importlib.resources`, not an import surface.
|
|
562
576
|
|
|
563
577
|
## Project Structure
|
|
564
578
|
|
|
@@ -589,13 +603,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
|
589
603
|
|
|
590
604
|
packages/schema-0/ # distribution: span-panel-api-schema-0
|
|
591
605
|
└── src/span_panel_api_schema_0/
|
|
592
|
-
|
|
606
|
+
├── reference/ # homie_schema.json — test-support package data, not read at runtime
|
|
607
|
+
└── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
|
|
593
608
|
# HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
|
|
594
609
|
|
|
595
610
|
packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
596
|
-
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
611
|
+
├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed
|
|
597
612
|
└── src/span_panel_api_schema_1/
|
|
598
|
-
|
|
613
|
+
├── reference/ # parent_child_tree.json — test-support package data, not read at runtime
|
|
614
|
+
└── ... # Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
599
615
|
# adoption, catalog validator, spec_lock.json
|
|
600
616
|
```
|
|
601
617
|
|
|
@@ -527,11 +527,25 @@ The `PanelCapability` flag enum advertises transport features at runtime:
|
|
|
527
527
|
|
|
528
528
|
## Reference Payloads
|
|
529
529
|
|
|
530
|
-
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree —
|
|
530
|
+
Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — ship as package data of the adapter that parses each, and their provenance is documented in
|
|
531
|
+
[`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them.
|
|
531
532
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
533
|
+
| Capture | Read from |
|
|
534
|
+
| ------------------------ | ---------------------------------------------------------- |
|
|
535
|
+
| `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` |
|
|
536
|
+
| `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` |
|
|
537
|
+
|
|
538
|
+
**Test-support data, and no runtime path reads either.** They ship so a downstream test suite pinned to a version of an adapter reads the same bytes that version was tested against, out of its own site-packages:
|
|
539
|
+
|
|
540
|
+
```python
|
|
541
|
+
from importlib.resources import files
|
|
542
|
+
import json
|
|
543
|
+
|
|
544
|
+
schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8"))
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
The bootstrap distribution ships neither: it registers no adapter and parses nothing. `span_panel_api.reference_payloads` and `span_panel_api_schema_1.reference_payloads` — the importable modules that existed until 3.1.0 — are still gone; these are data
|
|
548
|
+
files read through `importlib.resources`, not an import surface.
|
|
535
549
|
|
|
536
550
|
## Project Structure
|
|
537
551
|
|
|
@@ -562,13 +576,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser)
|
|
|
562
576
|
|
|
563
577
|
packages/schema-0/ # distribution: span-panel-api-schema-0
|
|
564
578
|
└── src/span_panel_api_schema_0/
|
|
565
|
-
|
|
579
|
+
├── reference/ # homie_schema.json — test-support package data, not read at runtime
|
|
580
|
+
└── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
|
|
566
581
|
# HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
|
|
567
582
|
|
|
568
583
|
packages/schema-1/ # distribution: span-panel-api-schema-1
|
|
569
|
-
├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
|
|
584
|
+
├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed
|
|
570
585
|
└── src/span_panel_api_schema_1/
|
|
571
|
-
|
|
586
|
+
├── reference/ # parent_child_tree.json — test-support package data, not read at runtime
|
|
587
|
+
└── ... # Parent/child parser: ControllerRoutes, snapshot mapper,
|
|
572
588
|
# adoption, catalog validator, spec_lock.json
|
|
573
589
|
```
|
|
574
590
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.2.0"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -78,6 +78,12 @@ dev = [
|
|
|
78
78
|
# two distributions together, which is the configuration users will run.
|
|
79
79
|
"span-panel-api-schema-0",
|
|
80
80
|
"span-panel-api-schema-1",
|
|
81
|
+
# The producer of `span_panel_api_schema_1/reference/parent_child_tree.json`, pinned
|
|
82
|
+
# exactly because a reference capture is only evidence if what made it is
|
|
83
|
+
# known. Dependabot raises the bump on its own; the bump PR re-runs
|
|
84
|
+
# `scripts/capture_parent_child_reference.py` and the suite then says whether
|
|
85
|
+
# the wire moved.
|
|
86
|
+
"ebus-panel-sim==0.8.0",
|
|
81
87
|
"pytest>=9.0.2",
|
|
82
88
|
"pytest-asyncio>=1.3.0",
|
|
83
89
|
"pytest-cov",
|
|
@@ -102,7 +108,7 @@ dev = [
|
|
|
102
108
|
# transitive of `twine -> keyring -> secretstorage`, which is marked
|
|
103
109
|
# `sys_platform == 'linux'` -- so the whole module ran in CI and silently
|
|
104
110
|
# skipped on every macOS checkout, which reads in the summary line exactly
|
|
105
|
-
# like passing. See DEVELOPMENT.md, "A skip
|
|
111
|
+
# like passing. See DEVELOPMENT.md, "A skip is still not a pass".
|
|
106
112
|
#
|
|
107
113
|
# Floored at 50.0.0 rather than at whatever the lock happens to hold: four
|
|
108
114
|
# advisories cover the range below it (the highest first patched in 50.0.0),
|
|
@@ -153,8 +159,20 @@ include = [
|
|
|
153
159
|
|
|
154
160
|
[tool.ruff]
|
|
155
161
|
line-length = 125
|
|
162
|
+
# `scripts/` is excluded file by file rather than as a directory, so that a new
|
|
163
|
+
# script is linted by default and only the ones written before that was true are
|
|
164
|
+
# grandfathered out. `capture_parent_child_reference.py` is the one in scope: it
|
|
165
|
+
# produces a fixture the whole schema_1 suite is written against, and the rest are
|
|
166
|
+
# hand-run tools.
|
|
156
167
|
exclude = [
|
|
157
|
-
"scripts/",
|
|
168
|
+
"scripts/capture_flat_reference.py",
|
|
169
|
+
"scripts/capture_live_flat.py",
|
|
170
|
+
"scripts/coverage.py",
|
|
171
|
+
"scripts/format_markdown.py",
|
|
172
|
+
"scripts/test_live_auth.py",
|
|
173
|
+
"scripts/validate_lug_derivation/",
|
|
174
|
+
"scripts/verify_adapterless_install.py",
|
|
175
|
+
"scripts/verify_reconnect.py",
|
|
158
176
|
".*_cache/",
|
|
159
177
|
"dist/",
|
|
160
178
|
"venv/",
|
|
@@ -164,6 +182,10 @@ exclude = [
|
|
|
164
182
|
[tool.ruff.lint.per-file-ignores]
|
|
165
183
|
# Exclude tests from ALL linting checks (formatting still applies)
|
|
166
184
|
"tests/**/*.py" = ["ALL"]
|
|
185
|
+
# Its whole output is a report on stdout naming the producer, the device count and
|
|
186
|
+
# where the capture landed, which is what an operator reads to decide whether to
|
|
187
|
+
# adopt it. T20 is right for the library and wrong for a hand-run tool.
|
|
188
|
+
"scripts/capture_parent_child_reference.py" = ["T201"]
|
|
167
189
|
|
|
168
190
|
[tool.ruff.format]
|
|
169
191
|
quote-style = "double"
|
|
@@ -217,8 +239,18 @@ warn_unused_configs = true
|
|
|
217
239
|
disallow_untyped_defs = true
|
|
218
240
|
explicit_package_bases = true
|
|
219
241
|
mypy_path = "src"
|
|
242
|
+
# Excluded by name for the reason `[tool.ruff]` gives above: the capture script is
|
|
243
|
+
# checked like the library, and the hand-run tools written before that was expected
|
|
244
|
+
# are not.
|
|
220
245
|
exclude = [
|
|
221
|
-
"scripts/",
|
|
246
|
+
"scripts/capture_flat_reference.py",
|
|
247
|
+
"scripts/capture_live_flat.py",
|
|
248
|
+
"scripts/coverage.py",
|
|
249
|
+
"scripts/format_markdown.py",
|
|
250
|
+
"scripts/test_live_auth.py",
|
|
251
|
+
"scripts/validate_lug_derivation/",
|
|
252
|
+
"scripts/verify_adapterless_install.py",
|
|
253
|
+
"scripts/verify_reconnect.py",
|
|
222
254
|
"tests/",
|
|
223
255
|
"docs/",
|
|
224
256
|
".*_cache/",
|
|
@@ -6,7 +6,7 @@ supporting MQTT/Homie (v2) transport.
|
|
|
6
6
|
|
|
7
7
|
from importlib.metadata import version as _pkg_version
|
|
8
8
|
|
|
9
|
-
from ._ssl import build_panel_ssl_context, ca_fingerprint
|
|
9
|
+
from ._ssl import build_panel_ssl_context, ca_fingerprint, leaf_names_host
|
|
10
10
|
from .auth import (
|
|
11
11
|
delete_fqdn,
|
|
12
12
|
download_ca_cert,
|
|
@@ -154,6 +154,9 @@ __all__ = [ # noqa: RUF022
|
|
|
154
154
|
# both live here rather than being reimplemented on the other side.
|
|
155
155
|
"build_panel_ssl_context",
|
|
156
156
|
"ca_fingerprint",
|
|
157
|
+
# Added 2026-08-28: the hostname half of verification, split out so a
|
|
158
|
+
# caller using a relaxed context can still establish the name binding.
|
|
159
|
+
"leaf_names_host",
|
|
157
160
|
"delete_fqdn",
|
|
158
161
|
"download_ca_cert",
|
|
159
162
|
"get_fqdn",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""The panel's trust anchor: building a context from it, and naming it.
|
|
2
|
+
|
|
3
|
+
Both functions here take a CA in PEM form and nothing else. They make no network
|
|
4
|
+
call and hold no state, which is the point -- a trust anchor that is fetched at
|
|
5
|
+
the moment it is used is not an anchor, it is whatever answered. The fetching
|
|
6
|
+
lives in ``auth.download_ca_cert``, and deciding whether a fetched PEM may be
|
|
7
|
+
trusted lives with the caller.
|
|
8
|
+
|
|
9
|
+
Public rather than private (``_ssl`` is a module-name convention here, and every
|
|
10
|
+
name is re-exported from the package root) because the consumer needs all three:
|
|
11
|
+
it builds the same context for its own HTTPS calls, it prints and compares the
|
|
12
|
+
same fingerprint string, and it applies the same hostname rules when it has to
|
|
13
|
+
judge a name binding for itself. Two implementations of a fingerprint that must
|
|
14
|
+
agree byte-for-byte is a defect waiting for a firmware upgrade to find it, and
|
|
15
|
+
the same is true of a hand-written hostname matcher -- more so, since that one
|
|
16
|
+
is security-relevant and has no standard-library implementation left to defer
|
|
17
|
+
to since ``ssl.match_hostname`` was removed in Python 3.12.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import binascii
|
|
24
|
+
from collections.abc import Iterator, Mapping
|
|
25
|
+
import hashlib
|
|
26
|
+
import ipaddress
|
|
27
|
+
import ssl
|
|
28
|
+
|
|
29
|
+
from .exceptions import SpanPanelValidationError
|
|
30
|
+
|
|
31
|
+
_PEM_HEADER = "-----BEGIN CERTIFICATE-----"
|
|
32
|
+
_PEM_FOOTER = "-----END CERTIFICATE-----"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_panel_ssl_context(ca_pem: str, *, check_hostname: bool = True) -> ssl.SSLContext:
|
|
36
|
+
"""Build an SSLContext that trusts only the provided panel CA.
|
|
37
|
+
|
|
38
|
+
The panel issues a private CA and a server cert signed by it. We do
|
|
39
|
+
not want to trust system CAs for this connection, so the context is
|
|
40
|
+
built fresh rather than via ``ssl.create_default_context()``.
|
|
41
|
+
|
|
42
|
+
The panel's CA is a minimal self-signed certificate that omits the
|
|
43
|
+
Authority Key Identifier (AKI) X.509v3 extension. Python 3.13 enabled
|
|
44
|
+
``VERIFY_X509_STRICT`` by default, and that flag rejects such a
|
|
45
|
+
certificate with "Missing Authority Key Identifier", which makes the
|
|
46
|
+
MQTTS handshake fail on otherwise healthy panels. The flag is cleared
|
|
47
|
+
here so the library keeps working across Python versions.
|
|
48
|
+
|
|
49
|
+
This does not weaken the parts of verification that matter for this
|
|
50
|
+
connection: the trust anchor is still only the panel's own CA, hostname
|
|
51
|
+
checking stays enabled by default, and signature/expiry validation is
|
|
52
|
+
unchanged.
|
|
53
|
+
|
|
54
|
+
``check_hostname=False`` asks a narrower question: *does the peer hold a
|
|
55
|
+
private key whose certificate chains to this anchor?* The chain, the
|
|
56
|
+
signature and the expiry are still verified -- only the binding between
|
|
57
|
+
the certificate and the name used to dial it is left unasserted. That is
|
|
58
|
+
a real distinction and not a relaxation of trust: an attacker without a
|
|
59
|
+
CA-signed key cannot complete the handshake either way.
|
|
60
|
+
|
|
61
|
+
It exists because the two failures are otherwise indistinguishable, and
|
|
62
|
+
they call for opposite responses. A panel that has moved to a new DHCP
|
|
63
|
+
lease serves a perfectly good certificate that no longer names the
|
|
64
|
+
address it is reached at; something impersonating a panel serves one that
|
|
65
|
+
chains to nothing. Collapsing both into "verification failed" tells a
|
|
66
|
+
user their panel has been intercepted when its address merely changed.
|
|
67
|
+
|
|
68
|
+
Never pass ``check_hostname=False`` for a connection that carries data.
|
|
69
|
+
The name binding is what stops a validated certificate being replayed by
|
|
70
|
+
a host it was not issued to, so a relaxed context belongs only in code
|
|
71
|
+
that is deciding *which* host to talk to, paired with
|
|
72
|
+
:func:`leaf_names_host` to establish the binding separately.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ssl.SSLError: ``ca_pem`` is not a certificate the ssl module accepts.
|
|
76
|
+
ValueError: ``ca_pem`` is malformed in a way ``ssl`` reports as such.
|
|
77
|
+
"""
|
|
78
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
79
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
80
|
+
ctx.check_hostname = check_hostname
|
|
81
|
+
ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT
|
|
82
|
+
ctx.load_verify_locations(cadata=ca_pem)
|
|
83
|
+
return ctx
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def leaf_names_host(peer_cert: Mapping[str, object], host: str) -> bool:
|
|
87
|
+
"""Whether a validated peer certificate names ``host`` in its SAN.
|
|
88
|
+
|
|
89
|
+
The hostname half of what ``check_hostname=True`` does in one step, split
|
|
90
|
+
out so a caller that built a relaxed context can still ask the question
|
|
91
|
+
and act on the answer. ``peer_cert`` is what ``SSLSocket.getpeercert()``
|
|
92
|
+
returns, which is populated only for a certificate the handshake already
|
|
93
|
+
validated -- so this function decides naming, never trust.
|
|
94
|
+
|
|
95
|
+
Hand-written because ``ssl.match_hostname`` was removed in Python 3.12
|
|
96
|
+
and nothing replaced it as public API. The rules here are deliberately
|
|
97
|
+
stricter than the ones it implemented, because a panel's leaf is
|
|
98
|
+
machine-generated from a fixed template and needs none of the latitude a
|
|
99
|
+
general-purpose matcher owes the public web:
|
|
100
|
+
|
|
101
|
+
- **No wildcards.** ``*.example.com`` is not matched against anything. A
|
|
102
|
+
panel names literal addresses, so a wildcard in one of its certificates
|
|
103
|
+
would be an anomaly rather than a case to support.
|
|
104
|
+
- **No ``commonName`` fallback.** Deprecated for two decades, and every
|
|
105
|
+
certificate this library meets carries a SAN.
|
|
106
|
+
- **IP and DNS entries are not interchangeable.** A host that parses as an
|
|
107
|
+
IP address is matched only against ``IP Address`` entries and a name
|
|
108
|
+
only against ``DNS`` entries, so a certificate naming the *string*
|
|
109
|
+
"10.0.0.5" in a DNS entry does not authorise the address 10.0.0.5.
|
|
110
|
+
- **Addresses compare parsed, names compare casefolded.** ``::1`` and
|
|
111
|
+
``0:0:0:0:0:0:0:1`` are one address; ``Panel.local`` and ``panel.local``
|
|
112
|
+
are one name. A single trailing dot is insignificant on both sides.
|
|
113
|
+
|
|
114
|
+
Returns False for anything it cannot read -- a certificate with no SAN, a
|
|
115
|
+
malformed entry, an unparseable address. The caller's question is "may I
|
|
116
|
+
treat this name as bound to this certificate", and the honest answer to a
|
|
117
|
+
SAN that cannot be understood is no.
|
|
118
|
+
"""
|
|
119
|
+
candidate = _without_root_dot(host)
|
|
120
|
+
if not candidate:
|
|
121
|
+
return False
|
|
122
|
+
entries = list(_san_entries(peer_cert))
|
|
123
|
+
try:
|
|
124
|
+
wanted = ipaddress.ip_address(candidate)
|
|
125
|
+
except ValueError:
|
|
126
|
+
return _names_dns(entries, candidate)
|
|
127
|
+
return _names_address(entries, wanted)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _without_root_dot(name: str) -> str:
|
|
131
|
+
"""Strip surrounding space and a single root dot, which is not significant."""
|
|
132
|
+
stripped = name.strip()
|
|
133
|
+
return stripped[:-1] if stripped.endswith(".") else stripped
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _san_entries(peer_cert: Mapping[str, object]) -> Iterator[tuple[str, str]]:
|
|
137
|
+
"""Yield the readable ``(kind, value)`` pairs of a certificate's SAN.
|
|
138
|
+
|
|
139
|
+
Anything malformed is skipped rather than rejected wholesale, so one broken
|
|
140
|
+
entry cannot hide a good one sitting beside it.
|
|
141
|
+
"""
|
|
142
|
+
san = peer_cert.get("subjectAltName")
|
|
143
|
+
if not isinstance(san, tuple | list):
|
|
144
|
+
return
|
|
145
|
+
for entry in san:
|
|
146
|
+
if not isinstance(entry, tuple | list) or len(entry) != 2:
|
|
147
|
+
continue
|
|
148
|
+
kind, value = entry
|
|
149
|
+
if isinstance(kind, str) and isinstance(value, str):
|
|
150
|
+
yield kind, value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _names_address(entries: list[tuple[str, str]], wanted: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
154
|
+
"""Whether an ``IP Address`` entry denotes ``wanted``, compared as addresses."""
|
|
155
|
+
for kind, value in entries:
|
|
156
|
+
if kind != "IP Address":
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
if ipaddress.ip_address(value.strip()) == wanted:
|
|
160
|
+
return True
|
|
161
|
+
except ValueError:
|
|
162
|
+
continue
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _names_dns(entries: list[tuple[str, str]], candidate: str) -> bool:
|
|
167
|
+
"""Whether a ``DNS`` entry equals ``candidate``, casefolded and exact."""
|
|
168
|
+
folded = candidate.casefold()
|
|
169
|
+
for kind, value in entries:
|
|
170
|
+
if kind != "DNS":
|
|
171
|
+
continue
|
|
172
|
+
named = _without_root_dot(value)
|
|
173
|
+
if named and named.casefold() == folded:
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def ca_fingerprint(ca_pem: str) -> str:
|
|
179
|
+
"""SHA-256 over the certificate's DER bytes, lowercase hex, no separators.
|
|
180
|
+
|
|
181
|
+
The identity of a trust anchor, in a form a user can compare by eye against
|
|
182
|
+
what the panel's label or another install reports, and a consumer can store
|
|
183
|
+
in a config entry.
|
|
184
|
+
|
|
185
|
+
Taken over the DER rather than over the PEM text on purpose. PEM is a
|
|
186
|
+
presentation of the same bytes -- line width, line endings, surrounding
|
|
187
|
+
blank lines and any explanatory text a firmware chooses to put above the
|
|
188
|
+
header all vary without the certificate changing -- so a hash of the text
|
|
189
|
+
would report a rotation that did not happen. That is the worse error of the
|
|
190
|
+
two available: an integration that raises "your panel's CA changed" every
|
|
191
|
+
time a firmware reflows its PEM teaches its users to dismiss the one time it
|
|
192
|
+
matters.
|
|
193
|
+
|
|
194
|
+
Only the first certificate in the PEM is read. The panel serves a single
|
|
195
|
+
self-signed CA; if a future firmware appends a chain, the anchor is still the
|
|
196
|
+
first element, and silently hashing a concatenation would change the
|
|
197
|
+
fingerprint of an unchanged anchor.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
SpanPanelValidationError: no certificate block, or one whose body is not
|
|
201
|
+
valid base64. Distinct from an ``ssl`` error because nothing has been
|
|
202
|
+
asked of ``ssl`` yet -- this is a malformed input, and the caller
|
|
203
|
+
handling it has a different remedy from one whose certificate is
|
|
204
|
+
well-formed and unacceptable.
|
|
205
|
+
"""
|
|
206
|
+
start = ca_pem.find(_PEM_HEADER)
|
|
207
|
+
if start == -1:
|
|
208
|
+
raise SpanPanelValidationError("No PEM certificate block found; cannot fingerprint the CA")
|
|
209
|
+
body_start = start + len(_PEM_HEADER)
|
|
210
|
+
end = ca_pem.find(_PEM_FOOTER, body_start)
|
|
211
|
+
if end == -1:
|
|
212
|
+
raise SpanPanelValidationError("PEM certificate block is not terminated; cannot fingerprint the CA")
|
|
213
|
+
|
|
214
|
+
# Every run of whitespace is dropped rather than only line breaks, so a PEM
|
|
215
|
+
# reflowed, re-indented or converted to CRLF fingerprints identically.
|
|
216
|
+
body = "".join(ca_pem[body_start:end].split())
|
|
217
|
+
try:
|
|
218
|
+
der = base64.b64decode(body, validate=True)
|
|
219
|
+
except (binascii.Error, ValueError) as exc:
|
|
220
|
+
raise SpanPanelValidationError("PEM certificate body is not valid base64; cannot fingerprint the CA") from exc
|
|
221
|
+
if not der:
|
|
222
|
+
raise SpanPanelValidationError("PEM certificate block is empty; cannot fingerprint the CA")
|
|
223
|
+
return hashlib.sha256(der).hexdigest()
|
|
@@ -275,11 +275,12 @@ class SchemaAdapter(Protocol):
|
|
|
275
275
|
under the flat schema, `$settable` on `load-shed/priority` under v1.0 --
|
|
276
276
|
which is the same reading `SpanCircuitSnapshot.is_never_backup` reports.
|
|
277
277
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
Under v1.0 the lock is announced by *omitting* `$settable`, which is
|
|
279
|
+
Homie 5's default for the attribute and what a conforming publisher
|
|
280
|
+
emits for a control that accepts no write. A device that declares no
|
|
281
|
+
`load-shed/priority` at all answers None for the plainer reason that it
|
|
282
|
+
has offered no such control -- as does a panel carrying no circuit under
|
|
283
|
+
that id.
|
|
283
284
|
"""
|
|
284
285
|
|
|
285
286
|
def has_circuit(self, circuit_id: str) -> bool:
|
|
@@ -26,15 +26,15 @@ _DOTENV = Path(__file__).parent.parent / ".env"
|
|
|
26
26
|
def _load_dotenv() -> None:
|
|
27
27
|
"""Populate the environment from `.env`, without overriding what is set.
|
|
28
28
|
|
|
29
|
-
Read directly rather than through python-dotenv: this supplies
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
Read directly rather than through python-dotenv: this supplies the credentials
|
|
30
|
+
for the one check that needs a real panel (`LIVE_PANEL_*`), and taking a
|
|
31
|
+
dependency to parse four lines would put a package in the test path to save
|
|
32
|
+
nothing.
|
|
33
33
|
|
|
34
34
|
`setdefault`, never assignment. An exported value is a deliberate choice for
|
|
35
|
-
this run — pointing at a
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
this run — pointing at a second panel to reproduce something — and a file
|
|
36
|
+
silently winning over it is the kind of surprise that costs an afternoon. See
|
|
37
|
+
`.env.example`; absence is fine, `test_live_flat_differential.py` skips.
|
|
38
38
|
"""
|
|
39
39
|
if not _DOTENV.exists():
|
|
40
40
|
return
|