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:
@@ -2,17 +2,33 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import typer
|
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 . import utils as _utils
|
||||||
from .utils import console, log
|
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:
|
def register(app: typer.Typer) -> None:
|
||||||
@app.command(name="web")
|
@app.command(name="web")
|
||||||
def serve_web(
|
def serve_web(
|
||||||
web_port: int = typer.Option(DECNET_WEB_PORT, "--web-port", help="Port to serve the DECNET Web Dashboard"),
|
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"),
|
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"),
|
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"),
|
daemon: bool = typer.Option(False, "--daemon", "-d", help="Detach to background as a daemon process"),
|
||||||
) -> None:
|
) -> 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'.[/]")
|
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)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
_api_target = _proxy_target(api_host)
|
||||||
|
|
||||||
if daemon:
|
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()
|
_utils._daemonize()
|
||||||
|
|
||||||
_api_port = api_port
|
_api_port = api_port
|
||||||
@@ -87,7 +108,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
if k.lower() not in ("host", "connection")}
|
if k.lower() not in ("host", "connection")}
|
||||||
|
|
||||||
try:
|
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)
|
conn.request(method, self.path, body=body, headers=forward)
|
||||||
resp = conn.getresponse()
|
resp = conn.getresponse()
|
||||||
|
|
||||||
@@ -125,7 +146,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
socketserver.TCPServer.allow_reuse_address = True
|
socketserver.TCPServer.allow_reuse_address = True
|
||||||
with socketserver.ThreadingTCPServer((host, web_port), SPAHTTPRequestHandler) as httpd:
|
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"[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:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
36
tests/cli/test_web_proxy_target.py
Normal file
36
tests/cli/test_web_proxy_target.py
Normal 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"
|
||||||
Reference in New Issue
Block a user