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>
221 lines
10 KiB
Python
221 lines
10 KiB
Python
"""The Home Assistant page. Every GET reads Steward's own records, which the poll
|
|
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 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, 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]:
|
|
return session.get("user_id"), session.get("username", "")
|
|
|
|
|
|
async def _connection(db) -> HomeAssistantConnection | None:
|
|
return (await db.execute(select(HomeAssistantConnection))).scalars().first()
|
|
|
|
|
|
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 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:
|
|
return "down", connection.last_error
|
|
return "up", "Answering."
|
|
|
|
|
|
async def _render_index(error: str | None = None, status: int = 200):
|
|
async with current_app.db_sessionmaker() as db:
|
|
connection = await _connection(db)
|
|
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, error=error), status
|
|
now = datetime.now(timezone.utc)
|
|
reach, says = connection_says(connection, now)
|
|
updates = connection.updates or []
|
|
backup = connection.backup or {}
|
|
tone, backup_says = backup_verdict(backup, now)
|
|
return await render_template(
|
|
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says,
|
|
installable=installable(connection),
|
|
others=[u for u in updates if u["waiting"] and not u["kind"]],
|
|
current=[u for u in updates if not u["waiting"] and u["kind"]],
|
|
runs=runs, under_way=next((r for r in runs if r.state in ("backing_up", "installing")), None),
|
|
backup=backup, backup_tone=tone, backup_says=backup_says,
|
|
backup_at=parse_moment(backup.get("last_ok")), backup_next=parse_moment(backup.get("next")),
|
|
backup_age=backup_age_hours(backup, now), stale_hours=STALE_BACKUP_HOURS), status
|
|
|
|
|
|
@homeassistant_bp.get("/")
|
|
@require_role(UserRole.viewer)
|
|
async def index():
|
|
return await _render_index()
|
|
|
|
|
|
@homeassistant_bp.post("/install")
|
|
@require_role(UserRole.admin)
|
|
async def install():
|
|
form = await request.form
|
|
entity_id = (form.get("entity_id") or "").strip()
|
|
user_id, username = _actor()
|
|
try:
|
|
run = await start_update(current_app, entity_id, username)
|
|
except CannotStart as exc:
|
|
return await _render_index(f"Nothing was started: {exc}.", 409)
|
|
await log_audit(current_app, user_id, username, "homeassistant.update_started",
|
|
entity_type="homeassistant_update", entity_id=run.id,
|
|
detail={"entity_id": entity_id, "title": run.title, "to": run.to_version})
|
|
return redirect("/plugins/homeassistant/")
|
|
|
|
|
|
# ── The connection ───────────────────────────────────────────────────────────
|
|
|
|
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")
|
|
@require_role(UserRole.admin)
|
|
async def connection_form():
|
|
async with current_app.db_sessionmaker() as db:
|
|
connection = await _connection(db)
|
|
return await _render_form(connection)
|
|
|
|
|
|
@homeassistant_bp.post("/connection/sign-in")
|
|
@require_role(UserRole.admin)
|
|
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("/")
|
|
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)
|
|
if connection is None:
|
|
connection = HomeAssistantConnection(url=url, client_id=client_id, refresh_token="")
|
|
db.add(connection)
|
|
else:
|
|
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
|
|
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 a ban has been dealt with."""
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
connection = await _connection(db)
|
|
if connection is not None:
|
|
connection.refused, connection.last_polled_at = False, None
|
|
return redirect("/plugins/homeassistant/")
|
|
|
|
|
|
@homeassistant_bp.post("/connection/delete")
|
|
@require_role(UserRole.admin)
|
|
async def delete_connection():
|
|
async with current_app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
connection = await _connection(db)
|
|
if connection is None:
|
|
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, 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/")
|