from __future__ import annotations import asyncio import json from html import escape as _escape from typing import Any from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from device.manager import DeviceManager from host_agent.client import HostAgentEnrollmentClient from host_agent.config import HostAgentConfig from host_agent.devices import register_local_device, unregister_local_device from host_agent.history import ConsoleHistoryStore from host_agent.identity import HostIdentityState, HostIdentityStore from host_agent.local_account import LocalAccountState, LocalAccountStore from host_agent.status import AgentStatusTracker from host_agent.web.auth import ( SessionManager, SessionState, attempt_login, change_password, ) from storage.device_config import DeviceConfigStore SESSION_COOKIE_NAME = "host_console_session" CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_FORM_FIELD = "csrf_token" _LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) _CSS = """ body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; } header { background: #20303f; color: #fff; padding: 0.75rem 1.5rem; } header nav { display: inline; margin-left: 1.5rem; } header nav a, header nav form { display: inline-block; margin-right: 1rem; } header a { color: #fff; text-decoration: none; } header button { background: none; border: none; color: #fff; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; } main { padding: 1.5rem; max-width: 960px; margin: 0 auto; } table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; background: #fff; } th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; } form.inline { display: inline; margin: 0; } .error { color: #b00020; } .notice { color: #1b5e20; } """ def escape(value: object) -> str: if value is None: return "" return _escape(str(value), quote=True) def _chrome(title: str, body_html: str, *, session: SessionState | None) -> str: nav = "" if session is not None: nav = f""" """ return f""" {escape(title)}
Host Agent Console {nav}
{body_html}
""" def _login_page( *, account: LocalAccountState | None, error: str | None = None ) -> HTMLResponse: if account is None: body = """

Login

No local account exists yet. Run device-host-agent setup on this machine to create one before logging in to the console.

""" return HTMLResponse(_chrome("Login", body, session=None)) error_html = f'

{escape(error)}

' if error else "" body = f"""

Login

{error_html}


""" return HTMLResponse(_chrome("Login", body, session=None)) def _dashboard_body( *, identity: HostIdentityState | None, snapshot: dict[str, Any], devices: list[Any], config: HostAgentConfig, ) -> str: heartbeat = snapshot.get("last_heartbeat") assignment = snapshot.get("current_assignment") heartbeat_text = ( f"{'ok' if heartbeat['ok'] else 'failed'} at {heartbeat['at']} " f"({heartbeat['device_count']} devices)" if heartbeat else "never" ) assignment_text = ( f"{assignment['task_id']} on {assignment['device_id']} " f"(started {assignment['started_at']})" if assignment else "none" ) device_rows = "".join( f"{escape(device.id)}{escape(device.name or '')}" f"{escape(device.driver_type)}{escape(device.status)}" for device in devices ) return f"""

Status

Enrollment

Host ID: {escape(identity.host_id if identity else None) or "not enrolled"}

Agent instance ID: {escape(identity.agent_instance_id if identity else None) or "unknown"}

Control plane: {escape(config.control_plane_url)}

Heartbeat

{escape(heartbeat_text)}

Current assignment

{escape(assignment_text)}

Devices

{device_rows}
IDNameDriverStatus
""" def _devices_body( *, devices: list[dict[str, Any]], csrf_token: str, edit_record: dict[str, Any] | None, error: str | None, ) -> str: error_html = f'

{escape(error)}

' if error else "" rows = "".join( f""" {escape(device["device_id"])} {escape(device["name"] or "")} {escape(device["driver_type"])} {escape(device["cloud_device_id"] or "")} Edit
""" for device in devices ) form_device_id = escape(edit_record["device_id"]) if edit_record else "" form_name = escape(edit_record["name"] or "") if edit_record else "" form_driver_type = escape(edit_record["driver_type"]) if edit_record else "wda" form_connection_info = ( escape(json.dumps(edit_record["connection_info"])) if edit_record else "{}" ) return f"""

Devices

{error_html} {rows}
IDNameDriverCloud ID

{"Edit device" if edit_record else "Add device"}





""" def _account_body(*, csrf_token: str, message: str | None, error: str | None) -> str: message_html = f'

{escape(message)}

' if message else "" error_html = f'

{escape(error)}

' if error else "" return f"""

Account

{message_html} {error_html}



""" def _history_body(entries: list[dict[str, Any]]) -> str: rows = "".join( f"{escape(entry['occurred_at'])}{escape(entry['kind'])}" f"{escape(entry['summary'])}" for entry in entries ) return f"""

History

