mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-08 13:58:38 +00:00
README documents DeerFlow as deployed by default "in a local trusted
environment (accessible only via the 127.0.0.1 loopback interface)", but both
compose files published nginx as `"${PORT:-2026}:2026"`, which Docker binds to
0.0.0.0 and [::]. The shipped artifact did not match its own documented
default, so running it on a LAN or cloud host produced a wider surface than
the docs implied without the operator changing anything -- and the agent can
execute commands.
Publish as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"` in both compose
files, so the default matches the documented model while operators who front
the stack with their own TLS/auth can still widen it via BIND_HOST. The
Gateway keeps binding 0.0.0.0:8001 inside the container (nginx reaches it over
the compose network) and its port stays unpublished, so the published nginx
port is the entire external surface.
BREAKING CHANGE: a deployment that relied on the previous 0.0.0.0 default
becomes unreachable from other hosts after this upgrade. Set BIND_HOST=0.0.0.0
in .env to restore it, after putting authentication in front and completing
first-run setup.
Also:
- .env.example documents BIND_HOST and PORT with the reasoning.
- deploy.sh reports the address the stack actually bound and, when it is not
loopback, tells the operator to complete first-run setup immediately. It
reads BIND_HOST/PORT from .env via a new read_dotenv_value helper following
compose precedence; the shell does not source .env, so reading the
environment alone would have reported "loopback only" for a stack .env had
exposed. The pre-existing ${PORT} summary line had the same defect and is
fixed with it.
- test_compose_default_bind_host.py pins the loopback default, that BIND_HOST
stays overridable, and that no service in either compose file publishes a
port without an explicit bind address, so a later addition cannot drift back
to 0.0.0.0 unnoticed.
113 lines
4.6 KiB
Python
113 lines
4.6 KiB
Python
"""Regression test for the Docker Compose default published bind address.
|
|
|
|
``README.md`` documents DeerFlow as being deployed by default "in a local
|
|
trusted environment (accessible only via the 127.0.0.1 loopback interface)",
|
|
but the shipped compose files published the nginx entry as
|
|
``"${PORT:-2026}:2026"``, which Docker binds to ``0.0.0.0`` (and ``[::]``). The
|
|
shipped artifact therefore did not match its own documented default, and an
|
|
operator running it on a LAN or cloud host got a wider surface than the docs
|
|
implied without changing anything.
|
|
|
|
The Gateway itself binds ``0.0.0.0`` inside the container on purpose (nginx has
|
|
to reach it over the compose network) and its port is deliberately not
|
|
published, so the published nginx port is the whole external surface. This test
|
|
pins the loopback default there while keeping it overridable for operators who
|
|
intentionally expose the stack behind their own TLS/auth front door.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
COMPOSE_PATHS = {
|
|
"prod": REPO_ROOT / "docker" / "docker-compose.yaml",
|
|
"dev": REPO_ROOT / "docker" / "docker-compose-dev.yaml",
|
|
}
|
|
|
|
EXPECTED_NGINX_PORT_MAPPING = "${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"
|
|
|
|
|
|
def _published_ports(compose_path: Path) -> dict[str, list[str]]:
|
|
"""Return {service_name: [port mapping, ...]} for every published port."""
|
|
compose = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
|
|
published: dict[str, list[str]] = {}
|
|
for service_name, service in (compose.get("services") or {}).items():
|
|
ports = service.get("ports") if isinstance(service, dict) else None
|
|
if not ports:
|
|
continue
|
|
published[service_name] = [str(entry) for entry in ports]
|
|
return published
|
|
|
|
|
|
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
|
|
def test_nginx_entry_defaults_to_loopback(variant: str):
|
|
"""With BIND_HOST unset, the entry port must bind 127.0.0.1, not 0.0.0.0."""
|
|
published = _published_ports(COMPOSE_PATHS[variant])
|
|
|
|
assert published.get("nginx") == [EXPECTED_NGINX_PORT_MAPPING], f"{variant} compose must publish nginx as {EXPECTED_NGINX_PORT_MAPPING!r}; got: {published.get('nginx')!r}"
|
|
|
|
|
|
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
|
|
def test_no_service_publishes_on_all_interfaces(variant: str):
|
|
"""No compose service may publish a port without an explicit bind address.
|
|
|
|
A bare ``"HOST:CONTAINER"`` mapping binds every interface. Any port added
|
|
later must either stay internal to the compose network or opt in to the
|
|
same ``BIND_HOST`` default.
|
|
"""
|
|
offenders: list[str] = []
|
|
for service_name, mappings in _published_ports(COMPOSE_PATHS[variant]).items():
|
|
for mapping in mappings:
|
|
# A bind address is present only when the mapping has three
|
|
# colon-separated parts (``ADDR:HOST:CONTAINER``). Variable
|
|
# substitutions such as ``${PORT:-2026}`` also contain colons, so
|
|
# count separators outside ``${...}`` instead of splitting naively.
|
|
if _bind_address(mapping) is None:
|
|
offenders.append(f"{service_name}: {mapping}")
|
|
|
|
assert not offenders, f"{variant} compose publishes ports on all interfaces (add a bind address): {offenders}"
|
|
|
|
|
|
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
|
|
def test_bind_address_remains_overridable(variant: str):
|
|
"""Operators fronting the stack themselves must be able to widen the bind."""
|
|
mapping = _published_ports(COMPOSE_PATHS[variant])["nginx"][0]
|
|
|
|
assert _bind_address(mapping) == "${BIND_HOST:-127.0.0.1}", f"{variant} compose must keep the bind address overridable via BIND_HOST; got: {mapping!r}"
|
|
|
|
|
|
def _bind_address(mapping: str) -> str | None:
|
|
"""Return the bind-address segment of a compose port mapping, if any.
|
|
|
|
Splits on ``:`` at nesting depth zero so ``${PORT:-2026}`` is treated as a
|
|
single segment rather than two.
|
|
"""
|
|
segments: list[str] = []
|
|
current: list[str] = []
|
|
depth = 0
|
|
index = 0
|
|
while index < len(mapping):
|
|
char = mapping[index]
|
|
if mapping.startswith("${", index):
|
|
depth += 1
|
|
current.append("${")
|
|
index += 2
|
|
continue
|
|
if char == "}" and depth > 0:
|
|
depth -= 1
|
|
elif char == ":" and depth == 0:
|
|
segments.append("".join(current))
|
|
current = []
|
|
index += 1
|
|
continue
|
|
current.append(char)
|
|
index += 1
|
|
segments.append("".join(current))
|
|
|
|
# ADDR:HOST:CONTAINER -> bound; HOST:CONTAINER or CONTAINER -> unbound.
|
|
return segments[0] if len(segments) >= 3 else None
|