diff --git a/api/dashboard_probe.py b/api/dashboard_probe.py new file mode 100644 index 00000000..cc15ef91 --- /dev/null +++ b/api/dashboard_probe.py @@ -0,0 +1,211 @@ +"""Safe server-side probe for the official Hermes Agent dashboard. + +The official `hermes dashboard` binds to 127.0.0.1:9119 by default and exposes +GET /api/status as a public, read-only identity/status endpoint. Keep all +probing server-side to avoid browser CORS/mixed-content failures, and only allow +loopback targets so a user-controlled setting cannot become an SSRF primitive. +""" + +from __future__ import annotations + +import json +import logging +import os +import urllib.request +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +DEFAULT_DASHBOARD_PORT = 9119 +DEFAULT_DASHBOARD_TIMEOUT = 0.5 +DEFAULT_DASHBOARD_TARGETS = (("127.0.0.1", DEFAULT_DASHBOARD_PORT), ("localhost", DEFAULT_DASHBOARD_PORT)) +_DASHBOARD_ENABLED_VALUES = {"auto", "always", "never"} +_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} + + +def _base_url(host: str, port: int, scheme: str = "http") -> str: + display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host + return f"{scheme}://{display_host}:{port}" + + +def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] | None: + """Return (host, port, scheme, base_url) for a safe loopback dashboard URL. + + Overrides intentionally accept only scheme + loopback host + explicit port. + Paths, query strings, fragments, and credentials are rejected: the probe + appends the official `/api/status` fingerprint itself and must not become an + arbitrary local URL fetcher. + """ + raw = str(raw_url or "").strip() + if not raw: + return None + parsed = urlparse(raw) + if parsed.scheme not in {"http", "https"}: + raise ValueError("invalid dashboard URL scheme") + if parsed.username or parsed.password: + raise ValueError("invalid dashboard URL credentials") + host = parsed.hostname or "" + normalized_host = host.strip().lower() + if normalized_host not in _LOOPBACK_HOSTS: + raise ValueError("invalid dashboard URL host") + try: + port = parsed.port + except ValueError as exc: + raise ValueError("invalid dashboard URL port") from exc + if not isinstance(port, int) or not (1 <= port <= 65535): + raise ValueError("invalid dashboard URL port") + path = parsed.path or "" + if path not in ("", "/") or parsed.params or parsed.query or parsed.fragment: + raise ValueError("invalid dashboard URL path") + base = _base_url(normalized_host, port, parsed.scheme) + return normalized_host, port, parsed.scheme, base + + +def _looks_like_official_dashboard(payload: object) -> bool: + if not isinstance(payload, dict): + return False + version = payload.get("version") + if not isinstance(version, str) or not version.strip(): + return False + # Verified against current Hermes Agent `hermes_cli.web_server.get_status()`: + # /api/status returns version plus these Hermes-specific fields. Requiring at + # least one avoids treating any generic {version: ...} local service as the + # official dashboard. + return any(key in payload for key in ("release_date", "hermes_home", "config_path", "gateway_running")) + + +def probe_official_dashboard( + host: str, + port: int, + timeout: float = DEFAULT_DASHBOARD_TIMEOUT, + scheme: str = "http", +) -> dict: + """Best-effort check that `hermes dashboard` is running on host:port.""" + try: + normalized_host = str(host or "").strip().lower() + if normalized_host not in _LOOPBACK_HOSTS: + raise ValueError("dashboard probe host must be loopback") + port = int(port) + if not (1 <= port <= 65535): + raise ValueError("dashboard probe port out of range") + if scheme not in {"http", "https"}: + raise ValueError("dashboard probe scheme must be http or https") + base = _base_url(normalized_host, port, scheme) + request = urllib.request.Request( + f"{base}/api/status", + headers={"Accept": "application/json", "User-Agent": "hermes-webui-dashboard-probe"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + if getattr(response, "status", None) != 200: + return {"running": False} + payload = json.loads(response.read().decode("utf-8")) + if not _looks_like_official_dashboard(payload): + return {"running": False} + result = {"running": True, "host": normalized_host, "port": port, "url": base} + version = payload.get("version") + if isinstance(version, str) and version.strip(): + result["version"] = version.strip() + return result + except Exception: + logger.debug("official Hermes dashboard probe failed", exc_info=True) + return {"running": False} + + +def _dashboard_config(config_data: dict | None = None) -> dict: + if config_data is None: + try: + from api.config import get_config + + config_data = get_config() + except Exception: + config_data = {} + webui_cfg = config_data.get("webui", {}) if isinstance(config_data, dict) else {} + dashboard_cfg = webui_cfg.get("dashboard", {}) if isinstance(webui_cfg, dict) else {} + return dashboard_cfg if isinstance(dashboard_cfg, dict) else {} + + +def get_dashboard_config(config_data: dict | None = None) -> dict: + """Return normalized profile config for the Settings → System controls.""" + dashboard_cfg = _dashboard_config(config_data) + enabled = str(dashboard_cfg.get("enabled", "auto") or "auto").strip().lower() + if enabled not in _DASHBOARD_ENABLED_VALUES: + enabled = "auto" + raw_url = str(dashboard_cfg.get("url") or "").strip() + if raw_url: + # Normalize before echoing so the UI never displays unsafe/stale values. + _host, _port, _scheme, raw_url = normalize_dashboard_url(raw_url) + return {"enabled": enabled, "url": raw_url} + + +def save_dashboard_config(payload: dict) -> dict: + """Persist dashboard link settings under webui.dashboard in config.yaml.""" + enabled = str((payload or {}).get("enabled", "auto") or "auto").strip().lower() + if enabled not in _DASHBOARD_ENABLED_VALUES: + raise ValueError("invalid dashboard enabled mode") + raw_url = str((payload or {}).get("url", "") or "").strip() + normalized_url = "" + if raw_url: + _host, _port, _scheme, normalized_url = normalize_dashboard_url(raw_url) + + from api import config as webui_config + + config_path = webui_config._get_config_path() + config_data = webui_config._load_yaml_config_file(config_path) + webui_section = config_data.get("webui") + if not isinstance(webui_section, dict): + webui_section = {} + config_data["webui"] = webui_section + dashboard_section = webui_section.get("dashboard") + if not isinstance(dashboard_section, dict): + dashboard_section = {} + webui_section["dashboard"] = dashboard_section + dashboard_section["enabled"] = enabled + if normalized_url: + dashboard_section["url"] = normalized_url + else: + dashboard_section.pop("url", None) + webui_config._save_yaml_config_file(config_path, config_data) + webui_config.reload_config() + return {"enabled": enabled, "url": normalized_url} + + +def _webui_bind_host_allows_auto_probe() -> bool: + raw_host = str(os.environ.get("HERMES_WEBUI_HOST") or "127.0.0.1").strip().lower() + host = raw_host.replace("[", "").replace("]", "") + return host in _LOOPBACK_HOSTS + + +def get_dashboard_status(config_data: dict | None = None) -> dict: + """Return the safe status payload consumed by GET /api/dashboard/status.""" + dashboard_cfg = _dashboard_config(config_data) + enabled = str(dashboard_cfg.get("enabled", "auto") or "auto").strip().lower() + if enabled not in _DASHBOARD_ENABLED_VALUES: + enabled = "auto" + if enabled == "never": + return {"running": False, "enabled": "never"} + + raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or "" + try: + override = normalize_dashboard_url(raw_url) + except ValueError: + return {"running": False, "enabled": enabled, "error": "invalid dashboard url"} + + targets: list[tuple[str, int, str, str]] + if override: + targets = [override] + else: + targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS] + + if enabled == "always": + host, port, scheme, base = targets[0] + return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base} + + if not _webui_bind_host_allows_auto_probe(): + return {"running": False, "enabled": enabled} + + for host, port, scheme, _base in targets: + result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme) + if result.get("running"): + result["enabled"] = enabled + return result + return {"running": False, "enabled": enabled} diff --git a/api/routes.py b/api/routes.py index 8cb81380..0f28a15e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2161,6 +2161,21 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/models/live": return _handle_live_models(handler, parsed) + if parsed.path == "/api/dashboard/status": + from api import dashboard_probe + + j(handler, dashboard_probe.get_dashboard_status()) + return True + + if parsed.path == "/api/dashboard/config": + from api import dashboard_probe + + try: + j(handler, dashboard_probe.get_dashboard_config()) + except ValueError as exc: + bad(handler, str(exc), status=400) + return True + # ── Providers (GET) ── if parsed.path == "/api/providers": return j(handler, get_providers()) @@ -2880,6 +2895,17 @@ def handle_post(handler, parsed) -> bool: from api.kanban_bridge import handle_kanban_post return handle_kanban_post(handler, parsed, body) + if parsed.path == "/api/dashboard/config": + from api import dashboard_probe + + try: + j(handler, dashboard_probe.save_dashboard_config(body)) + except ValueError as exc: + bad(handler, str(exc), status=400) + except Exception as exc: + logger.exception("dashboard config save failed") + bad(handler, str(exc), status=500) + return True if parsed.path == "/api/session/new": try: diff --git a/docs/pr-media/1459/dashboard-nav-link.png b/docs/pr-media/1459/dashboard-nav-link.png new file mode 100644 index 00000000..85b68084 Binary files /dev/null and b/docs/pr-media/1459/dashboard-nav-link.png differ diff --git a/static/i18n.js b/static/i18n.js index 20dac616..5a522e23 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -530,6 +530,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: 'Insights', + tab_dashboard: 'Hermes Dashboard', + dashboard_loopback_warning: 'Dashboard is loopback-only on the server. Either browse from the server itself or restart it with --host 0.0.0.0 (insecure).', tab_settings: 'Settings', new_conversation: 'New conversation', filter_conversations: 'Filter conversations...', @@ -1521,6 +1523,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'ToDo', tab_insights: 'インサイト', + tab_dashboard: 'Hermes ダッシュボード', + dashboard_loopback_warning: 'ダッシュボードはサーバー上のループバック専用です。サーバー上で閲覧するか、--host 0.0.0.0(安全ではありません)で再起動してください。', tab_settings: '設定', new_conversation: '新しい会話', filter_conversations: '会話を絞り込み...', @@ -2351,6 +2355,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Список дел', tab_insights: 'Аналитика', + tab_dashboard: 'Панель Hermes', + dashboard_loopback_warning: 'Панель доступна только через loopback на сервере. Откройте её с самого сервера или перезапустите с --host 0.0.0.0 (небезопасно).', tab_settings: 'Настройки', new_conversation: 'Новая беседа', filter_conversations: 'Фильтр бесед...', @@ -3277,6 +3283,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: 'Analíticas', + tab_dashboard: 'Panel de Hermes', + dashboard_loopback_warning: 'El panel solo usa loopback en el servidor. Navega desde el propio servidor o reinícialo con --host 0.0.0.0 (inseguro).', tab_settings: 'Ajustes', new_conversation: 'Nueva conversación', filter_conversations: 'Filtrar conversaciones...', @@ -4191,6 +4199,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: 'Statistiken', + tab_dashboard: 'Hermes-Dashboard', + dashboard_loopback_warning: 'Das Dashboard ist auf dem Server nur per Loopback erreichbar. Öffne es direkt auf dem Server oder starte es mit --host 0.0.0.0 neu (unsicher).', tab_settings: 'Einstellungen', new_conversation: 'Neuer Chat', filter_conversations: 'Chats filtern...', @@ -5126,6 +5136,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: '待办', tab_insights: '统计', + tab_dashboard: 'Hermes 仪表盘', + dashboard_loopback_warning: '仪表盘在服务器上仅限 loopback 访问。请从服务器本机浏览,或使用 --host 0.0.0.0 重启(不安全)。', tab_workspaces: '工作区', tab_profiles: '配置', tab_settings: '设置', @@ -6011,6 +6023,8 @@ const LOCALES = { tab_tasks: '\u4efb\u52d9', tab_todos: '待辦', tab_insights: '統計', + tab_dashboard: 'Hermes 儀表板', + dashboard_loopback_warning: '儀表板在伺服器上僅限 loopback 存取。請從伺服器本機瀏覽,或使用 --host 0.0.0.0 重新啟動(不安全)。', tab_workspaces: '\u5de5\u4f5c\u5340', new_conversation: '新對話', filter_conversations: '篩選對話', @@ -7078,6 +7092,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: 'Estatísticas', + tab_dashboard: 'Painel Hermes', + dashboard_loopback_warning: 'O painel é somente loopback no servidor. Navegue pelo próprio servidor ou reinicie com --host 0.0.0.0 (inseguro).', tab_settings: 'Configurações', new_conversation: 'Nova conversa', filter_conversations: 'Filtrar conversas...', @@ -7968,6 +7984,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: '통계', + tab_dashboard: 'Hermes 대시보드', + dashboard_loopback_warning: '대시보드는 서버에서 loopback 전용입니다. 서버 자체에서 접속하거나 --host 0.0.0.0(안전하지 않음)으로 다시 시작하세요.', tab_settings: '설정', new_conversation: '새 대화', filter_conversations: '대화 필터…', diff --git a/static/index.html b/static/index.html index 8c217a4e..ebef7368 100644 --- a/static/index.html +++ b/static/index.html @@ -90,6 +90,7 @@ +
@@ -105,6 +106,7 @@ + @@ -1016,6 +1018,18 @@ +hermes dashboard is reachable. Overrides are restricted to loopback URLs.