{rows}
TimeKindSummary
""" def create_console_app( *, config: HostAgentConfig, manager: DeviceManager, config_store: DeviceConfigStore, local_account_store: LocalAccountStore, identity_store: HostIdentityStore, history_store: ConsoleHistoryStore, status_tracker: AgentStatusTracker, session_manager: SessionManager, enrollment_client: HostAgentEnrollmentClient | None, ) -> FastAPI: app = FastAPI(title="Host Agent Console") cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS def _session_token(request: Request) -> str | None: return request.cookies.get(SESSION_COOKIE_NAME) def require_session(request: Request) -> SessionState: token = _session_token(request) state = session_manager.validate(token) if token else None if state is None: raise HTTPException(status_code=303, headers={"Location": "/login"}) return state async def require_csrf( request: Request, session: SessionState = Depends(require_session), ) -> SessionState: provided = request.headers.get(CSRF_HEADER_NAME) if not provided: form = await request.form() raw = form.get(CSRF_FORM_FIELD) provided = str(raw) if raw is not None else None token = _session_token(request) if ( not token or not provided or not session_manager.validate_csrf(token, provided) ): raise HTTPException(status_code=403, detail="invalid CSRF token") return session @app.get("/login", response_class=HTMLResponse) async def login_page() -> HTMLResponse: account = await asyncio.to_thread(local_account_store.load) return _login_page(account=account) @app.post("/login") async def login_submit(request: Request) -> Response: account = await asyncio.to_thread(local_account_store.load) if account is None: return _login_page(account=None) form = await request.form() username = str(form.get("username", "")) password = str(form.get("password", "")) ok = await asyncio.to_thread( attempt_login, local_account_store, username=username, password=password ) if not ok: return _login_page(account=account, error="Invalid username or password.") session_token, _ = session_manager.create_session(username) response = RedirectResponse(url="/", status_code=303) response.set_cookie( key=SESSION_COOKIE_NAME, value=session_token, httponly=True, samesite="strict", secure=cookie_secure, path="/", ) return response @app.post("/logout") async def logout( request: Request, session: SessionState = Depends(require_csrf), ) -> Response: token = _session_token(request) if token: session_manager.invalidate(token) response = RedirectResponse(url="/login", status_code=303) response.delete_cookie(key=SESSION_COOKIE_NAME, path="/") return response @app.get("/", response_class=HTMLResponse) async def dashboard( session: SessionState = Depends(require_session), ) -> HTMLResponse: identity = await asyncio.to_thread(identity_store.load) snapshot = status_tracker.snapshot() devices = manager.list_devices() body = _dashboard_body( identity=identity, snapshot=snapshot, devices=devices, config=config ) return HTMLResponse(_chrome("Status", body, session=session)) @app.get("/api/status") async def api_status( session: SessionState = Depends(require_session), ) -> JSONResponse: snapshot = status_tracker.snapshot() devices = [ { "id": device.id, "name": device.name, "driver_type": device.driver_type, "status": device.status, } for device in manager.list_devices() ] return JSONResponse({"status": snapshot, "devices": devices}) @app.get("/devices", response_class=HTMLResponse) async def devices_page( request: Request, session: SessionState = Depends(require_session), ) -> HTMLResponse: devices = await asyncio.to_thread(config_store.list) edit_id = request.query_params.get("edit") edit_record = ( await asyncio.to_thread(config_store.get, edit_id) if edit_id else None ) body = _devices_body( devices=devices, csrf_token=session.csrf_token, edit_record=edit_record, error=None, ) return HTMLResponse(_chrome("Devices", body, session=session)) @app.post("/devices/save") async def devices_save( request: Request, session: SessionState = Depends(require_csrf), ) -> Response: form = await request.form() device_id = str(form.get("device_id", "")).strip() driver_type = str(form.get("driver_type", "")).strip() name = str(form.get("name", "")).strip() or None connection_info_raw = str(form.get("connection_info", "") or "{}") error: str | None = None connection_info: dict[str, Any] = {} if not device_id or not driver_type: error = "Device ID and driver type are required." else: try: parsed = json.loads(connection_info_raw) except ValueError: error = "Connection info must be valid JSON." else: if not isinstance(parsed, dict): error = "Connection info must be a JSON object." else: connection_info = parsed if error is None: try: await asyncio.to_thread( register_local_device, config_store, manager, device_id=device_id, driver_type=driver_type, connection_info=connection_info, name=name, config=config, enrollment_client=enrollment_client, ) except ValueError as exc: error = str(exc) if error is not None: devices = await asyncio.to_thread(config_store.list) body = _devices_body( devices=devices, csrf_token=session.csrf_token, edit_record=None, error=error, ) return HTMLResponse( _chrome("Devices", body, session=session), status_code=400 ) return RedirectResponse(url="/devices", status_code=303) @app.post("/devices/remove") async def devices_remove( request: Request, session: SessionState = Depends(require_csrf), ) -> Response: form = await request.form() device_id = str(form.get("device_id", "")).strip() if device_id: await asyncio.to_thread( unregister_local_device, config_store, manager, device_id=device_id ) return RedirectResponse(url="/devices", status_code=303) @app.get("/account", response_class=HTMLResponse) async def account_page( session: SessionState = Depends(require_session), ) -> HTMLResponse: body = _account_body(csrf_token=session.csrf_token, message=None, error=None) return HTMLResponse(_chrome("Account", body, session=session)) @app.post("/account", response_class=HTMLResponse) async def account_submit( request: Request, session: SessionState = Depends(require_csrf), ) -> HTMLResponse: form = await request.form() current_password = str(form.get("current_password", "")) new_password = str(form.get("new_password", "")) confirm_password = str(form.get("confirm_password", "")) if not new_password or new_password != confirm_password: body = _account_body( csrf_token=session.csrf_token, message=None, error="New password and confirmation must match.", ) return HTMLResponse( _chrome("Account", body, session=session), status_code=400 ) ok = await asyncio.to_thread( change_password, local_account_store, current_password=current_password, new_password=new_password, ) if not ok: body = _account_body( csrf_token=session.csrf_token, message=None, error="Current password is incorrect.", ) return HTMLResponse( _chrome("Account", body, session=session), status_code=400 ) body = _account_body( csrf_token=session.csrf_token, message="Password updated.", error=None, ) return HTMLResponse(_chrome("Account", body, session=session)) @app.get("/history", response_class=HTMLResponse) async def history_page( session: SessionState = Depends(require_session), ) -> HTMLResponse: entries = await asyncio.to_thread(history_store.list_recent) body = _history_body(entries) return HTMLResponse(_chrome("History", body, session=session)) return app