feat(cloud): remove static credentials and add host console
Tests / Test No test results found

This commit is contained in:
2026-07-13 19:45:53 +08:00
parent efeb3eb926
commit c162c2501b
61 changed files with 3118 additions and 1221 deletions
+115 -22
View File
@@ -4,18 +4,25 @@ import asyncio
from contextlib import suppress
from dataclasses import dataclass
import uvicorn
from cloud.internal_api.models import AssignmentModel
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.assignment import AssignmentExecutor
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, load_host_agent_config
from host_agent.devices import register_local_device
from host_agent.enrollment import resolve_host_identity
from host_agent.execution import create_execution_factories
from host_agent.heartbeat import HeartbeatSynchronizer
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.lease import ActiveAssignmentRunner
from host_agent.local_account import LocalAccountStore
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
from storage.device_config import DeviceConfigStore
@@ -24,6 +31,8 @@ class HostAgentApplication:
client: HostAgentClient
heartbeat: HeartbeatSynchronizer
processor: AssignmentProcessor
console_server: uvicorn.Server | None = None
console_enrollment_client: HostAgentEnrollmentClient | None = None
def run(self) -> None:
asyncio.run(self.run_async())
@@ -32,6 +41,11 @@ class HostAgentApplication:
stop_requested = stop or asyncio.Event()
heartbeat_stop = asyncio.Event()
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
console_task = (
asyncio.create_task(self.console_server.serve())
if self.console_server is not None
else None
)
active_processing: asyncio.Task[AssignmentProcessingResult] | None = None
try:
while not stop_requested.is_set():
@@ -60,11 +74,18 @@ class HostAgentApplication:
with suppress(Exception):
await asyncio.shield(active_processing)
heartbeat_stop.set()
if self.console_server is not None:
self.console_server.should_exit = True
try:
await asyncio.gather(heartbeat_task, return_exceptions=True)
with suppress(Exception):
await self.heartbeat.sync_once()
if console_task is not None:
with suppress(asyncio.CancelledError):
await asyncio.gather(console_task, return_exceptions=True)
finally:
if self.console_enrollment_client is not None:
self.console_enrollment_client.close()
await self.client.aclose()
async def _claim_until_stopped(
@@ -104,13 +125,15 @@ def create_application(
) -> HostAgentApplication:
startup_config = config or load_host_agent_config()
config_store = device_config_store or DeviceConfigStore()
resolved_identity_store = identity_store or HostIdentityStore(
startup_config.identity_path
)
owned_enrollment_client = enrollment_client is None
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
try:
resolved_config = resolve_host_identity(
startup_config,
identity_store=identity_store
or HostIdentityStore(startup_config.identity_path),
identity_store=resolved_identity_store,
client=bootstrap_client,
)
bootstrap_client.config = resolved_config
@@ -123,13 +146,93 @@ def create_application(
if owned_enrollment_client:
bootstrap_client.close()
client = HostAgentClient(resolved_config)
heartbeat = HeartbeatSynchronizer(resolved_manager, client, resolved_config)
history_store: ConsoleHistoryStore | None = None
status_tracker: AgentStatusTracker | None = None
console_server: uvicorn.Server | None = None
console_enrollment_client: HostAgentEnrollmentClient | None = None
if resolved_config.console_enabled:
history_store = ConsoleHistoryStore(
resolved_config.identity_path.parent / "host_console_history.sqlite3",
limit=resolved_config.console_history_limit,
)
status_tracker = AgentStatusTracker()
if resolved_config.enrollment_managed:
console_enrollment_client = HostAgentEnrollmentClient(resolved_config)
console_app = create_console_app(
config=resolved_config,
manager=resolved_manager,
config_store=config_store,
local_account_store=LocalAccountStore(resolved_config.local_account_path),
identity_store=resolved_identity_store,
history_store=history_store,
status_tracker=status_tracker,
session_manager=SessionManager(
ttl_seconds=resolved_config.console_session_ttl_seconds
),
enrollment_client=console_enrollment_client,
)
console_server = uvicorn.Server(
uvicorn.Config(
console_app,
host=resolved_config.console_bind_host,
port=resolved_config.console_port,
log_level="warning",
)
)
heartbeat = HeartbeatSynchronizer(
resolved_manager,
client,
resolved_config,
status_tracker=status_tracker,
on_sync=(
(
lambda device_count: history_store.record_heartbeat(
device_count=device_count
)
)
if history_store is not None
else None
),
)
executor = AssignmentExecutor(create_execution_factories(resolved_manager))
active_runner = ActiveAssignmentRunner(client, executor)
processor = AssignmentProcessor(
client,
active_runner,
status_tracker=status_tracker,
on_result=(
(
lambda assignment, result: _record_assignment_history(
history_store, assignment, result
)
)
if history_store is not None
else None
),
)
return HostAgentApplication(
client=client,
heartbeat=heartbeat,
processor=AssignmentProcessor(client, active_runner),
processor=processor,
console_server=console_server,
console_enrollment_client=console_enrollment_client,
)
def _record_assignment_history(
history_store: ConsoleHistoryStore,
assignment: AssignmentModel,
result: AssignmentProcessingResult,
) -> None:
status = "done" if result.execution.status == "done" else "failed"
history_store.record_assignment(
task_id=assignment.task_id,
attempt=assignment.attempt,
status=status,
failure_reason=result.execution.failure_reason if status == "failed" else None,
device_id=assignment.device_id,
)
@@ -141,24 +244,14 @@ def _configured_device_manager(
) -> DeviceManager:
manager = DeviceManager()
for device in config_store.list():
runtime_device_id = device["device_id"]
if config.enrollment_managed:
enrollment = enrollment_client.enroll_device(
local_device_id=device["device_id"],
driver_type=device["driver_type"],
name=device["name"],
capability_tags=[],
)
runtime_device_id = enrollment.device_id
config_store.set_cloud_device_id(device["device_id"], runtime_device_id)
manager.register_device(
runtime_device_id,
build_driver_factory(
device["driver_type"],
device["connection_info"],
),
name=device["name"],
register_local_device(
config_store,
manager,
device_id=device["device_id"],
driver_type=device["driver_type"],
connection_info=device["connection_info"],
name=device["name"],
config=config,
enrollment_client=enrollment_client,
)
return manager
+1 -1
View File
@@ -56,7 +56,7 @@ class HostAgentEnrollmentClient:
response = self._request(
"POST",
"/internal/v1/enrollments",
token=self.config.enrollment_token or None,
token=None,
json={
"agent_instance_id": agent_instance_id,
"host_token": host_token,
+48 -12
View File
@@ -11,12 +11,14 @@ class HostAgentConfigurationError(ValueError):
"""Raised when Host Agent process configuration is invalid."""
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
@dataclass(frozen=True)
class HostAgentConfig:
control_plane_url: str
host_id: str = ""
token: str = field(default="", repr=False)
enrollment_token: str = field(default="", repr=False)
identity_path: Path = Path("tasks/host_identity.json")
local_account_path: Path = Path("tasks/host_local_account.json")
enrollment_managed: bool = False
@@ -26,6 +28,12 @@ class HostAgentConfig:
retry_backoff_seconds: float = 1.0
max_retry_backoff_seconds: float = 30.0
max_retry_attempts: int = 5
console_enabled: bool = False
console_bind_host: str = "127.0.0.1"
console_port: int = 8765
console_allow_non_loopback: bool = False
console_session_ttl_seconds: float = 43200.0
console_history_limit: int = 200
def load_host_agent_config(
@@ -46,13 +54,6 @@ def load_host_agent_config(
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
)
host_id = values.get("HOST_AGENT_HOST_ID", "").strip()
token = values.get("HOST_AGENT_TOKEN", "").strip()
if bool(host_id) != bool(token):
raise HostAgentConfigurationError(
"HOST_AGENT_HOST_ID and HOST_AGENT_TOKEN must be configured together"
)
enrollment_token = values.get("HOST_AGENT_ENROLLMENT_TOKEN", "").strip()
identity_path = Path(
values.get("HOST_AGENT_IDENTITY_PATH", "tasks/host_identity.json").strip()
)
@@ -64,12 +65,9 @@ def load_host_agent_config(
config = HostAgentConfig(
control_plane_url=control_plane_url,
host_id=host_id,
token=token,
enrollment_token=enrollment_token,
identity_path=identity_path,
local_account_path=local_account_path,
enrollment_managed=not bool(host_id),
enrollment_managed=True,
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
heartbeat_interval_seconds=_positive_float(
values,
@@ -96,11 +94,38 @@ def load_host_agent_config(
"HOST_AGENT_MAX_RETRY_ATTEMPTS",
5,
),
console_enabled=_truthy(values, "HOST_AGENT_CONSOLE_ENABLED", False),
console_bind_host=values.get(
"HOST_AGENT_CONSOLE_BIND_HOST", "127.0.0.1"
).strip(),
console_port=_positive_int(values, "HOST_AGENT_CONSOLE_PORT", 8765),
console_allow_non_loopback=_truthy(
values, "HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK", False
),
console_session_ttl_seconds=_positive_float(
values,
"HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS",
43200.0,
),
console_history_limit=_positive_int(
values,
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
200,
),
)
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
raise HostAgentConfigurationError(
"maximum retry backoff must not be less than initial backoff"
)
if config.console_enabled and (
config.console_bind_host not in _LOOPBACK_BIND_HOSTS
and not config.console_allow_non_loopback
):
raise HostAgentConfigurationError(
"HOST_AGENT_CONSOLE_BIND_HOST must be a loopback address "
f"({', '.join(sorted(_LOOPBACK_BIND_HOSTS))}) unless "
"HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK is set"
)
return config
@@ -136,3 +161,14 @@ def _positive_int(
if value <= 0:
raise HostAgentConfigurationError(f"{name} must be greater than zero")
return value
def _truthy(
values: Mapping[str, str],
name: str,
default: bool,
) -> bool:
raw_value = values.get(name)
if raw_value is None:
return default
return raw_value.strip().lower() in {"true", "1"}
@@ -0,0 +1,66 @@
from __future__ import annotations
from typing import Any
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.client import HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig
from storage.device_config import DeviceConfigStore
def register_local_device(
config_store: DeviceConfigStore,
manager: DeviceManager,
*,
device_id: str,
driver_type: str,
connection_info: dict[str, Any],
name: str | None,
config: HostAgentConfig,
enrollment_client: HostAgentEnrollmentClient | None,
) -> None:
previous = config_store.get(device_id)
previous_runtime_id = (
previous["cloud_device_id"] or previous["device_id"] if previous else None
)
config_store.add(
device_id=device_id,
name=name,
driver_type=driver_type,
connection_info=connection_info,
)
runtime_device_id = device_id
if config.enrollment_managed:
assert enrollment_client is not None
enrollment = enrollment_client.enroll_device(
local_device_id=device_id,
driver_type=driver_type,
name=name,
capability_tags=[],
)
runtime_device_id = enrollment.device_id
config_store.set_cloud_device_id(device_id, runtime_device_id)
if previous_runtime_id is not None and previous_runtime_id != runtime_device_id:
manager.unregister_device(previous_runtime_id)
manager.register_device(
runtime_device_id,
build_driver_factory(driver_type, connection_info),
name=name,
driver_type=driver_type,
connection_info=connection_info,
)
def unregister_local_device(
config_store: DeviceConfigStore,
manager: DeviceManager,
*,
device_id: str,
) -> None:
record = config_store.get(device_id)
if record is None:
return
runtime_device_id = record["cloud_device_id"] or record["device_id"]
manager.unregister_device(runtime_device_id)
config_store.remove(device_id)
+16 -2
View File
@@ -8,6 +8,7 @@ from core.errors import DeviceRuntimeError
from device.manager import DeviceManager
from host_agent.client import HostAgentClient
from host_agent.config import HostAgentConfig
from host_agent.status import AgentStatusTracker
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
@@ -34,18 +35,31 @@ class HeartbeatSynchronizer:
*,
address: str | None = None,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
status_tracker: AgentStatusTracker | None = None,
on_sync: Callable[[int], None] | None = None,
) -> None:
self.manager = manager
self.client = client
self.config = config
self.address = address
self._sleep = sleep
self.status_tracker = status_tracker
self.on_sync = on_sync
async def sync_once(self) -> HeartbeatResponse:
return await self.client.heartbeat(
build_device_snapshot(self.manager),
snapshot = build_device_snapshot(self.manager)
response = await self.client.heartbeat(
snapshot,
address=self.address,
)
if self.status_tracker is not None:
self.status_tracker.mark_heartbeat(ok=True, device_count=len(snapshot))
if self.on_sync is not None:
try:
self.on_sync(len(snapshot))
except Exception:
pass
return response
async def run(self, stop: asyncio.Event) -> None:
self.connect_devices()
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
import sqlite3
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
class ConsoleHistoryStore:
def __init__(
self,
db_path: str | Path = "tasks/host_console_history.sqlite3",
*,
limit: int = 200,
now: Callable[[], datetime] | None = None,
) -> None:
self.db_path = Path(db_path)
self.limit = limit
self._now = now or (lambda: datetime.now(UTC))
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
def record_assignment(
self,
*,
task_id: str,
attempt: int,
status: str,
failure_reason: str | None,
device_id: str,
) -> None:
summary = f"{task_id} attempt {attempt} on {device_id}: {status}"
if failure_reason:
summary = f"{summary} ({failure_reason})"
detail = {
"task_id": task_id,
"attempt": attempt,
"status": status,
"failure_reason": failure_reason,
"device_id": device_id,
}
self._insert("assignment", summary, detail)
def record_heartbeat(self, *, device_count: int) -> None:
summary = f"heartbeat: {device_count} devices"
detail = {"device_count": device_count}
self._insert("heartbeat", summary, detail)
def list_recent(self, limit: int | None = None) -> list[dict[str, Any]]:
effective_limit = limit if limit is not None else self.limit
with self._connect() as connection:
rows = connection.execute(
"select kind, occurred_at, summary, detail_json"
" from history_entries order by id desc limit ?",
(effective_limit,),
).fetchall()
return [
{
"kind": row["kind"],
"occurred_at": row["occurred_at"],
"summary": row["summary"],
"detail": json.loads(row["detail_json"]),
}
for row in rows
]
def _insert(self, kind: str, summary: str, detail: dict[str, Any]) -> None:
occurred_at = self._now().isoformat()
with self._connect() as connection:
connection.execute(
"""
insert into history_entries (kind, occurred_at, summary, detail_json)
values (?, ?, ?, ?)
""",
(kind, occurred_at, summary, json.dumps(detail, ensure_ascii=False)),
)
connection.execute(
"""
delete from history_entries where id not in (
select id from history_entries order by id desc limit ?
)
""",
(self.limit,),
)
def _ensure_schema(self) -> None:
with self._connect() as connection:
connection.execute(
"""
create table if not exists history_entries (
id integer primary key autoincrement,
kind text not null,
occurred_at text not null,
summary text not null,
detail_json text not null
)
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
return connection
+36 -14
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Protocol
from typing import TYPE_CHECKING, Literal, Protocol
from cloud.internal_api.models import AssignmentModel
from host_agent.assignment import AssignmentExecutionResult
from host_agent.client import HostAgentClient
from host_agent.status import AgentStatusTracker
if TYPE_CHECKING:
from collections.abc import Callable
class ActiveAssignmentExecutor(Protocol):
@@ -25,24 +29,42 @@ class AssignmentProcessor:
self,
client: HostAgentClient,
active_executor: ActiveAssignmentExecutor,
*,
status_tracker: AgentStatusTracker | None = None,
on_result: Callable[[AssignmentModel, AssignmentProcessingResult], None]
| None = None,
) -> None:
self.client = client
self.active_executor = active_executor
self.status_tracker = status_tracker
self.on_result = on_result
def request_stop(self) -> None:
self.active_executor.request_stop()
async def process(self, assignment: AssignmentModel) -> AssignmentProcessingResult:
execution = await self.active_executor.run(assignment)
status = "done" if execution.status == "done" else "failed"
failure_reason = execution.failure_reason if status == "failed" else None
response = await self.client.report_result(
assignment,
status=status,
failure_reason=failure_reason,
result=dict(execution.metadata),
)
return AssignmentProcessingResult(
execution=execution,
report_status=response.status,
)
if self.status_tracker is not None:
self.status_tracker.mark_assignment_started(assignment)
try:
execution = await self.active_executor.run(assignment)
status = "done" if execution.status == "done" else "failed"
failure_reason = execution.failure_reason if status == "failed" else None
response = await self.client.report_result(
assignment,
status=status,
failure_reason=failure_reason,
result=dict(execution.metadata),
)
result = AssignmentProcessingResult(
execution=execution,
report_status=response.status,
)
finally:
if self.status_tracker is not None:
self.status_tracker.mark_assignment_finished()
if self.on_result is not None:
try:
self.on_result(assignment, result)
except Exception:
pass
return result
@@ -0,0 +1,84 @@
from __future__ import annotations
import threading
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from cloud.internal_api.models import AssignmentModel
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass(frozen=True)
class _CurrentAssignment:
task_id: str
device_id: str
goal: str | None
workflow_definition_id: str | None
started_at: datetime
@dataclass(frozen=True)
class _LastHeartbeat:
ok: bool
device_count: int
at: datetime
class AgentStatusTracker:
def __init__(self, *, now: Callable[[], datetime] | None = None) -> None:
self._now = now or (lambda: datetime.now(UTC))
self._lock = threading.Lock()
self._current_assignment: _CurrentAssignment | None = None
self._last_heartbeat: _LastHeartbeat | None = None
def mark_assignment_started(self, assignment: AssignmentModel) -> None:
with self._lock:
self._current_assignment = _CurrentAssignment(
task_id=assignment.task_id,
device_id=assignment.device_id,
goal=assignment.goal,
workflow_definition_id=assignment.workflow_definition_id,
started_at=self._now(),
)
def mark_assignment_finished(self) -> None:
with self._lock:
self._current_assignment = None
def mark_heartbeat(self, *, ok: bool, device_count: int) -> None:
with self._lock:
self._last_heartbeat = _LastHeartbeat(
ok=ok,
device_count=device_count,
at=self._now(),
)
def snapshot(self) -> dict[str, Any]:
with self._lock:
current_assignment = self._current_assignment
last_heartbeat = self._last_heartbeat
return {
"current_assignment": (
{
"task_id": current_assignment.task_id,
"device_id": current_assignment.device_id,
"goal": current_assignment.goal,
"workflow_definition_id": current_assignment.workflow_definition_id,
"started_at": current_assignment.started_at.isoformat(),
}
if current_assignment is not None
else None
),
"last_heartbeat": (
{
"ok": last_heartbeat.ok,
"device_count": last_heartbeat.device_count,
"at": last_heartbeat.at.isoformat(),
}
if last_heartbeat is not None
else None
),
}
@@ -0,0 +1,535 @@
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"""
<nav>
<a href="/">Status</a>
<a href="/devices">Devices</a>
<a href="/account">Account</a>
<a href="/history">History</a>
<form class="inline" method="post" action="/logout">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(session.csrf_token)}">
<button type="submit">Logout</button>
</form>
</nav>
"""
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{escape(title)}</title>
<style>{_CSS}</style>
</head>
<body>
<header>
<strong>Host Agent Console</strong>
{nav}
</header>
<main>
{body_html}
</main>
</body>
</html>"""
def _login_page(
*, account: LocalAccountState | None, error: str | None = None
) -> HTMLResponse:
if account is None:
body = """
<h1>Login</h1>
<p>No local account exists yet. Run <code>device-host-agent setup</code>
on this machine to create one before logging in to the console.</p>
"""
return HTMLResponse(_chrome("Login", body, session=None))
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
body = f"""
<h1>Login</h1>
{error_html}
<form method="post" action="/login">
<label>Username <input type="text" name="username" required></label><br>
<label>Password <input type="password" name="password" required></label><br>
<button type="submit">Log in</button>
</form>
"""
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"<tr><td>{escape(device.id)}</td><td>{escape(device.name or '')}</td>"
f"<td>{escape(device.driver_type)}</td><td>{escape(device.status)}</td></tr>"
for device in devices
)
return f"""
<h1>Status</h1>
<section>
<h2>Enrollment</h2>
<p>Host ID: {escape(identity.host_id if identity else None) or "not enrolled"}</p>
<p>Agent instance ID: {escape(identity.agent_instance_id if identity else None) or "unknown"}</p>
<p>Control plane: {escape(config.control_plane_url)}</p>
</section>
<section>
<h2>Heartbeat</h2>
<p id="last-heartbeat">{escape(heartbeat_text)}</p>
</section>
<section>
<h2>Current assignment</h2>
<p id="current-assignment">{escape(assignment_text)}</p>
</section>
<section>
<h2>Devices</h2>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Status</th></tr></thead>
<tbody id="device-status-body">{device_rows}</tbody>
</table>
</section>
<script>
(function () {{
function render(data) {{
var hb = data.status.last_heartbeat;
document.getElementById("last-heartbeat").textContent = hb
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
: "never";
var current = data.status.current_assignment;
document.getElementById("current-assignment").textContent = current
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
: "none";
var body = document.getElementById("device-status-body");
body.innerHTML = "";
data.devices.forEach(function (device) {{
var row = document.createElement("tr");
["id", "name", "driver_type", "status"].forEach(function (key) {{
var cell = document.createElement("td");
cell.textContent = device[key] || "";
row.appendChild(cell);
}});
body.appendChild(row);
}});
}}
function poll() {{
fetch("/api/status", {{ credentials: "same-origin" }})
.then(function (response) {{ return response.ok ? response.json() : null; }})
.then(function (data) {{ if (data) render(data); }})
.catch(function () {{}});
}}
setInterval(poll, 5000);
}})();
</script>
"""
def _devices_body(
*,
devices: list[dict[str, Any]],
csrf_token: str,
edit_record: dict[str, Any] | None,
error: str | None,
) -> str:
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
rows = "".join(
f"""
<tr>
<td>{escape(device["device_id"])}</td>
<td>{escape(device["name"] or "")}</td>
<td>{escape(device["driver_type"])}</td>
<td>{escape(device["cloud_device_id"] or "")}</td>
<td>
<a href="/devices?edit={escape(device["device_id"])}">Edit</a>
<form class="inline" method="post" action="/devices/remove">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<input type="hidden" name="device_id" value="{escape(device["device_id"])}">
<button type="submit">Remove</button>
</form>
</td>
</tr>
"""
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"""
<h1>Devices</h1>
{error_html}
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
<tbody>{rows}</tbody>
</table>
<h2>{"Edit device" if edit_record else "Add device"}</h2>
<form method="post" action="/devices/save">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<label>Device ID <input type="text" name="device_id" value="{form_device_id}" required></label><br>
<label>Name <input type="text" name="name" value="{form_name}"></label><br>
<label>Driver type <input type="text" name="driver_type" value="{form_driver_type}" required></label><br>
<label>Connection info (JSON)<br>
<textarea name="connection_info" rows="3" cols="50">{form_connection_info}</textarea>
</label><br>
<button type="submit">Save</button>
</form>
"""
def _account_body(*, csrf_token: str, message: str | None, error: str | None) -> str:
message_html = f'<p class="notice">{escape(message)}</p>' if message else ""
error_html = f'<p class="error">{escape(error)}</p>' if error else ""
return f"""
<h1>Account</h1>
{message_html}
{error_html}
<form method="post" action="/account">
<input type="hidden" name="{CSRF_FORM_FIELD}" value="{escape(csrf_token)}">
<label>Current password <input type="password" name="current_password" required></label><br>
<label>New password <input type="password" name="new_password" required></label><br>
<label>Confirm new password <input type="password" name="confirm_password" required></label><br>
<button type="submit">Change password</button>
</form>
"""
def _history_body(entries: list[dict[str, Any]]) -> str:
rows = "".join(
f"<tr><td>{escape(entry['occurred_at'])}</td><td>{escape(entry['kind'])}</td>"
f"<td>{escape(entry['summary'])}</td></tr>"
for entry in entries
)
return f"""
<h1>History</h1>
<table>
<thead><tr><th>Time</th><th>Kind</th><th>Summary</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
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
@@ -0,0 +1,89 @@
from __future__ import annotations
import hmac
import secrets
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from threading import Lock
from host_agent.local_account import LocalAccountStore
SESSION_TOKEN_BYTES = 32
CSRF_TOKEN_BYTES = 32
@dataclass(frozen=True)
class SessionState:
username: str
csrf_token: str
expires_at: datetime
class SessionManager:
def __init__(
self,
*,
ttl_seconds: float,
now: Callable[[], datetime] | None = None,
) -> None:
self._ttl = timedelta(seconds=ttl_seconds)
self._now = now or (lambda: datetime.now(UTC))
self._lock = Lock()
self._sessions: dict[str, SessionState] = {}
def create_session(self, username: str) -> tuple[str, str]:
session_token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
csrf_token = secrets.token_urlsafe(CSRF_TOKEN_BYTES)
state = SessionState(
username=username,
csrf_token=csrf_token,
expires_at=self._now() + self._ttl,
)
with self._lock:
self._sessions[session_token] = state
return session_token, csrf_token
def validate(self, session_token: str) -> SessionState | None:
now = self._now()
with self._lock:
state = self._sessions.get(session_token)
if state is None:
return None
if state.expires_at <= now:
del self._sessions[session_token]
return None
renewed = SessionState(
username=state.username,
csrf_token=state.csrf_token,
expires_at=now + self._ttl,
)
self._sessions[session_token] = renewed
return renewed
def validate_csrf(self, session_token: str, csrf_token: str) -> bool:
state = self.validate(session_token)
if state is None:
return False
return hmac.compare_digest(state.csrf_token, csrf_token)
def invalidate(self, session_token: str) -> None:
with self._lock:
self._sessions.pop(session_token, None)
def attempt_login(store: LocalAccountStore, *, username: str, password: str) -> bool:
account = store.load()
if account is None or account.username != username:
return False
return store.verify(account, password)
def change_password(
store: LocalAccountStore, *, current_password: str, new_password: str
) -> bool:
account = store.load()
if account is None or not store.verify(account, current_password):
return False
store.create(account.username, new_password)
return True