feat(homeassistant): an admin signs in to Home Assistant, with nothing added to it (#6355)
Replaces the read-only token and the webhook automation, which every install would have had to paste into its own Home Assistant. Connect now goes to Home Assistant's own sign-in page, and Steward keeps the refresh token that comes back, encrypted, and calls backup.create_automatic and update.install directly. The connection page warns that the stored credential has the same access as the user who signed in, and recommends a service account so Steward's actions are easy to tell apart in Home Assistant's logs. A user who isn't an administrator gets the status page and no Install. Those actions answer only when finished, so a request cut off once sent counts as sent and the outcome is read from the entities. Removing the connection, or signing in again, ends the old sign-in on Home Assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user