diff --git a/cli-config.yaml.example b/cli-config.yaml.example index fb6912642ae9..6271462e7bbe 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1144,6 +1144,12 @@ display: # (the ~/.hermes/.env file is reserved for API keys and secrets). # # dashboard: +# # Additional Host headers accepted only when the dashboard is bound to a +# # loopback address. Use this for Tailscale/headscale Serve, cloudflared, or +# # same-host reverse proxies that forward a public hostname to +# # http://127.0.0.1:9119 without using --insecure. +# allowed_hosts: [] +# # oauth: # client_id: "" # agent:{instance_id}; Portal provisions this at deploy # portal_url: "" # blank → default https://portal.nousresearch.com diff --git a/hermes_cli/config.py b/hermes_cli/config.py index cec27809fdd0..bfa49cfa88ea 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1433,6 +1433,12 @@ def _ensure_hermes_home_managed(home: Path): # falls through to request reconstruction rather than breaking # the login flow. "public_url": "", + # Extra Host header names accepted by the dashboard DNS-rebinding + # guard when the server stays loopback-bound behind a trusted local + # proxy/tunnel such as Tailscale Serve or cloudflared. This is a + # list of exact hostnames; command-line ``--allowed-hosts`` and + # ``HERMES_DASHBOARD_ALLOWED_HOSTS`` can override it per launch. + "allowed_hosts": [], }, # Privacy settings diff --git a/hermes_cli/dashboard_service.py b/hermes_cli/dashboard_service.py new file mode 100644 index 000000000000..3fad008f5bc5 --- /dev/null +++ b/hermes_cli/dashboard_service.py @@ -0,0 +1,1167 @@ +"""Durable dashboard service and secure access helpers. + +The dashboard is intentionally separate from the messaging gateway, but the +host-service contract should feel the same to operators: profile-scoped +systemd/launchd/Windows service definitions, stable Hermes home anchoring, and +commands that can be invoked from either the CLI or the dashboard admin page. +""" + +from __future__ import annotations + +import html +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from hermes_cli.config import get_hermes_home, is_managed, managed_error +from hermes_cli.gateway import ( + PROJECT_ROOT, + UserSystemdUnavailableError, + SystemScopeRequiresRootError, + _build_service_path_dirs, + _build_user_local_paths, + _build_wsl_interop_paths, + _detect_venv_dir, + _ensure_linger_enabled, + _hermes_home_for_target_user, + _launchd_domain, + _preflight_user_systemd, + _profile_arg, + _profile_suffix, + _read_systemd_user_from_unit, + _remap_path_for_user, + _require_root_for_system_service, + _run_systemctl, + _service_scope_label, + _stable_service_working_dir, + _system_service_identity, + _sync_hermes_home_from_systemd_unit, + get_python_path, + is_container, + is_macos, + is_termux, + is_windows, + is_wsl, + supports_systemd_services, +) + + +SERVICE_BASE = "hermes-dashboard" +SERVICE_DESCRIPTION = "Hermes Agent Dashboard" +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 9119 +DEFAULT_TAILSCALE_HTTPS_PORT = 443 + + +@dataclass(frozen=True) +class DashboardServiceOptions: + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + tui: bool = False + insecure: bool = False + allowed_hosts: tuple[str, ...] = () + public_url: str = "" + + +def _service_config_path() -> Path: + return get_hermes_home() / "dashboard-service" / "config.json" + + +def _options_to_dict(options: DashboardServiceOptions) -> dict[str, Any]: + return { + "host": options.host, + "port": options.port, + "tui": options.tui, + "insecure": options.insecure, + "allowed_hosts": list(options.allowed_hosts), + "public_url": options.public_url, + } + + +def save_service_options(options: DashboardServiceOptions) -> None: + path = _service_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(_options_to_dict(options), indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(path) + + +def load_service_options() -> DashboardServiceOptions | None: + path = _service_config_path() + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(raw, dict): + return None + return DashboardServiceOptions( + host=str(raw.get("host") or DEFAULT_HOST), + port=int(raw.get("port") or DEFAULT_PORT), + tui=bool(raw.get("tui", False)), + insecure=bool(raw.get("insecure", False)), + allowed_hosts=normalize_allowed_hosts(raw.get("allowed_hosts", ())), + public_url=str(raw.get("public_url") or ""), + ) + + +def normalize_allowed_hosts(value: Any) -> tuple[str, ...]: + """Normalize CLI/config/env host allowlist values.""" + if value is None: + return () + if isinstance(value, str): + raw_items = re.split(r"[,\s]+", value) + elif isinstance(value, (list, tuple, set)): + raw_items = [] + for item in value: + if isinstance(item, str): + raw_items.extend(re.split(r"[,\s]+", item)) + elif item is not None: + raw_items.append(str(item)) + else: + raw_items = [str(value)] + + hosts: list[str] = [] + for raw in raw_items: + item = raw.strip() + if not item: + continue + if "://" in item: + from urllib.parse import urlparse + + parsed = urlparse(item) + item = parsed.netloc or parsed.path + if item.startswith("["): + close = item.find("]") + item = item[1:close] if close != -1 else item.strip("[]") + elif ":" in item: + item = item.rsplit(":", 1)[0] + item = item.strip().strip(".").lower() + if item and item not in hosts: + hosts.append(item) + return tuple(hosts) + + +def options_from_args(args: Any) -> DashboardServiceOptions: + return DashboardServiceOptions( + host=getattr(args, "host", DEFAULT_HOST) or DEFAULT_HOST, + port=int(getattr(args, "port", DEFAULT_PORT) or DEFAULT_PORT), + tui=bool(getattr(args, "tui", False)), + insecure=bool(getattr(args, "insecure", False)), + allowed_hosts=normalize_allowed_hosts(getattr(args, "allowed_hosts", None)), + public_url=(getattr(args, "public_url", "") or "").strip(), + ) + + +def get_service_name() -> str: + suffix = _profile_suffix() + return f"{SERVICE_BASE}-{suffix}" if suffix else SERVICE_BASE + + +def get_systemd_unit_path(system: bool = False) -> Path: + name = get_service_name() + if system: + return Path("/etc/systemd/system") / f"{name}.service" + return Path.home() / ".config" / "systemd" / "user" / f"{name}.service" + + +def get_launchd_label() -> str: + suffix = _profile_suffix() + return f"ai.hermes.dashboard-{suffix}" if suffix else "ai.hermes.dashboard" + + +def get_launchd_plist_path() -> Path: + return Path.home() / "Library" / "LaunchAgents" / f"{get_launchd_label()}.plist" + + +def _dashboard_cli_args(options: DashboardServiceOptions, hermes_home: str | None = None) -> list[str]: + profile_arg = _profile_arg(hermes_home) + args: list[str] = [] + if profile_arg: + args.extend(shlex.split(profile_arg)) + args.extend( + [ + "dashboard", + "--host", + options.host, + "--port", + str(options.port), + "--no-open", + "--skip-build", + ] + ) + if options.tui: + args.append("--tui") + if options.insecure: + args.append("--insecure") + if options.allowed_hosts: + args.extend(["--allowed-hosts", ",".join(options.allowed_hosts)]) + return args + + +def _service_env_lines(options: DashboardServiceOptions, hermes_home: str) -> list[str]: + lines = [f'Environment="HERMES_HOME={hermes_home}"'] + if options.public_url: + lines.append(f'Environment="HERMES_DASHBOARD_PUBLIC_URL={options.public_url}"') + if options.allowed_hosts: + lines.append( + f'Environment="HERMES_DASHBOARD_ALLOWED_HOSTS={",".join(options.allowed_hosts)}"' + ) + return lines + + +def generate_systemd_unit( + options: DashboardServiceOptions | None = None, + *, + system: bool = False, + run_as_user: str | None = None, +) -> str: + options = options or load_service_options() or DashboardServiceOptions() + python_path = get_python_path() + working_dir = _stable_service_working_dir() + detected_venv = _detect_venv_dir() + venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") + path_entries = _build_service_path_dirs() + resolved_node = shutil.which("node") + if resolved_node: + node_dir = str(Path(resolved_node).resolve().parent) + if node_dir not in path_entries: + path_entries.append(node_dir) + common_bin_paths = [ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + ] + + if system: + username, group_name, home_dir = _system_service_identity(run_as_user) + hermes_home = _hermes_home_for_target_user(home_dir) + python_path = _remap_path_for_user(python_path, home_dir) + working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir) + venv_dir = _remap_path_for_user(venv_dir, home_dir) + path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries] + path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries)) + path_entries.extend(_build_wsl_interop_paths(path_entries)) + path_entries.extend(common_bin_paths) + sane_path = ":".join(path_entries) + exec_args = " ".join(_dashboard_cli_args(options, hermes_home)) + env_lines = "\n".join( + [ + f'Environment="HOME={home_dir}"', + f'Environment="USER={username}"', + f'Environment="LOGNAME={username}"', + f'Environment="PATH={sane_path}"', + f'Environment="VIRTUAL_ENV={venv_dir}"', + *_service_env_lines(options, hermes_home), + ] + ) + return f"""[Unit] +Description={SERVICE_DESCRIPTION} +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +User={username} +Group={group_name} +ExecStart={python_path} -m hermes_cli.main {exec_args} +WorkingDirectory={working_dir} +{env_lines} +Restart=always +RestartSec=5 +RestartMaxDelaySec=300 +RestartSteps=5 +KillMode=mixed +KillSignal=SIGTERM +TimeoutStopSec=60 +NoNewPrivileges=true +UMask=0077 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +""" + + hermes_home = str(get_hermes_home().resolve()) + path_entries.extend(_build_user_local_paths(Path.home(), path_entries)) + path_entries.extend(_build_wsl_interop_paths(path_entries)) + path_entries.extend(common_bin_paths) + sane_path = ":".join(path_entries) + exec_args = " ".join(_dashboard_cli_args(options, hermes_home)) + env_lines = "\n".join( + [ + f'Environment="PATH={sane_path}"', + f'Environment="VIRTUAL_ENV={venv_dir}"', + *_service_env_lines(options, hermes_home), + ] + ) + return f"""[Unit] +Description={SERVICE_DESCRIPTION} +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +ExecStart={python_path} -m hermes_cli.main {exec_args} +WorkingDirectory={working_dir} +{env_lines} +Restart=always +RestartSec=5 +RestartMaxDelaySec=300 +RestartSteps=5 +KillMode=mixed +KillSignal=SIGTERM +TimeoutStopSec=60 +NoNewPrivileges=true +UMask=0077 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target +""" + + +def _normalize_definition(text: str) -> str: + return "\n".join(line.rstrip() for line in text.strip().splitlines()) + + +def _normalize_launchd_plist(text: str) -> str: + return re.sub( + r"(PATH\s*)(.*?)()", + r"\1__HERMES_PATH__\3", + _normalize_definition(text), + flags=re.S, + ) + + +def systemd_unit_is_current( + options: DashboardServiceOptions | None = None, *, system: bool = False +) -> bool: + unit_path = get_systemd_unit_path(system=system) + if not unit_path.exists(): + return False + installed = unit_path.read_text(encoding="utf-8") + expected_user = _read_systemd_user_from_unit(unit_path) if system else None + expected = generate_systemd_unit(options, system=system, run_as_user=expected_user) + return _normalize_definition(installed) == _normalize_definition(expected) + + +def _select_systemd_scope(system: bool = False) -> bool: + if system: + return True + return ( + get_systemd_unit_path(system=True).exists() + and not get_systemd_unit_path(system=False).exists() + ) + + +def refresh_systemd_unit_if_needed( + options: DashboardServiceOptions | None = None, *, system: bool = False +) -> bool: + unit_path = get_systemd_unit_path(system=system) + if not unit_path.exists() or systemd_unit_is_current(options, system=system): + return False + expected_user = _read_systemd_user_from_unit(unit_path) if system else None + new_unit = generate_systemd_unit(options, system=system, run_as_user=expected_user) + if not system and ( + "/pytest-of-" in new_unit + or '/hermes_test"' in new_unit + or "/hermes_test/" in new_unit + ): + return False + unit_path.write_text(new_unit, encoding="utf-8") + _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) + print("Updated dashboard service definition to match the current Hermes install") + return True + + +def systemd_install( + options: DashboardServiceOptions | None = None, + *, + force: bool = False, + system: bool = False, + run_as_user: str | None = None, + enable_on_startup: bool = True, +) -> None: + options = options or DashboardServiceOptions() + save_service_options(options) + if system: + _require_root_for_system_service("install") + unit_path = get_systemd_unit_path(system=system) + if unit_path.exists() and not force: + if not systemd_unit_is_current(options, system=system): + print(f"Repairing outdated dashboard {_service_scope_label(system)} service at: {unit_path}") + refresh_systemd_unit_if_needed(options, system=system) + if enable_on_startup: + _run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30) + print("Dashboard service definition updated") + return + print(f"Service already installed at: {unit_path}") + print("Use --force to reinstall") + return + + unit_path.parent.mkdir(parents=True, exist_ok=True) + unit_path.write_text( + generate_systemd_unit(options, system=system, run_as_user=run_as_user), + encoding="utf-8", + ) + _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) + if enable_on_startup: + _run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30) + print(f"Dashboard {_service_scope_label(system)} service installed at: {unit_path}") + if not system: + _ensure_linger_enabled() + + +def _require_systemd_service_installed(action: str, *, system: bool = False) -> None: + unit_path = get_systemd_unit_path(system=system) + if unit_path.exists(): + return + scope_flag = " --system" if system else "" + print("Dashboard service is not installed") + print(f"Run: {'sudo ' if system else ''}hermes dashboard service install{scope_flag}") + sys.exit(1) + + +def systemd_uninstall(*, system: bool = False) -> None: + system = _select_systemd_scope(system) + if system: + _require_root_for_system_service("uninstall") + _run_systemctl(["stop", get_service_name()], system=system, check=False, timeout=90) + _run_systemctl(["disable", get_service_name()], system=system, check=False, timeout=30) + unit_path = get_systemd_unit_path(system=system) + if unit_path.exists(): + unit_path.unlink() + print(f"Removed {unit_path}") + _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) + print("Dashboard service uninstalled") + + +def systemd_start( + options: DashboardServiceOptions | None = None, *, system: bool = False +) -> None: + options = options or load_service_options() + system = _select_systemd_scope(system) + if system: + _require_root_for_system_service("start") + else: + _preflight_user_systemd() + _require_systemd_service_installed("start", system=system) + refresh_systemd_unit_if_needed(options, system=system) + _run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30) + print("Dashboard service started") + + +def systemd_stop(*, system: bool = False) -> None: + system = _select_systemd_scope(system) + if system: + _require_root_for_system_service("stop") + _require_systemd_service_installed("stop", system=system) + _sync_hermes_home_from_systemd_unit(system=system) + _run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90) + print("Dashboard service stopped") + + +def systemd_restart( + options: DashboardServiceOptions | None = None, *, system: bool = False +) -> None: + options = options or load_service_options() + system = _select_systemd_scope(system) + if system: + _require_root_for_system_service("restart") + else: + _preflight_user_systemd() + _require_systemd_service_installed("restart", system=system) + refresh_systemd_unit_if_needed(options, system=system) + _sync_hermes_home_from_systemd_unit(system=system) + _run_systemctl(["restart", get_service_name()], system=system, check=True, timeout=90) + print("Dashboard service restarted") + + +def _systemd_is_active(system: bool = False) -> bool: + if not get_systemd_unit_path(system=system).exists(): + return False + result = _run_systemctl( + ["is-active", get_service_name()], + system=system, + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout.strip() == "active" + + +def systemd_status(*, deep: bool = False, system: bool = False, full: bool = False) -> None: + system = _select_systemd_scope(system) + unit_path = get_systemd_unit_path(system=system) + print(f"Systemd unit: {unit_path}") + if not unit_path.exists(): + print("Dashboard service is not installed") + return + if not systemd_unit_is_current(system=system): + print("Installed dashboard service definition is outdated") + print("Run: hermes dashboard service restart") + status_cmd = ["status", get_service_name(), "--no-pager"] + if full: + status_cmd.append("-l") + _run_systemctl(status_cmd, system=system, capture_output=False, timeout=10) + print("Dashboard service is running" if _systemd_is_active(system) else "Dashboard service is stopped") + if deep: + subprocess.run( + (["journalctl"] if system else ["journalctl", "--user"]) + + ["-u", get_service_name(), "-n", "20", "--no-pager"], + timeout=10, + ) + + +def generate_launchd_plist(options: DashboardServiceOptions | None = None) -> str: + options = options or load_service_options() or DashboardServiceOptions() + python_path = get_python_path() + working_dir = _stable_service_working_dir() + hermes_home = str(get_hermes_home().resolve()) + log_dir = get_hermes_home() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + label = get_launchd_label() + detected_venv = _detect_venv_dir() + venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") + priority_dirs = _build_service_path_dirs() + resolved_node = shutil.which("node") + if resolved_node: + node_dir = str(Path(resolved_node).resolve().parent) + if node_dir not in priority_dirs: + priority_dirs.append(node_dir) + sane_path = ":".join( + dict.fromkeys(priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p]) + ) + prog_args = [python_path, "-m", "hermes_cli.main", *_dashboard_cli_args(options, hermes_home)] + prog_args_xml = "\n ".join(f"{html.escape(arg)}" for arg in prog_args) + public_url = ( + f"\n HERMES_DASHBOARD_PUBLIC_URL\n {html.escape(options.public_url)}" + if options.public_url + else "" + ) + allowed_hosts = ( + "\n HERMES_DASHBOARD_ALLOWED_HOSTS\n" + f" {html.escape(','.join(options.allowed_hosts))}" + if options.allowed_hosts + else "" + ) + return f""" + + + + Label + {html.escape(label)} + + ProgramArguments + + {prog_args_xml} + + + WorkingDirectory + {html.escape(working_dir)} + + EnvironmentVariables + + PATH + {html.escape(sane_path)} + VIRTUAL_ENV + {html.escape(venv_dir)} + HERMES_HOME + {html.escape(hermes_home)}{public_url}{allowed_hosts} + + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + SoftResourceLimits + + NumberOfFiles + 4096 + + + StandardOutPath + {html.escape(str(log_dir / "dashboard.log"))} + + StandardErrorPath + {html.escape(str(log_dir / "dashboard.error.log"))} + + +""" + + +def launchd_plist_is_current(options: DashboardServiceOptions | None = None) -> bool: + path = get_launchd_plist_path() + if not path.exists(): + return False + return _normalize_launchd_plist(path.read_text(encoding="utf-8")) == _normalize_launchd_plist( + generate_launchd_plist(options) + ) + + +def refresh_launchd_plist_if_needed(options: DashboardServiceOptions | None = None) -> bool: + path = get_launchd_plist_path() + if not path.exists() or launchd_plist_is_current(options): + return False + path.write_text(generate_launchd_plist(options), encoding="utf-8") + label = get_launchd_label() + subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False, timeout=90) + subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(path)], check=False, timeout=30) + print("Updated dashboard launchd service definition") + return True + + +def launchd_install(options: DashboardServiceOptions | None = None, *, force: bool = False) -> None: + if options is not None: + save_service_options(options) + path = get_launchd_plist_path() + if path.exists() and not force: + if not launchd_plist_is_current(options): + refresh_launchd_plist_if_needed(options) + print("Dashboard service definition updated") + return + print(f"Service already installed at: {path}") + print("Use --force to reinstall") + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(generate_launchd_plist(options), encoding="utf-8") + subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(path)], check=True, timeout=30) + print(f"Dashboard launchd service installed at: {path}") + + +def launchd_uninstall() -> None: + path = get_launchd_plist_path() + label = get_launchd_label() + subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=False, timeout=90) + if path.exists(): + path.unlink() + print(f"Removed {path}") + print("Dashboard service uninstalled") + + +def launchd_start(options: DashboardServiceOptions | None = None) -> None: + options = options or load_service_options() + path = get_launchd_plist_path() + label = get_launchd_label() + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(generate_launchd_plist(options), encoding="utf-8") + subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(path)], check=True, timeout=30) + else: + refresh_launchd_plist_if_needed(options) + subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) + print("Dashboard service started") + + +def launchd_stop() -> None: + label = get_launchd_label() + try: + subprocess.run(["launchctl", "bootout", f"{_launchd_domain()}/{label}"], check=True, timeout=90) + except subprocess.CalledProcessError as exc: + if exc.returncode not in {3, 113}: + raise + print("Dashboard service stopped") + + +def launchd_restart(options: DashboardServiceOptions | None = None) -> None: + options = options or load_service_options() + label = get_launchd_label() + refresh_launchd_plist_if_needed(options) + try: + subprocess.run(["launchctl", "kickstart", "-k", f"{_launchd_domain()}/{label}"], check=True, timeout=90) + except subprocess.CalledProcessError as exc: + if exc.returncode not in {3, 113}: + raise + launchd_start(options) + return + print("Dashboard service restarted") + + +def launchd_status(*, deep: bool = False) -> None: + path = get_launchd_plist_path() + label = get_launchd_label() + print(f"Launchd plist: {path}") + result = subprocess.run(["launchctl", "list", label], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + print("Dashboard service is loaded") + print(result.stdout) + else: + print("Dashboard service is not loaded") + if path.exists() and not launchd_plist_is_current(): + print("Installed dashboard service definition is stale") + if deep: + log_file = get_hermes_home() / "logs" / "dashboard.log" + if log_file.exists(): + subprocess.run(["tail", "-20", str(log_file)], timeout=10) + + +def get_windows_task_name() -> str: + suffix = _profile_suffix() + return f"Hermes_Dashboard_{suffix}" if suffix else "Hermes_Dashboard" + + +def _windows_script_path() -> Path: + return get_hermes_home() / "dashboard-service" / f"{re.sub(r'[^A-Za-z0-9_.-]', '_', get_windows_task_name())}.cmd" + + +def _quote_cmd(value: str) -> str: + if "\r" in value or "\n" in value: + raise ValueError("refusing to quote value containing newline") + if not value: + return '""' + if not re.search(r'[ \t"]', value): + return value + return '"' + value.replace('"', '""') + '"' + + +def generate_windows_cmd_script(options: DashboardServiceOptions | None = None) -> str: + options = options or load_service_options() or DashboardServiceOptions() + hermes_home = str(get_hermes_home().resolve()) + python_path = get_python_path() + args = [python_path, "-m", "hermes_cli.main", *_dashboard_cli_args(options, hermes_home)] + lines = [ + "@echo off", + "rem Hermes Agent Dashboard", + f"cd /d {_quote_cmd(str(PROJECT_ROOT))}", + f'set "HERMES_HOME={hermes_home}"', + "set \"PYTHONIOENCODING=utf-8\"", + ] + if options.public_url: + lines.append(f'set "HERMES_DASHBOARD_PUBLIC_URL={options.public_url}"') + if options.allowed_hosts: + lines.append(f'set "HERMES_DASHBOARD_ALLOWED_HOSTS={",".join(options.allowed_hosts)}"') + lines.append(" ".join(_quote_cmd(a) for a in args)) + lines.append("exit /b 0") + return "\r\n".join(lines) + "\r\n" + + +def _write_windows_script(options: DashboardServiceOptions | None = None) -> Path: + path = _windows_script_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(generate_windows_cmd_script(options), encoding="utf-8", newline="") + tmp.replace(path) + return path + + +def _exec_schtasks(args: list[str]) -> tuple[int, str, str]: + exe = shutil.which("schtasks") + if exe is None: + return 1, "", "schtasks.exe not found" + proc = subprocess.run([exe, *args], capture_output=True, text=True, timeout=20) + return proc.returncode, proc.stdout or "", proc.stderr or "" + + +def windows_is_installed() -> bool: + code, _out, _err = _exec_schtasks(["/Query", "/TN", get_windows_task_name()]) + return code == 0 + + +def windows_install(options: DashboardServiceOptions | None = None, *, force: bool = False) -> None: + if options is not None: + save_service_options(options) + script = _write_windows_script(options) + if force: + _exec_schtasks(["/Delete", "/F", "/TN", get_windows_task_name()]) + code, out, err = _exec_schtasks( + [ + "/Create", + "/F", + "/SC", + "ONLOGON", + "/RL", + "LIMITED", + "/TN", + get_windows_task_name(), + "/TR", + str(script), + ] + ) + if code != 0: + raise RuntimeError((err or out or "schtasks failed").strip()) + print(f"Dashboard Scheduled Task installed: {get_windows_task_name()}") + + +def windows_uninstall() -> None: + _exec_schtasks(["/End", "/TN", get_windows_task_name()]) + code, out, err = _exec_schtasks(["/Delete", "/F", "/TN", get_windows_task_name()]) + if code != 0 and "cannot find" not in (out + err).lower(): + raise RuntimeError((err or out or "schtasks delete failed").strip()) + script = _windows_script_path() + if script.exists(): + script.unlink() + print("Dashboard Scheduled Task uninstalled") + + +def windows_start(options: DashboardServiceOptions | None = None) -> None: + options = options or load_service_options() + if not windows_is_installed(): + _write_windows_script(options) + code, out, err = _exec_schtasks(["/Run", "/TN", get_windows_task_name()]) + if code != 0: + raise RuntimeError((err or out or "schtasks run failed").strip()) + print("Dashboard Scheduled Task started") + + +def windows_stop() -> None: + code, out, err = _exec_schtasks(["/End", "/TN", get_windows_task_name()]) + if code != 0: + raise RuntimeError((err or out or "schtasks end failed").strip()) + print("Dashboard Scheduled Task stopped") + + +def windows_status() -> None: + code, out, err = _exec_schtasks(["/Query", "/TN", get_windows_task_name(), "/V", "/FO", "LIST"]) + if code == 0: + print(out) + else: + print("Dashboard Scheduled Task is not installed") + if err: + print(err.strip()) + + +def _installed_manager() -> str: + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists() + ): + return "systemd" + if is_macos() and get_launchd_plist_path().exists(): + return "launchd" + if is_windows() and windows_is_installed(): + return "windows" + if supports_systemd_services(): + return "systemd" + if is_macos(): + return "launchd" + if is_windows(): + return "windows" + return "none" + + +def get_dashboard_service_snapshot(system: bool = False) -> dict[str, Any]: + manager = _installed_manager() + installed = False + running = False + path = "" + name = get_service_name() + scope = None + if manager == "systemd": + selected_system = _select_systemd_scope(system) + path = str(get_systemd_unit_path(system=selected_system)) + installed = Path(path).exists() + scope = "system" if selected_system else "user" + if installed: + try: + running = _systemd_is_active(selected_system) + except Exception: + running = False + elif manager == "launchd": + path = str(get_launchd_plist_path()) + installed = Path(path).exists() + name = get_launchd_label() + try: + running = subprocess.run( + ["launchctl", "list", name], + capture_output=True, + text=True, + timeout=5, + ).returncode == 0 + except Exception: + running = False + elif manager == "windows": + name = get_windows_task_name() + path = str(_windows_script_path()) + installed = windows_is_installed() + return { + "manager": manager, + "installed": installed, + "running": running, + "name": name, + "path": path, + "scope": scope, + } + + +def _unsupported_platform() -> None: + if is_termux(): + print("Dashboard service installation is not supported on Termux.") + print("Run manually: hermes dashboard") + elif is_wsl(): + print("WSL detected but systemd is not running.") + print("Run manually in tmux/screen: hermes dashboard --no-open") + elif is_container(): + print("Container dashboard supervision is handled by the container runtime/s6.") + else: + print("Dashboard service management is not supported on this platform.") + sys.exit(1) + + +def _service_command(args: Any) -> None: + action = getattr(args, "dashboard_service_command", None) or "status" + options = options_from_args(args) if action in {"install", "unit"} else None + system = bool(getattr(args, "system", False)) + + if action == "unit": + if supports_systemd_services() or getattr(args, "systemd", False): + print(generate_systemd_unit(options, system=system, run_as_user=getattr(args, "run_as_user", None))) + elif is_macos() or getattr(args, "launchd", False): + print(generate_launchd_plist(options)) + elif is_windows(): + print(generate_windows_cmd_script(options)) + else: + print(generate_systemd_unit(options, system=system, run_as_user=getattr(args, "run_as_user", None))) + return + + if action == "install": + if is_managed(): + managed_error("install dashboard service (managed by NixOS)") + return + force = bool(getattr(args, "force", False)) + start_now = bool(getattr(args, "start_now", False)) + enable_on_startup = bool(getattr(args, "start_on_login", True)) + if supports_systemd_services(): + systemd_install( + options, + force=force, + system=system, + run_as_user=getattr(args, "run_as_user", None), + enable_on_startup=enable_on_startup, + ) + if start_now: + systemd_start(options, system=system) + elif is_macos(): + launchd_install(options, force=force) + if start_now: + launchd_start(options) + elif is_windows(): + windows_install(options, force=force) + if start_now: + windows_start(options) + else: + _unsupported_platform() + return + + if action == "uninstall": + if supports_systemd_services(): + systemd_uninstall(system=system) + elif is_macos(): + launchd_uninstall() + elif is_windows(): + windows_uninstall() + else: + _unsupported_platform() + return + + if action == "start": + if supports_systemd_services(): + systemd_start(options, system=system) + elif is_macos(): + launchd_start(options) + elif is_windows(): + windows_start(options) + else: + _unsupported_platform() + return + + if action == "stop": + if supports_systemd_services(): + systemd_stop(system=system) + elif is_macos(): + launchd_stop() + elif is_windows(): + windows_stop() + else: + _unsupported_platform() + return + + if action == "restart": + if supports_systemd_services(): + systemd_restart(options, system=system) + elif is_macos(): + launchd_restart(options) + elif is_windows(): + windows_stop() + windows_start(options) + else: + _unsupported_platform() + return + + if action == "status": + deep = bool(getattr(args, "deep", False)) + full = bool(getattr(args, "full", False)) + if supports_systemd_services() and ( + get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists() + ): + systemd_status(deep=deep, system=system, full=full) + elif is_macos() and get_launchd_plist_path().exists(): + launchd_status(deep=deep) + elif is_windows() and windows_is_installed(): + windows_status() + else: + snap = get_dashboard_service_snapshot(system=system) + print("Dashboard service is not installed") + print(f"Detected manager: {snap['manager']}") + print("Run: hermes dashboard service install") + return + + print(f"Unknown dashboard service command: {action}") + sys.exit(2) + + +def build_tailscale_serve_command( + *, + target: str, + https_port: int | None = DEFAULT_TAILSCALE_HTTPS_PORT, + http_port: int | None = None, + set_path: str = "", + background: bool = True, + yes: bool = True, +) -> list[str]: + cmd = ["tailscale", "serve"] + if background: + cmd.append("--bg") + if yes: + cmd.append("--yes") + if set_path: + cmd.extend(["--set-path", set_path]) + if http_port is not None: + cmd.append(f"--http={http_port}") + elif https_port is not None: + cmd.append(f"--https={https_port}") + cmd.append(target) + return cmd + + +def build_cloudflare_config( + *, + tunnel: str, + credentials_file: str, + hostname: str, + service: str, +) -> str: + return "\n".join( + [ + f"tunnel: {tunnel}", + f"credentials-file: {credentials_file}", + "", + "ingress:", + f" - hostname: {hostname}", + f" service: {service}", + " - service: http_status:404", + "", + ] + ) + + +def _cloudflared_service_command(action: str) -> list[str] | None: + if action == "install": + return ["cloudflared", "service", "install"] + if action == "uninstall": + return ["cloudflared", "service", "uninstall"] + if sys.platform.startswith("linux"): + return ["systemctl", action, "cloudflared"] + if is_macos(): + if action == "restart": + return ["sh", "-c", "launchctl stop com.cloudflare.cloudflared; launchctl start com.cloudflare.cloudflared"] + return ["launchctl", action, "com.cloudflare.cloudflared"] + if is_windows(): + mapped = {"start": "start", "stop": "stop", "restart": "restart", "status": "query"} + return ["sc", mapped.get(action, action), "cloudflared"] + return None + + +def _access_command(args: Any) -> None: + action = getattr(args, "dashboard_access_command", None) + if action == "tailscale-serve": + target = getattr(args, "target", None) or f"127.0.0.1:{getattr(args, 'port', DEFAULT_PORT)}" + http_port = getattr(args, "http", None) + https_port = None if http_port is not None else int(getattr(args, "https", DEFAULT_TAILSCALE_HTTPS_PORT)) + cmd = build_tailscale_serve_command( + target=target, + https_port=https_port, + http_port=http_port, + set_path=getattr(args, "set_path", "") or "", + background=not bool(getattr(args, "foreground", False)), + yes=not bool(getattr(args, "interactive", False)), + ) + print(" ".join(shlex.quote(part) for part in cmd)) + if getattr(args, "apply", False): + subprocess.run(cmd, check=True, timeout=60) + return + + if action == "cloudflare-config": + service = getattr(args, "service", None) or f"http://127.0.0.1:{getattr(args, 'port', DEFAULT_PORT)}" + config = build_cloudflare_config( + tunnel=getattr(args, "tunnel", ""), + credentials_file=getattr(args, "credentials_file", ""), + hostname=getattr(args, "hostname", ""), + service=service, + ) + output = getattr(args, "output", None) + if output: + path = Path(output).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(config, encoding="utf-8") + print(f"Wrote {path}") + else: + print(config, end="") + return + + if action == "cloudflare-service": + verb = getattr(args, "cloudflare_service_command", None) or "status" + cmd = _cloudflared_service_command(verb) + if cmd is None: + print("cloudflared service management is not supported on this platform") + sys.exit(1) + if verb == "status": + print(" ".join(shlex.quote(part) for part in cmd)) + subprocess.run(cmd, check=False, timeout=30) + else: + subprocess.run(cmd, check=True, timeout=90) + return + + print("Run: hermes dashboard access tailscale-serve|cloudflare-config|cloudflare-service") + sys.exit(2) + + +def dashboard_command(args: Any) -> bool: + """Handle dashboard service/access subcommands. + + Returns True when a subcommand was handled. The regular dashboard server + startup path should run when this returns False. + """ + if getattr(args, "dashboard_command", None) == "service": + try: + _service_command(args) + except UserSystemdUnavailableError as exc: + print("User systemd not reachable:") + for line in str(exc).splitlines(): + print(f" {line}") + sys.exit(1) + except SystemScopeRequiresRootError as exc: + print(str(exc)) + sys.exit(1) + return True + if getattr(args, "dashboard_command", None) == "access": + _access_command(args) + return True + return False diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a83dbff4d18d..cf90d45b9143 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7052,10 +7052,11 @@ def _find_stale_dashboard_pids() -> list[int]: disk is updated, causing a silent frontend/backend mismatch (e.g. new auth headers the old backend doesn't recognise → every API call 401s). - The dashboard has no service manager (systemd / launchd), no PID file, - and we can't know the original launch args — so the only sane action - after an update is to kill the stale process and let the user restart - it. This helper is just the detection step; see + Manual dashboard processes have no PID file, and we can't know the + original launch args — so the only sane action after an update is to kill + the stale process and let the user restart it. Service-managed dashboards + are controlled by ``hermes dashboard service ...``. This helper is just + the detection step; see ``_kill_stale_dashboard_processes`` for the kill. Returns an empty list on any scan error (missing ps/wmic, timeout, etc.). @@ -7269,12 +7270,11 @@ def _kill_stale_dashboard_processes( """Kill running ``hermes dashboard`` processes. Called at the end of ``hermes update`` (default ``reason``) and also - from ``hermes dashboard --stop`` (which overrides ``reason``). The - dashboard has no service manager, so after a code update the running - process is guaranteed to be serving stale Python against a - freshly-updated JS bundle. Leaving it alive produces silent - frontend/backend mismatches (new auth headers the old backend doesn't - recognise → every API call 401s). + from ``hermes dashboard --stop`` (which overrides ``reason``). Manual + dashboard processes serve stale Python against a freshly-updated JS bundle + after a code update. Leaving them alive produces silent frontend/backend + mismatches (new auth headers the old backend doesn't recognise → every API + call 401s). POSIX: SIGTERM, wait up to ~3s for graceful exit, SIGKILL any survivors. Windows: ``taskkill /PID /F`` since there's no clean SIGTERM @@ -10544,11 +10544,9 @@ def _service_restart_sec( except Exception as e: logger.debug("Legacy unit check during update failed: %s", e) - # Kill stale dashboard processes — the dashboard has no service - # manager, so leaving it alive after a code update produces a - # silent frontend/backend mismatch. We can't auto-restart it - # (no saved launch args) but we can stop it, and a hint is - # printed for the user to re-launch. + # Kill stale manual dashboard processes. Service-managed dashboards + # are restarted through their service manager; stray manual processes + # still need this cleanup to avoid frontend/backend mismatches. _kill_stale_dashboard_processes() print() @@ -11314,6 +11312,14 @@ def _report_dashboard_status() -> int: def cmd_dashboard(args): """Start the web UI server, or (with --stop/--status) manage running ones.""" + try: + from hermes_cli.dashboard_service import dashboard_command + + if dashboard_command(args): + return + except SystemExit: + raise + # --status: report running dashboards and exit, no deps needed. if getattr(args, "status", False): count = _report_dashboard_status() @@ -11398,12 +11404,25 @@ def cmd_dashboard(args): from hermes_cli.web_server import start_server embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1" + allowed_hosts = getattr(args, "allowed_hosts", None) + if not allowed_hosts: + allowed_hosts = os.environ.get("HERMES_DASHBOARD_ALLOWED_HOSTS", "") + if not allowed_hosts: + try: + from hermes_cli.config import load_config + + cfg = load_config() + dash_cfg = cfg.get("dashboard", {}) if isinstance(cfg, dict) else {} + allowed_hosts = dash_cfg.get("allowed_hosts", "") + except Exception: + allowed_hosts = "" start_server( host=args.host, port=args.port, open_browser=not args.no_open, allow_public=getattr(args, "insecure", False), embedded_chat=embedded_chat, + allowed_hosts=allowed_hosts, ) @@ -14679,6 +14698,15 @@ def cmd_acp(args): action="store_true", help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)", ) + dashboard_parser.add_argument( + "--allowed-hosts", + action="append", + default=None, + help=( + "Extra Host header names to accept when the dashboard is reached " + "through a loopback proxy/tunnel. Comma-separated; repeatable." + ), + ) dashboard_parser.add_argument( "--tui", action="store_true", @@ -14696,12 +14724,9 @@ def cmd_acp(args): "where npm may not be available. Pre-build with: cd web && npm run build" ), ) - # Lifecycle flags — mutually exclusive with each other and with the - # start-a-server flags above (if both are passed, --stop / --status win - # because they exit before the server is started). The dashboard has - # no service manager and no PID file, so these scan the process table - # for `hermes dashboard` cmdlines and SIGTERM them directly — the same - # path `hermes update` uses to clean up stale dashboards. + # Legacy process-table lifecycle flags. The durable service manager lives + # under `hermes dashboard service ...`; these flags keep the older manual + # dashboard process behavior working for scripts. dashboard_parser.add_argument( "--stop", action="store_true", @@ -14712,6 +14737,130 @@ def cmd_acp(args): action="store_true", help="List running hermes dashboard processes and exit", ) + dashboard_subparsers = dashboard_parser.add_subparsers(dest="dashboard_command") + + dashboard_service = dashboard_subparsers.add_parser( + "service", + help="Install or manage the durable dashboard service", + ) + dashboard_service_sub = dashboard_service.add_subparsers( + dest="dashboard_service_command" + ) + + def _add_dashboard_service_options(p): + p.add_argument("--port", type=int, default=9119, help="Port (default 9119)") + p.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)") + p.add_argument("--tui", action="store_true", help="Enable embedded dashboard chat") + p.add_argument("--insecure", action="store_true", help="Disable the OAuth auth gate") + p.add_argument( + "--allowed-hosts", + action="append", + default=None, + help="Extra accepted Host headers, comma-separated; repeatable", + ) + p.add_argument( + "--public-url", + default="", + help="Public dashboard URL for OAuth callbacks behind a proxy", + ) + + dashboard_service_install = dashboard_service_sub.add_parser( + "install", + help="Install the dashboard as a systemd/launchd/Windows service", + ) + _add_dashboard_service_options(dashboard_service_install) + dashboard_service_install.add_argument("--force", action="store_true", help="Force reinstall") + dashboard_service_install.add_argument( + "--system", + action="store_true", + help="Install as a Linux system-level service", + ) + dashboard_service_install.add_argument( + "--run-as-user", + dest="run_as_user", + help="User account the Linux system service should run as", + ) + dashboard_service_install.add_argument( + "--start-now", + dest="start_now", + action="store_true", + default=False, + help="Start the dashboard service after installing", + ) + dashboard_service_install.add_argument( + "--no-start-on-login", + dest="start_on_login", + action="store_false", + default=True, + help="Install without enabling automatic startup", + ) + + for _name in ("start", "restart"): + _p = dashboard_service_sub.add_parser(_name, help=f"{_name.capitalize()} dashboard service") + _add_dashboard_service_options(_p) + _p.add_argument("--system", action="store_true", help="Target the Linux system service") + + dashboard_service_stop = dashboard_service_sub.add_parser("stop", help="Stop dashboard service") + dashboard_service_stop.add_argument("--system", action="store_true", help="Target the Linux system service") + + dashboard_service_uninstall = dashboard_service_sub.add_parser( + "uninstall", help="Uninstall dashboard service" + ) + dashboard_service_uninstall.add_argument( + "--system", action="store_true", help="Target the Linux system service" + ) + + dashboard_service_status = dashboard_service_sub.add_parser( + "status", help="Show dashboard service status" + ) + dashboard_service_status.add_argument("--deep", action="store_true", help="Show recent service logs") + dashboard_service_status.add_argument("-l", "--full", action="store_true", help="Show full service output") + dashboard_service_status.add_argument("--system", action="store_true", help="Target the Linux system service") + + dashboard_service_unit = dashboard_service_sub.add_parser( + "unit", help="Print the generated service definition" + ) + _add_dashboard_service_options(dashboard_service_unit) + dashboard_service_unit.add_argument("--system", action="store_true", help="Print Linux system unit") + dashboard_service_unit.add_argument("--systemd", action="store_true", help="Force systemd output") + dashboard_service_unit.add_argument("--launchd", action="store_true", help="Force launchd output") + dashboard_service_unit.add_argument("--run-as-user", dest="run_as_user") + + dashboard_access = dashboard_subparsers.add_parser( + "access", help="Secure dashboard access helpers" + ) + dashboard_access_sub = dashboard_access.add_subparsers(dest="dashboard_access_command") + tailscale_serve = dashboard_access_sub.add_parser( + "tailscale-serve", + help="Print or apply a Tailscale/headscale Serve command", + ) + tailscale_serve.add_argument("--port", type=int, default=9119, help="Local dashboard port") + tailscale_serve.add_argument("--target", help="Serve target (default 127.0.0.1:)") + tailscale_serve.add_argument("--https", type=int, default=443, help="Tailnet HTTPS port") + tailscale_serve.add_argument("--http", type=int, help="Use a tailnet HTTP port instead") + tailscale_serve.add_argument("--set-path", default="", help="Optional Serve path") + tailscale_serve.add_argument("--foreground", action="store_true", help="Do not pass --bg") + tailscale_serve.add_argument("--interactive", action="store_true", help="Do not pass --yes") + tailscale_serve.add_argument("--apply", action="store_true", help="Run the command") + + cloudflare_config = dashboard_access_sub.add_parser( + "cloudflare-config", help="Generate a cloudflared tunnel config" + ) + cloudflare_config.add_argument("--tunnel", required=True, help="Tunnel UUID or name") + cloudflare_config.add_argument("--credentials-file", required=True, help="Tunnel credentials JSON") + cloudflare_config.add_argument("--hostname", required=True, help="Public hostname") + cloudflare_config.add_argument("--service", help="Origin service URL") + cloudflare_config.add_argument("--port", type=int, default=9119, help="Local dashboard port") + cloudflare_config.add_argument("--output", help="Write config YAML to this path") + + cloudflare_service = dashboard_access_sub.add_parser( + "cloudflare-service", help="Manage the native cloudflared service" + ) + cloudflare_service_sub = cloudflare_service.add_subparsers( + dest="cloudflare_service_command" + ) + for _name in ("install", "uninstall", "start", "stop", "restart", "status"): + cloudflare_service_sub.add_parser(_name, help=f"{_name.capitalize()} cloudflared service") dashboard_parser.set_defaults(func=cmd_dashboard) # ========================================================================= diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 92d4119cf7d6..d455b3151f73 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -188,34 +188,59 @@ def should_require_auth(host: str, allow_public: bool) -> bool: return (host not in _LOOPBACK_HOST_VALUES) and (not allow_public) -def _is_accepted_host(host_header: str, bound_host: str) -> bool: +def _host_header_host(host_header: str) -> str: + """Extract a normalized hostname from a Host header or URL netloc.""" + if not host_header: + return "" + h = host_header.strip() + if "://" in h: + parsed = urllib.parse.urlparse(h) + h = parsed.netloc or parsed.path + if h.startswith("["): + close = h.find("]") + if close != -1: + host_only = h[1:close] + else: + host_only = h.strip("[]") + else: + host_only = h.rsplit(":", 1)[0] if ":" in h else h + return host_only.strip().strip(".").lower() + + +def _normalize_allowed_hosts(allowed_hosts: Any = None) -> set[str]: + if not allowed_hosts: + return set() + if isinstance(allowed_hosts, str): + raw_items = allowed_hosts.replace(",", " ").split() + elif isinstance(allowed_hosts, (list, tuple, set)): + raw_items = [] + for item in allowed_hosts: + if isinstance(item, str): + raw_items.extend(item.replace(",", " ").split()) + elif item is not None: + raw_items.append(str(item)) + else: + raw_items = [str(allowed_hosts)] + return {h for h in (_host_header_host(item) for item in raw_items) if h} + + +def _is_accepted_host( + host_header: str, + bound_host: str, + allowed_hosts: Any = None, +) -> bool: """True if the Host header targets the interface we bound to. Accepts: - Exact bound host (with or without port suffix) - Loopback aliases when bound to loopback + - Explicitly configured proxy/tunnel hostnames when bound to loopback - Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback, no protection possible at this layer) """ if not host_header: return False - # Strip port suffix. IPv6 addresses use bracket notation: - # [::1] — no port - # [::1]:9119 — with port - # Plain hosts/v4: - # localhost:9119 - # 127.0.0.1:9119 - h = host_header.strip() - if h.startswith("["): - # IPv6 bracketed — port (if any) follows "]:" - close = h.find("]") - if close != -1: - host_only = h[1:close] # strip brackets - else: - host_only = h.strip("[]") - else: - host_only = h.rsplit(":", 1)[0] if ":" in h else h - host_only = host_only.lower() + host_only = _host_header_host(host_header) # 0.0.0.0 bind means operator explicitly opted into all-interfaces # (requires --insecure per web_server.start_server). No Host-layer @@ -226,7 +251,10 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool: # Loopback bind: accept the loopback names bound_lc = bound_host.lower() if bound_lc in _LOOPBACK_HOST_VALUES: - return host_only in _LOOPBACK_HOST_VALUES + return ( + host_only in _LOOPBACK_HOST_VALUES + or host_only in _normalize_allowed_hosts(allowed_hosts) + ) # Explicit non-loopback bind: require exact host match return host_only == bound_lc @@ -249,13 +277,15 @@ async def host_header_middleware(request: Request, call_next): bound_host = getattr(app.state, "bound_host", None) if bound_host: host_header = request.headers.get("host", "") - if not _is_accepted_host(host_header, bound_host): + allowed_hosts = getattr(app.state, "allowed_hosts", ()) + if not _is_accepted_host(host_header, bound_host, allowed_hosts): return JSONResponse( status_code=400, content={ "detail": ( "Invalid Host header. Dashboard requests must use " - "the hostname the server was bound to." + "the hostname the server was bound to, or one of " + "the configured dashboard.allowed_hosts entries." ), }, ) @@ -750,6 +780,7 @@ async def get_status(): "active_sessions": active_sessions, "auth_required": auth_required, "auth_providers": auth_providers, + "allowed_hosts": list(getattr(app.state, "allowed_hosts", ())), } @@ -989,6 +1020,17 @@ async def run_config_migrate(): "gateway-restart": "gateway-restart.log", "gateway-start": "gateway-start.log", "gateway-stop": "gateway-stop.log", + "dashboard-service-install": "dashboard-service-install.log", + "dashboard-service-start": "dashboard-service-start.log", + "dashboard-service-stop": "dashboard-service-stop.log", + "dashboard-service-restart": "dashboard-service-restart.log", + "dashboard-service-uninstall": "dashboard-service-uninstall.log", + "dashboard-access-tailscale": "dashboard-access-tailscale.log", + "dashboard-access-cloudflared-install": "dashboard-access-cloudflared-install.log", + "dashboard-access-cloudflared-start": "dashboard-access-cloudflared-start.log", + "dashboard-access-cloudflared-stop": "dashboard-access-cloudflared-stop.log", + "dashboard-access-cloudflared-restart": "dashboard-access-cloudflared-restart.log", + "dashboard-access-cloudflared-uninstall": "dashboard-access-cloudflared-uninstall.log", "hermes-update": "hermes-update.log", "doctor": "action-doctor.log", "security-audit": "action-security-audit.log", @@ -4691,6 +4733,38 @@ class WebhookCreate(BaseModel): secret: Optional[str] = None +class DashboardServiceInstallRequest(BaseModel): + host: str = "127.0.0.1" + port: int = 9119 + tui: bool = False + insecure: bool = False + allowed_hosts: List[str] = [] + public_url: str = "" + force: bool = False + system: bool = False + run_as_user: Optional[str] = None + start_now: bool = False + start_on_login: bool = True + + +class TailscaleServeRequest(BaseModel): + port: int = 9119 + target: Optional[str] = None + https: Optional[int] = 443 + http: Optional[int] = None + set_path: str = "" + foreground: bool = False + interactive: bool = False + + +class CloudflareConfigRequest(BaseModel): + tunnel: str + credentials_file: str + hostname: str + service: Optional[str] = None + port: int = 9119 + + def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> Dict[str, Any]: return { "name": name, @@ -4844,6 +4918,182 @@ async def stop_gateway(): return {"ok": True, "pid": proc.pid, "name": "gateway-stop"} +# --------------------------------------------------------------------------- +# Dashboard service + secure access endpoints. +# +# These mirror the CLI service helpers and intentionally spawn the real CLI for +# mutating actions. The currently served dashboard may be the service being +# restarted, so every action returns an action-log handle before the service +# manager is allowed to interrupt this process. +# --------------------------------------------------------------------------- + + +def _dashboard_service_install_args(body: DashboardServiceInstallRequest) -> List[str]: + if body.port <= 0 or body.port > 65535: + raise HTTPException(status_code=400, detail="port must be between 1 and 65535") + args = [ + "dashboard", + "service", + "install", + "--host", + body.host or "127.0.0.1", + "--port", + str(body.port), + ] + if body.tui: + args.append("--tui") + if body.insecure: + args.append("--insecure") + if body.allowed_hosts: + args.extend(["--allowed-hosts", ",".join(h.strip() for h in body.allowed_hosts if h.strip())]) + if body.public_url: + args.extend(["--public-url", body.public_url.strip()]) + if body.force: + args.append("--force") + if body.system: + args.append("--system") + if body.run_as_user: + args.extend(["--run-as-user", body.run_as_user]) + if body.start_now: + args.append("--start-now") + if not body.start_on_login: + args.append("--no-start-on-login") + return args + + +@app.get("/api/dashboard/service/status") +async def dashboard_service_status(): + try: + from hermes_cli.dashboard_service import get_dashboard_service_snapshot + + return get_dashboard_service_snapshot() + except Exception as exc: + _log.exception("Failed to read dashboard service status") + raise HTTPException(status_code=500, detail=f"Failed to read service status: {exc}") + + +@app.post("/api/dashboard/service/install") +async def install_dashboard_service(body: DashboardServiceInstallRequest): + try: + proc = _spawn_hermes_action( + _dashboard_service_install_args(body), + "dashboard-service-install", + ) + except HTTPException: + raise + except Exception as exc: + _log.exception("Failed to spawn dashboard service install") + raise HTTPException(status_code=500, detail=f"Failed to install dashboard service: {exc}") + return {"ok": True, "pid": proc.pid, "name": "dashboard-service-install"} + + +def _spawn_dashboard_service_verb(verb: str) -> Dict[str, Any]: + if verb not in {"start", "stop", "restart", "uninstall"}: + raise HTTPException(status_code=404, detail="unknown dashboard service action") + name = f"dashboard-service-{verb}" + proc = _spawn_hermes_action(["dashboard", "service", verb], name) + return {"ok": True, "pid": proc.pid, "name": name} + + +@app.post("/api/dashboard/service/start") +async def start_dashboard_service(): + try: + return _spawn_dashboard_service_verb("start") + except Exception as exc: + _log.exception("Failed to spawn dashboard service start") + raise HTTPException(status_code=500, detail=f"Failed to start dashboard service: {exc}") + + +@app.post("/api/dashboard/service/stop") +async def stop_dashboard_service(): + try: + return _spawn_dashboard_service_verb("stop") + except Exception as exc: + _log.exception("Failed to spawn dashboard service stop") + raise HTTPException(status_code=500, detail=f"Failed to stop dashboard service: {exc}") + + +@app.post("/api/dashboard/service/restart") +async def restart_dashboard_service(): + try: + return _spawn_dashboard_service_verb("restart") + except Exception as exc: + _log.exception("Failed to spawn dashboard service restart") + raise HTTPException(status_code=500, detail=f"Failed to restart dashboard service: {exc}") + + +@app.post("/api/dashboard/service/uninstall") +async def uninstall_dashboard_service(): + try: + return _spawn_dashboard_service_verb("uninstall") + except Exception as exc: + _log.exception("Failed to spawn dashboard service uninstall") + raise HTTPException(status_code=500, detail=f"Failed to uninstall dashboard service: {exc}") + + +@app.post("/api/dashboard/access/tailscale-serve") +async def apply_tailscale_serve(body: TailscaleServeRequest): + if body.port <= 0 or body.port > 65535: + raise HTTPException(status_code=400, detail="port must be between 1 and 65535") + args = ["dashboard", "access", "tailscale-serve", "--port", str(body.port), "--apply"] + if body.target: + args.extend(["--target", body.target]) + if body.http is not None: + args.extend(["--http", str(body.http)]) + elif body.https is not None: + args.extend(["--https", str(body.https)]) + if body.set_path: + args.extend(["--set-path", body.set_path]) + if body.foreground: + args.append("--foreground") + if body.interactive: + args.append("--interactive") + try: + proc = _spawn_hermes_action(args, "dashboard-access-tailscale") + except Exception as exc: + _log.exception("Failed to spawn tailscale serve") + raise HTTPException(status_code=500, detail=f"Failed to apply Tailscale Serve: {exc}") + return {"ok": True, "pid": proc.pid, "name": "dashboard-access-tailscale"} + + +@app.post("/api/dashboard/access/cloudflare-config") +async def generate_cloudflare_config(body: CloudflareConfigRequest): + if not body.tunnel.strip() or not body.credentials_file.strip() or not body.hostname.strip(): + raise HTTPException(status_code=400, detail="tunnel, credentials_file, and hostname are required") + try: + from hermes_cli.dashboard_service import build_cloudflare_config + + service = body.service or f"http://127.0.0.1:{body.port}" + return { + "ok": True, + "config": build_cloudflare_config( + tunnel=body.tunnel.strip(), + credentials_file=body.credentials_file.strip(), + hostname=body.hostname.strip(), + service=service, + ), + } + except Exception as exc: + _log.exception("Failed to generate cloudflared config") + raise HTTPException(status_code=500, detail=f"Failed to generate cloudflared config: {exc}") + + +@app.post("/api/dashboard/access/cloudflare-service/{verb}") +async def run_cloudflare_service(verb: str): + if verb not in {"install", "uninstall", "start", "stop", "restart"}: + raise HTTPException(status_code=404, detail="unknown cloudflared service action") + name = f"dashboard-access-cloudflared-{verb}" + try: + proc = _spawn_hermes_action( + ["dashboard", "access", "cloudflare-service", verb], + name, + ) + except Exception as exc: + _log.exception("Failed to spawn cloudflared service action") + raise HTTPException(status_code=500, detail=f"Failed to run cloudflared service action: {exc}") + return {"ok": True, "pid": proc.pid, "name": name} + + # --------------------------------------------------------------------------- # Credential pool endpoints — list / add / remove rotation keys. # @@ -6162,7 +6412,8 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: return True host_header = ws.headers.get("host", "") - if not _is_accepted_host(host_header, bound_host): + allowed_hosts = getattr(app.state, "allowed_hosts", ()) + if not _is_accepted_host(host_header, bound_host, allowed_hosts): return False origin = ws.headers.get("origin", "") @@ -6182,7 +6433,7 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: if not parsed.netloc: return False - return _is_accepted_host(parsed.netloc, bound_host) + return _is_accepted_host(parsed.netloc, bound_host, allowed_hosts) def _ws_request_is_allowed(ws: "WebSocket") -> bool: @@ -7629,6 +7880,7 @@ def start_server( allow_public: bool = False, *, embedded_chat: bool = False, + allowed_hosts: Any = None, ): """Start the web UI server.""" import uvicorn @@ -7706,6 +7958,7 @@ def start_server( # PTY child uses to publish events to the dashboard sidebar. app.state.bound_host = host app.state.bound_port = port + app.state.allowed_hosts = tuple(sorted(_normalize_allowed_hosts(allowed_hosts))) if open_browser: import webbrowser diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 0190117be3c4..c6d58bc5fa31 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -263,6 +263,113 @@ def test_import_missing_archive_404(self): assert r.status_code == 404 +class TestDashboardServiceEndpoints: + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, _ = _client() + + def test_status_uses_service_snapshot(self, monkeypatch): + import hermes_cli.dashboard_service as dashboard_service + + monkeypatch.setattr( + dashboard_service, + "get_dashboard_service_snapshot", + lambda: { + "manager": "systemd", + "installed": True, + "running": False, + "name": "hermes-dashboard", + "path": "/tmp/hermes-dashboard.service", + "scope": "user", + }, + ) + + data = self.client.get("/api/dashboard/service/status").json() + assert data["manager"] == "systemd" + assert data["installed"] is True + + def test_install_spawns_cli_with_options(self, monkeypatch): + import hermes_cli.web_server as web_server + + class Proc: + pid = 123 + + calls = [] + + def fake_spawn(subcommand, name): + calls.append((subcommand, name)) + return Proc() + + monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn) + + resp = self.client.post( + "/api/dashboard/service/install", + json={ + "host": "127.0.0.1", + "port": 9120, + "tui": True, + "allowed_hosts": ["node.tailnet.ts.net"], + "public_url": "https://node.tailnet.ts.net", + "force": True, + "start_now": True, + }, + ) + + assert resp.status_code == 200 + assert resp.json() == { + "ok": True, + "pid": 123, + "name": "dashboard-service-install", + } + subcommand, name = calls[0] + assert name == "dashboard-service-install" + assert subcommand[:3] == ["dashboard", "service", "install"] + assert "--tui" in subcommand + assert ["--allowed-hosts", "node.tailnet.ts.net"] == [ + subcommand[subcommand.index("--allowed-hosts")], + subcommand[subcommand.index("--allowed-hosts") + 1], + ] + assert "--start-now" in subcommand + + def test_access_helpers(self, monkeypatch): + import hermes_cli.web_server as web_server + + class Proc: + pid = 456 + + calls = [] + + def fake_spawn(subcommand, name): + calls.append((subcommand, name)) + return Proc() + + monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn) + + r = self.client.post( + "/api/dashboard/access/tailscale-serve", + json={"port": 9119}, + ) + assert r.status_code == 200 + assert calls[-1][0][:3] == ["dashboard", "access", "tailscale-serve"] + assert "--apply" in calls[-1][0] + + cfg = self.client.post( + "/api/dashboard/access/cloudflare-config", + json={ + "tunnel": "abc", + "credentials_file": "/tmp/abc.json", + "hostname": "dash.example.com", + "port": 9119, + }, + ) + assert cfg.status_code == 200 + assert "hostname: dash.example.com" in cfg.json()["config"] + + r = self.client.post("/api/dashboard/access/cloudflare-service/restart") + assert r.status_code == 200 + assert calls[-1][0] == ["dashboard", "access", "cloudflare-service", "restart"] + + class TestSystemStatsEndpoint: @pytest.fixture(autouse=True) def _setup(self, _isolate_hermes_home): @@ -387,7 +494,6 @@ def test_create_toggle_disable(self): ).status_code == 404 - class TestAdminEndpointsAuthGate: """Every admin endpoint must sit behind the dashboard session-token gate.""" @@ -407,6 +513,7 @@ def _setup(self, _isolate_hermes_home): "/api/webhooks", "/api/credentials/pool", "/api/memory", + "/api/dashboard/service/status", "/api/ops/hooks", "/api/ops/checkpoints", "/api/curator", diff --git a/tests/hermes_cli/test_dashboard_service.py b/tests/hermes_cli/test_dashboard_service.py new file mode 100644 index 000000000000..d19becfb6898 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_service.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from pathlib import Path + + +def test_normalize_allowed_hosts_accepts_urls_ports_and_lists(): + from hermes_cli.dashboard_service import normalize_allowed_hosts + + assert normalize_allowed_hosts( + ["https://node.tailnet.ts.net/hermes", "dashboard.example.com:443", "NODE.tailnet.ts.net"] + ) == ("node.tailnet.ts.net", "dashboard.example.com") + + +def test_generate_systemd_unit_uses_dashboard_service_command(monkeypatch, tmp_path, _isolate_hermes_home): + import hermes_cli.dashboard_service as svc + + monkeypatch.setattr(svc, "get_python_path", lambda: "/usr/bin/python3") + monkeypatch.setattr(svc, "_stable_service_working_dir", lambda: str(tmp_path)) + monkeypatch.setattr(svc, "_detect_venv_dir", lambda: tmp_path / "venv") + monkeypatch.setattr(svc, "_build_service_path_dirs", lambda: ["/opt/hermes/bin"]) + + unit = svc.generate_systemd_unit( + svc.DashboardServiceOptions( + host="127.0.0.1", + port=9120, + tui=True, + allowed_hosts=("node.tailnet.ts.net",), + public_url="https://node.tailnet.ts.net", + ) + ) + + assert "Description=Hermes Agent Dashboard" in unit + assert "dashboard --host 127.0.0.1 --port 9120 --no-open --skip-build --tui" in unit + assert "--allowed-hosts node.tailnet.ts.net" in unit + assert 'Environment="HERMES_DASHBOARD_PUBLIC_URL=https://node.tailnet.ts.net"' in unit + assert 'Environment="HERMES_DASHBOARD_ALLOWED_HOSTS=node.tailnet.ts.net"' in unit + assert "NoNewPrivileges=true" in unit + assert "UMask=0077" in unit + + +def test_service_options_roundtrip(_isolate_hermes_home): + import hermes_cli.dashboard_service as svc + + options = svc.DashboardServiceOptions( + host="127.0.0.1", + port=9120, + tui=True, + allowed_hosts=("node.tailnet.ts.net",), + public_url="https://node.tailnet.ts.net", + ) + svc.save_service_options(options) + + assert svc.load_service_options() == options + + +def test_generate_launchd_plist_has_profiled_dashboard_args(monkeypatch, tmp_path, _isolate_hermes_home): + import hermes_cli.dashboard_service as svc + + monkeypatch.setattr(svc, "get_python_path", lambda: "/usr/bin/python3") + monkeypatch.setattr(svc, "_stable_service_working_dir", lambda: str(tmp_path)) + monkeypatch.setattr(svc, "_detect_venv_dir", lambda: tmp_path / "venv") + monkeypatch.setattr(svc, "_build_service_path_dirs", lambda: ["/opt/hermes/bin"]) + + plist = svc.generate_launchd_plist( + svc.DashboardServiceOptions(port=9121, allowed_hosts=("dash.example.com",)) + ) + + assert "dashboard" in plist + assert "--port" in plist + assert "9121" in plist + assert "HERMES_DASHBOARD_ALLOWED_HOSTS" in plist + assert "4096" in plist + assert "dashboard.log" in plist + + +def test_windows_cmd_script_runs_dashboard(monkeypatch, tmp_path, _isolate_hermes_home): + import hermes_cli.dashboard_service as svc + + monkeypatch.setattr(svc, "get_python_path", lambda: r"C:\Python\python.exe") + monkeypatch.setattr(svc, "PROJECT_ROOT", Path(r"C:\Hermes")) + + script = svc.generate_windows_cmd_script( + svc.DashboardServiceOptions(port=9122, tui=True) + ) + + assert "hermes_cli.main" in script + assert "dashboard" in script + assert "--skip-build" in script + assert "--tui" in script + + +def test_access_helper_command_generation(): + from hermes_cli.dashboard_service import ( + build_cloudflare_config, + build_tailscale_serve_command, + ) + + assert build_tailscale_serve_command(target="127.0.0.1:9119") == [ + "tailscale", + "serve", + "--bg", + "--yes", + "--https=443", + "127.0.0.1:9119", + ] + + config = build_cloudflare_config( + tunnel="abc", + credentials_file="/secure/abc.json", + hostname="dash.example.com", + service="http://127.0.0.1:9119", + ) + assert "tunnel: abc" in config + assert "credentials-file: /secure/abc.json" in config + assert "hostname: dash.example.com" in config + assert "service: http://127.0.0.1:9119" in config diff --git a/tests/hermes_cli/test_web_server_host_header.py b/tests/hermes_cli/test_web_server_host_header.py index 9afef09d136d..737b2c22cac8 100644 --- a/tests/hermes_cli/test_web_server_host_header.py +++ b/tests/hermes_cli/test_web_server_host_header.py @@ -54,6 +54,14 @@ def test_loopback_bind_rejects_attacker_hostnames(self): f"bound={bound} must reject attacker host={attacker!r}" ) + def test_loopback_bind_accepts_explicit_allowed_hosts(self): + from hermes_cli.web_server import _is_accepted_host + + allowed = ["node.tailnet.ts.net", "https://dash.example.com/hermes"] + assert _is_accepted_host("node.tailnet.ts.net", "127.0.0.1", allowed) + assert _is_accepted_host("dash.example.com:443", "127.0.0.1", allowed) + assert not _is_accepted_host("evil.example", "127.0.0.1", allowed) + def test_zero_zero_bind_accepts_anything(self): """0.0.0.0 means operator explicitly opted into all-interfaces (requires --insecure). No Host-layer defence is possible — rely @@ -76,6 +84,27 @@ def test_explicit_non_loopback_bind_requires_exact_match(self): # Loopback — reject (we bound to a specific non-loopback name) assert not _is_accepted_host("localhost", "my-server.corp.net") + def test_allowed_hosts_do_not_widen_explicit_non_loopback_bind(self): + """Reverse-proxy allowlists are only for loopback-bound dashboards.""" + from hermes_cli.web_server import _is_accepted_host + + allowed = ["proxy.example.com", "node.tailnet.ts.net"] + assert _is_accepted_host( + "my-server.corp.net", + "my-server.corp.net", + allowed, + ) + assert not _is_accepted_host( + "proxy.example.com", + "my-server.corp.net", + allowed, + ) + assert not _is_accepted_host( + "node.tailnet.ts.net:443", + "my-server.corp.net", + allowed, + ) + def test_case_insensitive_comparison(self): """Host headers are case-insensitive per RFC — accept variations.""" from hermes_cli.web_server import _is_accepted_host @@ -114,6 +143,7 @@ def test_legit_loopback_request_accepted(self): from hermes_cli.web_server import app app.state.bound_host = "127.0.0.1" + app.state.allowed_hosts = ("node.tailnet.ts.net",) try: client = TestClient(app) # /api/status is in _PUBLIC_API_PATHS — passes auth — so the @@ -130,6 +160,47 @@ def test_legit_loopback_request_accepted(self): finally: if hasattr(app.state, "bound_host"): del app.state.bound_host + if hasattr(app.state, "allowed_hosts"): + del app.state.allowed_hosts + + def test_allowed_proxy_host_request_accepted(self): + from fastapi.testclient import TestClient + from hermes_cli.web_server import app + + app.state.bound_host = "127.0.0.1" + app.state.allowed_hosts = ("node.tailnet.ts.net",) + try: + client = TestClient(app) + resp = client.get( + "/api/status", + headers={"Host": "node.tailnet.ts.net"}, + ) + assert resp.status_code != 400 + finally: + if hasattr(app.state, "bound_host"): + del app.state.bound_host + if hasattr(app.state, "allowed_hosts"): + del app.state.allowed_hosts + + def test_allowed_proxy_host_does_not_override_explicit_bind(self): + from fastapi.testclient import TestClient + from hermes_cli.web_server import app + + app.state.bound_host = "my-server.corp.net" + app.state.allowed_hosts = ("node.tailnet.ts.net",) + try: + client = TestClient(app) + resp = client.get( + "/api/status", + headers={"Host": "node.tailnet.ts.net"}, + ) + assert resp.status_code == 400 + assert "Invalid Host header" in resp.json()["detail"] + finally: + if hasattr(app.state, "bound_host"): + del app.state.bound_host + if hasattr(app.state, "allowed_hosts"): + del app.state.allowed_hosts def test_no_bound_host_skips_validation(self): """If app.state.bound_host isn't set (e.g. running under test @@ -215,3 +286,23 @@ def test_loopback_websocket_host_and_origin_are_accepted(self, monkeypatch): }, ): pass + + def test_allowed_proxy_websocket_host_and_origin_are_accepted(self, monkeypatch): + from fastapi.testclient import TestClient + + import hermes_cli.web_server as ws + + monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False) + monkeypatch.setattr(ws.app.state, "allowed_hosts", ("node.tailnet.ts.net",), raising=False) + monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True) + + client = TestClient(ws.app) + url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test" + with client.websocket_connect( + url, + headers={ + "Host": "node.tailnet.ts.net", + "Origin": "https://node.tailnet.ts.net", + }, + ): + pass diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6202b9f28c01..74abb88abf48 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -682,6 +682,50 @@ export const api = { stopGateway: () => fetchJSON("/api/gateway/stop", { method: "POST" }), + // ── Admin: Dashboard service and secure access ───────────────────── + getDashboardService: () => + fetchJSON("/api/dashboard/service/status"), + installDashboardService: (body: DashboardServiceInstallRequest) => + fetchJSON("/api/dashboard/service/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + startDashboardService: () => + fetchJSON("/api/dashboard/service/start", { method: "POST" }), + stopDashboardService: () => + fetchJSON("/api/dashboard/service/stop", { method: "POST" }), + restartDashboardService: () => + fetchJSON("/api/dashboard/service/restart", { + method: "POST", + }), + uninstallDashboardService: () => + fetchJSON("/api/dashboard/service/uninstall", { + method: "POST", + }), + applyTailscaleServe: (body: TailscaleServeRequest) => + fetchJSON("/api/dashboard/access/tailscale-serve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + generateCloudflareConfig: (body: CloudflareConfigRequest) => + fetchJSON<{ ok: boolean; config: string }>( + "/api/dashboard/access/cloudflare-config", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + runCloudflareService: ( + verb: "install" | "uninstall" | "start" | "stop" | "restart", + ) => + fetchJSON( + `/api/dashboard/access/cloudflare-service/${verb}`, + { method: "POST" }, + ), + // ── Admin: Operations ─────────────────────────────────────────────── runDoctor: () => fetchJSON("/api/ops/doctor", { method: "POST" }), @@ -1052,6 +1096,47 @@ export interface CheckpointsResponse { total_bytes: number; } +export interface DashboardServiceStatus { + manager: string; + installed: boolean; + running: boolean; + name: string; + path: string; + scope: string | null; +} + +export interface DashboardServiceInstallRequest { + host: string; + port: number; + tui: boolean; + insecure: boolean; + allowed_hosts: string[]; + public_url: string; + force: boolean; + system: boolean; + run_as_user?: string; + start_now: boolean; + start_on_login: boolean; +} + +export interface TailscaleServeRequest { + port: number; + target?: string; + https?: number | null; + http?: number | null; + set_path?: string; + foreground?: boolean; + interactive?: boolean; +} + +export interface CloudflareConfigRequest { + tunnel: string; + credentials_file: string; + hostname: string; + service?: string; + port: number; +} + /** Per-call overrides for {@link fetchJSON}. */ interface FetchJSONOptions { /** When true, a 401 response is surfaced as a normal thrown error rather @@ -1078,6 +1163,7 @@ export interface PlatformStatus { export interface StatusResponse { active_sessions: number; + allowed_hosts?: string[]; /** Phase 7: ``true`` when the dashboard's OAuth gate is engaged * (public bind, no ``--insecure``). Read alongside ``auth_providers`` * to render a "gated / loopback" badge. */ diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index d8a5b0e15b80..e16e4a8507ed 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -2,16 +2,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Activity, Brain, + Cloud, Cpu, Database, Globe, + Globe2, HardDrive, KeyRound, + Network, Play, Plus, Power, RotateCw, Server, + ServerCog, ShieldCheck, Sparkles, Stethoscope, @@ -40,6 +44,7 @@ import type { CredentialPoolProvider, CheckpointsResponse, HooksResponse, + DashboardServiceStatus, HookEntry, SystemStats, CuratorStatus, @@ -147,6 +152,8 @@ export default function SystemPage() { null, ); const [hooks, setHooks] = useState(null); + const [dashboardService, setDashboardService] = + useState(null); const [curator, setCurator] = useState(null); const [portal, setPortal] = useState(null); const [loading, setLoading] = useState(true); @@ -161,6 +168,23 @@ export default function SystemPage() { const [importPath, setImportPath] = useState(""); + // Dashboard service install form. + const [dashHost, setDashHost] = useState("127.0.0.1"); + const [dashPort, setDashPort] = useState("9119"); + const [dashAllowedHosts, setDashAllowedHosts] = useState(""); + const [dashPublicUrl, setDashPublicUrl] = useState(""); + const [dashTui, setDashTui] = useState(false); + const [dashStartNow, setDashStartNow] = useState(false); + const [dashSystem, setDashSystem] = useState(false); + const [dashRunAsUser, setDashRunAsUser] = useState(""); + + // Secure access helpers. + const [tailscalePort, setTailscalePort] = useState("9119"); + const [cloudflareHostname, setCloudflareHostname] = useState(""); + const [cloudflareTunnel, setCloudflareTunnel] = useState(""); + const [cloudflareCredentials, setCloudflareCredentials] = useState(""); + const [cloudflareConfig, setCloudflareConfig] = useState(""); + // Create-hook modal. const [hookModalOpen, setHookModalOpen] = useState(false); const closeHookModal = useCallback(() => setHookModalOpen(false), []); @@ -183,16 +207,18 @@ export default function SystemPage() { api.getCredentialPool(), api.getCheckpoints(), api.getHooks(), + api.getDashboardService(), api.getCurator(), api.getPortal(), ]) - .then(([s, st, m, p, c, h, cur, prt]) => { + .then(([s, st, m, p, c, h, d, cur, prt]) => { if (s.status === "fulfilled") setStatus(s.value); if (st.status === "fulfilled") setStats(st.value); if (m.status === "fulfilled") setMemory(m.value); if (p.status === "fulfilled") setPool(p.value.providers); if (c.status === "fulfilled") setCheckpoints(c.value); if (h.status === "fulfilled") setHooks(h.value); + if (d.status === "fulfilled") setDashboardService(d.value); if (cur.status === "fulfilled") setCurator(cur.value); if (prt.status === "fulfilled") setPortal(prt.value); }) @@ -223,6 +249,86 @@ export default function SystemPage() { } }; + // ── Dashboard service ───────────────────────────────────────────── + const runDashboardService = async ( + verb: "install" | "start" | "stop" | "restart" | "uninstall", + ) => { + try { + let res: { name: string }; + if (verb === "install") { + const port = Number(dashPort || "9119"); + res = await api.installDashboardService({ + host: dashHost.trim() || "127.0.0.1", + port, + tui: dashTui, + insecure: false, + allowed_hosts: dashAllowedHosts + .split(/[,\s]+/) + .map((h) => h.trim()) + .filter(Boolean), + public_url: dashPublicUrl.trim(), + force: true, + system: dashSystem, + run_as_user: dashRunAsUser.trim() || undefined, + start_now: dashStartNow, + start_on_login: true, + }); + } else if (verb === "start") { + res = await api.startDashboardService(); + } else if (verb === "stop") { + res = await api.stopDashboardService(); + } else if (verb === "restart") { + res = await api.restartDashboardService(); + } else { + res = await api.uninstallDashboardService(); + } + setActiveAction(res.name); + showToast(`Dashboard service ${verb} started`, "success"); + setTimeout(loadAll, 3000); + } catch (e) { + showToast(`Dashboard service ${verb} failed: ${e}`, "error"); + } + }; + + const applyTailscale = async () => { + try { + const res = await api.applyTailscaleServe({ + port: Number(tailscalePort || "9119"), + }); + setActiveAction(res.name); + showToast("Tailscale Serve apply started", "success"); + } catch (e) { + showToast(`Tailscale Serve failed: ${e}`, "error"); + } + }; + + const generateCloudflare = async () => { + try { + const res = await api.generateCloudflareConfig({ + tunnel: cloudflareTunnel.trim(), + credentials_file: cloudflareCredentials.trim(), + hostname: cloudflareHostname.trim(), + port: Number(dashPort || "9119"), + }); + setCloudflareConfig(res.config); + showToast("cloudflared config generated", "success"); + } catch (e) { + showToast(`cloudflared config failed: ${e}`, "error"); + } + }; + + const runCloudflared = async ( + verb: "install" | "uninstall" | "start" | "stop" | "restart", + ) => { + try { + const res = await api.runCloudflareService(verb); + setActiveAction(res.name); + showToast(`cloudflared ${verb} started`, "success"); + } catch (e) { + showToast(`cloudflared ${verb} failed: ${e}`, "error"); + } + }; + // ── Curator ──────────────────────────────────────────────────────── const toggleCuratorPaused = async () => { if (!curator) return; @@ -741,6 +847,226 @@ export default function SystemPage() { + {/* ── Dashboard service ─────────────────────────────────────── */} +
+

