Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion python/packages/jumpstarter-driver-http-power/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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}
Expand Down Expand Up @@ -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.
```
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -34,9 +36,16 @@ class HttpBasicAuth:
password: str = field(default="")


@dataclass(kw_only=True)
class HttpDigestAuth:
user: str = field(default="")
password: str = field(default="")
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@dataclass(kw_only=True)
class HttpAuthConfig:
basic: Optional[HttpBasicAuth] = field(default=None)
digest: Optional[HttpDigestAuth] = field(default=None)


@dataclass(kw_only=True)
Expand Down Expand Up @@ -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,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"anyio>=4.10.0",
"jumpstarter",
"jumpstarter-driver-power",
"requests>=2.32.5",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch making the requests dependency explicit. The package was already using import requests but relying on it being transitively available.

]

[project.entry-points."jumpstarter.drivers"]
Expand Down
2 changes: 2 additions & 0 deletions python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading