diff --git a/docs/plugins/homeassistant.md b/docs/plugins/homeassistant.md index 3611c29..9e5543a 100644 --- a/docs/plugins/homeassistant.md +++ b/docs/plugins/homeassistant.md @@ -1,17 +1,24 @@ # 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. - -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. +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. ## Setting it up 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. -3. On Steward's Home Assistant page, choose Connect, enter Home Assistant's local address and the token, and save. -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. +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 address, and continue to its sign-in page. Sign in as that user. -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 diff --git a/plugins/homeassistant/client.py b/plugins/homeassistant/client.py index 689bf64..a1d1017 100644 --- a/plugins/homeassistant/client.py +++ b/plugins/homeassistant/client.py @@ -1,15 +1,30 @@ # plugins/homeassistant/client.py -"""Talking to Home Assistant. Every request but the webhook carries the token, -because Home Assistant bans an address after a few refused requests, token-less -ones included. Every failure comes back as one sentence a page can show.""" +"""Talking to Home Assistant, with what its own sign-in gave Steward: a refresh +token kept in the connection, and access tokens made from it that last half an +hour. Home Assistant bans an address after a few refused requests, so nothing is +sent without a token and a refusal stops the asking. Every failure comes back as +one sentence a page can show.""" from __future__ import annotations +import asyncio import json +import time +from urllib.parse import urlencode import httpx +from steward.core.crypto import decrypt_secret + TIMEOUT_SECONDS = 15.0 +# Home Assistant answers an action when it has finished, minutes later for a +# backup or an update, and carries on if the request ends. So its answer is only +# waited for this long, and the outcome is read from its entities. +ACTION_READ_SECONDS = 10.0 MAX_BYTES = 16 * 1024 * 1024 +# An access token this close to running out isn't used for another request. +EXPIRY_MARGIN_SECONDS = 60 + +_access: dict[str, tuple[str, float]] = {} # refresh token -> access token, when to stop using it class Unreachable(Exception): @@ -17,35 +32,147 @@ class Unreachable(Exception): class Refused(Exception): - """Home Assistant refused the token, or has banned this address.""" + """Home Assistant refused Steward's sign-in, or has banned this address.""" +class Rejected(Exception): + """Home Assistant understood an action and wouldn't do it.""" + + +def _refusal(status: int) -> Refused | None: + if status == 401: + _access.clear() + return Refused("Home Assistant refused Steward's sign-in. Sign in again, as a user who may do this.") + if status == 403: + return Refused("Home Assistant answered 403: it may have banned Steward's address " + "after refused requests. Look in its ip_bans.yaml.") + return None + + +async def _request(method: str, url: str, path: str, verify_tls: bool, transport, *, token: str | None = None, + read_timeout: float = TIMEOUT_SECONDS, **kwargs) -> tuple[int, bytes]: + headers = {"Authorization": f"Bearer {token}"} if token else {} + timeout = httpx.Timeout(TIMEOUT_SECONDS, read=read_timeout) + try: + # A slow drip of bytes can keep each read inside its own timeout for ever. + async with asyncio.timeout(TIMEOUT_SECONDS + read_timeout): + async with httpx.AsyncClient(timeout=timeout, verify=verify_tls, transport=transport, + follow_redirects=False) as client: + async with client.stream(method, url.rstrip("/") + path, headers=headers, **kwargs) as response: + body = bytearray() + async for chunk in response.aiter_bytes(): + body.extend(chunk) + if len(body) > MAX_BYTES: + raise Unreachable("Home Assistant's answer was too large to read.") + return response.status_code, bytes(body) + except TimeoutError: + raise httpx.ReadTimeout("no complete answer in time") from None + + +def _unreachable(exc: httpx.HTTPError) -> Unreachable: + if isinstance(exc, httpx.TimeoutException): + return Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") + return Unreachable(f"Couldn't reach Home Assistant: {exc}") + + +# ── Signing in ─────────────────────────────────────────────────────────────── + +def sign_in_url(url: str, client_id: str, redirect_uri: str, state: str) -> str: + """Home Assistant's own sign-in page, which sends the browser back to + `redirect_uri`. It accepts `client_id` as it is because the two share an + address, so it needn't be able to reach Steward.""" + return f"{url.rstrip('/')}/auth/authorize?" + urlencode( + {"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "state": state}) + + +async def _tokens(url: str, form: dict, verify_tls: bool, transport) -> dict: + try: + status, body = await _request("POST", url, "/auth/token", verify_tls, transport, data=form) + except httpx.HTTPError as exc: + raise _unreachable(exc) from None + if status in (400, 401, 403): + raise Refused("Home Assistant no longer accepts Steward's sign-in. Sign in again.") + if status != 200: + raise Unreachable(f"Home Assistant answered {status}.") + try: + found = json.loads(body) + found["access_token"], int(found["expires_in"]) + except (ValueError, KeyError, TypeError): + raise Unreachable("Home Assistant's answer wasn't a sign-in.") from None + return found + + +def _remember(refresh_token: str, found: dict) -> str: + until = time.monotonic() + int(found["expires_in"]) - EXPIRY_MARGIN_SECONDS + _access[refresh_token] = (found["access_token"], until) + return found["access_token"] + + +async def redeem_code(url: str, client_id: str, code: str, verify_tls: bool, transport=None) -> tuple[str, str]: + """(refresh token, access token) for the code the sign-in page sent back.""" + found = await _tokens(url, {"grant_type": "authorization_code", "client_id": client_id, "code": code}, + verify_tls, transport) + refresh_token = found.get("refresh_token") + if not refresh_token: + raise Unreachable("Home Assistant's answer wasn't a sign-in.") + return refresh_token, _remember(refresh_token, found) + + +async def access_token(connection, transport=None) -> str: + refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in") + token, until = _access.get(refresh_token, (None, 0.0)) + if token and time.monotonic() < until: + return token + found = await _tokens(connection.url, {"grant_type": "refresh_token", "client_id": connection.client_id, + "refresh_token": refresh_token}, connection.verify_tls, transport) + return _remember(refresh_token, found) + + +async def is_admin(url: str, token: str, verify_tls: bool, transport=None) -> bool: + """Whether the signed-in user is an administrator, which its API only says by + refusing someone who isn't. So this is asked once, right after signing in, and + a refusal here counts once toward a ban.""" + try: + status, _ = await _request("POST", url, "/api/template", verify_tls, transport, token=token, + json={"template": "1"}) + except httpx.HTTPError as exc: + raise _unreachable(exc) from None + if status == 401: + return False + if status != 200: + raise (_refusal(status) or Unreachable(f"Home Assistant answered {status}.")) + return True + + +async def revoke(connection, transport=None) -> None: + """End this sign-in on Home Assistant. It answers 200 whether or not it knew it.""" + refresh_token = decrypt_secret(connection.refresh_token, context="Home Assistant sign-in") + _access.pop(refresh_token, None) + try: + status, _ = await _request("POST", connection.url, "/auth/revoke", connection.verify_tls, transport, + data={"token": refresh_token}) + except httpx.HTTPError as exc: + raise _unreachable(exc) from None + if status != 200: + raise Unreachable(f"Home Assistant answered {status}.") + + +# ── Reading and acting ─────────────────────────────────────────────────────── + async def _get(url: str, path: str, token: str, verify_tls: bool, transport) -> list | dict | None: """The parsed JSON of a GET, or None for a 404.""" - headers = {"Authorization": f"Bearer {token}"} try: - async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport, - follow_redirects=False) as client: - async with client.stream("GET", url.rstrip("/") + path, headers=headers) as response: - if response.status_code == 401: - raise Refused("Home Assistant refused the token. Check it, or make a new one.") - if response.status_code == 403: - raise Refused("Home Assistant answered 403: it may have banned Steward's address " - "after refused requests. Look in its ip_bans.yaml.") - if response.status_code == 404: - return None - if response.status_code != 200: - raise Unreachable(f"Home Assistant answered {response.status_code}.") - body = bytearray() - async for chunk in response.aiter_bytes(): - body.extend(chunk) - if len(body) > MAX_BYTES: - raise Unreachable("Home Assistant's answer was too large to read.") - return json.loads(body) - except httpx.TimeoutException: - raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None + status, body = await _request("GET", url, path, verify_tls, transport, token=token) except httpx.HTTPError as exc: - raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None + raise _unreachable(exc) from None + if refusal := _refusal(status): + raise refusal + if status == 404: + return None + if status != 200: + raise Unreachable(f"Home Assistant answered {status}.") + try: + return json.loads(body) except ValueError: raise Unreachable("Home Assistant's answer wasn't JSON.") from None @@ -61,16 +188,23 @@ async def read_state(url: str, entity_id: str, token: str, verify_tls: bool, tra return await _get(url, f"/api/states/{entity_id}", token, verify_tls, transport) -async def send_webhook(url: str, webhook_id: str, body: dict, verify_tls: bool, transport=None) -> None: - """Hand the maintenance automation a request. Home Assistant answers 200 - whether or not the id is known, so what the request did is read afterwards.""" +async def call_action(url: str, domain: str, action: str, data: dict, token: str, verify_tls: bool, + transport=None) -> None: + """Ask Home Assistant to do something. Returning means it got the request, + not that it has finished: a request cut off once sent has been taken up, and + an update that restarts Home Assistant cuts its own request off.""" try: - async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, verify=verify_tls, transport=transport, - follow_redirects=False) as client: - response = await client.post(f"{url.rstrip('/')}/api/webhook/{webhook_id}", json=body) - except httpx.TimeoutException: - raise Unreachable(f"Home Assistant didn't answer within {TIMEOUT_SECONDS:.0f} s.") from None + status, body = await _request("POST", url, f"/api/services/{domain}/{action}", verify_tls, transport, + token=token, read_timeout=ACTION_READ_SECONDS, json=data) + except (httpx.ReadTimeout, httpx.ReadError, httpx.RemoteProtocolError): + return except httpx.HTTPError as exc: - raise Unreachable(f"Couldn't reach Home Assistant: {exc}") from None - if response.status_code != 200: - raise Unreachable(f"Home Assistant answered {response.status_code} to the webhook.") + raise _unreachable(exc) from None + if refusal := _refusal(status): + raise refusal + if status != 200: + try: + said = json.loads(body).get("message") + except (ValueError, AttributeError): + said = None + raise Rejected(said or f"Home Assistant answered {status}.") diff --git a/plugins/homeassistant/mcp_tools.py b/plugins/homeassistant/mcp_tools.py index ffdaaae..8054b74 100644 --- a/plugins/homeassistant/mcp_tools.py +++ b/plugins/homeassistant/mcp_tools.py @@ -1,7 +1,7 @@ """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 -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 @@ -34,8 +34,8 @@ async def get_home_assistant() -> dict: `installable` is what update_home_assistant takes (Core, OS, add-ons); `others_waiting` is firmware and custom integrations, which Steward leaves - to Home Assistant. `automation` is the Steward maintenance automation's - state: "on" is needed to install, null means it wasn't found. + to Home Assistant. `can_install` is whether the Home Assistant user who signed + Steward in is an administrator, which installing needs. """ now = now_utc() 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( HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars()) 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) backup = c.backup or {} age = backup_age_hours(backup, now) 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")} for u in installable(c)], "others_waiting": [u["title"] for u in c.updates or [] if u["waiting"] and not u["kind"]], diff --git a/plugins/homeassistant/migrations/versions/homeassistant_002_sign_in.py b/plugins/homeassistant/migrations/versions/homeassistant_002_sign_in.py new file mode 100644 index 0000000..89277a0 --- /dev/null +++ b/plugins/homeassistant/migrations/versions/homeassistant_002_sign_in.py @@ -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)) diff --git a/plugins/homeassistant/models.py b/plugins/homeassistant/models.py index e3634a6..28b1e8b 100644 --- a/plugins/homeassistant/models.py +++ b/plugins/homeassistant/models.py @@ -13,19 +13,23 @@ from steward.models.base import Base class HomeAssistantConnection(Base): """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 - maintenance automation, so nothing stored here can act on the house. Both - are encrypted. The last_* columns and `updates` and `backup` are the newest - poll's outcome, which the pages read. `refused` stops all polling until - someone saves the connection or tries again: Home Assistant bans an address - after a few refused requests. + `refresh_token` is what Home Assistant's sign-in gave Steward, encrypted. It + has the same access as the user who signed in, whatever that is: Home + Assistant has no narrower permission. `can_install` is whether that user was + an administrator, which backups need. The last_* columns and `updates` and + `backup` are the newest poll's outcome, which the pages read. `refused` stops + all polling until someone signs in again or tries again: Home Assistant bans + an address after a few refused requests. """ __tablename__ = "homeassistant_connection" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id) url: Mapped[str] = mapped_column(String(500), nullable=False) - token: Mapped[str] = mapped_column(Text, nullable=False) - webhook_id: Mapped[str] = mapped_column(Text, nullable=False) + client_id: Mapped[str] = mapped_column(String(500), 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) refused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 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) updates: Mapped[list] = mapped_column(JSON, nullable=False, default=list) backup: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) - automation: Mapped[str | None] = mapped_column(String(20), nullable=True) class HomeAssistantUpdate(Base): @@ -62,7 +65,6 @@ class HomeAssistantUpdate(Base): DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) backup_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) 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) diff --git a/plugins/homeassistant/routes.py b/plugins/homeassistant/routes.py index bc739e0..3b9720a 100644 --- a/plugins/homeassistant/routes.py +++ b/plugins/homeassistant/routes.py @@ -1,27 +1,34 @@ """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 -anything (System 178's ruling).""" +fills. Only signing in, removing the connection and Install, an admin's explicit +actions, ask Home Assistant anything (System 178's ruling).""" from __future__ import annotations +import hmac +import logging +import secrets from datetime import datetime, timezone +from types import SimpleNamespace from quart import Blueprint, current_app, redirect, render_template, request, session from sqlalchemy import select from steward.auth.middleware import require_role 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 . import client from .models import HomeAssistantConnection, HomeAssistantUpdate -from .service import (STALE_BACKUP_HOURS, automation_yaml, backup_age_hours, backup_verdict, installable, - new_webhook_id, no_spaces, parse_moment, read_snapshot) +from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment from .updates import CannotStart, start_update +logger = logging.getLogger(__name__) + homeassistant_bp = Blueprint("homeassistant", __name__, template_folder="templates") 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]: @@ -35,7 +42,7 @@ async def _connection(db) -> HomeAssistantConnection | None: def connection_says(connection, now: datetime) -> tuple[str, str]: """(badge state, sentence) for whether Home Assistant is answering.""" 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: return "pending", "Not read yet: the first read runs within seconds." 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( HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars()) 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) reach, says = connection_says(connection, now) updates = connection.updates or [] @@ -90,19 +97,12 @@ async def install(): # ── The connection ─────────────────────────────────────────────────────────── -def _values(connection, form=None) -> dict: - if form is not None: - return {"url": form.get("url", ""), "verify_tls": bool(form.get("verify_tls")), - "has_token": bool(connection), "has_webhook": bool(connection)} - return {"url": connection.url if connection else "", "verify_tls": connection.verify_tls if connection else True, - "has_token": bool(connection), "has_webhook": bool(connection)} - - -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 +async def _render_form(connection, url: str | None = None, verify_tls: bool | None = None, + error: str | None = None, status: int = 200): + return await render_template( + "homeassistant/form.html", connection=connection, error=error, + url=url if url is not None else (connection.url if connection else ""), + verify_tls=verify_tls if verify_tls is not None else (connection.verify_tls if connection else True)), status @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 with current_app.db_sessionmaker() as 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) -async def save_connection(): +async def sign_in(): + """Send the browser to Home Assistant's own sign-in page.""" form = await request.form 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 db.begin(): 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: - connection = HomeAssistantConnection(url=url, token=encrypt_secret(token), - webhook_id=encrypt_secret(webhook or new_webhook_id())) + connection = HomeAssistantConnection(url=url, client_id=client_id, refresh_token="") db.add(connection) else: - connection.url = url - if token: - connection.token = encrypt_secret(token) - if webhook: - connection.webhook_id = encrypt_secret(webhook) - connection.verify_tls = bool(form.get("verify_tls")) + previous = (connection.url, connection.client_id, connection.refresh_token, connection.verify_tls) + connection.url, connection.client_id, connection.verify_tls = url, client_id, verify_tls + connection.refresh_token = encrypt_secret(refresh_token) + connection.can_install, connection.signed_in_by = can_install, username + connection.signed_in_at = datetime.now(timezone.utc) connection.refused, connection.last_error, connection.last_polled_at = False, None, None await db.flush() connection_id = connection.id - await log_audit(current_app, *_actor(), "homeassistant.connection_saved", - entity_type="homeassistant_connection", entity_id=connection_id, detail={"url": url}) - return redirect("/plugins/homeassistant/connection") + if previous is not None: + await _end_sign_in(SimpleNamespace(url=previous[0], client_id=previous[1], refresh_token=previous[2], + 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") @require_role(UserRole.admin) 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 db.begin(): connection = await _connection(db) @@ -170,37 +211,10 @@ async def delete_connection(): return redirect("/plugins/homeassistant/") connection_id = connection.id await db.delete(connection) + ended = await _end_sign_in(connection) 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/") - - -@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) diff --git a/plugins/homeassistant/scheduler.py b/plugins/homeassistant/scheduler.py index f7e84ac..221926f 100644 --- a/plugins/homeassistant/scheduler.py +++ b/plugins/homeassistant/scheduler.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING from sqlalchemy import select -from steward.core.crypto import decrypt_secret from steward.core.scheduler import ScheduledTask from . import client @@ -65,9 +64,9 @@ async def poll(app: "Quart", transport=None) -> None: connection = (await session.execute(select(HomeAssistantConnection))).scalars().first() if connection is None or not is_due(connection, now): return - token = decrypt_secret(connection.token, context="Home Assistant token") snapshot, error, refused = None, None, False try: + token = await client.access_token(connection, transport) snapshot = read_snapshot(await client.read_states(connection.url, token, connection.verify_tls, transport)) except client.Refused as exc: 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) else: 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="updates_waiting", value=len(installable(row)), recorded_at=now) diff --git a/plugins/homeassistant/service.py b/plugins/homeassistant/service.py index 0f1f145..de65328 100644 --- a/plugins/homeassistant/service.py +++ b/plugins/homeassistant/service.py @@ -3,14 +3,12 @@ Steward may install, and how its backups stand.""" from __future__ import annotations -import secrets from datetime import datetime, timezone SOURCE = "homeassistant" RESOURCE = "homeassistant" # Backups run daily, so past a day and a bit one has been missed. STALE_BACKUP_HOURS = 26 -AUTOMATION_ID = "steward_maintenance" CORE = "update.home_assistant_core_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_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: """"core", "os" or "addon" for an update Steward may install; None for the 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())) event = by_id.get(BACKUP_EVENT) 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 { "updates": updates, "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"), - "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, + "failed_reason": event_attrs.get("failed_reason"), "known": LAST_BACKUP in by_id}, } @@ -162,8 +108,3 @@ def backup_verdict(backup: dict | None, now: datetime) -> tuple[str, str]: 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 "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}" diff --git a/plugins/homeassistant/templates/homeassistant/_test.html b/plugins/homeassistant/templates/homeassistant/_test.html deleted file mode 100644 index 325c09c..0000000 --- a/plugins/homeassistant/templates/homeassistant/_test.html +++ /dev/null @@ -1,6 +0,0 @@ -{# plugins/homeassistant/templates/homeassistant/_test.html — what Test found. #} -{% if found.error %}
{{ found.error }}
-{% else %}Answered: {{ found.entities }} entities, {{ found.updates }} Core, OS and add-on update entities, {{ found.installable }} waiting.
-{{ "Automatic backups are readable." if found.backup else "No automatic backup sensor: turn on automatic backups in Home Assistant." }}
-{% 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 %}
-{% endif %} diff --git a/plugins/homeassistant/templates/homeassistant/form.html b/plugins/homeassistant/templates/homeassistant/form.html index 908062b..f0e59c1 100644 --- a/plugins/homeassistant/templates/homeassistant/form.html +++ b/plugins/homeassistant/templates/homeassistant/form.html @@ -1,7 +1,5 @@ {# plugins/homeassistant/templates/homeassistant/form.html — where Home Assistant - is, the read-only token Steward reads it with, and the automation to add so - Steward can install updates. A secret is never sent back in a field; a blank - one keeps what's saved. #} + is, what signing in gives Steward, and leaving for its sign-in page. #} {% extends "base.html" %} {% from "_macros.html" import crumbs, page_header %} {% block title %}Home Assistant connection — Steward{% endblock %} @@ -11,55 +9,27 @@{{ error }}
{% endif %} -