deer-flow/backend/tests/test_local_dev_loopback_bind.py
Hyeonsang Cho b9b74d2338
fix(scripts): reject local BIND_HOST values nginx would misread (#6635)
* fix(scripts): reject local BIND_HOST values nginx would misread

nginx-local-conf.sh checked only for address and hostname characters.
BIND_HOST=0 rendered `listen 0:2026;`, which nginx resolves to 0.0.0.0 and
binds on every IPv4 interface, and 1.2.3 bound 1.2.0.3. Values nginx rejects
outright ([[fd00::1]], -foo) passed the serve.sh preflight and failed only
after stop_all had torn down the running stack.

Accept exactly a dotted-quad IPv4 address, an IPv6 address (brackets
allowed around IPv6 only), or an RFC 1123 hostname. Returning to the
loopback default removes a temp/nginx.local.conf rendered for an earlier
value, and stop_all now says why it can quit a rendered-config nginx
through the tracked config; a test pins the shared pid directive.

* docs(changelog): reference #6635

* docs(changelog): match the zh reference spacing

* fix(scripts): check every BIND_HOST hostname character before splitting labels

read -r -a stops at the first newline, so is_hostname validated only the
first line of a value like $'localhost\nbad' and the rest reached the
rendered listen directive. Check the allowed characters across the whole
value first, and cover multi-line, CR and tab values for each validator.
2026-10-11 10:48:31 +08:00

244 lines
9.8 KiB
Python

"""Regression test for the local (non-Docker) launch bind addresses.
``README.md`` documents DeerFlow as deployed by default "in a local trusted
environment (accessible only via the 127.0.0.1 loopback interface)", and the
Docker stack publishes only its nginx entry, on ``${BIND_HOST:-127.0.0.1}``
(``test_compose_default_bind_host.py``). ``make dev`` / ``make start`` instead
started the Gateway with ``--host 0.0.0.0``, nginx with ``listen 2026`` and the
frontend on Next's ``0.0.0.0`` default, so on a LAN or VPN every one of those
ports, including ``/setup`` before the first admin exists, was reachable from
other machines.
Gateway and frontend now always bind loopback; nginx listens on loopback unless
``BIND_HOST`` opts in, the same variable the Docker stack honors.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from support.shell import require_script_bash
REPO_ROOT = Path(__file__).resolve().parents[2]
SERVE_SH = REPO_ROOT / "scripts" / "serve.sh"
NGINX_SH = REPO_ROOT / "scripts" / "nginx.sh"
NGINX_LOCAL_CONF_SH = REPO_ROOT / "scripts" / "nginx-local-conf.sh"
NGINX_LOCAL_CONF = REPO_ROOT / "docker" / "nginx" / "nginx.local.conf"
BACKEND_MAKEFILE = REPO_ROOT / "backend" / "Makefile"
def _listen_directives(conf: str) -> list[str]:
return re.findall(r"^\s*listen\s+([^;]+);", conf, re.M)
# ── Launch commands ─────────────────────────────────────────────────────────
@pytest.mark.parametrize("path", [SERVE_SH, BACKEND_MAKEFILE], ids=["serve.sh", "backend-Makefile"])
def test_gateway_launch_commands_bind_loopback(path: Path):
text = path.read_text(encoding="utf-8")
launches = [line for line in text.splitlines() if "uvicorn app.gateway.app:app --" in line]
assert launches, f"no Gateway launch found in {path.name}"
for line in launches:
assert "--host 127.0.0.1" in line, f"{path.name} must start the Gateway on loopback: {line.strip()}"
def test_frontend_launch_commands_bind_loopback():
"""Every serve.sh frontend mode must pass a loopback --hostname to Next.
``frontend/scripts/dev.mjs`` keeps Next's all-interfaces default off Windows
because the Docker dev frontend container needs it, so serve.sh passes the
hostname itself.
"""
commands = re.findall(r"^\s*FRONTEND_CMD=(.*)$", SERVE_SH.read_text(encoding="utf-8"), re.M)
assert len(commands) == 3, f"expected dev, start and preview frontend commands; got {commands}"
for command in commands:
assert "--hostname 127.0.0.1" in command, f"frontend command must bind loopback: {command}"
def test_local_nginx_config_listens_on_loopback_only():
assert _listen_directives(NGINX_LOCAL_CONF.read_text(encoding="utf-8")) == ["127.0.0.1:2026", "[::1]:2026"]
@pytest.mark.parametrize("path", [SERVE_SH, NGINX_SH], ids=["serve.sh", "nginx.sh"])
def test_nginx_launchers_use_the_bind_host_aware_config(path: Path):
text = path.read_text(encoding="utf-8")
assert 'NGINX_CONF="$(bash "$REPO_ROOT/scripts/nginx-local-conf.sh")"' in text
launches = [line for line in text.splitlines() if "daemon off;" in line]
assert launches and all("$NGINX_CONF" in line for line in launches), launches
def test_serve_resolves_nginx_config_before_stopping_services():
"""An invalid BIND_HOST must fail before restart/start tear down a running stack."""
serve = SERVE_SH.read_text(encoding="utf-8")
assert serve.index("scripts/nginx-local-conf.sh") < serve.index('if [ "$ACTION" = "restart" ]; then')
def test_stop_signals_nginx_through_the_tracked_config():
"""``stop_all`` quits nginx via the tracked config even when it runs the rendered copy.
``nginx -s quit`` finds the master through the config's ``pid`` directive, so
this works because the render keeps that directive (pinned in
``test_bind_host_renders_a_config_listening_there``). The tracked path stays
usable for ``--stop`` with an invalid ``BIND_HOST``, which never renders.
"""
serve = SERVE_SH.read_text(encoding="utf-8")
stop_all = serve[serve.index("stop_all() {") : serve.index("\n}\n", serve.index("stop_all() {"))]
assert 'nginx -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" -s quit' in stop_all
# ── nginx-local-conf.sh ─────────────────────────────────────────────────────
@pytest.fixture
def checkout(tmp_path: Path) -> Path:
"""A minimal checkout: the script resolves the repo root from its own location."""
(tmp_path / "scripts").mkdir()
(tmp_path / "docker" / "nginx").mkdir(parents=True)
shutil.copy2(NGINX_LOCAL_CONF_SH, tmp_path / "scripts" / NGINX_LOCAL_CONF_SH.name)
shutil.copy2(NGINX_LOCAL_CONF, tmp_path / "docker" / "nginx" / NGINX_LOCAL_CONF.name)
return tmp_path
def _run(checkout: Path, bind_host: str | None) -> subprocess.CompletedProcess[str]:
env = {key: value for key, value in os.environ.items() if key != "BIND_HOST"}
if bind_host is not None:
env["BIND_HOST"] = bind_host
return subprocess.run(
[require_script_bash(), str(checkout / "scripts" / NGINX_LOCAL_CONF_SH.name)],
env=env,
capture_output=True,
text=True,
check=False,
)
@pytest.mark.parametrize("bind_host", [None, "", "127.0.0.1"], ids=["unset", "blank", "loopback"])
def test_default_bind_host_uses_the_tracked_config(checkout: Path, bind_host: str | None):
result = _run(checkout, bind_host)
assert result.returncode == 0, result.stderr
assert Path(result.stdout.strip()) == (checkout / "docker" / "nginx" / "nginx.local.conf").resolve()
assert not (checkout / "temp").exists()
@pytest.mark.parametrize("bind_host", [None, "", "127.0.0.1"], ids=["unset", "blank", "loopback"])
def test_default_bind_host_removes_a_config_rendered_for_an_earlier_bind_host(checkout: Path, bind_host: str | None):
assert _run(checkout, "0.0.0.0").returncode == 0
rendered = checkout / "temp" / "nginx.local.conf"
assert rendered.exists()
result = _run(checkout, bind_host)
assert result.returncode == 0, result.stderr
assert Path(result.stdout.strip()) == (checkout / "docker" / "nginx" / "nginx.local.conf").resolve()
assert not rendered.exists(), "a stale render with the old listen lines should not be left behind"
@pytest.mark.parametrize(
("bind_host", "expected"),
[
("0.0.0.0", ["2026", "[::]:2026"]),
("192.0.2.10", ["192.0.2.10:2026"]),
("fd00::1", ["[fd00::1]:2026"]),
("[fd00::1]", ["[fd00::1]:2026"]),
("::", ["[::]:2026"]),
("::ffff:192.0.2.10", ["[::ffff:192.0.2.10]:2026"]),
("2001:db8:0:0:0:0:0:1", ["[2001:db8:0:0:0:0:0:1]:2026"]),
("localhost", ["localhost:2026"]),
("devbox.example-corp.internal", ["devbox.example-corp.internal:2026"]),
],
)
def test_bind_host_renders_a_config_listening_there(checkout: Path, bind_host: str, expected: list[str]):
result = _run(checkout, bind_host)
assert result.returncode == 0, result.stderr
rendered = Path(result.stdout.strip())
assert rendered == (checkout / "temp" / "nginx.local.conf").resolve()
content = rendered.read_text(encoding="utf-8")
assert _listen_directives(content) == expected
# Only the listen directives change.
def without_listen(text: str) -> list[str]:
return [line for line in text.splitlines() if not line.strip().startswith("listen ")]
assert without_listen(content) == without_listen(NGINX_LOCAL_CONF.read_text(encoding="utf-8"))
# serve.sh's stop_all finds the running master through the tracked config's pid file.
pid_directive = re.compile(r"^\s*pid\s+[^;]+;", re.M)
assert pid_directive.findall(content) == pid_directive.findall(NGINX_LOCAL_CONF.read_text(encoding="utf-8")) != []
@pytest.mark.parametrize(
"bind_host",
[
"0.0.0.0; return 200",
"a b",
"$(id)",
# nginx accepts these and binds an address the operator did not write:
# "0" resolves to 0.0.0.0, "1.2.3" to 1.2.0.3, "010.0.0.1" to 10.0.0.1.
"0",
"1.2.3",
"010.0.0.1",
# nginx fails on these at startup ("host not found", "invalid IPv6
# address"), after serve.sh has already stopped the running stack.
"256.1.1.1",
"1.2.3.4.5",
"-foo",
"foo-",
"foo..bar",
"foo.",
"host_name",
"a" * 64,
"[[fd00::1]]",
"[fd00::1",
"fd00::1]",
"[192.0.2.10]",
"[localhost]",
"fd00:::1",
"fd00::1::2",
"1:2:3:4:5:6:7",
"1:2:3:4:5:6:7:8:9",
"12345::1",
"fd00::1%eth0",
"::ffff:192.0.2",
":1",
"1:",
# Every validator must see the whole value, not just its first line:
# a second line would become extra listen parameters.
"localhost\nbad",
"localhost\n",
"localhost\r",
"local\thost",
"192.0.2.10\nbad",
"fd00::1\nbad",
"[fd00::1]\nbad",
],
)
def test_bind_host_rejects_values_that_are_not_addresses(checkout: Path, bind_host: str):
result = _run(checkout, bind_host)
assert result.returncode != 0
assert "BIND_HOST must be an IP address or hostname" in result.stderr
assert not (checkout / "temp" / "nginx.local.conf").exists()
def test_render_fails_loudly_when_the_listen_directive_drifts(checkout: Path):
conf = checkout / "docker" / "nginx" / "nginx.local.conf"
conf.write_text(conf.read_text(encoding="utf-8").replace("listen 127.0.0.1:2026;", "listen 127.0.0.1:2027;"), encoding="utf-8")
result = _run(checkout, "0.0.0.0")
assert result.returncode != 0
assert "Could not find the loopback listen directive" in result.stderr
assert not (checkout / "temp" / "nginx.local.conf").exists()