+ Dashboard service +

+ + +
+ + {dashboardService?.running ? "running" : "stopped"} + + + {dashboardService?.installed ? "installed" : "not installed"} + + + {dashboardService?.manager ?? "—"} + {dashboardService?.name ? ` · ${dashboardService.name}` : ""} + +
+ +
+
+ + setDashHost(e.target.value)} + /> +
+
+ + setDashPort(e.target.value)} + /> +
+
+ + setDashAllowedHosts(e.target.value)} + placeholder="device.tailnet.ts.net" + /> +
+
+ + setDashPublicUrl(e.target.value)} + placeholder="https://dashboard.example.com" + /> +
+
+ +
+ + + + {dashSystem && ( +
+ + setDashRunAsUser(e.target.value)} + placeholder="hermes" + /> +
+ )} +
+ + + + + +
+
+
+
+
+ + {/* ── Secure access ─────────────────────────────────────────── */} +
+

+ Secure access +

+ + +
+
+ + Tailscale / headscale +
+
+
+ + setTailscalePort(e.target.value)} + /> +
+ +
+
+ +
+
+ + cloudflared +
+
+ setCloudflareHostname(e.target.value)} + placeholder="dashboard.example.com" + /> + setCloudflareTunnel(e.target.value)} + placeholder="tunnel id" + /> + setCloudflareCredentials(e.target.value)} + placeholder="credentials.json" + /> +
+
+ + + +
+ {cloudflareConfig && ( +
+                  {cloudflareConfig}
+                
+ )} +
+
+
+
+ {/* ── Memory ────────────────────────────────────────────────── */}

diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 439e64a42724..53d1ea3d83fa 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1348,6 +1348,7 @@ Launch the web dashboard — a browser-based UI for managing configuration, API | `--no-open` | — | Don't auto-open the browser | | `--tui` | off | Enable the in-browser Chat tab by running `hermes --tui` behind a PTY/WebSocket bridge. Requires `pip install 'hermes-agent[web,pty]'` and a POSIX PTY environment such as Linux, macOS, or WSL2. | | `--insecure` | off | Allow binding to non-localhost hosts. Exposes dashboard credentials on the network; use only behind trusted network controls. | +| `--allowed-hosts` | — | Extra accepted Host headers for loopback proxy/tunnel access | | `--stop` | — | Stop running `hermes dashboard` processes and exit. | | `--status` | — | List running `hermes dashboard` processes and exit. | @@ -1362,6 +1363,25 @@ hermes dashboard --port 8080 --no-open hermes dashboard --tui ``` +### Dashboard service + +```bash +hermes dashboard service install [--host 127.0.0.1] [--port 9119] [--tui] [--start-now] +hermes dashboard service start|stop|restart|status|uninstall +hermes dashboard service unit +``` + +Linux uses systemd user services by default (`--system` installs a boot-time +system service), macOS uses launchd, and Windows uses a Scheduled Task. + +### Dashboard access helpers + +```bash +hermes dashboard access tailscale-serve --port 9119 [--apply] +hermes dashboard access cloudflare-config --tunnel --credentials-file --hostname +hermes dashboard access cloudflare-service install|start|stop|restart|status|uninstall +``` + ## `hermes profile` ```bash diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 67cbe0e2a74c..d12997f55c8a 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -28,7 +28,9 @@ This starts a local web server and opens `http://127.0.0.1:9119` in your browser | `--host` | `127.0.0.1` | Bind address | | `--no-open` | — | Don't auto-open the browser | | `--insecure` | off | Allow binding to non-localhost hosts (**DANGEROUS** — exposes API keys on the network; pair with a firewall and strong auth) | +| `--allowed-hosts` | — | Extra Host headers accepted for loopback proxy/tunnel access | | `--tui` | off | Expose the in-browser Chat tab (embedded `hermes --tui` via PTY/WebSocket). Alternatively set `HERMES_DASHBOARD_TUI=1`. | +| `--skip-build` | off | Serve the existing bundled web dist without running the frontend build | ```bash # Custom port @@ -44,6 +46,69 @@ hermes dashboard --no-open hermes dashboard --tui ``` +## Durable service + +Use `hermes dashboard service` to run the dashboard under the host service +manager, matching the gateway service workflow: + +```bash +# Linux systemd user service, macOS launchd, or Windows Scheduled Task +hermes dashboard service install --tui --start-now +hermes dashboard service status +hermes dashboard service restart +hermes dashboard service stop +hermes dashboard service uninstall + +# Linux boot-time system service +sudo hermes dashboard service install --system --run-as-user "$USER" --start-now +``` + +The generated service runs `hermes dashboard --no-open --skip-build` with the +selected host, port, embedded-chat mode, profile, `HERMES_HOME`, PATH, and venv +captured at install time. Pre-build the dashboard before installing if your +deployment does not ship `hermes_cli/web_dist`. + +### Secure remote access + +The safest default is still a loopback-bound dashboard: + +```bash +hermes dashboard service install --host 127.0.0.1 --port 9119 +``` + +Expose that loopback service through an identity-aware tunnel or reverse proxy: + +```bash +# Tailscale or headscale tailnet-only access +hermes dashboard access tailscale-serve --port 9119 --apply + +# Generate a locally managed cloudflared tunnel config +hermes dashboard access cloudflare-config \ + --tunnel \ + --credentials-file ~/.cloudflared/.json \ + --hostname dashboard.example.com + +# Install/manage cloudflared's native service +hermes dashboard access cloudflare-service install +hermes dashboard access cloudflare-service restart +``` + +When a proxy forwards requests with a hostname other than `localhost`, allow it +explicitly while keeping DNS-rebinding protection on: + +```bash +hermes dashboard service install \ + --allowed-hosts device.tailnet.ts.net,dashboard.example.com \ + --public-url https://dashboard.example.com \ + --start-now +``` + +`dashboard.allowed_hosts` in `config.yaml` and +`HERMES_DASHBOARD_ALLOWED_HOSTS` provide the same allowlist for direct +`hermes dashboard` runs. Public internet hostnames should be protected by +Cloudflare Access, an OAuth `DashboardAuthProvider`, or an equivalent upstream +identity policy. + ## Prerequisites The default `hermes-agent` install does not ship the HTTP stack or PTY helper — those are optional extras. The **web dashboard** needs FastAPI and Uvicorn (`web` extra). The **Chat** tab also needs `ptyprocess` to spawn the embedded TUI behind a pseudo-terminal (`pty` extra on POSIX). Install both with: @@ -425,6 +490,11 @@ same auth gate as the rest of `/api/`. | `PUT /api/memory/provider` | Select a provider (empty = built-in only) | | `POST /api/memory/reset` | Reset built-in memory. Body: `{target: all\|memory\|user}` | | `POST /api/gateway/start` · `/stop` · `/restart` | Gateway lifecycle (backgrounded) | +| `GET /api/dashboard/service/status` | Dashboard service manager status | +| `POST /api/dashboard/service/install` · `/start` · `/stop` · `/restart` · `/uninstall` | Dashboard service lifecycle (backgrounded) | +| `POST /api/dashboard/access/tailscale-serve` | Apply Tailscale/headscale Serve for loopback dashboard access | +| `POST /api/dashboard/access/cloudflare-config` | Generate a cloudflared tunnel config | +| `POST /api/dashboard/access/cloudflare-service/{verb}` | Manage cloudflared's native service | | `POST /api/ops/doctor` · `/security-audit` · `/backup` · `/import` | Diagnostics & maintenance (backgrounded; tail via `/api/actions/{name}/status`) | | `GET /api/ops/hooks` | Configured shell hooks + allowlist status | | `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store |