A Prometheus target's token_from names the plugin that supplies its token at each scrape. Read its metrics makes the target; Use this sign-in moves one at that address across, keeping its name, filters and history. A refusal stops the connection, so one Try again or sign-in starts everything, and the sign-in is only sent to its own address. A 404 says how to turn on Home Assistant's Prometheus integration, as Steward doesn't change Home Assistant. Refused also joins the volatile columns a backup check ignores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
12 KiB
Python
245 lines
12 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, metrics
|
|
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())
|
|
scrape = await metrics.state(db, connection) if connection else None
|
|
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, metrics=scrape,
|
|
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/")
|
|
|
|
|
|
@homeassistant_bp.post("/metrics")
|
|
@require_role(UserRole.admin)
|
|
async def connect_metrics():
|
|
"""Read Home Assistant's metrics with this sign-in, after asking for them once
|
|
to show they're there. An admin's explicit action, so it may ask."""
|
|
async with current_app.db_sessionmaker() as db:
|
|
connection = await _connection(db)
|
|
if connection is None:
|
|
return redirect("/plugins/homeassistant/")
|
|
try:
|
|
await metrics.try_reading(current_app, connection)
|
|
target_id, name, created = await metrics.use_sign_in(current_app, connection)
|
|
except metrics.CannotRead as exc:
|
|
return await _render_index(f"Nothing changed: {exc}", 409)
|
|
await log_audit(current_app, *_actor(), "prometheus.target_created" if created else "prometheus.target_updated",
|
|
entity_type="prometheus_target", entity_id=target_id,
|
|
detail={"name": name, "token_from": metrics.SOURCE})
|
|
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 metrics.follow_sign_in(db, connection)
|
|
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={"name": 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. A metrics scrape
|
|
using this sign-in starts again with it."""
|
|
client.forget_access()
|
|
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, url = connection.id, connection.url
|
|
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={"name": url, "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/")
|