diff --git a/python/packages/jumpstarter-driver-http-power/README.md b/python/packages/jumpstarter-driver-http-power/README.md index 98007b27b..6ee8bbcb9 100644 --- a/python/packages/jumpstarter-driver-http-power/README.md +++ b/python/packages/jumpstarter-driver-http-power/README.md @@ -35,6 +35,15 @@ export: password: "secret" ``` +For devices that require HTTP Digest Auth instead, replace the `basic` block with `digest`: + +```yaml + auth: + digest: + user: "admin" + password: "secret" +``` + ### Example configuration for Shelly Smart Plug (Gen1): ```yaml @@ -102,6 +111,10 @@ voltage=236.6 V current=0.0 A apparent_power=0.0 VA | power_read | HTTP endpoint config for reading power measurements. When unset, `read()` raises rather than returning a fake zero measurement | HttpEndpointConfig | no | None | | auth | Authentication configuration | HttpAuthConfig | no | None | | auth.basic | Basic authentication credentials | HttpBasicAuth | no | None | +| auth.digest | Digest authentication credentials | HttpDigestAuth | no | None | + +`auth.basic` and `auth.digest` are mutually exclusive; configuring both raises an +error at exporter startup. #### HttpEndpointConfig parameters @@ -120,6 +133,13 @@ voltage=236.6 V current=0.0 A apparent_power=0.0 VA | user | Username for basic authentication | str | yes | | | password | Password for basic authentication | str | yes | | +#### HttpDigestAuth parameters + +| Parameter | Description | Type | Required | Default | +|-----------|-------------|------|----------|---------| +| user | Username for digest authentication | str | yes | | +| password | Password for digest authentication | str | yes | | + ## API Reference ```{eval-rst} @@ -153,5 +173,5 @@ configured path that isn't found raises an error. ``` ```{note} -Authentication is optional and supports HTTP Basic Auth only. +Authentication is optional and supports HTTP Basic Auth and HTTP Digest Auth. ``` diff --git a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py index fdfbc6ae6..999e8017d 100644 --- a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py +++ b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver.py @@ -1,8 +1,10 @@ import json from dataclasses import dataclass, field from typing import Any, Generator, Optional +from urllib.parse import urlsplit import requests +import requests.auth from jumpstarter_driver_power.common import PowerReading from jumpstarter_driver_power.driver import PowerInterface @@ -34,9 +36,16 @@ class HttpBasicAuth: password: str = field(default="") +@dataclass(kw_only=True) +class HttpDigestAuth: + user: str = field(default="") + password: str = field(default="") + + @dataclass(kw_only=True) class HttpAuthConfig: basic: Optional[HttpBasicAuth] = field(default=None) + digest: Optional[HttpDigestAuth] = field(default=None) @dataclass(kw_only=True) @@ -64,19 +73,41 @@ def __post_init__(self): self.power_off = HttpEndpointConfig(**self.power_off) if self.power_read and isinstance(self.power_read, dict): self.power_read = HttpEndpointConfig(**self.power_read) - if self.auth and isinstance(self.auth, dict): + # Presence, not truthiness: an empty mapping is still a configured auth block. + if isinstance(self.auth, dict): self.auth = HttpAuthConfig(**self.auth) - if self.auth and self.auth.basic and isinstance(self.auth.basic, dict): + if self.auth is not None and isinstance(self.auth.basic, dict): self.auth.basic = HttpBasicAuth(**self.auth.basic) - + if self.auth is not None and isinstance(self.auth.digest, dict): + self.auth.digest = HttpDigestAuth(**self.auth.digest) + if self.auth is not None and self.auth.basic is not None and self.auth.digest is not None: + raise ValueError("auth.basic and auth.digest are mutually exclusive, configure only one of them") + + # requests.auth.HTTPDigestAuth keeps the server nonce in handler-local state, so one handler is + # reused per origin to avoid a 401 challenge on every request. Handlers are never + # shared across origins, whose nonces and realms are unrelated. + self._digest_auth: dict[tuple[str, str], requests.auth.HTTPDigestAuth] = {} + + def _build_auth(self, url: str) -> Optional[requests.auth.AuthBase]: + """Build the requests auth handler for ``url`` from the configured credentials""" + if self.auth is None: + return None + if self.auth.basic is not None: + return requests.auth.HTTPBasicAuth(self.auth.basic.user, self.auth.basic.password) + if self.auth.digest is not None: + origin = urlsplit(url)[:2] # origin is (scheme, netloc) + if origin not in self._digest_auth: + self._digest_auth[origin] = requests.auth.HTTPDigestAuth( + self.auth.digest.user, self.auth.digest.password + ) + return self._digest_auth[origin] + return None def _make_http_request(self, endpoint_config: HttpEndpointConfig) -> str: """Make HTTP request to the specified endpoint""" - auth = None - if self.auth and self.auth.basic: - auth = (self.auth.basic.user, self.auth.basic.password) method = endpoint_config.method.upper() url = endpoint_config.url + auth = self._build_auth(url) kwargs = { 'auth': auth, } diff --git a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py index b0407ad3b..f27190868 100644 --- a/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py +++ b/python/packages/jumpstarter-driver-http-power/jumpstarter_driver_http_power/driver_test.py @@ -1,9 +1,12 @@ +import re import threading +from contextlib import contextmanager from http.server import BaseHTTPRequestHandler, HTTPServer import pytest +import requests.auth -from .driver import HttpEndpointConfig, HttpPower +from .driver import HttpAuthConfig, HttpDigestAuth, HttpEndpointConfig, HttpPower from jumpstarter.common.utils import serve @@ -163,3 +166,142 @@ def test_read_without_endpoint_raises(): drv = _power(None) with pytest.raises(ValueError, match="not configured"): list(drv.read()) + + +CHALLENGE = 'Digest realm="r", nonce="n", qop="auth", algorithm=MD5' +# Without qop the client adds no cnonce, so the response is fully determined by the +# challenge and the credentials — which is what lets the test below pin a fixed value. +NO_QOP_CHALLENGE = 'Digest realm="r", nonce="n", algorithm=MD5' + + +class AuthHandler(BaseHTTPRequestHandler): + """Records Authorization headers; /digest paths demand a digest handshake first.""" + + def log_message(self, format, *args): + pass + + def do_GET(self): + auth = self.headers.get("Authorization") + self.server.auth_headers.append(auth) + if self.path.startswith("/digest") and not (auth or "").startswith("Digest "): + self.send_response(401) + self.send_header("WWW-Authenticate", NO_QOP_CHALLENGE if "noqop" in self.path else CHALLENGE) + else: + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + +@contextmanager +def _auth_server(): + server = HTTPServer(("localhost", 0), AuthHandler) + server.auth_headers = [] # ty: ignore[unresolved-attribute] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server, f"http://localhost:{server.server_address[1]}" + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_dict_config_is_reconstructed(): + """Exporter YAML arrives as plain dicts, which __post_init__ has to rebuild into the config types.""" + config = { + "power_on": {"url": "http://x/on"}, + "power_off": {"url": "http://x/off"}, + "power_read": {"url": "http://x/read", "voltage_path": "emeter.voltage"}, + "auth": {"digest": {"user": "u", "password": "p"}}, + } + drv = HttpPower(**config) + assert isinstance(drv.power_on, HttpEndpointConfig) + assert isinstance(drv.power_off, HttpEndpointConfig) + assert isinstance(drv.power_read, HttpEndpointConfig) + assert drv.power_read.voltage_path == "emeter.voltage" + assert isinstance(drv.auth, HttpAuthConfig) + assert isinstance(drv.auth.digest, HttpDigestAuth) + assert drv.auth.digest.user == "u" + + +def _auth_power(auth, on_url="http://x/on", off_url="http://x/off"): + return HttpPower( + power_on=HttpEndpointConfig(url=on_url), + power_off=HttpEndpointConfig(url=off_url), + auth=auth, + ) + + +@pytest.mark.parametrize( + ("auth", "expected"), + [ + ({"basic": {"user": "u", "password": "p"}}, "Basic dTpw"), + ({"digest": {"user": "u", "password": "p"}}, 'Digest username="u"'), + ], + ids=["basic", "digest"], +) +def test_auth_sends_credentials(auth, expected): + with _auth_server() as (server, base_url): + _auth_power(auth, f"{base_url}/{next(iter(auth))}").on() + assert expected in server.auth_headers[-1] + + +def test_digest_challenge_is_skipped_after_the_first_request(): + with _auth_server() as (server, base_url): + drv = _auth_power({"digest": {"user": "u", "password": "p"}}, f"{base_url}/digest/on", f"{base_url}/digest/off") + drv.on() + drv.off() + # Only the very first request is challenged: the cached handler keeps the negotiated + # nonce, so the second endpoint on the same origin authenticates without another 401. + challenged = [header is None for header in server.auth_headers] + assert challenged == [True, False, False], server.auth_headers + + +# RFC 2069: response = MD5(HA1:nonce:HA2) with HA1 = MD5("u:r:p") and HA2 = MD5("GET:/digest-noqop"). +# Precomputed from the RFC by hand so the test is an independent oracle, not a restatement +# of the implementation's own arithmetic. +EXPECTED_DIGEST_RESPONSE = "c4fc7e43cb6786ea4121bda32e36f196" + + +def test_digest_response_matches_a_known_value(): + with _auth_server() as (server, base_url): + _auth_power({"digest": {"user": "u", "password": "p"}}, f"{base_url}/digest-noqop").on() + header = server.auth_headers[-1] or "" + match = re.search(r'response="([0-9a-f]{32})"', header) + assert match is not None, f"no digest response in {header!r}" + assert match[1] == EXPECTED_DIGEST_RESPONSE + + +@pytest.mark.parametrize( + ("scheme", "handler"), + [("basic", requests.auth.HTTPBasicAuth), ("digest", requests.auth.HTTPDigestAuth)], +) +def test_build_auth_selects_handler(scheme, handler): + auth = {scheme: {"user": "u", "password": "p"}} + assert isinstance(_auth_power(auth)._build_auth("http://x/on"), handler) + + +def test_build_auth_without_credentials(): + assert _auth_power(None)._build_auth("http://x/on") is None + assert _auth_power({})._build_auth("http://x/on") is None + + +@pytest.mark.parametrize( + ("scheme", "handler"), + [("basic", requests.auth.HTTPBasicAuth), ("digest", requests.auth.HTTPDigestAuth)], +) +def test_empty_auth_block_is_still_configured(scheme, handler): + # An empty mapping is falsy but still a configured block, so it must be deserialized + assert isinstance(_auth_power({scheme: {}})._build_auth("http://x/on"), handler) + + +@pytest.mark.parametrize("basic", [{"user": "u", "password": "p"}, {}], ids=["populated", "empty"]) +def test_basic_and_digest_are_mutually_exclusive(basic): + with pytest.raises(ValueError, match="mutually exclusive"): + _auth_power({"basic": basic, "digest": {"user": "u", "password": "p"}}) + + +def test_digest_handler_is_reused_per_origin(): + drv = _auth_power({"digest": {"user": "u", "password": "p"}}) + # One handler per origin keeps the negotiated nonce, so repeat requests skip the 401. + assert drv._build_auth("http://x/on") is drv._build_auth("http://x/off") + assert drv._build_auth("http://x/on") is not drv._build_auth("http://y/on") diff --git a/python/packages/jumpstarter-driver-http-power/pyproject.toml b/python/packages/jumpstarter-driver-http-power/pyproject.toml index f6c5dba94..47d7bca97 100644 --- a/python/packages/jumpstarter-driver-http-power/pyproject.toml +++ b/python/packages/jumpstarter-driver-http-power/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "anyio>=4.10.0", "jumpstarter", "jumpstarter-driver-power", + "requests>=2.32.5", ] [project.entry-points."jumpstarter.drivers"] diff --git a/python/uv.lock b/python/uv.lock index 96953ba73..44704ae60 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2777,6 +2777,7 @@ dependencies = [ { name = "anyio" }, { name = "jumpstarter" }, { name = "jumpstarter-driver-power" }, + { name = "requests" }, ] [package.dev-dependencies] @@ -2790,6 +2791,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.10.0" }, { name = "jumpstarter", editable = "packages/jumpstarter" }, { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, + { name = "requests", specifier = ">=2.32.5" }, ] [package.metadata.requires-dev]