"""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, urlunparse 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 normalize_dashboard_browser_url(raw_url: str | None) -> str: """Return a safe browser-only dashboard link URL. Unlike the server-side probe target, this value is only returned to the browser for navigation. It may point at a public reverse-proxy hostname, but it still rejects credentials, paths, query strings, fragments, and non-HTTP schemes so it cannot hide secrets or script URLs in config. """ raw = str(raw_url or "").strip() if not raw: return "" 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") if not parsed.hostname: raise ValueError("invalid dashboard URL host") if parsed.params or parsed.query or parsed.fragment: raise ValueError("invalid dashboard URL path") path = parsed.path or "" if path not in ("", "/"): raise ValueError("invalid dashboard URL path") try: port = parsed.port except ValueError as exc: raise ValueError("invalid dashboard URL port") from exc netloc = parsed.hostname.lower() if port is not None: if not (1 <= port <= 65535): raise ValueError("invalid dashboard URL port") netloc = f"{netloc}:{port}" return urlunparse((parsed.scheme, netloc, "", "", "", "")) 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: raw_url = normalize_dashboard_browser_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 = normalize_dashboard_browser_url(raw_url) if raw_url else "" 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: browser_url = normalize_dashboard_browser_url(raw_url) if raw_url else "" except ValueError: return {"running": False, "enabled": enabled, "error": "invalid dashboard url"} try: override = normalize_dashboard_url(raw_url) except ValueError: override = None 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": if browser_url and not override: return {"running": True, "enabled": enabled, "url": browser_url, "browser_url": browser_url} host, port, scheme, base = targets[0] return {"running": True, "enabled": enabled, "host": host, "port": port, "url": browser_url or base, "browser_url": browser_url or 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 if browser_url: result["browser_url"] = browser_url result["url"] = browser_url return result return {"running": False, "enabled": enabled}