From f8b419ab381099c78615c09142780b7d0d6e12f0 Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Sat, 10 Oct 2026 16:47:30 +0900 Subject: [PATCH] fix(scripts): bind local make dev and make start to loopback (#6587) * fix(scripts): bind local make dev and make start to loopback serve.sh and backend/Makefile started the Gateway with --host 0.0.0.0, nginx.local.conf listened on every interface, and Next.js kept its all-interfaces default outside Windows, so a local stack on a LAN or VPN exposed ports 2026, 8001 and 3000, including /setup before the first admin existed. The Docker stack and the README already document a loopback-only default. Bind the Gateway and frontend to 127.0.0.1 and keep nginx on loopback unless BIND_HOST is set, the variable the Docker stack already honors. nginx-local-conf.sh renders a copy listening on BIND_HOST, and serve.sh resolves it before stopping anything so an invalid value cannot tear down a running stack. * docs(changelog): reference #6587 * docs(changelog): separate the loopback entry from the next bullet --------- Co-authored-by: Willem Jiang --- .env.example | 8 +- .gitignore | 2 + CHANGELOG.md | 16 ++ CHANGELOG_zh.md | 14 ++ README.md | 11 +- README_pt.md | 2 +- README_zh.md | 2 +- backend/Makefile | 4 +- backend/tests/test_local_dev_loopback_bind.py | 163 ++++++++++++++++++ .../tests/test_nginx_loopback_canonical.py | 2 +- backend/tests/test_pnpm_script.py | 4 +- backend/tests/test_serve_nginx_stop.py | 12 ++ docker/nginx/nginx.local.conf | 7 +- docs/ARCHITECTURE.md | 4 +- scripts/AGENTS.md | 7 +- scripts/nginx-local-conf.sh | 62 +++++++ scripts/nginx.sh | 3 +- scripts/serve.sh | 21 ++- 18 files changed, 320 insertions(+), 24 deletions(-) create mode 100644 backend/tests/test_local_dev_loopback_bind.py create mode 100755 scripts/nginx-local-conf.sh diff --git a/.env.example b/.env.example index e9dd6fa4b2..ff92a333de 100644 --- a/.env.example +++ b/.env.example @@ -27,9 +27,11 @@ UNBROWSE_API_KEY=your-unbrowse-api-key # Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026. # GATEWAY_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 -# Host interface the Docker stack publishes its entry port on. Defaults to -# 127.0.0.1 (loopback only), matching the local-trusted-environment deployment -# model documented in README.md -- the agent can execute commands. +# Host interface the nginx entry port listens on, for the Docker stack and for +# local `make dev` / `make start` (where the Gateway and frontend always stay on +# loopback). Defaults to 127.0.0.1 (loopback only), matching the +# local-trusted-environment deployment model documented in README.md -- the +# agent can execute commands. # Set 0.0.0.0 only when the host is protected by your own TLS/auth front door # or firewall, and complete first-run setup before it becomes reachable. # BIND_HOST=0.0.0.0 diff --git a/.gitignore b/.gitignore index d4649770f5..d6de60b522 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ coverage/ skills/custom/* logs/ log/ +# nginx temp dirs and the BIND_HOST-rendered nginx config from local runs +/temp/ debug.log # Local git hooks (keep only on this machine, do not push) diff --git a/CHANGELOG.md b/CHANGELOG.md index b310885461..9f499b2091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4337,6 +4337,21 @@ This release closes that milestone with **439 merged pull requests**. package. Previously a `hooks/install.jse` carrying one stray byte received no static analysis and no executable review. ([#6321]) +- **scripts:** Bind local `make dev` / `make start` to loopback. `serve.sh` and + `backend/Makefile` started the Gateway with `--host 0.0.0.0`, + `nginx.local.conf` listened on every interface, and Next.js kept its + all-interfaces default outside Windows, so on a LAN or VPN other machines could + reach ports `2026`, `8001`, and `3000`, including `/setup` before the first + admin existed. The Docker stack and the README's deployment model were already + loopback-only. The Gateway and frontend now bind `127.0.0.1`, and nginx + listens on `127.0.0.1` and `[::1]` unless `BIND_HOST` is set, the same + variable the Docker stack honors. An invalid `BIND_HOST` fails before any + running service is stopped. ([#6587]) + + **Behavior change:** a local stack opened from another device needs + `BIND_HOST` (e.g. `BIND_HOST=0.0.0.0` in `.env`) and the `2026` entry; the + Gateway and frontend ports are no longer reachable from other machines. + - **skills:** Stop `review_skill_package` from reading other users' skills. Local path targets were allowed anywhere under the Gateway cwd or `/tmp`, and every documented deployment keeps `DEER_FLOW_HOME` under the cwd, so a @@ -9480,6 +9495,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6558]: https://github.com/bytedance/deer-flow/pull/6558 [#6580]: https://github.com/bytedance/deer-flow/pull/6580 [#6582]: https://github.com/bytedance/deer-flow/pull/6582 +[#6587]: https://github.com/bytedance/deer-flow/pull/6587 [#6588]: https://github.com/bytedance/deer-flow/pull/6588 [#6590]: https://github.com/bytedance/deer-flow/pull/6590 [#6595]: https://github.com/bytedance/deer-flow/pull/6595 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index fd2f330681..5319f710f3 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -3353,6 +3353,19 @@ 文件。此前带一个杂散字节的 `hooks/install.jse` 既得不到静态分析,也 不经过可执行代码审查。([#6321]) +- **脚本:** 本地 `make dev` / `make start` 改为绑定回环地址。此前 `serve.sh` + 和 `backend/Makefile` 以 `--host 0.0.0.0` 启动 Gateway,`nginx.local.conf` + 监听所有网卡,Next.js 在 Windows 之外也沿用监听所有网卡的默认值,因此在局域网 + 或 VPN 中,其他机器可以访问 `2026`、`8001` 和 `3000` 端口,包括首个管理员创建 + 之前的 `/setup`。Docker 部署栈和 README 的部署模型原本就只监听回环地址。现在 + Gateway 和前端绑定 `127.0.0.1`,nginx 监听 `127.0.0.1` 和 `[::1]`,除非设置了 + `BIND_HOST`(与 Docker 部署栈使用同一个变量)。`BIND_HOST` 无效时,会在停止任何 + 正在运行的服务之前报错退出。([#6587]) + + **行为变更:** 需要从其他设备访问本地部署时,请设置 `BIND_HOST`(例如在 `.env` + 中设置 `BIND_HOST=0.0.0.0`)并使用 `2026` 入口;Gateway 和前端端口不再对其他 + 机器开放。 + - **技能:** `review_skill_package` 不再能读取其他用户的技能。此前本地路径 目标只要位于 Gateway 工作目录或 `/tmp` 之下即被放行,而所有文档化的部署都把 `DEER_FLOW_HOME` 放在工作目录之下,因此知道他人用户 id 的用户可以传入 @@ -7636,6 +7649,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6556]: https://github.com/bytedance/deer-flow/pull/6556 [#6580]: https://github.com/bytedance/deer-flow/pull/6580 [#6582]: https://github.com/bytedance/deer-flow/pull/6582 +[#6587]: https://github.com/bytedance/deer-flow/pull/6587 [#6588]: https://github.com/bytedance/deer-flow/pull/6588 [#6590]: https://github.com/bytedance/deer-flow/pull/6590 [#6595]: https://github.com/bytedance/deer-flow/pull/6595 diff --git a/README.md b/README.md index 1da22d3187..aa410b6d63 100644 --- a/README.md +++ b/README.md @@ -2914,10 +2914,13 @@ persisted injected instruction. ### Deployment Defaults -The Docker stack publishes its entry port on `127.0.0.1` only, matching the -local-trusted-environment model described above. To reach it from another -machine, set `BIND_HOST` in `.env` (e.g. `BIND_HOST=0.0.0.0`) — and only after -putting the security measures below in place. +The Docker stack publishes its entry port on `127.0.0.1` only, and local +`make dev` / `make start` bind nginx, the Gateway, and the frontend to loopback, +matching the local-trusted-environment model described above. To reach it from +another machine, set `BIND_HOST` in `.env` (e.g. `BIND_HOST=0.0.0.0`) — and only +after putting the security measures below in place. Local runs apply `BIND_HOST` +to nginx on port `2026` only; the Gateway and frontend stay on loopback behind +it. **Complete first-run setup before the host becomes reachable.** A fresh instance has no accounts yet, so create the admin account through `/setup` diff --git a/README_pt.md b/README_pt.md index cbcf720b10..d70c0d30b1 100644 --- a/README_pt.md +++ b/README_pt.md @@ -1653,7 +1653,7 @@ O PoC não faz limpeza automática. Concluída a verificação, exclua o chat de ### Padrões de implantação -A stack Docker publica a porta de entrada apenas em `127.0.0.1`, de acordo com o modelo de ambiente local confiável descrito acima. Para acessá-la de outra máquina, defina `BIND_HOST` no `.env` (por exemplo `BIND_HOST=0.0.0.0`), e só depois de adotar as medidas de segurança abaixo. +A stack Docker publica a porta de entrada apenas em `127.0.0.1`, e o `make dev` / `make start` local vinculam o nginx, o Gateway e o frontend ao loopback, de acordo com o modelo de ambiente local confiável descrito acima. Para acessá-la de outra máquina, defina `BIND_HOST` no `.env` (por exemplo `BIND_HOST=0.0.0.0`), e só depois de adotar as medidas de segurança abaixo. Na execução local, `BIND_HOST` vale apenas para o nginx na porta `2026`; o Gateway e o frontend continuam no loopback, atrás dele. **Conclua a configuração de primeiro uso antes que o host fique acessível.** Uma instância nova ainda não tem contas, então crie a conta de admin por `/setup` logo depois de iniciar qualquer implantação que não seja só de loopback. diff --git a/README_zh.md b/README_zh.md index aaa09cbdfc..7f28919191 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1104,7 +1104,7 @@ PoC 不会自动清理。验证完成后,请用网页侧边栏的删除操作 ### 部署默认值 -Docker 部署栈默认只把入口端口发布在 `127.0.0.1` 上,与上文所述的本地可信环境模型一致。若需要从其他机器访问,请在 `.env` 中设置 `BIND_HOST`(例如 `BIND_HOST=0.0.0.0`),并且必须在落实下方的安全措施之后再这样做。 +Docker 部署栈默认只把入口端口发布在 `127.0.0.1` 上,本地 `make dev` / `make start` 也把 nginx、Gateway 和前端绑定在回环地址上,与上文所述的本地可信环境模型一致。若需要从其他机器访问,请在 `.env` 中设置 `BIND_HOST`(例如 `BIND_HOST=0.0.0.0`),并且必须在落实下方的安全措施之后再这样做。本地运行时 `BIND_HOST` 只作用于 `2026` 端口上的 nginx,Gateway 和前端始终留在其后的回环地址上。 **请在主机变为可访问之前完成首次初始化设置。** 全新实例尚未创建任何账号,因此对于任何非仅回环访问的部署,请在启动后立即通过 `/setup` 创建管理员账号。 diff --git a/backend/Makefile b/backend/Makefile index 14d7bdd6e8..ca2f5ef9bf 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -7,7 +7,7 @@ install: dev: mkdir -p "$(DEER_FLOW_HOME)" "$(BACKEND_SANDBOX_HOME)" - PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 DEER_FLOW_HOME="$(DEER_FLOW_HOME)" uv run --locked uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 \ + PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 DEER_FLOW_HOME="$(DEER_FLOW_HOME)" uv run --locked uvicorn app.gateway.app:app --host 127.0.0.1 --port 8001 \ --reload \ --reload-include='*.yaml' \ --reload-include='.env' \ @@ -17,7 +17,7 @@ dev: --reload-exclude="$(DEER_FLOW_HOME)" gateway: - PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run --locked uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 + PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run --locked uvicorn app.gateway.app:app --host 127.0.0.1 --port 8001 # Run all four duration-balanced shards locally; wait for every shard on failure. TEST_JOBS ?= 4 diff --git a/backend/tests/test_local_dev_loopback_bind.py b/backend/tests/test_local_dev_loopback_bind.py new file mode 100644 index 0000000000..2c4da09002 --- /dev/null +++ b/backend/tests/test_local_dev_loopback_bind.py @@ -0,0 +1,163 @@ +"""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') + + +# ── 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", "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"]), + ], +) +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")) + + +@pytest.mark.parametrize("bind_host", ["0.0.0.0; return 200", "a b", "$(id)"]) +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() diff --git a/backend/tests/test_nginx_loopback_canonical.py b/backend/tests/test_nginx_loopback_canonical.py index 51b8ac6081..7dc3b3b1f1 100644 --- a/backend/tests/test_nginx_loopback_canonical.py +++ b/backend/tests/test_nginx_loopback_canonical.py @@ -126,7 +126,7 @@ def test_websocket_handshake_is_not_redirected(config_path: str, host: str) -> N @pytest.mark.parametrize("config_path", NGINX_CONFIGS) def test_main_server_redirects_to_the_canonical_origin(config_path: str) -> None: content = read_config(config_path) - server = _extract_block(content, re.compile(r"server\s*\{(?=[^}]*?listen\s+(?:\[::\]:)?2026\b)")) + server = _extract_block(content, re.compile(r"server\s*\{(?=[^}]*?listen\s+(?:\S+:)?2026\b)")) assert 'if ($loopback_origin != "")' in server assert "return 301 $scheme://$loopback_origin$request_uri;" in server diff --git a/backend/tests/test_pnpm_script.py b/backend/tests/test_pnpm_script.py index 7f6d28927d..55c103cca3 100644 --- a/backend/tests/test_pnpm_script.py +++ b/backend/tests/test_pnpm_script.py @@ -150,8 +150,8 @@ def test_official_entrypoints_route_pnpm_through_shared_runner(): assert "PNPM = $(PYTHON) ../scripts/pnpm.py" in frontend_makefile assert '"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" install --silent' in serve_script assert 'DEERFLOW_PNPM_RUNNER="$REPO_ROOT/scripts/pnpm.py"' in serve_script - assert 'FRONTEND_CMD=\'env PORT=3000 "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev\'' in serve_script - assert '"\\$DEERFLOW_PNPM_RUNNER\\" run preview"' in serve_script + assert 'FRONTEND_CMD=\'env PORT=3000 "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev --hostname 127.0.0.1\'' in serve_script + assert '"\\$DEERFLOW_PNPM_RUNNER\\" run preview --hostname 127.0.0.1"' in serve_script assert 'Path(__file__).resolve().with_name("pnpm.py")' in doctor_script assert 'project_root / "scripts" / "pnpm.py"' in support_bundle_script diff --git a/backend/tests/test_serve_nginx_stop.py b/backend/tests/test_serve_nginx_stop.py index b6440e8d14..46a6084be3 100644 --- a/backend/tests/test_serve_nginx_stop.py +++ b/backend/tests/test_serve_nginx_stop.py @@ -81,6 +81,18 @@ def test_repo_nginx_pid_accepts_macos_rewritten_master_command(tmp_path): ) +def test_repo_nginx_pid_accepts_bind_host_rendered_config(tmp_path): + # With BIND_HOST set, nginx-local-conf.sh starts nginx on temp/nginx.local.conf. + repo_root = (tmp_path / "deer-flow").as_posix() + nginx_conf = f"{repo_root}/temp/nginx.local.conf" + + assert _is_repo_nginx_pid( + command="nginx", + args=f"nginx: master process nginx -g daemon off; -c {nginx_conf} -p {repo_root}", + repo_root=repo_root, + ) + + def test_repo_nginx_pid_accepts_macos_rewritten_worker_after_repo_check(tmp_path): repo_root = tmp_path / "deer-flow" diff --git a/docker/nginx/nginx.local.conf b/docker/nginx/nginx.local.conf index 58d4d7e912..4a40cd6979 100644 --- a/docker/nginx/nginx.local.conf +++ b/docker/nginx/nginx.local.conf @@ -74,8 +74,11 @@ http { } server { - listen 2026; - listen [::]:2026; + # Loopback only, like the Docker stack's default BIND_HOST. Setting + # BIND_HOST makes scripts/nginx-local-conf.sh render a copy that + # listens on that address instead. + listen 127.0.0.1:2026; + listen [::1]:2026; server_name _; # Canonicalize the loopback origin (#6156); see the maps above. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 40cc5bfe0b..436a3a9f12 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,7 +44,9 @@ This lets standard LangGraph SDK clients talk to DeerFlow without a separate Lan server. Both compose files publish nginx as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"` — **loopback by default**; the Gateway's `8001` is never published. Any new published port must carry an explicit bind address (`backend/tests/test_compose_default_bind_host.py` -pins this for every service in both compose files). +pins this for every service in both compose files). Local `make dev` / `make start` follow +the same model: the Gateway and frontend bind `127.0.0.1`, and nginx listens on loopback +unless `BIND_HOST` is set (`backend/tests/test_local_dev_loopback_bind.py`). --- diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 4c3851a28d..30fcd5a63c 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -55,7 +55,12 @@ including after an Apple Container failure, and retain the final image-config note rather than exiting early on Apple Container success. The root `PORT` value configures Docker's published nginx ingress only; local -orchestration pins Next.js to `3000`. Runtime commands launch from the already +orchestration pins Next.js to `3000`. Local runs bind the Gateway and Next.js to +`127.0.0.1`; `nginx-local-conf.sh` keeps nginx on loopback or, for a set +`BIND_HOST`, renders `temp/nginx.local.conf` listening there. `serve.sh` +resolves it before stopping anything, so a bad value cannot tear down a running +stack. Keep `dev.mjs`'s all-interfaces default: the Docker dev frontend needs +it. Runtime commands launch from the already synchronized environment with `uv run --no-sync`. Production Compose probes Gateway `/health`, and `deploy.sh` waits for all services before reporting success; failures print Compose status and recent Gateway logs. diff --git a/scripts/nginx-local-conf.sh b/scripts/nginx-local-conf.sh new file mode 100755 index 0000000000..197255f118 --- /dev/null +++ b/scripts/nginx-local-conf.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# nginx-local-conf.sh — Print the nginx config path for local (non-Docker) runs. +# +# docker/nginx/nginx.local.conf listens on loopback only, matching the Docker +# stack's default BIND_HOST. When BIND_HOST names another address, render a +# copy under temp/ that listens there instead and print that path, so local +# runs honor the same opt-in as the Docker stack. Gateway and frontend stay on +# loopback either way: nginx is the only entry point. +# +# Usage: NGINX_CONF="$(bash ./scripts/nginx-local-conf.sh)" + +set -e + +REPO_ROOT="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd -P)" +SOURCE_CONF="$REPO_ROOT/docker/nginx/nginx.local.conf" +RENDERED_CONF="$REPO_ROOT/temp/nginx.local.conf" + +bind_host="${BIND_HOST:-127.0.0.1}" +if [ "$bind_host" = "127.0.0.1" ]; then + printf '%s\n' "$SOURCE_CONF" + exit 0 +fi + +# The value is written into an nginx directive, so accept address and hostname +# characters only. +bind_host="${bind_host#[}" +bind_host="${bind_host%]}" +case "$bind_host" in + ''|*[!A-Za-z0-9.:_-]*) + echo "BIND_HOST must be an IP address or hostname: ${BIND_HOST}" >&2 + exit 1 + ;; +esac + +case "$bind_host" in + 0.0.0.0) listen_lines='listen 2026;\nlisten [::]:2026;' ;; + *:*) listen_lines="listen [$bind_host]:2026;" ;; + *) listen_lines="listen $bind_host:2026;" ;; +esac + +# awk -v expands the \n separator; BSD awk rejects a literal newline there. +mkdir -p "$REPO_ROOT/temp" +awk -v listen_lines="$listen_lines" ' + /^[[:space:]]*listen 127\.0\.0\.1:2026;[[:space:]]*$/ { + match($0, /^[[:space:]]*/) + indent = substr($0, 1, RLENGTH) + n = split(listen_lines, lines, "\n") + for (i = 1; i <= n; i++) print indent lines[i] + replaced++ + next + } + /^[[:space:]]*listen \[::1\]:2026;[[:space:]]*$/ { next } + { print } + END { if (replaced != 1) exit 1 } +' "$SOURCE_CONF" > "$RENDERED_CONF.tmp" || { + rm -f "$RENDERED_CONF.tmp" + echo "Could not find the loopback listen directive in $SOURCE_CONF" >&2 + exit 1 +} +mv "$RENDERED_CONF.tmp" "$RENDERED_CONF" +printf '%s\n' "$RENDERED_CONF" diff --git a/scripts/nginx.sh b/scripts/nginx.sh index a7d6852894..6a5e526b45 100755 --- a/scripts/nginx.sh +++ b/scripts/nginx.sh @@ -15,4 +15,5 @@ cd "$REPO_ROOT" mkdir -p logs mkdir -p temp/client_body_temp temp/proxy_temp temp/fastcgi_temp temp/uwsgi_temp temp/scgi_temp -exec nginx -g 'daemon off;' -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" +NGINX_CONF="$(bash "$REPO_ROOT/scripts/nginx-local-conf.sh")" +exec nginx -g 'daemon off;' -c "$NGINX_CONF" -p "$REPO_ROOT" diff --git a/scripts/serve.sh b/scripts/serve.sh index 1cc3f9f76c..1999f1b8a8 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -290,6 +290,13 @@ if [ "$ACTION" != "stop" ] && ! $DEV_MODE && $SKIP_FRONTEND_BUILD; then fi fi +# Resolve the nginx config before any stop_all too: an invalid BIND_HOST must +# not tear down a running stack. Gateway and frontend always bind loopback; +# nginx is the only entry point, as in the Docker stack. +if [ "$ACTION" != "stop" ]; then + NGINX_CONF="$(bash "$REPO_ROOT/scripts/nginx-local-conf.sh")" || exit 1 +fi + # ── Action routing ─────────────────────────────────────────────────────────── if [ "$ACTION" = "stop" ]; then @@ -326,15 +333,15 @@ export DEERFLOW_PNPM_PYTHON DEERFLOW_PNPM_RUNNER # Frontend command if $DEV_MODE; then - FRONTEND_CMD='env PORT=3000 "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev' + FRONTEND_CMD='env PORT=3000 "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev --hostname 127.0.0.1' if $SKIP_FRONTEND_BUILD; then echo " Note: --skip-frontend-build is ignored in dev mode (next dev does not build)." fi elif $SKIP_FRONTEND_BUILD; then # The BUILD_ID preflight above already guarantees a reusable build exists. - FRONTEND_CMD="env PORT=3000 BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run start" + FRONTEND_CMD="env PORT=3000 BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run start --hostname 127.0.0.1" else - FRONTEND_CMD="env PORT=3000 BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run preview" + FRONTEND_CMD="env PORT=3000 BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run preview --hostname 127.0.0.1" fi # Runtime path defaults. Local `make dev` launches Gateway from `backend/`, @@ -495,7 +502,7 @@ mkdir -p temp/client_body_temp temp/proxy_temp temp/fastcgi_temp temp/uwsgi_temp # 1. Gateway API run_service "Gateway" \ - "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 $GATEWAY_EXTRA_FLAGS > ../logs/gateway.log 2>&1" \ + "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 127.0.0.1 --port 8001 $GATEWAY_EXTRA_FLAGS > ../logs/gateway.log 2>&1" \ 8001 30 # 2. Frontend @@ -505,7 +512,7 @@ run_service "Frontend" \ # 3. Nginx run_service "Nginx" \ - "nginx -g 'daemon off;' -c '$REPO_ROOT/docker/nginx/nginx.local.conf' -p '$REPO_ROOT' > logs/nginx.log 2>&1" \ + "nginx -g 'daemon off;' -c '$NGINX_CONF' -p '$REPO_ROOT' > logs/nginx.log 2>&1" \ 2026 10 # ── Ready ──────────────────────────────────────────────────────────────────── @@ -516,6 +523,10 @@ echo " ✓ DeerFlow is running! [$MODE_LABEL]" echo "==========================================" echo "" echo " 🌐 http://localhost:2026" +if [ "${BIND_HOST:-127.0.0.1}" != "127.0.0.1" ]; then + echo " ⚠ Nginx also accepts connections on BIND_HOST=$BIND_HOST." + echo " Complete first-run setup at /setup before other machines can reach it." +fi echo "" echo " Routing: Frontend → Nginx → Gateway" echo " API: /api/langgraph/* → Gateway agent runtime"