"""``web`` sub-commands -- ``ci web serve`` launches the dashboard app. **Import discipline (non-negotiable, see CLAUDE.md / the web-UI plan)**: this module imports only ``click`` and stdlib at module scope. ``fastapi``, ``uvicorn`` and ``ci_orchestrator.web`` (which pulls in fastapi/jinja2) are imported lazily, inside :func:`serve`'s body -- never at import time. This keeps ``ci --help`` (and every other CLI command) working on a host where the optional ``[web]`` extra is not installed. """ from __future__ import annotations import ipaddress from typing import TYPE_CHECKING import click if TYPE_CHECKING: # pragma: no cover from ci_orchestrator.config import Config _LOOPBACK_HOSTNAMES = {"localhost", ""} def _is_loopback(host: str) -> bool: """True if ``host`` is a loopback address / ``localhost``. Unresolvable hostnames are treated as NON-loopback (conservative — we would rather refuse an ambiguous bind than expose the dashboard). """ if host in _LOOPBACK_HOSTNAMES: return True try: return ipaddress.ip_address(host).is_loopback except ValueError: return False def _bind_guard_error(host: str, config: Config) -> str | None: """Refuse an unauthenticated bind on a non-loopback address. When ``[web.oidc]`` is not configured the dashboard runs in OPEN mode (no login — see ``web/auth.py``). That is only safe on loopback. Binding a LAN/any address in open mode would expose host-management actions unauthenticated, so it is refused here. Returns an error message to abort with, or ``None`` when the bind is allowed. """ if config.web.oidc is None and not _is_loopback(host): return ( f"refusing to bind {host!r} without authentication: [web.oidc] is " "not configured, so the dashboard would be served unauthenticated " "on a non-loopback address. Configure [web.oidc] (Gitea OIDC) or " "bind 127.0.0.1." ) return None @click.group("web") def web() -> None: """Web dashboard commands (read-only in this phase, no auth).""" @web.command("serve") @click.option( "--host", "host", default=None, help="Bind address (default: config web.bind_host, normally 127.0.0.1).", ) @click.option( "--port", "port", type=int, default=None, help="Bind port (default: config web.bind_port).", ) def serve(host: str | None, port: int | None) -> None: """Run the read-only web dashboard in the foreground (uvicorn). Binds to ``127.0.0.1`` by default (never ``0.0.0.0``) -- pass ``--host`` explicitly (or set ``[web].bind_host`` in ``config.toml``) to expose it on the LAN. """ # Heavy imports deferred to here on purpose -- see module docstring. import uvicorn from ci_orchestrator.config import load_config from ci_orchestrator.web.app import create_app config = load_config() bind_host = host if host is not None else config.web.bind_host bind_port = port if port is not None else config.web.bind_port guard_error = _bind_guard_error(bind_host, config) if guard_error is not None: raise click.ClickException(guard_error) app = create_app(config) uvicorn.run(app, host=bind_host, port=bind_port) __all__ = ["web"]