span-panel-api 3.4.0__tar.gz → 3.5.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.4.0 → span_panel_api-3.5.0}/CHANGELOG.md +42 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/PKG-INFO +10 -4
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/README.md +9 -3
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/pyproject.toml +1 -1
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/__init__.py +11 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/_http.py +33 -16
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/auth.py +98 -14
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/exceptions.py +10 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/models.py +9 -1
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/client.py +9 -1
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/connection.py +15 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/protocol.py +4 -2
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_detection_auth.py +75 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_https_transport.py +8 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_connect_flow.py +89 -2
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_homie.py +1 -1
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_plaintext_warning.py +50 -1
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_public_api_unchanged.py +8 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_rest_transport_contract.py +3 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_adapter.py +6 -2
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_control_refusal.py +25 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_panel.py +25 -4
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/.gitignore +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/LICENSE +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/conftest.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/reference_payloads/schema_one.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_adoption.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_packaging.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.4.0 → span_panel_api-3.5.0}/tests/tls_fixtures.py +0 -0
|
@@ -7,6 +7,48 @@ 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.5.0]
|
|
11
|
+
|
|
12
|
+
A rotation replaces the panel passphrase as well as the broker password, and the library now hands back both, so a caller no longer loses the only copy of the user's new passphrase. A broker that refuses the credentials is now reported as an authentication
|
|
13
|
+
failure rather than a connection failure.
|
|
14
|
+
|
|
15
|
+
**Upgrade note.** `connect()` raises `SpanPanelAuthError`, where it raised `SpanPanelConnectionError`, when the broker refuses the credentials (CONNACK "Bad user name or password" or "Not authorized"). The two are siblings under `SpanPanelError`, so a
|
|
16
|
+
caller that caught `SpanPanelConnectionError` to handle a refused login must catch `SpanPanelAuthError` too. Right after a rotation the same error is also how a broker that has not yet accepted the new password answers, so retry it with backoff for up to
|
|
17
|
+
about a minute before treating it as wrong credentials.
|
|
18
|
+
|
|
19
|
+
Reported and fixed by [@dcj](https://github.com/dcj) in [#179](https://github.com/SpanPanel/span-panel-api/pull/179), from issue [#178](https://github.com/SpanPanel/span-panel-api/issues/178).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`rotate_passphrase()` returns both new values** as a `PassphraseRotation` (`ebus_broker_password`, `hop_passphrase`), because a rotation replaces the hop passphrase as well as the broker password and `register_v2` afterwards accepts only the new one.
|
|
24
|
+
Exported with the result type. Access tokens already issued are not revoked. The broker may not accept the new password the moment the call returns, and the old one may keep working briefly: reconnect with the new one and never fall back to the old one.
|
|
25
|
+
If the broker refuses it, retry with backoff for up to about a minute, including `SpanPanelAuthError`, which `connect()` raises for a refused CONNACK and which is what a broker that has not yet accepted the new password produces. If it is still refused,
|
|
26
|
+
rotate again and use the newly returned value. Do not rely on the old password stopping, or on existing sessions being disconnected, at any particular moment.
|
|
27
|
+
- **`SpanPanelInsufficientPrivilegeError`** for a rotation refused with HTTP 403: the token is reduced-privilege (one obtained by proof of proximity), and registering with the passphrase yields a full-privilege token. A subclass of `SpanPanelAuthError`, so
|
|
28
|
+
existing except clauses still catch it.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **`regenerate_passphrase()` no longer claims the hop passphrase is unchanged.** It performs the same rotation and still returns only the new broker password as a `str`.
|
|
33
|
+
- **`set_dominant_power_source` refuses with "Panel offers no settable dominant power source control"** where it said "Core node not found in panel topology", since the parent/child schema has no core node and now also refuses a control the panel does not
|
|
34
|
+
declare settable.
|
|
35
|
+
- **Passphrase rotation raises `SpanPanelServerError` with `status_code` for a 5xx**, instead of a plain `SpanPanelAPIError`. After a 500 the outcome is unknown: the passphrase may or may not have changed.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **A broker that refuses the credentials now raises `SpanPanelAuthError` from `connect()`**, instead of `SpanPanelConnectionError`, so a consumer can ask for new credentials rather than retry. This covers the CONNACK refusals "Bad user name or password"
|
|
40
|
+
and "Not authorized"; the error message carries the reason code. Every other refusal still raises `SpanPanelConnectionError`, and the reconnect loop still retries all of them.
|
|
41
|
+
|
|
42
|
+
## [3.4.1]
|
|
43
|
+
|
|
44
|
+
A panel that merely advertises itself on the network no longer produces the plaintext-transport warning when discovery probes it, closing the remaining way issue span#264's log line reached an operator who could do nothing about it.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **The status endpoint no longer emits the plaintext-transport warning**, for the CA download's reason from the other side: it is the detection probe made against devices nobody has configured, where no pin can exist and the only action is configuring the
|
|
49
|
+
panel — whose flow pins before any credential moves. It carries no credential in either direction, and it no longer spends the once-per-host warning slot, which it previously claimed first in every flow so that a genuinely credential-bearing call behind
|
|
50
|
+
it said nothing. Registration, passphrase rotation, and the schema fetch warn exactly as before.
|
|
51
|
+
|
|
10
52
|
## [3.4.0]
|
|
11
53
|
|
|
12
54
|
A consumer that pinned the panel's CA could not put its schema fetches behind that pin, because the one port `SpanMqttClient` took served two transports with opposite security properties — the schema fetch, which should ride the pinned HTTPS transport, and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.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
|
|
@@ -476,7 +476,7 @@ Standalone async functions for v2-specific HTTP operations:
|
|
|
476
476
|
```python
|
|
477
477
|
from span_panel_api import (
|
|
478
478
|
register_v2, download_ca_cert, get_homie_schema,
|
|
479
|
-
|
|
479
|
+
rotate_passphrase, get_v2_status,
|
|
480
480
|
register_fqdn, get_fqdn, delete_fqdn,
|
|
481
481
|
)
|
|
482
482
|
|
|
@@ -493,8 +493,14 @@ schema = await get_homie_schema("192.168.1.100")
|
|
|
493
493
|
print(f"Panel size: {schema.panel_size} spaces")
|
|
494
494
|
print(f"Schema hash: {schema.types_schema_hash}")
|
|
495
495
|
|
|
496
|
-
# Rotate MQTT broker password
|
|
497
|
-
|
|
496
|
+
# Rotate the passphrase, which is currently also the MQTT broker password; both values change
|
|
497
|
+
# Access tokens already issued are not revoked
|
|
498
|
+
rotation = await rotate_passphrase("192.168.1.100", token=auth.access_token)
|
|
499
|
+
new_password = rotation.ebus_broker_password
|
|
500
|
+
# The broker may not accept new_password immediately, and the old one may keep
|
|
501
|
+
# working briefly. Retry a refused connect (including SpanPanelAuthError) with
|
|
502
|
+
# backoff for up to about a minute; if still refused, rotate again and use the
|
|
503
|
+
# newly returned value. Never fall back to the old password.
|
|
498
504
|
|
|
499
505
|
# Get panel status (unauthenticated)
|
|
500
506
|
status = await get_v2_status("192.168.1.100")
|
|
@@ -449,7 +449,7 @@ Standalone async functions for v2-specific HTTP operations:
|
|
|
449
449
|
```python
|
|
450
450
|
from span_panel_api import (
|
|
451
451
|
register_v2, download_ca_cert, get_homie_schema,
|
|
452
|
-
|
|
452
|
+
rotate_passphrase, get_v2_status,
|
|
453
453
|
register_fqdn, get_fqdn, delete_fqdn,
|
|
454
454
|
)
|
|
455
455
|
|
|
@@ -466,8 +466,14 @@ schema = await get_homie_schema("192.168.1.100")
|
|
|
466
466
|
print(f"Panel size: {schema.panel_size} spaces")
|
|
467
467
|
print(f"Schema hash: {schema.types_schema_hash}")
|
|
468
468
|
|
|
469
|
-
# Rotate MQTT broker password
|
|
470
|
-
|
|
469
|
+
# Rotate the passphrase, which is currently also the MQTT broker password; both values change
|
|
470
|
+
# Access tokens already issued are not revoked
|
|
471
|
+
rotation = await rotate_passphrase("192.168.1.100", token=auth.access_token)
|
|
472
|
+
new_password = rotation.ebus_broker_password
|
|
473
|
+
# The broker may not accept new_password immediately, and the old one may keep
|
|
474
|
+
# working briefly. Retry a refused connect (including SpanPanelAuthError) with
|
|
475
|
+
# backoff for up to about a minute; if still refused, rotate again and use the
|
|
476
|
+
# newly returned value. Never fall back to the old password.
|
|
471
477
|
|
|
472
478
|
# Get panel status (unauthenticated)
|
|
473
479
|
status = await get_v2_status("192.168.1.100")
|
|
@@ -16,6 +16,7 @@ from .auth import (
|
|
|
16
16
|
regenerate_passphrase,
|
|
17
17
|
register_fqdn,
|
|
18
18
|
register_v2,
|
|
19
|
+
rotate_passphrase,
|
|
19
20
|
)
|
|
20
21
|
from .detection import DetectionResult, detect_api_version
|
|
21
22
|
from .exceptions import (
|
|
@@ -26,6 +27,7 @@ from .exceptions import (
|
|
|
26
27
|
SpanPanelCAChangedError,
|
|
27
28
|
SpanPanelConnectionError,
|
|
28
29
|
SpanPanelError,
|
|
30
|
+
SpanPanelInsufficientPrivilegeError,
|
|
29
31
|
SpanPanelSchemaVersionError,
|
|
30
32
|
SpanPanelServerError,
|
|
31
33
|
SpanPanelStaleDataError,
|
|
@@ -46,6 +48,7 @@ from .models import (
|
|
|
46
48
|
ExtensionSubject,
|
|
47
49
|
FieldMetadata,
|
|
48
50
|
HomieSchemaTypes,
|
|
51
|
+
PassphraseRotation,
|
|
49
52
|
SpanBatterySnapshot,
|
|
50
53
|
SpanCircuitSnapshot,
|
|
51
54
|
SpanEvseSnapshot,
|
|
@@ -172,6 +175,11 @@ __all__ = [ # noqa: RUF022
|
|
|
172
175
|
"register_fqdn",
|
|
173
176
|
"regenerate_passphrase",
|
|
174
177
|
"register_v2",
|
|
178
|
+
# Added 2026-10-02 (3.5.0): the rotation reports both new values, because
|
|
179
|
+
# it replaces the hop passphrase as well as the broker password. Additive --
|
|
180
|
+
# regenerate_passphrase keeps its str return.
|
|
181
|
+
"rotate_passphrase",
|
|
182
|
+
"PassphraseRotation",
|
|
175
183
|
# Transport
|
|
176
184
|
"MqttClientConfig",
|
|
177
185
|
"SpanMqttClient",
|
|
@@ -199,6 +207,9 @@ __all__ = [ # noqa: RUF022
|
|
|
199
207
|
"SpanPanelSchemaVersionError",
|
|
200
208
|
"SpanPanelConnectionError",
|
|
201
209
|
"SpanPanelError",
|
|
210
|
+
# Added 2026-10-02 (3.5.0): a 403 from a reduced-privilege token. A subclass
|
|
211
|
+
# of SpanPanelAuthError, so every existing except clause keeps its meaning.
|
|
212
|
+
"SpanPanelInsufficientPrivilegeError",
|
|
202
213
|
"SpanPanelServerError",
|
|
203
214
|
"SpanPanelStaleDataError",
|
|
204
215
|
# Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
|
|
@@ -31,11 +31,14 @@ DEFAULT_HTTPS_PORT = 443
|
|
|
31
31
|
#: The one bootstrap path two modules request: the detector probes it to decide
|
|
32
32
|
#: whether the panel speaks v2 at all, and `get_v2_status` reads the same answer
|
|
33
33
|
#: for a caller that already knows it does. Named here rather than spelled out in
|
|
34
|
-
#: each, so the two cannot drift apart the way their parsers had.
|
|
34
|
+
#: each, so the two cannot drift apart the way their parsers had. Load-bearing
|
|
35
|
+
#: for `_warn_plaintext_transport`'s exemption: a request to this path is the
|
|
36
|
+
#: one the warning stays silent for, so a change here changes what warns.
|
|
35
37
|
V2_STATUS_PATH = "/api/v2/status"
|
|
36
38
|
|
|
37
|
-
#:
|
|
38
|
-
#: the transport is what grants the exemption. See
|
|
39
|
+
#: Exempt from the plaintext warning alongside `V2_STATUS_PATH`, named here
|
|
40
|
+
#: because the transport is what grants the exemption. See
|
|
41
|
+
#: `_warn_plaintext_transport`.
|
|
39
42
|
CA_CERT_PATH = "/api/v2/certificate/ca"
|
|
40
43
|
|
|
41
44
|
#: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
|
|
@@ -174,19 +177,33 @@ def _reset_plaintext_warnings() -> None:
|
|
|
174
177
|
def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext | None) -> None:
|
|
175
178
|
"""Say out loud, once per panel, that its bootstrap traffic is not encrypted.
|
|
176
179
|
|
|
177
|
-
**
|
|
180
|
+
**Two endpoints are exempt, and neither claims the once-per-host slot.**
|
|
178
181
|
The warning exists so an operator can tell a security property is off when
|
|
179
|
-
it could be on, and for
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
182
|
+
it could be on, and for both there is no "on". The CA download fetches the
|
|
183
|
+
very anchor verification would need — an unverified-TLS wrapping is
|
|
184
|
+
readable and forgeable by the same active on-path attacker, the payload is
|
|
185
|
+
a public certificate, and its authenticity control is the leaf check
|
|
186
|
+
callers run *after* the fetch. The status endpoint is the detection probe:
|
|
187
|
+
most prominently the request discovery makes against a device nobody has
|
|
188
|
+
configured, where no pin can exist because trust-on-first-use has not
|
|
189
|
+
happened and the only action available is configuring the panel — whose
|
|
190
|
+
flow pins before any credential moves, with a person confirming the
|
|
191
|
+
fingerprint. Neither call carries a credential in either direction, and
|
|
192
|
+
warning on them named credentials they never carry: the CA download's line
|
|
193
|
+
is what issue span#264 reported, and the status probe's is what a merely
|
|
194
|
+
*advertising* unconfigured panel produced at every boot.
|
|
195
|
+
|
|
196
|
+
Two limits of the status exemption, stated rather than implied. The body
|
|
197
|
+
informs decisions — `proximityProven`, the serial an identity check reads —
|
|
198
|
+
and a plaintext answer is one anything on the path can write; the controls
|
|
199
|
+
for that are the panel's own registration gate and the pin the consumer's
|
|
200
|
+
flow acquires before a credential moves, not a log line. And a consumer
|
|
201
|
+
*can* probe a configured, pinned panel's status without its context; the
|
|
202
|
+
exemption means no warning will point that out, so a consumer owes every
|
|
203
|
+
probe of a configured host the entry's own transport. Not marking the host matters as
|
|
204
|
+
much as not warning — the probe runs first in every flow and a diagnostic
|
|
205
|
+
re-read runs on pinned entries, and neither may spend the slot a
|
|
206
|
+
credential-bearing call needs later.
|
|
190
207
|
|
|
191
208
|
In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
|
|
192
209
|
reason: a security property that is off by default is only a decision if the
|
|
@@ -214,7 +231,7 @@ def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext
|
|
|
214
231
|
"""
|
|
215
232
|
if ssl_context is not None:
|
|
216
233
|
return
|
|
217
|
-
if path
|
|
234
|
+
if path in (CA_CERT_PATH, V2_STATUS_PATH):
|
|
218
235
|
return
|
|
219
236
|
if host in _warned_plaintext_hosts:
|
|
220
237
|
return
|
|
@@ -18,9 +18,9 @@ import uuid
|
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
21
|
-
from ._http import CA_CERT_PATH, V2_STATUS_PATH, _request
|
|
22
|
-
from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelServerError
|
|
23
|
-
from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo
|
|
21
|
+
from ._http import CA_CERT_PATH, V2_STATUS_PATH, _Reply, _request
|
|
22
|
+
from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelInsufficientPrivilegeError, SpanPanelServerError
|
|
23
|
+
from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
|
|
24
24
|
|
|
25
25
|
_LOGGER = logging.getLogger(__name__)
|
|
26
26
|
|
|
@@ -491,6 +491,64 @@ async def get_homie_schema(
|
|
|
491
491
|
)
|
|
492
492
|
|
|
493
493
|
|
|
494
|
+
async def rotate_passphrase(
|
|
495
|
+
host: str,
|
|
496
|
+
token: str,
|
|
497
|
+
timeout: float = 10.0,
|
|
498
|
+
port: int | None = None,
|
|
499
|
+
httpx_client: httpx.AsyncClient | None = None,
|
|
500
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
501
|
+
) -> PassphraseRotation:
|
|
502
|
+
"""Rotate the panel's passphrase, which is currently also its MQTT broker password.
|
|
503
|
+
|
|
504
|
+
One PUT replaces both: the hop passphrase that ``register_v2`` accepts and
|
|
505
|
+
the broker password, which the panel reports as two fields currently
|
|
506
|
+
carrying the same new value. Use ``ebus_broker_password`` for the broker.
|
|
507
|
+
Afterwards ``register_v2`` accepts only the new passphrase. Access tokens
|
|
508
|
+
already issued are not revoked.
|
|
509
|
+
|
|
510
|
+
The broker may not accept the new password the moment this call returns,
|
|
511
|
+
and the old one may keep working briefly. Reconnect with the new password
|
|
512
|
+
and never fall back to the old one. If the broker refuses it, retry with
|
|
513
|
+
backoff for up to about a minute, and include ``SpanPanelAuthError`` in the
|
|
514
|
+
retry: ``connect()`` raises it for a refused CONNACK, which is what a broker
|
|
515
|
+
that has not yet accepted the new password produces. If it is still refused
|
|
516
|
+
after that, rotate again and use the newly returned value. Do not rely on
|
|
517
|
+
the old password stopping, or on existing sessions being disconnected, at
|
|
518
|
+
any particular moment.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
host: IP address or hostname of the SPAN Panel
|
|
522
|
+
token: Valid JWT access token
|
|
523
|
+
timeout: Request timeout in seconds when ``httpx_client`` is None; ignored when injected.
|
|
524
|
+
port: Port of the panel bootstrap API. ``None`` means "unspecified" and takes
|
|
525
|
+
the scheme's default -- 80 without ``ssl_context``, 443 with one.
|
|
526
|
+
httpx_client: Optional shared ``httpx.AsyncClient``; not closed by this function.
|
|
527
|
+
Not used when ``ssl_context`` is supplied -- httpx fixes its trust store at
|
|
528
|
+
construction, so a pinned CA needs a client built for it. See ``_get_client``.
|
|
529
|
+
ssl_context: Trust anchor for the panel's HTTPS certificate. Supplying one moves
|
|
530
|
+
this call to ``https://``; ``None`` is byte-identical to 3.0.1.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
The new broker password and the new hop passphrase.
|
|
534
|
+
|
|
535
|
+
Raises:
|
|
536
|
+
SpanPanelInsufficientPrivilegeError: HTTP 403; the token is reduced-privilege
|
|
537
|
+
SpanPanelAuthError: HTTP 401 (token invalid or stale) or 412 (no bearer token)
|
|
538
|
+
SpanPanelServerError: HTTP 5xx. After a 500 the outcome is unknown: the
|
|
539
|
+
passphrase may or may not have changed.
|
|
540
|
+
SpanPanelConnectionError: Cannot reach panel
|
|
541
|
+
SpanPanelTimeoutError: Request timed out
|
|
542
|
+
SpanPanelAPIError: Unexpected response
|
|
543
|
+
"""
|
|
544
|
+
reply = await _put_passphrase(host, token, timeout, port, httpx_client, ssl_context)
|
|
545
|
+
data = reply.json_object("ebusBrokerPassword", "hopPassphrase")
|
|
546
|
+
return PassphraseRotation(
|
|
547
|
+
ebus_broker_password=_str(data["ebusBrokerPassword"]),
|
|
548
|
+
hop_passphrase=_str(data["hopPassphrase"]),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
494
552
|
async def regenerate_passphrase(
|
|
495
553
|
host: str,
|
|
496
554
|
token: str,
|
|
@@ -499,11 +557,13 @@ async def regenerate_passphrase(
|
|
|
499
557
|
httpx_client: httpx.AsyncClient | None = None,
|
|
500
558
|
ssl_context: ssl.SSLContext | None = None,
|
|
501
559
|
) -> str:
|
|
502
|
-
"""Rotate the
|
|
560
|
+
"""Rotate the panel's passphrase and return only the new broker password.
|
|
503
561
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
562
|
+
The same PUT as ``rotate_passphrase``, with the same effect: the hop
|
|
563
|
+
passphrase changes too, ``register_v2`` accepts only the new one, access
|
|
564
|
+
tokens already issued are not revoked, and the same reconnect advice
|
|
565
|
+
applies. Use ``rotate_passphrase`` to receive both values; this function is
|
|
566
|
+
kept for callers written against its ``str`` return.
|
|
507
567
|
|
|
508
568
|
Args:
|
|
509
569
|
host: IP address or hostname of the SPAN Panel
|
|
@@ -521,11 +581,21 @@ async def regenerate_passphrase(
|
|
|
521
581
|
New MQTT broker password
|
|
522
582
|
|
|
523
583
|
Raises:
|
|
524
|
-
|
|
525
|
-
SpanPanelConnectionError: Cannot reach panel
|
|
526
|
-
SpanPanelTimeoutError: Request timed out
|
|
527
|
-
SpanPanelAPIError: Unexpected response
|
|
584
|
+
Exactly what ``rotate_passphrase`` raises.
|
|
528
585
|
"""
|
|
586
|
+
reply = await _put_passphrase(host, token, timeout, port, httpx_client, ssl_context)
|
|
587
|
+
return _str(reply.json_object("ebusBrokerPassword")["ebusBrokerPassword"])
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def _put_passphrase(
|
|
591
|
+
host: str,
|
|
592
|
+
token: str,
|
|
593
|
+
timeout: float,
|
|
594
|
+
port: int | None,
|
|
595
|
+
httpx_client: httpx.AsyncClient | None,
|
|
596
|
+
ssl_context: ssl.SSLContext | None,
|
|
597
|
+
) -> _Reply:
|
|
598
|
+
"""Send the rotation PUT and raise for every status but 200."""
|
|
529
599
|
reply = await _request(
|
|
530
600
|
"PUT",
|
|
531
601
|
host,
|
|
@@ -537,13 +607,27 @@ async def regenerate_passphrase(
|
|
|
537
607
|
headers=_bearer(token),
|
|
538
608
|
)
|
|
539
609
|
|
|
540
|
-
if reply.status_code
|
|
610
|
+
if reply.status_code == 403:
|
|
611
|
+
raise SpanPanelInsufficientPrivilegeError(
|
|
612
|
+
f"Token lacks the privilege to rotate the passphrase (HTTP {reply.status_code})"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
if reply.status_code in (401, 412):
|
|
541
616
|
raise SpanPanelAuthError(f"Authentication failed (HTTP {reply.status_code})")
|
|
542
617
|
|
|
618
|
+
if reply.status_code >= 500:
|
|
619
|
+
raise SpanPanelServerError(
|
|
620
|
+
f"Panel could not rotate the passphrase: HTTP {reply.status_code}",
|
|
621
|
+
status_code=reply.status_code,
|
|
622
|
+
)
|
|
623
|
+
|
|
543
624
|
if reply.status_code != 200:
|
|
544
|
-
raise SpanPanelAPIError(
|
|
625
|
+
raise SpanPanelAPIError(
|
|
626
|
+
f"Failed to regenerate passphrase: HTTP {reply.status_code}",
|
|
627
|
+
status_code=reply.status_code,
|
|
628
|
+
)
|
|
545
629
|
|
|
546
|
-
return
|
|
630
|
+
return reply
|
|
547
631
|
|
|
548
632
|
|
|
549
633
|
async def register_fqdn(
|
|
@@ -9,6 +9,16 @@ class SpanPanelAuthError(SpanPanelError):
|
|
|
9
9
|
"""Authentication failed."""
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class SpanPanelInsufficientPrivilegeError(SpanPanelAuthError):
|
|
13
|
+
"""The token is valid but reduced-privilege, so the panel refused the call (HTTP 403).
|
|
14
|
+
|
|
15
|
+
A token obtained by proof of proximity (the door bypass) carries reduced
|
|
16
|
+
privilege. Registering with the panel's passphrase yields a full-privilege
|
|
17
|
+
token. A subclass of `SpanPanelAuthError`, so an existing except clause
|
|
18
|
+
keeps catching it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
12
22
|
class SpanPanelConnectionError(SpanPanelError):
|
|
13
23
|
"""Connection to SPAN panel failed."""
|
|
14
24
|
|
|
@@ -526,7 +526,15 @@ class V2AuthResponse:
|
|
|
526
526
|
ebus_broker_wss_port: int
|
|
527
527
|
hostname: str
|
|
528
528
|
serial_number: str
|
|
529
|
-
hop_passphrase: str # For REST auth
|
|
529
|
+
hop_passphrase: str # For REST auth; currently the same value as ebus_broker_password
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@dataclass(frozen=True, slots=True)
|
|
533
|
+
class PassphraseRotation:
|
|
534
|
+
"""Response from PUT /api/v2/auth/passphrase: both values, both new."""
|
|
535
|
+
|
|
536
|
+
ebus_broker_password: str = field(repr=False)
|
|
537
|
+
hop_passphrase: str = field(repr=False)
|
|
530
538
|
|
|
531
539
|
|
|
532
540
|
@dataclass(frozen=True, slots=True)
|
|
@@ -416,6 +416,7 @@ class SpanMqttClient:
|
|
|
416
416
|
5. Wait for $state==ready and $description parsed
|
|
417
417
|
|
|
418
418
|
Raises:
|
|
419
|
+
SpanPanelAuthError: The broker refused the credentials
|
|
419
420
|
SpanPanelConnectionError: Cannot connect or device not ready
|
|
420
421
|
SpanPanelTimeoutError: Connection or ready timed out
|
|
421
422
|
"""
|
|
@@ -865,6 +866,13 @@ class SpanMqttClient:
|
|
|
865
866
|
directly; v1.0 routes the command to `shed/asserted-islanding-state`,
|
|
866
867
|
whose enum is `NONE`/`ON_GRID`/`OFF_GRID`. Publishing `value` unchanged
|
|
867
868
|
would put a string outside that enum on the wire.
|
|
869
|
+
|
|
870
|
+
Refused, with nothing published, when the panel does not declare the
|
|
871
|
+
control settable, and under v1.0 for `NONE` and `UNKNOWN`, which the
|
|
872
|
+
panel would ignore. Under v1.0 the panel also ignores `ON_GRID` and
|
|
873
|
+
`OFF_GRID` while its link to the battery is healthy (the battery's
|
|
874
|
+
`status/communication-state` is `OK`), leaving the published value
|
|
875
|
+
unchanged.
|
|
868
876
|
"""
|
|
869
877
|
adapter = self._require_adapter()
|
|
870
878
|
target = adapter.set_dominant_power_source_target()
|
|
@@ -873,7 +881,7 @@ class SpanMqttClient:
|
|
|
873
881
|
device_id=self._serial_number,
|
|
874
882
|
value=value,
|
|
875
883
|
detail="no such control",
|
|
876
|
-
message="
|
|
884
|
+
message="Panel offers no settable dominant power source control",
|
|
877
885
|
)
|
|
878
886
|
payload = adapter.dominant_power_source_payload(value)
|
|
879
887
|
if payload is None:
|
|
@@ -26,6 +26,7 @@ from .._ssl import LeafNameMismatch, LeafProbe, build_panel_ssl_context, ca_fing
|
|
|
26
26
|
from ..auth import download_ca_cert
|
|
27
27
|
from ..exceptions import (
|
|
28
28
|
SpanPanelAPIError,
|
|
29
|
+
SpanPanelAuthError,
|
|
29
30
|
SpanPanelCAChangedError,
|
|
30
31
|
SpanPanelConnectionError,
|
|
31
32
|
SpanPanelError,
|
|
@@ -49,6 +50,11 @@ if TYPE_CHECKING:
|
|
|
49
50
|
|
|
50
51
|
_LOGGER = logging.getLogger(__name__)
|
|
51
52
|
|
|
53
|
+
# CONNACK refusals that mean the credentials were rejected: "Bad user name or
|
|
54
|
+
# password" (0x86) and "Not authorized" (0x87). paho reports the MQTT 3.1.1
|
|
55
|
+
# return codes 4 and 5 as these same MQTT 5 reason codes.
|
|
56
|
+
_CONNACK_AUTH_REFUSALS = frozenset({0x86, 0x87})
|
|
57
|
+
|
|
52
58
|
|
|
53
59
|
class AsyncMqttBridge:
|
|
54
60
|
"""Event-loop-driven paho-mqtt wrapper with async callback dispatch.
|
|
@@ -111,6 +117,8 @@ class AsyncMqttBridge:
|
|
|
111
117
|
self._connected = False
|
|
112
118
|
self._client: AsyncMQTTClient | None = None
|
|
113
119
|
self._connect_event: asyncio.Event | None = None
|
|
120
|
+
# Reason code of the most recent refused CONNACK; `connect()` reads it.
|
|
121
|
+
self._connack_refusal: ReasonCode | None = None
|
|
114
122
|
|
|
115
123
|
self._misc_timer: asyncio.TimerHandle | None = None
|
|
116
124
|
self._should_reconnect = False
|
|
@@ -420,6 +428,8 @@ class AsyncMqttBridge:
|
|
|
420
428
|
(blocking I/O), and waits for CONNACK.
|
|
421
429
|
|
|
422
430
|
Raises:
|
|
431
|
+
SpanPanelAuthError: The broker refused the username or password
|
|
432
|
+
(CONNACK "Bad user name or password" or "Not authorized").
|
|
423
433
|
SpanPanelConnectionError: Cannot connect to broker.
|
|
424
434
|
SpanPanelTimeoutError: Connection timed out.
|
|
425
435
|
SpanPanelCAChangedError: The panel is pinned and now advertises a
|
|
@@ -432,6 +442,7 @@ class AsyncMqttBridge:
|
|
|
432
442
|
self._loop = asyncio.get_running_loop()
|
|
433
443
|
|
|
434
444
|
self._connect_event = asyncio.Event()
|
|
445
|
+
self._connack_refusal = None
|
|
435
446
|
self._should_reconnect = True
|
|
436
447
|
|
|
437
448
|
_LOGGER.debug(
|
|
@@ -505,6 +516,9 @@ class AsyncMqttBridge:
|
|
|
505
516
|
raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc
|
|
506
517
|
|
|
507
518
|
if not self._connected:
|
|
519
|
+
refusal = self._connack_refusal
|
|
520
|
+
if refusal is not None and refusal.value in _CONNACK_AUTH_REFUSALS:
|
|
521
|
+
raise SpanPanelAuthError(f"MQTT broker at {self._host}:{self._port} refused the credentials: {refusal}")
|
|
508
522
|
raise SpanPanelConnectionError(f"MQTT connection failed to {self._host}:{self._port}")
|
|
509
523
|
|
|
510
524
|
self._initial_connect_done = True
|
|
@@ -763,6 +777,7 @@ class AsyncMqttBridge:
|
|
|
763
777
|
self._reconnect_task.cancel()
|
|
764
778
|
self._reconnect_task = None
|
|
765
779
|
else:
|
|
780
|
+
self._connack_refusal = reason_code
|
|
766
781
|
_LOGGER.warning("MQTT connection refused: %s", reason_code)
|
|
767
782
|
|
|
768
783
|
# Signal the asyncio connect() waiter
|
|
@@ -320,7 +320,7 @@ class SchemaAdapter(Protocol):
|
|
|
320
320
|
"""
|
|
321
321
|
|
|
322
322
|
def set_dominant_power_source_target(self) -> ControlTarget | None:
|
|
323
|
-
"""Where a dominant-power-source command goes, or None if the panel
|
|
323
|
+
"""Where a dominant-power-source command goes, or None if the panel offers no settable one."""
|
|
324
324
|
|
|
325
325
|
def set_evse_charge_limit_target(self, node_id: str) -> ControlTarget | None:
|
|
326
326
|
"""The target that writes one charger's charge-current limit, or None.
|
|
@@ -355,7 +355,9 @@ class SchemaAdapter(Protocol):
|
|
|
355
355
|
`NONE`/`ON_GRID`/`OFF_GRID`, so the value has to be mapped rather than
|
|
356
356
|
forwarded. Returning None means "no legal representation", and the
|
|
357
357
|
transport should refuse the command rather than publish a value the
|
|
358
|
-
panel will reject.
|
|
358
|
+
panel will reject. v1.0 returns None for `NONE` and `UNKNOWN`: the panel
|
|
359
|
+
ignores a written `NONE`, and an assertion clears itself once the
|
|
360
|
+
panel's link to the battery recovers.
|
|
359
361
|
"""
|
|
360
362
|
|
|
361
363
|
def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
|
|
@@ -11,9 +11,12 @@ from span_panel_api.exceptions import (
|
|
|
11
11
|
SpanPanelAPIError,
|
|
12
12
|
SpanPanelAuthError,
|
|
13
13
|
SpanPanelConnectionError,
|
|
14
|
+
SpanPanelInsufficientPrivilegeError,
|
|
15
|
+
SpanPanelServerError,
|
|
14
16
|
SpanPanelTimeoutError,
|
|
15
17
|
)
|
|
16
18
|
from span_panel_api.models import (
|
|
19
|
+
PassphraseRotation,
|
|
17
20
|
V2AuthResponse,
|
|
18
21
|
V2HomieSchema,
|
|
19
22
|
V2StatusInfo,
|
|
@@ -28,6 +31,7 @@ from span_panel_api.auth import (
|
|
|
28
31
|
regenerate_passphrase,
|
|
29
32
|
register_fqdn,
|
|
30
33
|
register_v2,
|
|
34
|
+
rotate_passphrase,
|
|
31
35
|
)
|
|
32
36
|
|
|
33
37
|
# ---------------------------------------------------------------------------
|
|
@@ -686,6 +690,77 @@ class TestRegeneratePassphrase:
|
|
|
686
690
|
await regenerate_passphrase("192.168.65.70", "")
|
|
687
691
|
|
|
688
692
|
|
|
693
|
+
def _put_client(response: httpx.Response) -> AsyncMock:
|
|
694
|
+
"""An injected client whose PUT returns `response`."""
|
|
695
|
+
client = AsyncMock(spec=httpx.AsyncClient)
|
|
696
|
+
client.put.return_value = response
|
|
697
|
+
return client
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
ROTATIONS = [
|
|
701
|
+
pytest.param(regenerate_passphrase, id="regenerate_passphrase"),
|
|
702
|
+
pytest.param(rotate_passphrase, id="rotate_passphrase"),
|
|
703
|
+
]
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
class TestRotatePassphrase:
|
|
707
|
+
@pytest.mark.asyncio
|
|
708
|
+
async def test_returns_both_new_values(self):
|
|
709
|
+
body = {"ebusBrokerPassword": "new-broker", "hopPassphrase": "new-hop"}
|
|
710
|
+
result = await rotate_passphrase("192.168.65.70", "jwt", httpx_client=_put_client(_mock_response(200, body)))
|
|
711
|
+
|
|
712
|
+
assert result == PassphraseRotation(ebus_broker_password="new-broker", hop_passphrase="new-hop")
|
|
713
|
+
|
|
714
|
+
@pytest.mark.asyncio
|
|
715
|
+
async def test_repr_omits_the_secrets(self):
|
|
716
|
+
body = {"ebusBrokerPassword": "new-broker", "hopPassphrase": "new-hop"}
|
|
717
|
+
result = await rotate_passphrase("192.168.65.70", "jwt", httpx_client=_put_client(_mock_response(200, body)))
|
|
718
|
+
|
|
719
|
+
assert "new-broker" not in repr(result)
|
|
720
|
+
assert "new-hop" not in repr(result)
|
|
721
|
+
|
|
722
|
+
@pytest.mark.asyncio
|
|
723
|
+
async def test_a_200_without_hop_passphrase_is_an_api_error(self):
|
|
724
|
+
response = _mock_response(200, {"ebusBrokerPassword": "new-broker"})
|
|
725
|
+
with pytest.raises(SpanPanelAPIError, match="hopPassphrase"):
|
|
726
|
+
await rotate_passphrase("192.168.65.70", "jwt", httpx_client=_put_client(response))
|
|
727
|
+
|
|
728
|
+
@pytest.mark.asyncio
|
|
729
|
+
@pytest.mark.parametrize("call", ROTATIONS)
|
|
730
|
+
async def test_403_is_insufficient_privilege(self, call):
|
|
731
|
+
with pytest.raises(SpanPanelInsufficientPrivilegeError, match="403") as caught:
|
|
732
|
+
await call("192.168.65.70", "door-token", httpx_client=_put_client(_mock_response(403)))
|
|
733
|
+
assert isinstance(caught.value, SpanPanelAuthError)
|
|
734
|
+
|
|
735
|
+
@pytest.mark.asyncio
|
|
736
|
+
@pytest.mark.parametrize("call", ROTATIONS)
|
|
737
|
+
@pytest.mark.parametrize("status", [401, 412])
|
|
738
|
+
async def test_401_and_412_are_auth_errors(self, call, status):
|
|
739
|
+
with pytest.raises(SpanPanelAuthError, match=str(status)) as caught:
|
|
740
|
+
await call("192.168.65.70", "jwt", httpx_client=_put_client(_mock_response(status)))
|
|
741
|
+
assert not isinstance(caught.value, SpanPanelInsufficientPrivilegeError)
|
|
742
|
+
|
|
743
|
+
@pytest.mark.asyncio
|
|
744
|
+
@pytest.mark.parametrize("call", ROTATIONS)
|
|
745
|
+
@pytest.mark.parametrize("status", [500, 503])
|
|
746
|
+
async def test_5xx_is_a_server_error_with_status(self, call, status):
|
|
747
|
+
response = _mock_response(status, text="secret-in-body")
|
|
748
|
+
with pytest.raises(SpanPanelServerError) as caught:
|
|
749
|
+
await call("192.168.65.70", "jwt", httpx_client=_put_client(response))
|
|
750
|
+
assert caught.value.status_code == status
|
|
751
|
+
assert "secret-in-body" not in str(caught.value)
|
|
752
|
+
|
|
753
|
+
@pytest.mark.asyncio
|
|
754
|
+
@pytest.mark.parametrize("call", ROTATIONS)
|
|
755
|
+
async def test_other_4xx_is_an_api_error_not_a_server_error(self, call):
|
|
756
|
+
response = _mock_response(404, text="secret-in-body")
|
|
757
|
+
with pytest.raises(SpanPanelAPIError) as caught:
|
|
758
|
+
await call("192.168.65.70", "jwt", httpx_client=_put_client(response))
|
|
759
|
+
assert not isinstance(caught.value, SpanPanelServerError)
|
|
760
|
+
assert caught.value.status_code == 404
|
|
761
|
+
assert "secret-in-body" not in str(caught.value)
|
|
762
|
+
|
|
763
|
+
|
|
689
764
|
# ===================================================================
|
|
690
765
|
# register_fqdn
|
|
691
766
|
# ===================================================================
|