diff --git a/decnet/cli/web.py b/decnet/cli/web.py index edc21e5e..d28038ff 100644 --- a/decnet/cli/web.py +++ b/decnet/cli/web.py @@ -2,17 +2,33 @@ from __future__ import annotations import typer -from decnet.env import DECNET_API_PORT, DECNET_WEB_HOST, DECNET_WEB_PORT +from decnet.env import DECNET_API_HOST, DECNET_API_PORT, DECNET_WEB_HOST, DECNET_WEB_PORT from . import utils as _utils from .utils import console, log +def _proxy_target(api_host: str) -> str: + """Resolve the host the web proxy should connect to. + + The API binds at ``DECNET_API_HOST``; when that's a wildcard + (``0.0.0.0`` / ``::``) we still connect over loopback because the + web and API run in the same host. When the operator binds the API + to a specific address (e.g. a Tailscale IP), the API is *only* + reachable there — loopback is closed — so the proxy must follow. + """ + wildcard = {"0.0.0.0", "::", ""} # nosec B104 — comparison only + if api_host in wildcard: + return "127.0.0.1" + return api_host + + def register(app: typer.Typer) -> None: @app.command(name="web") def serve_web( web_port: int = typer.Option(DECNET_WEB_PORT, "--web-port", help="Port to serve the DECNET Web Dashboard"), host: str = typer.Option(DECNET_WEB_HOST, "--host", help="Host IP to serve the Web Dashboard"), + api_host: str = typer.Option(DECNET_API_HOST, "--api-host", help="Host the DECNET API is listening on (loopback for wildcard binds)"), api_port: int = typer.Option(DECNET_API_PORT, "--api-port", help="Port the DECNET API is listening on"), daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"), ) -> None: @@ -33,8 +49,13 @@ def register(app: typer.Typer) -> None: console.print(f"[red]Frontend build not found at {dist_dir}. Make sure you run 'npm run build' inside 'decnet_web'.[/]") raise typer.Exit(1) + _api_target = _proxy_target(api_host) + if daemon: - log.info("web daemonizing host=%s port=%d api_port=%d", host, web_port, api_port) + log.info( + "web daemonizing host=%s port=%d api_target=%s:%d", + host, web_port, _api_target, api_port, + ) _utils._daemonize() _api_port = api_port @@ -87,7 +108,7 @@ def register(app: typer.Typer) -> None: if k.lower() not in ("host", "connection")} try: - conn = http.client.HTTPConnection("127.0.0.1", _api_port, timeout=120) + conn = http.client.HTTPConnection(_api_target, _api_port, timeout=120) conn.request(method, self.path, body=body, headers=forward) resp = conn.getresponse() @@ -125,7 +146,7 @@ def register(app: typer.Typer) -> None: socketserver.TCPServer.allow_reuse_address = True with socketserver.ThreadingTCPServer((host, web_port), SPAHTTPRequestHandler) as httpd: console.print(f"[green]Serving DECNET Web Dashboard on http://{host}:{web_port}[/]") - console.print(f"[dim]Proxying /api/* → http://127.0.0.1:{_api_port}[/]") + console.print(f"[dim]Proxying /api/* → http://{_api_target}:{_api_port}[/]") try: httpd.serve_forever() except KeyboardInterrupt: diff --git a/tests/cli/test_web_proxy_target.py b/tests/cli/test_web_proxy_target.py new file mode 100644 index 00000000..00429a4e --- /dev/null +++ b/tests/cli/test_web_proxy_target.py @@ -0,0 +1,36 @@ +"""The web dashboard proxy must follow DECNET_API_HOST. + +Hardcoding 127.0.0.1 broke deploys where the operator binds the API to +a specific tailnet/VPN address: the API drops loopback entirely and the +proxy gets ECONNREFUSED. Wildcard binds still proxy via loopback because +both processes share the host. +""" +from __future__ import annotations + +from decnet.cli.web import _proxy_target + + +def test_loopback_passthrough() -> None: + assert _proxy_target("127.0.0.1") == "127.0.0.1" + + +def test_wildcard_v4_falls_back_to_loopback() -> None: + assert _proxy_target("0.0.0.0") == "127.0.0.1" + + +def test_wildcard_v6_falls_back_to_loopback() -> None: + assert _proxy_target("::") == "127.0.0.1" + + +def test_empty_falls_back_to_loopback() -> None: + assert _proxy_target("") == "127.0.0.1" + + +def test_specific_address_is_followed() -> None: + # The case that was broken: API bound only on tailnet IP, proxy + # tried loopback and got ECONNREFUSED. + assert _proxy_target("100.64.1.7") == "100.64.1.7" + + +def test_hostname_is_followed() -> None: + assert _proxy_target("decnet-master.tailnet.ts.net") == "decnet-master.tailnet.ts.net"