feat(homeassistant): an admin signs in to Home Assistant, with nothing added to it (#6355)
Replaces the read-only token and the webhook automation, which every install would have had to paste into its own Home Assistant. Connect now goes to Home Assistant's own sign-in page, and Steward keeps the refresh token that comes back, encrypted, and calls backup.create_automatic and update.install directly. The connection page warns that the stored credential has the same access as the user who signed in, and recommends a service account so Steward's actions are easy to tell apart in Home Assistant's logs. A user who isn't an administrator gets the status page and no Install. Those actions answer only when finished, so a request cut off once sent counts as sent and the outcome is read from the entities. Removing the connection, or signing in again, ends the old sign-in on Home Assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+171
-37
@@ -1,15 +1,30 @@
|
||||
# plugins/homeassistant/client.py
|
||||
"""Talking to Home Assistant. Every request but the webhook carries the token,
|
||||
because Home Assistant bans an address after a few refused requests, token-less
|
||||
ones included. Every failure comes back as one sentence a page can show."""
|
||||
"""Talking to Home Assistant, with what its own sign-in gave Steward: a refresh
|
||||
token kept in the connection, and access tokens made from it that last half an
|
||||
hour. Home Assistant bans an address after a few refused requests, so nothing is
|
||||
sent without a token and a refusal stops the asking. Every failure comes back as
|
||||
one sentence a page can show."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from steward.core.crypto import decrypt_secret
|
||||
|
||||
TIMEOUT_SECONDS = 15.0
|
||||
# Home Assistant answers an action when it has finished, minutes later for a
|
||||
# backup or an update, and carries on if the request ends. So its answer is only
|
||||
# waited for this long, and the outcome is read from its entities.
|
||||
ACTION_READ_SECONDS = 10.0
|
||||
MAX_BYTES = 16 * 1024 * 1024
|
||||
# An access token this close to running out isn't used for another request.
|
||||
EXPIRY_MARGIN_SECONDS = 60
|
||||
|
||||
_access: dict[str, tuple[str, float]] = {} # refresh token -> access token, when to stop using it
|
||||
|
||||
|
||||
class Unreachable(Exception):
|
||||
@@ -17,35 +32,147 @@ class Unreachable(Exception):
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""Home Assistant refused the token, or has banned this address."""
|
||||
"""Home Assistant refused Steward's sign-in, or has banned this address."""
|
||||
|
||||
|
||||
class Rejected(Exception):
|
||||
"""Home Assistant understood an action and wouldn't do it."""
|
||||
|
||||
|
||||
def _refusal(status: int) -> Refused | None:
|
||||
if status == 401:
|
||||
_access.clear()
|
||||
return Refused("Home Assistant refused Steward's sign-in. Sign in again, as a user who may do this.")
|
||||
if status == 403:
|
||||
return Refused("Home Assistant answered 403: it may have banned Steward's address "
|
||||
"after refused requests. Look in its ip_bans.yaml.")
|
||||
return None
|
||||
|
||||
|
||||
async def _request(method: str, url: str, path: str, verify_tls: bool, transport, *, token: str | None = None,
|
||||
read_timeout: float = TIMEOUT_SECONDS, **kwargs) -> tuple[int, bytes]:
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
timeout = httpx.Timeout(TIMEOUT_SECONDS, read=read_timeout)
|
||||
try:
|
||||
# A slow drip of bytes can keep each read inside its own timeout for ever.
|
||||
async with asyncio.timeout(TIMEOUT_SECONDS + read_timeout):
|
||||
async with httpx.AsyncClient(timeout=timeout, verify=verify_tls, transport=transport,
|
||||
follow_redirects=False) as client:
|
||||
async with client.stream(method, url.rstrip("/") + path, headers=headers, **kwargs) as response:
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
body.extend(chunk)
|
||||
if len(body) > MAX_BYTES:
|
||||
raise Unreachable("Home Assistant's answer was too large to read.")
|
||||
return response.status_code, bytes(body)
|
||||
except TimeoutError:
|
||||
raise httpx.ReadTimeout("no complete answer in time") from None
|
||||
|
||||
|
||||
def _unreachable(exc: httpx.HTTPError) -> Unreachable:
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.")
|
||||
return Unreachable(f"Couldn't reach Home Assistant: {exc}")
|
||||
|
||||
|
||||
# ── Signing in ───────────────────────────────────────────────────────────────
|
||||
|
||||
def sign_in_url(url: str, client_id: str, redirect_uri: str, state: str) -> str:
|
||||
"""Home Assistant's own sign-in page, which sends the browser back to
|
||||
`redirect_uri`. It accepts `client_id` as it is because the two share an
|
||||
address, so it needn't be able to reach Steward."""
|
||||
return f"{url.rstrip('/')}/auth/authorize?" + urlencode(
|
||||
{"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "state": state})
|
||||
|
||||
|
||||
async def _tokens(url: str, form: dict, verify_tls: bool, transport) -> dict:
|
||||
try:
|
||||
status, body = await _request("POST", url, "/auth/token", verify_tls, transport, data=form)
|
||||
except httpx.HTTPError as exc:
|
||||
raise _unreachable(exc) from None
|
||||
if status in (400, 401, 403):
|
||||
raise Refused("Home Assistant no longer accepts Steward's sign-in. Sign in again.")
|
||||
if status != 200:
|
||||
raise Unreachable(f"Home Assistant answered {status}.")
|
||||
try:
|
||||
found = json.loads(body)
|
||||
found["access_token"], int(found["expires_in"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise Unreachable("Home Assistant's answer wasn't a sign-in.") from None
|
||||
return found
|
||||
|
||||
|
||||
def _remember(refresh_token: str, found: dict) -> str:
|
||||
until = time.monotonic() + int(found["expires_in"]) - EXPIRY_MARGIN_SECONDS
|
||||
_access[refresh_token] = (found["access_token"], until)
|
||||
return found["access_token"]
|
||||
|
||||
|
||||
async def redeem_code(url: str, client_id: str, code: str, verify_tls: bool, transport=None) -> tuple[str, str]:
|
||||
"""(refresh token, access token) for the code the sign-in page sent back."""
|
||||
found = await _tokens(url, {"grant_type": "authorization_code", "client_id": client_id, "code": code},
|
||||
verify_tls, transport)
|
||||
refresh_token = found.get("refresh_token")
|
||||
if not refresh_token:
|
||||
raise Unreachable("Home Assistant's answer wasn't a sign-in.")
|
||||
return refresh_token, _remember(refresh_token, found)
|
||||
|
||||
|
||||
async def access_token(connection, transport=None) -> str:
|
||||
refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in")
|
||||
token, until = _access.get(refresh_token, (None, 0.0))
|
||||
if token and time.monotonic() < until:
|
||||
return token
|
||||
found = await _tokens(connection.url, {"grant_type": "refresh_token", "client_id": connection.client_id,
|
||||
"refresh_token": refresh_token}, connection.verify_tls, transport)
|
||||
return _remember(refresh_token, found)
|
||||
|
||||
|
||||
async def is_admin(url: str, token: str, verify_tls: bool, transport=None) -> bool:
|
||||
"""Whether the signed-in user is an administrator, which its API only says by
|
||||
refusing someone who isn't. So this is asked once, right after signing in, and
|
||||
a refusal here counts once toward a ban."""
|
||||
try:
|
||||
status, _ = await _request("POST", url, "/api/template", verify_tls, transport, token=token,
|
||||
json={"template": "1"})
|
||||
except httpx.HTTPError as exc:
|
||||
raise _unreachable(exc) from None
|
||||
if status == 401:
|
||||
return False
|
||||
if status != 200:
|
||||
raise (_refusal(status) or Unreachable(f"Home Assistant answered {status}."))
|
||||
return True
|
||||
|
||||
|
||||
async def revoke(connection, transport=None) -> None:
|
||||
"""End this sign-in on Home Assistant. It answers 200 whether or not it knew it."""
|
||||
refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in")
|
||||
_access.pop(refresh_token, None)
|
||||
try:
|
||||
status, _ = await _request("POST", connection.url, "/auth/revoke", connection.verify_tls, transport,
|
||||
data={"token": refresh_token})
|
||||
except httpx.HTTPError as exc:
|
||||
raise _unreachable(exc) from None
|
||||
if status != 200:
|
||||
raise Unreachable(f"Home Assistant answered {status}.")
|
||||
|
||||
|
||||
# ── Reading and acting ───────────────────────────────────────────────────────
|
||||
|
||||
async def _get(url: str, path: str, token: str, verify_tls: bool, transport) -> list | dict | None:
|
||||
"""The parsed JSON of a GET, or None for a 404."""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport,
|
||||
follow_redirects=False) as client:
|
||||
async with client.stream("GET", url.rstrip("/") + path, headers=headers) as response:
|
||||
if response.status_code == 401:
|
||||
raise Refused("Home Assistant refused the token. Check it, or make a new one.")
|
||||
if response.status_code == 403:
|
||||
raise Refused("Home Assistant answered 403: it may have banned Steward's address "
|
||||
"after refused requests. Look in its ip_bans.yaml.")
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
raise Unreachable(f"Home Assistant answered {response.status_code}.")
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
body.extend(chunk)
|
||||
if len(body) > MAX_BYTES:
|
||||
raise Unreachable("Home Assistant's answer was too large to read.")
|
||||
return json.loads(body)
|
||||
except httpx.TimeoutException:
|
||||
raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None
|
||||
status, body = await _request("GET", url, path, verify_tls, transport, token=token)
|
||||
except httpx.HTTPError as exc:
|
||||
raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None
|
||||
raise _unreachable(exc) from None
|
||||
if refusal := _refusal(status):
|
||||
raise refusal
|
||||
if status == 404:
|
||||
return None
|
||||
if status != 200:
|
||||
raise Unreachable(f"Home Assistant answered {status}.")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError:
|
||||
raise Unreachable("Home Assistant's answer wasn't JSON.") from None
|
||||
|
||||
@@ -61,16 +188,23 @@ async def read_state(url: str, entity_id: str, token: str, verify_tls: bool, tra
|
||||
return await _get(url, f"/api/states/{entity_id}", token, verify_tls, transport)
|
||||
|
||||
|
||||
async def send_webhook(url: str, webhook_id: str, body: dict, verify_tls: bool, transport=None) -> None:
|
||||
"""Hand the maintenance automation a request. Home Assistant answers 200
|
||||
whether or not the id is known, so what the request did is read afterwards."""
|
||||
async def call_action(url: str, domain: str, action: str, data: dict, token: str, verify_tls: bool,
|
||||
transport=None) -> None:
|
||||
"""Ask Home Assistant to do something. Returning means it got the request,
|
||||
not that it has finished: a request cut off once sent has been taken up, and
|
||||
an update that restarts Home Assistant cuts its own request off."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport,
|
||||
follow_redirects=False) as client:
|
||||
response = await client.post(f"{url.rstrip('/')}/api/webhook/{webhook_id}", json=body)
|
||||
except httpx.TimeoutException:
|
||||
raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None
|
||||
status, body = await _request("POST", url, f"/api/services/{domain}/{action}", verify_tls, transport,
|
||||
token=token, read_timeout=ACTION_READ_SECONDS, json=data)
|
||||
except (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError):
|
||||
return
|
||||
except httpx.HTTPError as exc:
|
||||
raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None
|
||||
if response.status_code != 200:
|
||||
raise Unreachable(f"Home Assistant answered {response.status_code} to the webhook.")
|
||||
raise _unreachable(exc) from None
|
||||
if refusal := _refusal(status):
|
||||
raise refusal
|
||||
if status != 200:
|
||||
try:
|
||||
said = json.loads(body).get("message")
|
||||
except (ValueError, AttributeError):
|
||||
said = None
|
||||
raise Rejected(said or f"Home Assistant answered {status}.")
|
||||
|
||||
Reference in New Issue
Block a user