In open mode ([web.oidc] unset) the dashboard has no login. Binding a LAN/any address there would expose host-management unauthenticated, so ci web serve now aborts unless the bind host is loopback. Auth-configured deployments bind LAN as before.
102 lines
3.2 KiB
Python
102 lines
3.2 KiB
Python
"""``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"]
|