fix(web): proxy follows DECNET_API_HOST instead of hardcoding 127.0.0.1

The dashboard's /api/* proxy hardcoded 127.0.0.1 as the target host.
That works when the API binds to a wildcard or to loopback, but
breaks the moment an operator binds the API to a specific address —
e.g. a Tailscale IP for tailnet-only deploys: the API stops listening
on loopback entirely and the proxy gets ECONNREFUSED on every request.

The web command now reads DECNET_API_HOST and falls back to loopback
only when the API is on a wildcard (0.0.0.0 / :: / unset). A new
--api-host flag overrides at the CLI level.
This commit is contained in:
2026-04-27 22:55:25 -04:00
parent 673bc5b819
commit 0a525ebd37
2 changed files with 61 additions and 4 deletions

View File

@@ -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:

View File

@@ -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"