feat(homeassistant): an admin signs in to Home Assistant, with nothing added to it (#6355)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m55s
CI / publish (push) Skipped

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:
2026-10-31 12:14:34 -04:00
co-authored by Claude Opus 5.5
parent 9c9e14e79c
commit 0aed863c67
16 changed files with 634 additions and 372 deletions
+14 -7
View File
@@ -1,17 +1,24 @@
# Home Assistant plugin # Home Assistant plugin
Shows which Home Assistant updates are waiting and how old its last backup is, and installs Core, OS and add-on updates, each only after a fresh backup is confirmed. Each update goes on the timeline. Shows which Home Assistant updates are waiting and how old its last backup is, and installs Core, OS and add-on updates, each only after a fresh backup is confirmed. Each update goes on the timeline. Nothing is added to Home Assistant: you sign in, and Steward uses its standard API.
Steward never holds a credential that can act on your house. It reads with a read-only token, and acts only through a webhook whose automation can take a backup or install an allowed update.
## Setting it up ## Setting it up
1. In Home Assistant, turn on automatic backups (Settings → System → Backups). 1. In Home Assistant, turn on automatic backups (Settings → System → Backups).
2. Make a local-only user in the read-only group (`system-read-only`) and a long-lived token for it. 2. Make a user for Steward (Settings → People → Add person), as an administrator if Steward should install updates.
3. On Steward's Home Assistant page, choose Connect, enter Home Assistant's local address and the token, and save. 3. On Steward's Home Assistant page, choose Connect, enter Home Assistant's address, and continue to its sign-in page. Sign in as that user.
4. Copy the automation the connection page shows into Home Assistant (Settings → Automations → Create → Edit in YAML). Steward reads whether it is there and on.
Steward must reach Home Assistant by a local address, because the webhook only answers local requests. Home Assistant bans an address after a few refused requests, so Steward stops asking after one refusal until you save the connection or choose Try again. Your browser and Steward both need to reach Home Assistant at that address. Home Assistant sends your browser back to Steward's Public base URL (Settings → General), so open Steward at that address.
## What Steward holds
**Steward stores a credential with the same access as the user who signed in.** Home Assistant has no narrower permission, so an administrator's credential can do anything that administrator can, including controlling every device. Steward only uses it to read status, take backups and install updates. It is stored encrypted.
We recommend a service account, a user made just for Steward, rather than your own. What Steward does then appears under its own name in Home Assistant's logs, so it's easy to tell apart from what people do.
Installing needs an administrator, because Home Assistant only lets administrators take backups. With any other user, Steward shows status only. Steward learns which it has by asking once after sign-in, and a user who isn't an administrator is refused that one request, which Home Assistant may show as a failed login notification.
Removing the connection ends the sign-in on Home Assistant. You can also end it yourself under Security on that user's profile, and Steward then stops asking until you sign in again. Home Assistant bans an address after a few refused requests, so Steward stops after one refusal until you sign in again or choose Try again.
## Installing an update ## Installing an update
+171 -37
View File
@@ -1,15 +1,30 @@
# plugins/homeassistant/client.py # plugins/homeassistant/client.py
"""Talking to Home Assistant. Every request but the webhook carries the token, """Talking to Home Assistant, with what its own sign-in gave Steward: a refresh
because Home Assistant bans an address after a few refused requests, token-less token kept in the connection, and access tokens made from it that last half an
ones included. Every failure comes back as one sentence a page can show.""" 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 from __future__ import annotations
import asyncio
import json import json
import time
from urllib.parse import urlencode
import httpx import httpx
from steward.core.crypto import decrypt_secret
TIMEOUT_SECONDS = 15.0 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 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): class Unreachable(Exception):
@@ -17,35 +32,147 @@ class Unreachable(Exception):
class Refused(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: 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.""" """The parsed JSON of a GET, or None for a 404."""
headers = {"Authorization": f"Bearer {token}"}
try: try:
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport, status, body = await _request("GET", url, path, verify_tls, transport, token=token)
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
except httpx.HTTPError as exc: 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: except ValueError:
raise Unreachable("Home Assistant's answer wasn't JSON.") from None 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) 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: async def call_action(url: str, domain: str, action: str, data: dict, token: str, verify_tls: bool,
"""Hand the maintenance automation a request. Home Assistant answers 200 transport=None) -> None:
whether or not the id is known, so what the request did is read afterwards.""" """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: try:
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport, status, body = await _request("POST", url, f"/api/services/{domain}/{action}", verify_tls, transport,
follow_redirects=False) as client: token=token, read_timeout=ACTION_READ_SECONDS, json=data)
response = await client.post(f"{url.rstrip('/')}/api/webhook/{webhook_id}", json=body) except (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError):
except httpx.TimeoutException: return
raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None raise _unreachable(exc) from None
if response.status_code != 200: if refusal := _refusal(status):
raise Unreachable(f"Home Assistant answered {response.status_code} to the webhook.") 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}.")
+5 -5
View File
@@ -1,7 +1,7 @@
"""The Home Assistant plugin's MCP tools and its get_overview findings. """The Home Assistant plugin's MCP tools and its get_overview findings.
get_home_assistant reads what the poll stored. update_home_assistant starts an get_home_assistant reads what the poll stored. update_home_assistant starts an
update as the page's Install does. The token and webhook id are never returned. update as the page's Install does. The sign-in Steward holds is never returned.
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,8 +34,8 @@ async def get_home_assistant() -> dict:
`installable` is what update_home_assistant takes (Core, OS, add-ons); `installable` is what update_home_assistant takes (Core, OS, add-ons);
`others_waiting` is firmware and custom integrations, which Steward leaves `others_waiting` is firmware and custom integrations, which Steward leaves
to Home Assistant. `automation` is the Steward maintenance automation's to Home Assistant. `can_install` is whether the Home Assistant user who signed
state: "on" is needed to install, null means it wasn't found. Steward in is an administrator, which installing needs.
""" """
now = now_utc() now = now_utc()
async with db_session() as db: async with db_session() as db:
@@ -43,12 +43,12 @@ async def get_home_assistant() -> dict:
runs = list((await db.execute(select(HomeAssistantUpdate).order_by( runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars()) HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
if c is None: if c is None:
return {"connected": False, "says": "No connection yet: an admin adds it on the Home Assistant page."} return {"connected": False, "says": "No connection yet: an admin signs in on the Home Assistant page."}
reach, says = connection_says(c, now) reach, says = connection_says(c, now)
backup = c.backup or {} backup = c.backup or {}
age = backup_age_hours(backup, now) age = backup_age_hours(backup, now)
return {"connected": True, "url": redact_url(c.url), "reach": reach, "says": says, return {"connected": True, "url": redact_url(c.url), "reach": reach, "says": says,
"polled_at": iso(c.last_polled_at), "ok_age_s": age_s(c.last_ok_at, now), "automation": c.automation, "polled_at": iso(c.last_polled_at), "ok_age_s": age_s(c.last_ok_at, now), "can_install": c.can_install,
"installable": [{k: u[k] for k in ("entity_id", "title", "kind", "installed", "latest", "in_progress")} "installable": [{k: u[k] for k in ("entity_id", "title", "kind", "installed", "latest", "in_progress")}
for u in installable(c)], for u in installable(c)],
"others_waiting": [u["title"] for u in c.updates or [] if u["waiting"] and not u["kind"]], "others_waiting": [u["title"] for u in c.updates or [] if u["waiting"] and not u["kind"]],
@@ -0,0 +1,42 @@
"""Home Assistant: Steward holds a sign-in, in place of a token and a webhook
Revision ID: homeassistant_002_sign_in
Revises: homeassistant_001_connection
Create Date: 2026-10-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "homeassistant_002_sign_in"
down_revision: Union[str, None] = "homeassistant_001_connection"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# A saved token can't be refreshed like a sign-in, so that connection signs in again.
op.execute("DELETE FROM homeassistant_connection")
op.drop_column("homeassistant_connection", "webhook_id")
op.drop_column("homeassistant_connection", "automation")
op.drop_column("homeassistant_updates", "triggered_before")
op.alter_column("homeassistant_connection", "token", new_column_name="refresh_token")
op.add_column("homeassistant_connection", sa.Column("client_id", sa.String(500), nullable=False))
op.add_column("homeassistant_connection",
sa.Column("can_install", sa.Boolean, nullable=False, server_default=sa.false()))
op.add_column("homeassistant_connection",
sa.Column("signed_in_by", sa.String(100), nullable=False, server_default=""))
op.add_column("homeassistant_connection", sa.Column("signed_in_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.execute("DELETE FROM homeassistant_connection")
op.drop_column("homeassistant_connection", "signed_in_at")
op.drop_column("homeassistant_connection", "signed_in_by")
op.drop_column("homeassistant_connection", "can_install")
op.drop_column("homeassistant_connection", "client_id")
op.alter_column("homeassistant_connection", "refresh_token", new_column_name="token")
op.add_column("homeassistant_updates", sa.Column("triggered_before", sa.String(64), nullable=True))
op.add_column("homeassistant_connection", sa.Column("automation", sa.String(20), nullable=True))
op.add_column("homeassistant_connection", sa.Column("webhook_id", sa.Text, nullable=False))
+12 -10
View File
@@ -13,19 +13,23 @@ from steward.models.base import Base
class HomeAssistantConnection(Base): class HomeAssistantConnection(Base):
"""The Home Assistant instance Steward looks after; at most one row. """The Home Assistant instance Steward looks after; at most one row.
The token is a read-only user's and the webhook id can only reach the `refresh_token` is what Home Assistant's sign-in gave Steward, encrypted. It
maintenance automation, so nothing stored here can act on the house. Both has the same access as the user who signed in, whatever that is: Home
are encrypted. The last_* columns and `updates` and `backup` are the newest Assistant has no narrower permission. `can_install` is whether that user was
poll's outcome, which the pages read. `refused` stops all polling until an administrator, which backups need. The last_* columns and `updates` and
someone saves the connection or tries again: Home Assistant bans an address `backup` are the newest poll's outcome, which the pages read. `refused` stops
after a few refused requests. all polling until someone signs in again or tries again: Home Assistant bans
an address after a few refused requests.
""" """
__tablename__ = "homeassistant_connection" __tablename__ = "homeassistant_connection"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
url: Mapped[str] = mapped_column(String(500), nullable=False) url: Mapped[str] = mapped_column(String(500), nullable=False)
token: Mapped[str] = mapped_column(Text, nullable=False) client_id: Mapped[str] = mapped_column(String(500), nullable=False)
webhook_id: Mapped[str] = mapped_column(Text, nullable=False) refresh_token: Mapped[str] = mapped_column(Text, nullable=False)
can_install: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
signed_in_by: Mapped[str] = mapped_column(String(100), nullable=False, default="")
signed_in_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
verify_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) verify_tls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
refused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) refused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -33,7 +37,6 @@ class HomeAssistantConnection(Base):
last_error: Mapped[str | None] = mapped_column(Text, nullable=True) last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
updates: Mapped[list] = mapped_column(JSON, nullable=False, default=list) updates: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
backup: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) backup: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
automation: Mapped[str | None] = mapped_column(String(20), nullable=True)
class HomeAssistantUpdate(Base): class HomeAssistantUpdate(Base):
@@ -62,7 +65,6 @@ class HomeAssistantUpdate(Base):
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
backup_before: Mapped[str | None] = mapped_column(String(64), nullable=True) backup_before: Mapped[str | None] = mapped_column(String(64), nullable=True)
backup_event_before: Mapped[str | None] = mapped_column(String(64), nullable=True) backup_event_before: Mapped[str | None] = mapped_column(String(64), nullable=True)
triggered_before: Mapped[str | None] = mapped_column(String(64), nullable=True)
backup_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) backup_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
install_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) install_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+89 -75
View File
@@ -1,27 +1,34 @@
"""The Home Assistant page. Every GET reads Steward's own records, which the poll """The Home Assistant page. Every GET reads Steward's own records, which the poll
fills. Only Test and Install, an admin's explicit actions, ask Home Assistant fills. Only signing in, removing the connection and Install, an admin's explicit
anything (System 178's ruling).""" actions, ask Home Assistant anything (System 178's ruling)."""
from __future__ import annotations from __future__ import annotations
import hmac
import logging
import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace
from quart import Blueprint, current_app, redirect, render_template, request, session from quart import Blueprint, current_app, redirect, render_template, request, session
from sqlalchemy import select from sqlalchemy import select
from steward.auth.middleware import require_role from steward.auth.middleware import require_role
from steward.core.audit import log_audit from steward.core.audit import log_audit
from steward.core.crypto import decrypt_secret, encrypt_secret from steward.core.crypto import encrypt_secret
from steward.core.settings import public_base_url
from steward.models.users import UserRole from steward.models.users import UserRole
from . import client from . import client
from .models import HomeAssistantConnection, HomeAssistantUpdate from .models import HomeAssistantConnection, HomeAssistantUpdate
from .service import (STALE_BACKUP_HOURS, automation_yaml, backup_age_hours, backup_verdict, installable, from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment
new_webhook_id, no_spaces, parse_moment, read_snapshot)
from .updates import CannotStart, start_update from .updates import CannotStart, start_update
logger = logging.getLogger(__name__)
homeassistant_bp = Blueprint("homeassistant", __name__, template_folder="templates") homeassistant_bp = Blueprint("homeassistant", __name__, template_folder="templates")
RECENT_UPDATES = 10 RECENT_UPDATES = 10
_SIGN_IN = "homeassistant_sign_in" # in the session between leaving for Home Assistant and coming back
def _actor() -> tuple[str | None, str]: def _actor() -> tuple[str | None, str]:
@@ -35,7 +42,7 @@ async def _connection(db) -> HomeAssistantConnection | None:
def connection_says(connection, now: datetime) -> tuple[str, str]: def connection_says(connection, now: datetime) -> tuple[str, str]:
"""(badge state, sentence) for whether Home Assistant is answering.""" """(badge state, sentence) for whether Home Assistant is answering."""
if connection.refused: if connection.refused:
return "down", f"{connection.last_error} Steward has stopped asking until you save the connection or try again." return "down", f"{connection.last_error} Steward has stopped asking until you sign in again or try again."
if connection.last_polled_at is None: if connection.last_polled_at is None:
return "pending", "Not read yet: the first read runs within seconds." return "pending", "Not read yet: the first read runs within seconds."
if connection.last_error: if connection.last_error:
@@ -49,7 +56,7 @@ async def _render_index(error: str | None = None, status: int = 200):
runs = list((await db.execute(select(HomeAssistantUpdate).order_by( runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars()) HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
if connection is None: if connection is None:
return await render_template("homeassistant/index.html", connection=None), status return await render_template("homeassistant/index.html", connection=None, error=error), status
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
reach, says = connection_says(connection, now) reach, says = connection_says(connection, now)
updates = connection.updates or [] updates = connection.updates or []
@@ -90,19 +97,12 @@ async def install():
# ── The connection ─────────────────────────────────────────────────────────── # ── The connection ───────────────────────────────────────────────────────────
def _values(connection, form=None) -> dict: async def _render_form(connection, url: str | None = None, verify_tls: bool | None = None,
if form is not None: error: str | None = None, status: int = 200):
return {"url": form.get("url", ""), "verify_tls": bool(form.get("verify_tls")), return await render_template(
"has_token": bool(connection), "has_webhook": bool(connection)} "homeassistant/form.html", connection=connection, error=error,
return {"url": connection.url if connection else "", "verify_tls": connection.verify_tls if connection else True, url=url if url is not None else (connection.url if connection else ""),
"has_token": bool(connection), "has_webhook": bool(connection)} verify_tls=verify_tls if verify_tls is not None else (connection.verify_tls if connection else True)), status
async def _render_form(connection, values: dict, error: str | None = None, status: int = 200):
yaml_text = automation_yaml(decrypt_secret(connection.webhook_id, context="Home Assistant webhook id")) \
if connection is not None else None
return await render_template("homeassistant/form.html", connection=connection, v=values, error=error,
automation=yaml_text), status
@homeassistant_bp.get("/connection") @homeassistant_bp.get("/connection")
@@ -110,48 +110,89 @@ async def _render_form(connection, values: dict, error: str | None = None, statu
async def connection_form(): async def connection_form():
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
connection = await _connection(db) connection = await _connection(db)
return await _render_form(connection, _values(connection)) return await _render_form(connection)
@homeassistant_bp.post("/connection") @homeassistant_bp.post("/connection/sign-in")
@require_role(UserRole.admin) @require_role(UserRole.admin)
async def save_connection(): async def sign_in():
"""Send the browser to Home Assistant's own sign-in page."""
form = await request.form form = await request.form
url = (form.get("url") or "").strip().rstrip("/") url = (form.get("url") or "").strip().rstrip("/")
token, webhook = no_spaces(form.get("token")), no_spaces(form.get("webhook_id")) verify_tls = bool(form.get("verify_tls"))
if not url.startswith(("http://", "https://")):
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
return await _render_form(connection, url, verify_tls, "Enter Home Assistant's address: http://… or https://…", 422)
base, state = public_base_url(request), secrets.token_urlsafe(32)
client_id = f"{base}/"
session[_SIGN_IN] = {"state": state, "url": url, "verify_tls": verify_tls, "client_id": client_id}
return redirect(client.sign_in_url(url, client_id, f"{base}/plugins/homeassistant/connection/callback", state))
@homeassistant_bp.get("/connection/callback")
@require_role(UserRole.admin)
async def sign_in_callback():
"""Where Home Assistant sends the browser back. A GET that asks Home Assistant
something, because the admin's sign-in has only just happened and its code
lasts minutes. The state shows the sign-in began here, for this admin."""
pending = session.pop(_SIGN_IN, None)
state, code = request.args.get("state", ""), request.args.get("code", "")
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
if not pending or not hmac.compare_digest(pending["state"].encode(), state.encode()):
return await _render_form(connection, error="That sign-in didn't start from this page, so it wasn't used. "
"If it keeps happening, check Public base URL in Settings → General: "
"Steward must be opened at that address.", status=400)
url, verify_tls, client_id = pending["url"], pending["verify_tls"], pending["client_id"]
if not code:
return await _render_form(connection, url, verify_tls, "Home Assistant didn't finish the sign-in. Nothing changed.", 400)
try:
refresh_token, token = await client.redeem_code(url, client_id, code, verify_tls)
can_install = await client.is_admin(url, token, verify_tls)
except (client.Refused, client.Unreachable) as exc:
return await _render_form(connection, url, verify_tls, f"The sign-in couldn't be finished: {exc}", 502)
user_id, username = _actor()
previous = None
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): async with db.begin():
connection = await _connection(db) connection = await _connection(db)
error = None
if not url.startswith(("http://", "https://")):
error = "Enter Home Assistant's address: http://… or https://…"
elif not token and connection is None:
error = "Enter a token: a long-lived token of a read-only Home Assistant user."
if error:
return await _render_form(connection, _values(connection, form), error, 422)
if connection is None: if connection is None:
connection = HomeAssistantConnection(url=url, token=encrypt_secret(token), connection = HomeAssistantConnection(url=url, client_id=client_id, refresh_token="")
webhook_id=encrypt_secret(webhook or new_webhook_id()))
db.add(connection) db.add(connection)
else: else:
connection.url = url previous = (connection.url, connection.client_id, connection.refresh_token, connection.verify_tls)
if token: connection.url, connection.client_id, connection.verify_tls = url, client_id, verify_tls
connection.token = encrypt_secret(token) connection.refresh_token = encrypt_secret(refresh_token)
if webhook: connection.can_install, connection.signed_in_by = can_install, username
connection.webhook_id = encrypt_secret(webhook) connection.signed_in_at = datetime.now(timezone.utc)
connection.verify_tls = bool(form.get("verify_tls"))
connection.refused, connection.last_error, connection.last_polled_at = False, None, None connection.refused, connection.last_error, connection.last_polled_at = False, None, None
await db.flush() await db.flush()
connection_id = connection.id connection_id = connection.id
await log_audit(current_app, *_actor(), "homeassistant.connection_saved", if previous is not None:
entity_type="homeassistant_connection", entity_id=connection_id, detail={"url": url}) await _end_sign_in(SimpleNamespace(url=previous[0], client_id=previous[1], refresh_token=previous[2],
return redirect("/plugins/homeassistant/connection") verify_tls=previous[3]))
await log_audit(current_app, user_id, username, "homeassistant.connection_saved",
entity_type="homeassistant_connection", entity_id=connection_id,
detail={"url": url, "can_install": can_install})
return redirect("/plugins/homeassistant/")
async def _end_sign_in(connection) -> bool:
"""End a sign-in Steward no longer needs, so it doesn't stay valid on Home
Assistant. It can be ended there by hand too, so failing here stops nothing."""
try:
await client.revoke(connection)
except (client.Refused, client.Unreachable) as exc:
logger.warning("Home Assistant: couldn't end Steward's old sign-in: %s", exc)
return False
return True
@homeassistant_bp.post("/connection/retry") @homeassistant_bp.post("/connection/retry")
@require_role(UserRole.admin) @require_role(UserRole.admin)
async def retry_connection(): async def retry_connection():
"""Ask again after a refusal, once the token or a ban has been dealt with.""" """Ask again after a refusal, once a ban has been dealt with."""
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
async with db.begin(): async with db.begin():
connection = await _connection(db) connection = await _connection(db)
@@ -170,37 +211,10 @@ async def delete_connection():
return redirect("/plugins/homeassistant/") return redirect("/plugins/homeassistant/")
connection_id = connection.id connection_id = connection.id
await db.delete(connection) await db.delete(connection)
ended = await _end_sign_in(connection)
await log_audit(current_app, *_actor(), "homeassistant.connection_removed", await log_audit(current_app, *_actor(), "homeassistant.connection_removed",
entity_type="homeassistant_connection", entity_id=connection_id) entity_type="homeassistant_connection", entity_id=connection_id, detail={"sign_in_ended": ended})
if not ended:
return await _render_index("Removed from Steward, but Home Assistant couldn't be reached to end the sign-in. "
"End it yourself under Security on that user's profile in Home Assistant.")
return redirect("/plugins/homeassistant/") return redirect("/plugins/homeassistant/")
@homeassistant_bp.post("/connection/test")
@require_role(UserRole.admin)
async def test_connection():
"""Read Home Assistant once with what's typed and say what Steward would see.
An admin's explicit action, so it may contact it."""
form = await request.form
url = (form.get("url") or "").strip().rstrip("/")
token = no_spaces(form.get("token"))
if not token:
async with current_app.db_sessionmaker() as db:
connection = await _connection(db)
if connection is not None:
token = decrypt_secret(connection.token, context="Home Assistant token")
found: dict = {}
if not url.startswith(("http://", "https://")):
found["error"] = "Enter the address first: http://… or https://…"
elif not token:
found["error"] = "Enter a token first. A request without one counts toward Home Assistant banning this address."
else:
try:
states = await client.read_states(url, token, bool(form.get("verify_tls")))
except (client.Unreachable, client.Refused) as exc:
found["error"] = str(exc)
else:
snapshot = read_snapshot(states)
found = {"entities": len(states), "installable": sum(1 for u in snapshot["updates"] if u["waiting"] and u["kind"]),
"updates": sum(1 for u in snapshot["updates"] if u["kind"]),
"backup": snapshot["backup"]["known"], "automation": snapshot["automation"]}
return await render_template("homeassistant/_test.html", found=found)
+2 -3
View File
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING
from sqlalchemy import select from sqlalchemy import select
from steward.core.crypto import decrypt_secret
from steward.core.scheduler import ScheduledTask from steward.core.scheduler import ScheduledTask
from . import client from . import client
@@ -65,9 +64,9 @@ async def poll(app: "Quart", transport=None) -> None:
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first() connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if connection is None or not is_due(connection, now): if connection is None or not is_due(connection, now):
return return
token = decrypt_secret(connection.token, context="Home Assistant token")
snapshot, error, refused = None, None, False snapshot, error, refused = None, None, False
try: try:
token = await client.access_token(connection, transport)
snapshot = read_snapshot(await client.read_states(connection.url, token, connection.verify_tls, transport)) snapshot = read_snapshot(await client.read_states(connection.url, token, connection.verify_tls, transport))
except client.Refused as exc: except client.Refused as exc:
error, refused = str(exc), True error, refused = str(exc), True
@@ -84,7 +83,7 @@ async def poll(app: "Quart", transport=None) -> None:
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="is_up", value=0.0, recorded_at=now) batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="is_up", value=0.0, recorded_at=now)
else: else:
row.last_ok_at = now row.last_ok_at = now
row.updates, row.backup, row.automation = snapshot["updates"], snapshot["backup"], snapshot["automation"] row.updates, row.backup = snapshot["updates"], snapshot["backup"]
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="is_up", value=1.0, recorded_at=now) batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="is_up", value=1.0, recorded_at=now)
batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="updates_waiting", batch.add(source_module=SOURCE, resource_name=RESOURCE, metric_name="updates_waiting",
value=len(installable(row)), recorded_at=now) value=len(installable(row)), recorded_at=now)
+1 -60
View File
@@ -3,14 +3,12 @@
Steward may install, and how its backups stand.""" Steward may install, and how its backups stand."""
from __future__ import annotations from __future__ import annotations
import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
SOURCE = "homeassistant" SOURCE = "homeassistant"
RESOURCE = "homeassistant" RESOURCE = "homeassistant"
# Backups run daily, so past a day and a bit one has been missed. # Backups run daily, so past a day and a bit one has been missed.
STALE_BACKUP_HOURS = 26 STALE_BACKUP_HOURS = 26
AUTOMATION_ID = "steward_maintenance"
CORE = "update.home_assistant_core_update" CORE = "update.home_assistant_core_update"
OS = "update.home_assistant_operating_system_update" OS = "update.home_assistant_operating_system_update"
@@ -23,54 +21,6 @@ BACKUP_EVENT = "event.backup_automatic_backup"
_KIND_WORDS = {"core": "Core", "os": "Operating system", "addon": "Add-on"} _KIND_WORDS = {"core": "Core", "os": "Operating system", "addon": "Add-on"}
_KIND_ORDER = {"core": 0, "os": 1, "addon": 2, None: 3} _KIND_ORDER = {"core": 0, "os": 1, "addon": 2, None: 3}
# The automation that gives Steward its only way to act on Home Assistant. It
# takes a backup or installs a Core, OS or add-on update, and does nothing else.
AUTOMATION_YAML = """\
id: steward_maintenance
alias: Steward maintenance
description: Lets Steward take a backup or install a Core, OS or add-on update. Does nothing else.
mode: single
triggers:
- trigger: webhook
webhook_id: {webhook_id}
allowed_methods:
- POST
local_only: true
variables:
request: "{{{{ trigger.json | default({{}}) }}}}"
allowed: >-
{{{{ integration_entities('hassio') | select('match', 'update\\\\.')
| reject('eq', 'update.home_assistant_supervisor_update') | list }}}}
actions:
- choose:
- conditions:
- condition: template
value_template: "{{{{ request.get('action') == 'backup' }}}}"
sequence:
- action: backup.create_automatic
- conditions:
- condition: template
value_template: "{{{{ request.get('action') == 'install' and request.get('entity_id') in allowed }}}}"
sequence:
- action: update.install
target:
entity_id: "{{{{ request.get('entity_id') }}}}"
"""
def new_webhook_id() -> str:
return "steward_" + secrets.token_urlsafe(32)
def automation_yaml(webhook_id: str) -> str:
return AUTOMATION_YAML.format(webhook_id=webhook_id)
def no_spaces(typed: str | None) -> str:
"""A pasted token or id with a terminal's wrapping taken back out."""
return "".join((typed or "").split())
def classify(entity: dict) -> str | None: def classify(entity: dict) -> str | None:
""""core", "os" or "addon" for an update Steward may install; None for the """"core", "os" or "addon" for an update Steward may install; None for the
rest: Supervisor updates itself, and firmware and custom integrations are rest: Supervisor updates itself, and firmware and custom integrations are
@@ -126,15 +76,11 @@ def read_snapshot(states: list[dict]) -> dict:
updates.sort(key=lambda u: (not u["waiting"], _KIND_ORDER[u["kind"]], u["title"].lower())) updates.sort(key=lambda u: (not u["waiting"], _KIND_ORDER[u["kind"]], u["title"].lower()))
event = by_id.get(BACKUP_EVENT) or {} event = by_id.get(BACKUP_EVENT) or {}
event_attrs = event.get("attributes") or {} event_attrs = event.get("attributes") or {}
automation = next((e for e in states if (e.get("entity_id") or "").startswith("automation.")
and (e.get("attributes") or {}).get("id") == AUTOMATION_ID), None)
return { return {
"updates": updates, "updates": updates,
"backup": {"last_ok": _state(by_id.get(LAST_BACKUP)), "next": _state(by_id.get(NEXT_BACKUP)), "backup": {"last_ok": _state(by_id.get(LAST_BACKUP)), "next": _state(by_id.get(NEXT_BACKUP)),
"manager": _state(by_id.get(BACKUP_MANAGER)), "last_result": event_attrs.get("event_type"), "manager": _state(by_id.get(BACKUP_MANAGER)), "last_result": event_attrs.get("event_type"),
"failed_reason": event_attrs.get("failed_reason"), "known": LAST_BACKUP in by_id, "failed_reason": event_attrs.get("failed_reason"), "known": LAST_BACKUP in by_id},
"automation_entity": automation.get("entity_id") if automation else None},
"automation": automation.get("state") if automation else None,
} }
@@ -162,8 +108,3 @@ def backup_verdict(backup: dict | None, now: datetime) -> tuple[str, str]:
if age > STALE_BACKUP_HOURS: if age > STALE_BACKUP_HOURS:
return "warn", f"The last good backup is {age:.0f} hours old, past the {STALE_BACKUP_HOURS} hours expected." return "warn", f"The last good backup is {age:.0f} hours old, past the {STALE_BACKUP_HOURS} hours expected."
return "ok", "Backups are up to date." return "ok", "Backups are up to date."
def automation_entity_id(connection) -> str:
"""The automation's entity, as a poll found it."""
return (connection.backup or {}).get("automation_entity") or f"automation.{AUTOMATION_ID}"
@@ -1,6 +0,0 @@
{# plugins/homeassistant/templates/homeassistant/_test.html — what Test found. #}
{% if found.error %}<p class="tone-crit" style="margin-bottom:0;">{{ found.error }}</p>
{% else %}<p class="tone-ok" style="margin-bottom:0.25rem;">Answered: {{ found.entities }} entities, {{ found.updates }} Core, OS and add-on update entities, {{ found.installable }} waiting.</p>
<p class="{{ 'field-hint' if found.backup else 'tone-warn' }}" style="margin:0;">{{ "Automatic backups are readable." if found.backup else "No automatic backup sensor: turn on automatic backups in Home Assistant." }}</p>
<p class="{{ 'field-hint' if found.automation == 'on' else 'tone-warn' }}" style="margin:0;">{% if found.automation == 'on' %}The Steward maintenance automation is on.{% elif found.automation %}The Steward maintenance automation is {{ found.automation }}.{% else %}The Steward maintenance automation isn't there yet.{% endif %}</p>
{% endif %}
@@ -1,7 +1,5 @@
{# plugins/homeassistant/templates/homeassistant/form.html — where Home Assistant {# plugins/homeassistant/templates/homeassistant/form.html — where Home Assistant
is, the read-only token Steward reads it with, and the automation to add so is, what signing in gives Steward, and leaving for its sign-in page. #}
Steward can install updates. A secret is never sent back in a field; a blank
one keeps what's saved. #}
{% extends "base.html" %} {% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %} {% from "_macros.html" import crumbs, page_header %}
{% block title %}Home Assistant connection — Steward{% endblock %} {% block title %}Home Assistant connection — Steward{% endblock %}
@@ -11,55 +9,27 @@
<div class="form-page"> <div class="form-page">
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %} {% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<form method="post" id="ha-form" action="/plugins/homeassistant/connection">{{ csrf_field() }} {% if connection %}
<section class="card form-section" aria-labelledby="ha-where"> <p class="field-hint">Signed in{% if connection.signed_in_at %} {{ connection.signed_in_at|ago }}{% endif %}{% if connection.signed_in_by %} by {{ connection.signed_in_by }}{% endif %}, as {{ "an administrator: Steward can install updates" if connection.can_install else "a user who isn't an administrator: Steward shows status only" }}.</p>
<h2 id="ha-where" class="section-title">Reading it</h2> {% endif %}
<div class="form-pair"> <form method="post" action="/plugins/homeassistant/connection/sign-in">{{ csrf_field() }}
<div class="field"> <section class="card form-section" aria-labelledby="ha-sign-in">
<label class="field-label" for="ha-url">Address</label> <h2 id="ha-sign-in" class="section-title">Sign in with Home Assistant</h2>
<input class="input mono" id="ha-url" name="url" required maxlength="500" value="{{ v.url or '' }}" placeholder="http://homeassistant.local:8123" autocomplete="off"> <div class="field">
<span class="field-hint">Its address on your local network, not a public one: the webhook only answers local requests.</span> <label class="field-label" for="ha-url">Address</label>
</div> <input class="input mono" id="ha-url" name="url" required maxlength="500" value="{{ url or '' }}" placeholder="http://homeassistant.local:8123" autocomplete="off">
<div class="field"> <span class="field-hint">Both your browser and Steward need to reach it at this address.</span>
<label class="field-label" for="ha-token">Read-only token</label>
<input class="input" id="ha-token" name="token" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_token else 'a long-lived access token' }}">
<span class="field-hint">Use a long-lived token of a user in Home Assistant's read-only group, so nothing Steward holds can act on your house.</span>
</div>
</div> </div>
<label style="display:flex;gap:0.5rem;align-items:center;cursor:pointer;margin-bottom:0.9rem;"> <label style="display:flex;gap:0.5rem;align-items:center;cursor:pointer;margin-bottom:0.9rem;">
<input type="checkbox" name="verify_tls" value="on"{% if v.verify_tls %} checked{% endif %}> Check its HTTPS certificate <input type="checkbox" name="verify_tls" value="on"{% if verify_tls %} checked{% endif %}> Check its HTTPS certificate
</label> </label>
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;"> <p class="tone-warn"><strong>Steward stores a credential with the same access as the user who signs in.</strong> Home Assistant has no narrower permission, so an administrator's credential can do anything that administrator can, including controlling every device. Steward only uses it to read status, take backups and install updates.</p>
<button type="button" class="btn btn-sm btn-ghost" <p class="field-hint"><strong>We recommend a service account:</strong> a Home Assistant user made just for Steward (Settings → People → Add person). What Steward does then appears under its own name in Home Assistant's logs, so it's easy to tell apart from what people do.</p>
hx-post="/plugins/homeassistant/connection/test" hx-include="#ha-form" hx-target="#ha-test" <p class="field-hint" style="margin-bottom:0;">Installing updates needs an administrator. With any other user, Steward shows status only.</p>
hx-indicator="#ha-testing">Test</button>
<span id="ha-testing" class="field-hint htmx-indicator">Reading…</span>
<span class="field-hint">Reads Home Assistant once with what's typed here. Nothing is saved.</span>
</div>
<div id="ha-test" aria-live="polite"></div>
</section>
<section class="card form-section" aria-labelledby="ha-install">
<h2 id="ha-install" class="section-title">Installing updates</h2>
<div class="field">
<label class="field-label" for="ha-webhook">Webhook id</label>
<input class="input" id="ha-webhook" name="webhook_id" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_webhook else 'blank makes a new one' }}">
<span class="field-hint">Steward makes one when you first save. Type one only if the automation in Home Assistant already has its own.</span>
</div>
{% if automation %}
<p class="field-hint">Add this automation in Home Assistant (Settings → Automations → Create → Edit in YAML). It can take a backup or install a Core, OS or add-on update, and nothing else. It contains the webhook id, so treat it like a password.</p>
<pre class="mono" id="ha-automation" style="overflow-x:auto;font-size:0.8rem;">{{ automation }}</pre>
<button type="button" class="btn btn-sm btn-ghost"
onclick="navigator.clipboard.writeText(document.getElementById('ha-automation').textContent).then(()=>this.textContent='Copied')">Copy</button>
{% else %}
<p class="field-hint" style="margin-bottom:0;">Once saved, this shows the automation to add to Home Assistant.</p>
{% endif %}
</section> </section>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn">Save</button> <button type="submit" class="btn">{{ "Sign in again" if connection else "Continue to Home Assistant" }}</button>
<a href="/plugins/homeassistant/" class="btn btn-ghost">Cancel</a> <a href="/plugins/homeassistant/" class="btn btn-ghost">Cancel</a>
</div> </div>
</form> </form>
@@ -68,10 +38,10 @@
<section class="card danger-zone" aria-labelledby="ha-remove"> <section class="card danger-zone" aria-labelledby="ha-remove">
<div> <div>
<h2 id="ha-remove" class="section-title">Remove this connection</h2> <h2 id="ha-remove" class="section-title">Remove this connection</h2>
<p class="field-hint">Steward stops reading Home Assistant and forgets its token. Remove the automation in Home Assistant too.</p> <p class="field-hint">Steward stops reading Home Assistant, forgets its credential and ends the sign-in there.</p>
</div> </div>
<form method="post" action="/plugins/homeassistant/connection/delete" <form method="post" action="/plugins/homeassistant/connection/delete"
data-confirm="Remove the Home Assistant connection? Steward stops reading it and forgets its token.">{{ csrf_field() }} data-confirm="Remove the Home Assistant connection? Steward stops reading it and forgets its credential.">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Remove connection</button> <button class="btn btn-danger" type="submit">Remove connection</button>
</form> </form>
</section> </section>
@@ -12,7 +12,8 @@
{% endcall %} {% endcall %}
{% if not connection %} {% if not connection %}
<div class="card">{{ empty_state("Not connected. Connect a Home Assistant instance to see its waiting updates and backups, and to install updates after a confirmed backup.", {% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<div class="card">{{ empty_state("Not connected. Sign in to a Home Assistant instance to see its waiting updates and backups, and to install updates after a confirmed backup.",
"Connect" if admin else none, "/plugins/homeassistant/connection") }}</div> "Connect" if admin else none, "/plugins/homeassistant/connection") }}</div>
{% else %} {% else %}
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %} {% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
@@ -29,9 +30,9 @@
{% else %}{{ status_badge('pending', 'Not read yet') }} {{ says }}{% endif %} {% else %}{{ status_badge('pending', 'Not read yet') }} {{ says }}{% endif %}
</p> </p>
{% if connection.last_ok_at and connection.automation != 'on' %} {% if not connection.can_install %}
<p class="tone-warn">{% if connection.automation %}The Steward maintenance automation is {{ connection.automation }} in Home Assistant{% else %}The Steward maintenance automation isn't in Home Assistant{% endif %}, so updates can't be installed from here. <p class="tone-warn">The Home Assistant user who signed in isn't an administrator, so updates can't be installed from here.
{% if admin %}<a href="/plugins/homeassistant/connection">See what to add.</a>{% endif %}</p> {% if admin %}<a href="/plugins/homeassistant/connection">Sign in again</a> as an administrator to install them.{% endif %}</p>
{% endif %} {% endif %}
<div class="stat-strip"> <div class="stat-strip">
@@ -53,7 +54,7 @@
<td class="mono">{{ u.installed or "—" }}</td> <td class="mono">{{ u.installed or "—" }}</td>
<td class="mono">{% if u.release_url %}<a href="{{ u.release_url }}" rel="noopener noreferrer" target="_blank">{{ u.latest or "—" }}</a>{% else %}{{ u.latest or "—" }}{% endif %}</td> <td class="mono">{% if u.release_url %}<a href="{{ u.release_url }}" rel="noopener noreferrer" target="_blank">{{ u.latest or "—" }}</a>{% else %}{{ u.latest or "—" }}{% endif %}</td>
{% if admin %}<td style="text-align:right;"> {% if admin %}<td style="text-align:right;">
{% if connection.automation == 'on' and not under_way and not connection.refused and not u.in_progress %} {% if connection.can_install and not under_way and not connection.refused and not u.in_progress %}
<form method="post" action="/plugins/homeassistant/install" <form method="post" action="/plugins/homeassistant/install"
data-confirm="Install {{ u.title }} {{ u.latest }}? Steward takes a backup first and won't install without it. {{ 'Home Assistant restarts.' if u.kind == 'core' else ('The machine reboots.' if u.kind == 'os' else 'The add-on restarts.') }}">{{ csrf_field() }} data-confirm="Install {{ u.title }} {{ u.latest }}? Steward takes a backup first and won't install without it. {{ 'Home Assistant restarts.' if u.kind == 'core' else ('The machine reboots.' if u.kind == 'os' else 'The add-on restarts.') }}">{{ csrf_field() }}
<input type="hidden" name="entity_id" value="{{ u.entity_id }}"> <input type="hidden" name="entity_id" value="{{ u.entity_id }}">
+32 -36
View File
@@ -10,19 +10,15 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from steward.core.crypto import decrypt_secret
from . import client from . import client
from .models import HomeAssistantConnection, HomeAssistantUpdate from .models import HomeAssistantConnection, HomeAssistantUpdate
from .service import BACKUP_EVENT, LAST_BACKUP, automation_entity_id, is_newer, parse_moment from .service import BACKUP_EVENT, LAST_BACKUP, is_newer, parse_moment
from .timeline import update_entry from .timeline import update_entry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
UNDER_WAY = ("backing_up", "installing") UNDER_WAY = ("backing_up", "installing")
BACKUP_DEADLINE_S = 30 * 60 BACKUP_DEADLINE_S = 30 * 60
# How long the automation has to show it ran before the webhook is called wrong.
TRIGGER_GRACE_S = 90
# An OS update reboots the machine, and a Pi is slow to come back. # An OS update reboots the machine, and a Pi is slow to come back.
INSTALL_DEADLINE_S = {"addon": 20 * 60, "core": 30 * 60, "os": 45 * 60} INSTALL_DEADLINE_S = {"addon": 20 * 60, "core": 30 * 60, "os": 45 * 60}
@@ -31,11 +27,6 @@ class CannotStart(Exception):
"""Why an update wasn't started, said to the person.""" """Why an update wasn't started, said to the person."""
def _secrets(connection) -> tuple[str, str]:
return (decrypt_secret(connection.token, context="Home Assistant token"),
decrypt_secret(connection.webhook_id, context="Home Assistant webhook id"))
async def _mark_refused(app, connection_id: str, said: str) -> None: async def _mark_refused(app, connection_id: str, said: str) -> None:
async with app.db_sessionmaker() as session: async with app.db_sessionmaker() as session:
async with session.begin(): async with session.begin():
@@ -52,27 +43,26 @@ async def under_way(session) -> HomeAssistantUpdate | None:
async def start_update(app, entity_id: str, username: str, transport=None) -> HomeAssistantUpdate: async def start_update(app, entity_id: str, username: str, transport=None) -> HomeAssistantUpdate:
"""Start installing `entity_id`, an explicit action that may contact Home """Start installing `entity_id`, an explicit action that may contact Home
Assistant. Raises CannotStart, with nothing started, unless the update is Assistant. Raises CannotStart, with nothing started, unless the update is
still waiting and the automation is there to install it.""" still waiting and the signed-in user may install it."""
async with app.db_sessionmaker() as session: async with app.db_sessionmaker() as session:
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first() connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
if connection is None: if connection is None:
raise CannotStart("Home Assistant isn't connected") raise CannotStart("Home Assistant isn't connected")
if connection.refused: if connection.refused:
raise CannotStart("Home Assistant refused Steward's token") raise CannotStart("Home Assistant refused Steward's sign-in")
if await under_way(session) is not None: if await under_way(session) is not None:
raise CannotStart("another update is under way") raise CannotStart("another update is under way")
if connection.automation != "on": if not connection.can_install:
raise CannotStart("the Steward maintenance automation isn't on in Home Assistant") raise CannotStart("the Home Assistant user who signed in isn't an administrator")
url, verify = connection.url, connection.verify_tls url, verify = connection.url, connection.verify_tls
token, webhook_id = _secrets(connection)
known = next((u for u in connection.updates or [] if u["entity_id"] == entity_id), None) known = next((u for u in connection.updates or [] if u["entity_id"] == entity_id), None)
if known is None or not known["kind"]: if known is None or not known["kind"]:
raise CannotStart("that isn't a Core, OS or add-on update") raise CannotStart("that isn't a Core, OS or add-on update")
try: try:
token = await client.access_token(connection, transport)
current = await client.read_state(url, entity_id, token, verify, transport) current = await client.read_state(url, entity_id, token, verify, transport)
backup = await client.read_state(url, LAST_BACKUP, token, verify, transport) backup = await client.read_state(url, LAST_BACKUP, token, verify, transport)
event = await client.read_state(url, BACKUP_EVENT, token, verify, transport) event = await client.read_state(url, BACKUP_EVENT, token, verify, transport)
automation = await client.read_state(url, automation_entity_id(connection), token, verify, transport)
except client.Refused as exc: except client.Refused as exc:
await _mark_refused(app, connection.id, str(exc)) await _mark_refused(app, connection.id, str(exc))
raise CannotStart(str(exc).rstrip(".")) from None raise CannotStart(str(exc).rstrip(".")) from None
@@ -87,8 +77,7 @@ async def start_update(app, entity_id: str, username: str, transport=None) -> Ho
entity_id=entity_id, title=known["title"], kind=known["kind"], entity_id=entity_id, title=known["title"], kind=known["kind"],
from_version=attrs.get("installed_version"), to_version=attrs.get("latest_version"), from_version=attrs.get("installed_version"), to_version=attrs.get("latest_version"),
started_by=username, backup_before=backup.get("state"), started_by=username, backup_before=backup.get("state"),
backup_event_before=(event or {}).get("state"), backup_event_before=(event or {}).get("state"))
triggered_before=((automation or {}).get("attributes") or {}).get("last_triggered"))
async with app.db_sessionmaker() as session: async with app.db_sessionmaker() as session:
try: try:
async with session.begin(): async with session.begin():
@@ -98,8 +87,12 @@ async def start_update(app, entity_id: str, username: str, transport=None) -> Ho
except IntegrityError: except IntegrityError:
raise CannotStart("another update is under way") from None raise CannotStart("another update is under way") from None
try: try:
await client.send_webhook(url, webhook_id, {"action": "backup"}, verify, transport) await client.call_action(url, "backup", "create_automatic", {}, token, verify, transport)
except client.Unreachable as exc: except client.Refused as exc:
await _mark_refused(app, connection.id, str(exc))
await _finish(app, run_id, "failed", f"The backup couldn't be started: {exc} Nothing was installed.")
raise CannotStart(str(exc).rstrip(".")) from None
except (client.Unreachable, client.Rejected) as exc:
await _finish(app, run_id, "failed", f"The backup couldn't be started: {exc} Nothing was installed.") await _finish(app, run_id, "failed", f"The backup couldn't be started: {exc} Nothing was installed.")
raise CannotStart(str(exc).rstrip(".")) from None raise CannotStart(str(exc).rstrip(".")) from None
async with app.db_sessionmaker() as session: async with app.db_sessionmaker() as session:
@@ -140,6 +133,8 @@ async def advance(app, transport=None) -> None:
except client.Refused as exc: except client.Refused as exc:
await _mark_refused(app, connection.id, str(exc)) await _mark_refused(app, connection.id, str(exc))
await _finish(app, run.id, "failed", str(exc)) await _finish(app, run.id, "failed", str(exc))
except client.Rejected as exc:
await _finish(app, run.id, "failed", f"Home Assistant wouldn't install it: {exc}")
except client.Unreachable as exc: except client.Unreachable as exc:
# Expected while Home Assistant restarts: the next tick asks again. # Expected while Home Assistant restarts: the next tick asks again.
logger.info("Home Assistant update %s: %s", run.id, exc) logger.info("Home Assistant update %s: %s", run.id, exc)
@@ -165,7 +160,7 @@ async def _past_deadline(app, run) -> bool:
async def _advance_backup(app, run, connection, transport) -> None: async def _advance_backup(app, run, connection, transport) -> None:
token, _webhook = _secrets(connection) token = await client.access_token(connection, transport)
url, verify = connection.url, connection.verify_tls url, verify = connection.url, connection.verify_tls
backup = await client.read_state(url, LAST_BACKUP, token, verify, transport) backup = await client.read_state(url, LAST_BACKUP, token, verify, transport)
value = (backup or {}).get("state") value = (backup or {}).get("state")
@@ -182,27 +177,28 @@ async def _advance_backup(app, run, connection, transport) -> None:
await _finish(app, run.id, "failed", await _finish(app, run.id, "failed",
f"The backup failed{f' ({reason})' if reason else ''}. Nothing was installed.") f"The backup failed{f' ({reason})' if reason else ''}. Nothing was installed.")
return return
if _elapsed(run.started_at) > TRIGGER_GRACE_S:
automation = await client.read_state(url, automation_entity_id(connection), token, verify, transport)
triggered = ((automation or {}).get("attributes") or {}).get("last_triggered")
if not is_newer(triggered, run.triggered_before):
await _finish(app, run.id, "failed",
"Home Assistant didn't run the maintenance automation, so its webhook id may not "
"match Steward's, or Steward isn't on its local network. Nothing was installed.")
return
await _past_deadline(app, run) await _past_deadline(app, run)
async def _mark_sent(app, run_id: str, at: datetime | None) -> None:
async with app.db_sessionmaker() as session:
async with session.begin():
row = await session.get(HomeAssistantUpdate, run_id)
row.install_sent_at = at
async def _advance_install(app, run, connection, transport) -> None: async def _advance_install(app, run, connection, transport) -> None:
token, webhook_id = _secrets(connection) token = await client.access_token(connection, transport)
url, verify = connection.url, connection.verify_tls url, verify = connection.url, connection.verify_tls
if run.install_sent_at is None: if run.install_sent_at is None:
await client.send_webhook(url, webhook_id, {"action": "install", "entity_id": run.entity_id}, # Marked first: Steward stopping here leaves an install that runs out its
verify, transport) # deadline, where asking twice would fail one that is going fine.
async with app.db_sessionmaker() as session: await _mark_sent(app, run.id, datetime.now(timezone.utc))
async with session.begin(): try:
row = await session.get(HomeAssistantUpdate, run.id) await client.call_action(url, "update", "install", {"entity_id": run.entity_id}, token, verify, transport)
row.install_sent_at = datetime.now(timezone.utc) except client.Unreachable:
await _mark_sent(app, run.id, None)
raise
return return
current = await client.read_state(url, run.entity_id, token, verify, transport) current = await client.read_state(url, run.entity_id, token, verify, transport)
attrs = (current or {}).get("attributes") or {} attrs = (current or {}).get("attributes") or {}
+1 -1
View File
@@ -214,7 +214,7 @@ ACTIONS = {
"prometheus.template_created": "added Prometheus widget template", "prometheus.template_created": "added Prometheus widget template",
"prometheus.template_updated": "changed Prometheus widget template", "prometheus.template_updated": "changed Prometheus widget template",
"prometheus.template_deleted": "deleted Prometheus widget template", "prometheus.template_deleted": "deleted Prometheus widget template",
"homeassistant.connection_saved": "saved the Home Assistant connection", "homeassistant.connection_saved": "signed Steward in to Home Assistant",
"homeassistant.connection_removed": "removed the Home Assistant connection", "homeassistant.connection_removed": "removed the Home Assistant connection",
"homeassistant.update_started": "started a Home Assistant update", "homeassistant.update_started": "started a Home Assistant update",
"syslog.source_added": "added syslog source", "syslog.source_added": "added syslog source",
+1 -1
View File
@@ -94,7 +94,7 @@ ACTIVITY: dict[str, frozenset[str]] = {
"prometheus_targets": frozenset({"last_scraped_at", "last_ok_at", "last_error", "last_kept", "prometheus_targets": frozenset({"last_scraped_at", "last_ok_at", "last_error", "last_kept",
"last_dropped", "last_capped"}), "last_dropped", "last_capped"}),
"homeassistant_connection": frozenset({"refused", "last_polled_at", "last_ok_at", "last_error", "homeassistant_connection": frozenset({"refused", "last_polled_at", "last_ok_at", "last_error",
"updates", "backup", "automation"}), "updates", "backup"}),
# What the sender last sent; its address, name, state and host are setup. # What the sender last sent; its address, name, state and host are setup.
"syslog_sources": frozenset({"hostname_seen", "first_seen_at", "last_seen_at", "lines_total", "syslog_sources": frozenset({"hostname_seen", "first_seen_at", "last_seen_at", "lines_total",
"last_severity", "last_line"}), "last_severity", "last_line"}),
+212 -37
View File
@@ -1,5 +1,5 @@
"""Integration: Steward reads Home Assistant, and installs one update through its """Integration: Steward signs in to Home Assistant, reads it, and installs one
webhook only after a newer backup, following it to its end. A fake Home Assistant update only after a newer backup, following it to its end. A fake Home Assistant
answers over httpx's mock transport. Requires STEWARD_DATABASE_URL.""" answers over httpx's mock transport. Requires STEWARD_DATABASE_URL."""
from __future__ import annotations from __future__ import annotations
@@ -7,7 +7,9 @@ import asyncio
import importlib import importlib
import json import json
import os import os
import time
import uuid import uuid
from urllib.parse import parse_qs, urlparse
import httpx import httpx
import pytest import pytest
@@ -35,20 +37,22 @@ def _ha(sub):
class FakeHomeAssistant: class FakeHomeAssistant:
"""Its entities, the webhook calls it received, and what it refuses.""" """Its entities, the actions it received, what it refuses, and its sign-ins."""
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
self.tokens = set() self.tokens = set()
self.refreshes = []
self.revoked = []
self.refuse = False self.refuse = False
self.down = False self.down = False
self.hook_runs = True self.admin = True
self.reject_install = None
self.cut_off = False
self.entities = { self.entities = {
ADDON: self._update("on", "1.0"), ADDON: self._update("on", "1.0"),
"sensor.backup_last_successful_automatic_backup": {"state": BEFORE, "attributes": {}}, "sensor.backup_last_successful_automatic_backup": {"state": BEFORE, "attributes": {}},
"event.backup_automatic_backup": {"state": BEFORE, "attributes": {"event_type": "completed"}}, "event.backup_automatic_backup": {"state": BEFORE, "attributes": {"event_type": "completed"}},
"automation.steward_maintenance": {"state": "on", "attributes": {"id": "steward_maintenance",
"last_triggered": None}},
} }
def _update(self, state, installed): def _update(self, state, installed):
@@ -66,15 +70,28 @@ class FakeHomeAssistant:
if self.down: if self.down:
raise httpx.ConnectError("connection refused") raise httpx.ConnectError("connection refused")
path = request.url.path path = request.url.path
if path.startswith("/api/webhook/"): if path == "/auth/token":
assert "authorization" not in request.headers form = parse_qs(request.content.decode())
self.calls.append(json.loads(request.content)) self.refreshes.append(form)
if self.hook_runs: if self.refuse:
self.entities["automation.steward_maintenance"]["attributes"]["last_triggered"] = AFTER return httpx.Response(400, json={"error": "invalid_grant"})
return httpx.Response(200, json={"access_token": f"access-{len(self.refreshes)}", "expires_in": 1800,
"refresh_token": "refresh-new", "token_type": "Bearer"})
if path == "/auth/revoke":
self.revoked.append(parse_qs(request.content.decode())["token"][0])
return httpx.Response(200) return httpx.Response(200)
self.tokens.add(request.headers.get("authorization")) self.tokens.add(request.headers.get("authorization"))
if self.refuse: if self.refuse:
return httpx.Response(401) return httpx.Response(401)
if path == "/api/template":
return httpx.Response(200, text="1") if self.admin else httpx.Response(401)
if path.startswith("/api/services/"):
self.calls.append((path.removeprefix("/api/services/"), json.loads(request.content)))
if self.cut_off:
raise httpx.ReadTimeout("no answer yet")
if self.reject_install and path.endswith("update/install"):
return httpx.Response(500, json={"message": self.reject_install})
return httpx.Response(200, json=[])
if path == "/api/states": if path == "/api/states":
return httpx.Response(200, json=[{"entity_id": k, **v} for k, v in self.entities.items()]) return httpx.Response(200, json=[{"entity_id": k, **v} for k, v in self.entities.items()])
entity = self.entities.get(path.removeprefix("/api/states/")) entity = self.entities.get(path.removeprefix("/api/states/"))
@@ -85,6 +102,10 @@ class FakeHomeAssistant:
return httpx.MockTransport(self.handler) return httpx.MockTransport(self.handler)
BACKUP = ("backup/create_automatic", {})
INSTALL = ("update/install", {"entity_id": ADDON})
async def _clean(app, **connection): async def _clean(app, **connection):
from steward.core.crypto import encrypt_secret from steward.core.crypto import encrypt_secret
m = _ha("models") m = _ha("models")
@@ -92,8 +113,9 @@ async def _clean(app, **connection):
async with s.begin(): async with s.begin():
await s.execute(delete(m.HomeAssistantUpdate)) await s.execute(delete(m.HomeAssistantUpdate))
await s.execute(delete(m.HomeAssistantConnection)) await s.execute(delete(m.HomeAssistantConnection))
s.add(m.HomeAssistantConnection(url="http://ha.test:8123", token=encrypt_secret("read-only"), s.add(m.HomeAssistantConnection(**{
webhook_id=encrypt_secret(f"hook{uuid.uuid4().hex}"), **connection)) "url": "http://ha.test:8123", "client_id": "http://steward.test/",
"refresh_token": encrypt_secret(f"refresh-{uuid.uuid4().hex}"), "can_install": True, **connection}))
async def _state(app): async def _state(app):
@@ -119,14 +141,33 @@ def test_a_poll_stores_what_it_read_and_records_its_metrics(app):
return connection, {r.metric_name: r.value for r in rows} return connection, {r.metric_name: r.value for r in rows}
connection, metrics = asyncio.run(go()) connection, metrics = asyncio.run(go())
assert ha.tokens == {"Bearer read-only"} assert ha.tokens == {"Bearer access-1"}
assert connection.automation == "on" and connection.last_error is None and not connection.refused assert connection.last_error is None and not connection.refused
assert [u["entity_id"] for u in connection.updates] == [ADDON] assert [u["entity_id"] for u in connection.updates] == [ADDON]
assert connection.backup["last_ok"] == BEFORE assert connection.backup["last_ok"] == BEFORE
assert metrics["is_up"] == 1.0 and metrics["updates_waiting"] == 1.0 and metrics["backup_age_hours"] >= 0 assert metrics["is_up"] == 1.0 and metrics["updates_waiting"] == 1.0 and metrics["backup_age_hours"] >= 0
def test_a_refused_token_is_asked_once_and_not_again(app): def test_a_sign_in_lasts_until_its_access_token_nearly_runs_out_then_is_refreshed(app):
client = _ha("client")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
connection = (await _state(app))[0]
first = await client.access_token(connection, ha.transport)
again = await client.access_token(connection, ha.transport)
assert first == again and len(ha.refreshes) == 1
for key, (token, _until) in list(client._access.items()):
client._access[key] = (token, time.monotonic() - 1)
return await client.access_token(connection, ha.transport)
assert asyncio.run(go()) == "access-2"
[form] = ha.refreshes[:1]
assert form["grant_type"] == ["refresh_token"] and form["client_id"] == ["http://steward.test/"]
def test_a_refused_sign_in_is_asked_once_and_not_again(app):
sched = _ha("scheduler") sched = _ha("scheduler")
ha = FakeHomeAssistant() ha = FakeHomeAssistant()
ha.refuse = True ha.refuse = True
@@ -145,8 +186,8 @@ def test_a_refused_token_is_asked_once_and_not_again(app):
return (await _state(app))[0] return (await _state(app))[0]
connection = asyncio.run(go()) connection = asyncio.run(go())
assert seen == ["/api/states"] assert seen == ["/auth/token"]
assert connection.refused and "refused the token" in connection.last_error assert connection.refused and "Sign in again" in connection.last_error
def test_an_update_waits_for_a_newer_backup_then_installs_and_ends_on_the_timeline(app): def test_an_update_waits_for_a_newer_backup_then_installs_and_ends_on_the_timeline(app):
@@ -158,20 +199,20 @@ def test_an_update_waits_for_a_newer_backup_then_installs_and_ends_on_the_timeli
await _clean(app) await _clean(app)
await sched.poll(app, transport=ha.transport) await sched.poll(app, transport=ha.transport)
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport) run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
assert ha.calls == [{"action": "backup"}] assert ha.calls == [BACKUP]
assert run.state == "backing_up" and run.from_version == "1.0" and run.to_version == "1.1" assert run.state == "backing_up" and run.from_version == "1.0" and run.to_version == "1.1"
# No newer backup yet: nothing is installed, however often it is asked. # No newer backup yet: nothing is installed, however often it is asked.
await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport)
await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport)
assert ha.calls == [{"action": "backup"}] assert ha.calls == [BACKUP]
ha.backup_lands() ha.backup_lands()
await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport)
_, [row] = await _state(app) _, [row] = await _state(app)
assert row.state == "installing" and row.backup_at is not None assert row.state == "installing" and row.backup_at is not None
await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport)
assert ha.calls == [{"action": "backup"}, {"action": "install", "entity_id": ADDON}] assert ha.calls == [BACKUP, INSTALL]
# Home Assistant restarting is expected, and isn't the end of the update. # Home Assistant restarting is expected, and isn't the end of the update.
ha.down = True ha.down = True
@@ -212,31 +253,68 @@ def test_a_failed_backup_installs_nothing(app):
[run] = asyncio.run(go()) [run] = asyncio.run(go())
assert run.state == "failed" and "upload_failed" in run.message and "Nothing was installed" in run.message assert run.state == "failed" and "upload_failed" in run.message and "Nothing was installed" in run.message
assert ha.calls == [{"action": "backup"}] assert ha.calls == [BACKUP]
def test_a_webhook_that_never_ran_the_automation_says_so(app): def test_an_action_home_assistant_is_still_working_on_counts_as_sent(app):
from datetime import timedelta """It answers when finished, and an update that restarts it never does."""
from sqlalchemy import update sched, upd = _ha("scheduler"), _ha("updates")
sched, upd, m = _ha("scheduler"), _ha("updates"), _ha("models")
ha = FakeHomeAssistant() ha = FakeHomeAssistant()
ha.hook_runs = False ha.cut_off = True
async def go(): async def go():
await _clean(app) await _clean(app)
await sched.poll(app, transport=ha.transport) await sched.poll(app, transport=ha.transport)
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport) run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
assert run.state == "backing_up"
ha.backup_lands()
await upd.advance(app, transport=ha.transport)
await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport)
assert (await _state(app))[1][0].state == "backing_up" # within its grace
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(update(m.HomeAssistantUpdate).where(m.HomeAssistantUpdate.id == run.id).values(
started_at=run.started_at - timedelta(seconds=upd.TRIGGER_GRACE_S + 1)))
await upd.advance(app, transport=ha.transport) await upd.advance(app, transport=ha.transport)
return (await _state(app))[1] return (await _state(app))[1]
[run] = asyncio.run(go()) [run] = asyncio.run(go())
assert run.state == "failed" and "webhook id" in run.message and "Nothing was installed" in run.message assert run.state == "installing" and run.install_sent_at is not None
assert ha.calls == [BACKUP, INSTALL]
def test_an_install_home_assistant_rejects_fails_with_what_it_said(app):
sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant()
ha.reject_install = "Not enough free space"
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
await upd.start_update(app, ADDON, "someone", transport=ha.transport)
ha.backup_lands()
await upd.advance(app, transport=ha.transport)
await upd.advance(app, transport=ha.transport)
return (await _state(app))[1]
[run] = asyncio.run(go())
assert run.state == "failed" and "Not enough free space" in run.message
def test_an_install_that_couldnt_reach_home_assistant_is_asked_for_again(app):
sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant()
async def go():
await _clean(app)
await sched.poll(app, transport=ha.transport)
await upd.start_update(app, ADDON, "someone", transport=ha.transport)
ha.backup_lands()
await upd.advance(app, transport=ha.transport)
ha.down = True
await upd.advance(app, transport=ha.transport)
assert (await _state(app))[1][0].install_sent_at is None
ha.down = False
await upd.advance(app, transport=ha.transport)
return (await _state(app))[1]
[run] = asyncio.run(go())
assert run.install_sent_at is not None and ha.calls == [BACKUP, INSTALL]
def test_an_install_past_its_deadline_fails_and_only_one_runs_at_a_time(app): def test_an_install_past_its_deadline_fails_and_only_one_runs_at_a_time(app):
@@ -268,16 +346,14 @@ def test_an_install_past_its_deadline_fails_and_only_one_runs_at_a_time(app):
@pytest.mark.parametrize("entity,change,says", [ @pytest.mark.parametrize("entity,change,says", [
("update.unknown", None, "isn't a Core, OS or add-on"), ("update.unknown", None, "isn't a Core, OS or add-on"),
(ADDON, "no_longer_waiting", "no longer waiting"), (ADDON, "no_longer_waiting", "no longer waiting"),
(ADDON, "no_automation", "automation isn't on"), (ADDON, "not_an_admin", "isn't an administrator"),
]) ])
def test_nothing_starts_unless_the_update_can_run(app, entity, change, says): def test_nothing_starts_unless_the_update_can_run(app, entity, change, says):
sched, upd = _ha("scheduler"), _ha("updates") sched, upd = _ha("scheduler"), _ha("updates")
ha = FakeHomeAssistant() ha = FakeHomeAssistant()
async def go(): async def go():
await _clean(app) await _clean(app, can_install=change != "not_an_admin")
if change == "no_automation":
del ha.entities["automation.steward_maintenance"]
await sched.poll(app, transport=ha.transport) await sched.poll(app, transport=ha.transport)
if change == "no_longer_waiting": if change == "no_longer_waiting":
ha.update_lands() ha.update_lands()
@@ -287,3 +363,102 @@ def test_nothing_starts_unless_the_update_can_run(app, entity, change, says):
assert asyncio.run(go()) == [] assert asyncio.run(go()) == []
assert ha.calls == [] assert ha.calls == []
def _admin_client(app):
from steward.models.users import User, UserRole
tag = uuid.uuid4().hex[:8]
uid = str(uuid.uuid4())
async def make():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=f"ha-{tag}", email=f"ha-{tag}@test", password_hash="x",
role=UserRole.admin, is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="admin", username=f"ha-{tag}", _csrf="test-token", login_at=time.time())
return client, f"ha-{tag}"
return make()
CSRF = {"X-CSRF-Token": "test-token"}
@pytest.mark.parametrize("admin", [True, False])
def test_signing_in_goes_to_home_assistant_and_keeps_what_comes_back(app, monkeypatch, admin):
from steward.core.crypto import decrypt_secret
client, m = _ha("client"), _ha("models")
ha = FakeHomeAssistant()
ha.admin = admin
for name in ("redeem_code", "is_admin", "revoke"):
real = getattr(client, name)
if name == "revoke":
monkeypatch.setattr(client, name, lambda c, real=real: real(c, ha.transport))
else:
monkeypatch.setattr(client, name, lambda *a, real=real: real(*a, ha.transport))
async def go():
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(delete(m.HomeAssistantUpdate))
await s.execute(delete(m.HomeAssistantConnection))
web, username = await _admin_client(app)
response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF,
form={"url": "http://ha.test:8123/", "verify_tls": "on"})
assert response.status_code == 302
to = urlparse(response.headers["Location"])
query = parse_qs(to.query)
assert f"{to.scheme}://{to.netloc}{to.path}" == "http://ha.test:8123/auth/authorize"
assert query["response_type"] == ["code"]
assert urlparse(query["redirect_uri"][0]).netloc == urlparse(query["client_id"][0]).netloc
assert query["redirect_uri"][0].endswith("/plugins/homeassistant/connection/callback")
# Coming back with someone else's state changes nothing.
forged = await web.get("/plugins/homeassistant/connection/callback?code=c&state=forged")
assert forged.status_code == 400 and not ha.refreshes
assert (await _state(app))[0] is None
response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF,
form={"url": "http://ha.test:8123/", "verify_tls": "on"})
state = parse_qs(urlparse(response.headers["Location"]).query)["state"][0]
back = await web.get(f"/plugins/homeassistant/connection/callback?code=the-code&state={state}")
assert back.status_code == 302
again = await web.get(f"/plugins/homeassistant/connection/callback?code=the-code&state={state}")
assert again.status_code == 400 and len(ha.refreshes) == 1 # a state works once
connection = (await _state(app))[0]
removed = await web.post("/plugins/homeassistant/connection/delete", headers=CSRF)
assert removed.status_code == 302
return connection, username, (await _state(app))[0]
connection, username, after = asyncio.run(go())
assert ha.refreshes[0]["grant_type"] == ["authorization_code"] and ha.refreshes[0]["code"] == ["the-code"]
assert connection.url == "http://ha.test:8123" and connection.verify_tls
assert decrypt_secret(connection.refresh_token, context="test") == "refresh-new"
assert connection.refresh_token != "refresh-new"
assert connection.can_install is admin and connection.signed_in_by == username and connection.signed_in_at
assert ha.revoked == ["refresh-new"] and after is None
def test_signing_in_again_ends_the_sign_in_it_replaces(app, monkeypatch):
from steward.core.crypto import encrypt_secret
client = _ha("client")
ha = FakeHomeAssistant()
for name in ("redeem_code", "is_admin"):
real = getattr(client, name)
monkeypatch.setattr(client, name, lambda *a, real=real: real(*a, ha.transport))
real_revoke = client.revoke
monkeypatch.setattr(client, "revoke", lambda c: real_revoke(c, ha.transport))
async def go():
await _clean(app, refresh_token=encrypt_secret("refresh-old"), refused=True)
web, _ = await _admin_client(app)
response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF,
form={"url": "http://ha.test:8123", "verify_tls": "on"})
state = parse_qs(urlparse(response.headers["Location"]).query)["state"][0]
await web.get(f"/plugins/homeassistant/connection/callback?code=c&state={state}")
return (await _state(app))[0]
connection = asyncio.run(go())
assert ha.revoked == ["refresh-old"] and not connection.refused and connection.last_polled_at is None
+28 -41
View File
@@ -1,10 +1,8 @@
"""Reading what Home Assistant reports: which updates Steward may install, how """Reading what Home Assistant reports: which updates Steward may install, how
its backups stand, the automation to add, and what the pages and MCP show.""" its backups stand, and what the pages and MCP show."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS from types import SimpleNamespace as NS
import yaml
import plugins.homeassistant as homeassistant_plugin import plugins.homeassistant as homeassistant_plugin
from plugins.homeassistant import mcp_tools, scheduler, service as s from plugins.homeassistant import mcp_tools, scheduler, service as s
from steward.models.users import UserRole from steward.models.users import UserRole
@@ -34,24 +32,20 @@ def test_only_core_os_and_add_ons_can_be_installed():
entity_picture="https://brands.home-assistant.io/_/locks/icon.png")) is None entity_picture="https://brands.home-assistant.io/_/locks/icon.png")) is None
def test_a_snapshot_keeps_waiting_updates_first_and_finds_the_automation_by_its_id(): def test_a_snapshot_keeps_waiting_updates_first():
snapshot = s.read_snapshot([ snapshot = s.read_snapshot([
_addon("core_quiet", "off"), _entity("update.switch_firmware", title="Switch"), _addon("core_quiet", "off"), _entity("update.switch_firmware", title="Switch"),
_addon(), _entity(s.CORE, title="Home Assistant Core"), _addon(), _entity(s.CORE, title="Home Assistant Core"),
_entity("update.gone", "unavailable"), _entity("update.gone", "unavailable"),
_entity(s.LAST_BACKUP, "2026-10-31T03:00:00+00:00"), _entity(s.NEXT_BACKUP, "unknown"), _entity(s.LAST_BACKUP, "2026-10-31T03:00:00+00:00"), _entity(s.NEXT_BACKUP, "unknown"),
_entity(s.BACKUP_EVENT, "2026-10-31T03:00:00+00:00", event_type="completed"), _entity(s.BACKUP_EVENT, "2026-10-31T03:00:00+00:00", event_type="completed"),
_entity("automation.renamed_by_someone", "on", id=s.AUTOMATION_ID, last_triggered=None),
_entity("light.kitchen", "on"), _entity("light.kitchen", "on"),
]) ])
assert [u["entity_id"] for u in snapshot["updates"]] == [ assert [u["entity_id"] for u in snapshot["updates"]] == [
s.CORE, "update.core_example_update", "update.switch_firmware", "update.core_quiet_update"] s.CORE, "update.core_example_update", "update.switch_firmware", "update.core_quiet_update"]
assert snapshot["automation"] == "on"
backup = snapshot["backup"] backup = snapshot["backup"]
assert backup["last_ok"] == "2026-10-31T03:00:00+00:00" and backup["next"] is None assert backup["last_ok"] == "2026-10-31T03:00:00+00:00" and backup["next"] is None
assert backup["last_result"] == "completed" assert backup["last_result"] == "completed"
assert s.automation_entity_id(NS(backup=backup)) == "automation.renamed_by_someone"
assert s.read_snapshot([])["automation"] is None
def test_a_release_link_is_only_kept_when_it_is_a_web_address(): def test_a_release_link_is_only_kept_when_it_is_a_web_address():
@@ -79,26 +73,7 @@ def test_how_the_backups_read():
assert "reports no automatic backups" in s.backup_verdict({}, NOW)[1] assert "reports no automatic backups" in s.backup_verdict({}, NOW)[1]
def test_the_automation_to_add_only_takes_a_backup_or_installs_what_it_allows(): def test_a_refused_sign_in_stops_polling_until_someone_acts():
text = s.automation_yaml("steward_abc")
config = yaml.safe_load(text)
assert config["id"] == s.AUTOMATION_ID and config["mode"] == "single"
[trigger] = config["triggers"]
assert trigger == {"trigger": "webhook", "webhook_id": "steward_abc", "allowed_methods": ["POST"], "local_only": True}
assert "integration_entities('hassio')" in config["variables"]["allowed"]
assert "'update\\\\.'" in config["variables"]["allowed"]
assert s.SUPERVISOR in config["variables"]["allowed"]
branches = config["actions"][0]["choose"]
assert [b["sequence"][0]["action"] for b in branches] == ["backup.create_automatic", "update.install"]
assert "in allowed" in branches[1]["conditions"][0]["value_template"]
def test_a_pasted_secret_loses_a_terminals_wrapping():
assert s.no_spaces(" eyJabc\n def ghi\n") == "eyJabcdefghi"
assert s.no_spaces(None) == ""
def test_a_refused_token_stops_polling_until_someone_acts():
fresh = NS(refused=False, last_polled_at=NOW - timedelta(seconds=60)) fresh = NS(refused=False, last_polled_at=NOW - timedelta(seconds=60))
assert not scheduler.is_due(fresh, NOW) assert not scheduler.is_due(fresh, NOW)
assert scheduler.is_due(NS(refused=False, last_polled_at=NOW - timedelta(seconds=300)), NOW) assert scheduler.is_due(NS(refused=False, last_polled_at=NOW - timedelta(seconds=300)), NOW)
@@ -107,9 +82,10 @@ def test_a_refused_token_stops_polling_until_someone_acts():
def _connection(**over): def _connection(**over):
return NS(**{"url": "http://ha.example:8123", "token": "tok-s3cret", "webhook_id": "hook-s3cret", return NS(**{"url": "http://ha.example:8123", "refresh_token": "tok-s3cret", "can_install": True,
"verify_tls": True, "signed_in_by": "someone", "signed_in_at": NOW,
"refused": False, "last_error": None, "last_polled_at": NOW, "last_ok_at": NOW, "refused": False, "last_error": None, "last_polled_at": NOW, "last_ok_at": NOW,
"automation": "on", "backup": {"known": True, "last_ok": NOW.isoformat(), "last_result": "completed"}, "backup": {"known": True, "last_ok": NOW.isoformat(), "last_result": "completed"},
"updates": [], **over}) "updates": [], **over})
@@ -158,7 +134,7 @@ async def test_an_admin_is_asked_before_an_install_and_a_viewer_cant_install(app
page = await _render(app, "admin", **_page_values()) page = await _render(app, "admin", **_page_values())
assert "1 update waiting" in page and 'action="/plugins/homeassistant/install"' in page assert "1 update waiting" in page and 'action="/plugins/homeassistant/install"' in page
assert "Steward takes a backup first" in page and "Home Assistant restarts." in page assert "Steward takes a backup first" in page and "Home Assistant restarts." in page
assert "tok-s3cret" not in page and "hook-s3cret" not in page assert "tok-s3cret" not in page
main = page[page.index("<main"):] main = page[page.index("<main"):]
assert not hex_colours(main), hex_colours(main) assert not hex_colours(main), hex_colours(main)
assert 'action="/plugins/homeassistant/install"' not in await _render(app, "viewer", **_page_values()) assert 'action="/plugins/homeassistant/install"' not in await _render(app, "viewer", **_page_values())
@@ -170,9 +146,9 @@ async def test_install_is_held_back_while_it_couldnt_work_and_the_page_says_why(
busy = await _render(app, "admin", **_page_values(under_way=run, runs=[run])) busy = await _render(app, "admin", **_page_values(under_way=run, runs=[run]))
assert 'action="/plugins/homeassistant/install"' not in busy and "taking a backup first" in busy assert 'action="/plugins/homeassistant/install"' not in busy and "taking a backup first" in busy
assert 'hx-trigger="every 15s"' in busy assert 'hx-trigger="every 15s"' in busy
gone = await _render(app, "admin", **_page_values(connection=_connection(automation=None))) status_only = await _render(app, "admin", **_page_values(connection=_connection(can_install=False)))
assert 'action="/plugins/homeassistant/install"' not in gone assert 'action="/plugins/homeassistant/install"' not in status_only
assert "maintenance automation isn't in Home Assistant" in gone assert "isn't an administrator" in status_only
async def test_an_unconnected_page_offers_to_connect(app): async def test_an_unconnected_page_offers_to_connect(app):
@@ -180,13 +156,24 @@ async def test_an_unconnected_page_offers_to_connect(app):
assert 'href="/plugins/homeassistant/connection"' in page assert 'href="/plugins/homeassistant/connection"' in page
async def test_the_form_shows_the_automation_but_never_a_saved_secret_in_a_field(app): async def _form(app, **values):
from quart import session from quart import session
async with app.test_request_context("/plugins/homeassistant/connection"): async with app.test_request_context("/plugins/homeassistant/connection"):
session["user_role"] = "admin" session["user_role"] = "admin"
page = await render_plugin_template( page = await render_plugin_template(app, "homeassistant", "homeassistant/form.html", error=None,
app, "homeassistant", "homeassistant/form.html", connection=_connection(), url="http://ha.example:8123", verify_tls=True, **values)
v={"url": "http://ha.example:8123", "verify_tls": True, "has_token": True, "has_webhook": True}, return " ".join(page.split())
error=None, automation=s.automation_yaml("generated-id"))
assert "generated-id" in page and "tok-s3cret" not in page
assert "kept; type to change" in page async def test_the_form_warns_what_the_credential_can_do_and_recommends_a_service_account(app):
page = await _form(app, connection=None)
assert "same access as the user who signs in" in page and "including controlling every device" in page
assert "We recommend a service account" in page and "in Home Assistant's logs" in page
assert 'action="/plugins/homeassistant/connection/sign-in"' in page and "Continue to Home Assistant" in page
assert "connection/delete" not in page
async def test_a_connected_form_says_who_signed_in_and_never_shows_the_credential(app):
page = await _form(app, connection=_connection())
assert "tok-s3cret" not in page and "by someone" in page and "an administrator" in page
assert "Sign in again" in page and "connection/delete" in page