From f0c969372b64d6affd6f18ba49e006edf5b9e55d Mon Sep 17 00:00:00 2001 From: "Brian.oh" <49855381+dev07060@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:12:36 +0900 Subject: [PATCH] fix(docker): handle kubernetes service-link port env --- deploy/docker/entrypoint.sh | 23 ++++- .../tests/test_security_container_posture.py | 98 +++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh index 1b403c72c..4575512ce 100644 --- a/deploy/docker/entrypoint.sh +++ b/deploy/docker/entrypoint.sh @@ -23,12 +23,31 @@ if [[ -z "${CRAWL4AI_API_TOKEN:-}" && -f /run/secrets/api_token ]]; then fi # --- Bind resolution: loopback unless a credential is present. --------------- -PORT="${CRAWL4AI_PORT:-11235}" +DEFAULT_PORT="11235" +resolve_port() { + local port="${CRAWL4AI_PORT:-$DEFAULT_PORT}" + if [[ ! "$port" =~ ^[0-9]+$ ]]; then + if [[ "$port" == tcp://* ]]; then + echo "entrypoint: ignoring non-numeric CRAWL4AI_PORT='${port}' " \ + "(looks like a Kubernetes service link); using ${DEFAULT_PORT}." >&2 + port="$DEFAULT_PORT" + else + echo "entrypoint: CRAWL4AI_PORT must be numeric, got '${port}'." >&2 + exit 1 + fi + fi + printf '%s\n' "$port" +} + if [[ -n "${CRAWL4AI_API_TOKEN:-}" || "${CRAWL4AI_JWT_ENABLED:-false}" == "true" ]]; then # A credential is configured -> the operator may expose all interfaces. - GUNICORN_BIND="${GUNICORN_BIND:-[::]:${PORT}}" + if [[ -z "${GUNICORN_BIND:-}" ]]; then + PORT="$(resolve_port)" + GUNICORN_BIND="[::]:${PORT}" + fi else # No credential -> refuse to expose; serve loopback only. + PORT="$(resolve_port)" GUNICORN_BIND="127.0.0.1:${PORT}" echo "entrypoint: no CRAWL4AI_API_TOKEN set; binding loopback only (${GUNICORN_BIND})." >&2 fi diff --git a/deploy/docker/tests/test_security_container_posture.py b/deploy/docker/tests/test_security_container_posture.py index c0317e9a9..e3e9c99ac 100644 --- a/deploy/docker/tests/test_security_container_posture.py +++ b/deploy/docker/tests/test_security_container_posture.py @@ -10,6 +10,8 @@ import os import re +import stat +import subprocess import pytest @@ -110,6 +112,102 @@ def test_entrypoint_exists_and_resolves_bind(self): assert "GUNICORN_BIND" in src and "REDIS_PASSWORD" in src assert "127.0.0.1" in src # loopback default when no credential + def _run_entrypoint(self, tmp_path, extra_env): + fake_supervisord = tmp_path / "supervisord" + fake_supervisord.write_text( + "#!/usr/bin/env sh\n" + "echo \"GUNICORN_BIND=${GUNICORN_BIND}\"\n" + "echo \"REDIS_PASSWORD_SET=${REDIS_PASSWORD:+yes}\"\n", + encoding="utf-8", + ) + fake_supervisord.chmod( + fake_supervisord.stat().st_mode | stat.S_IXUSR + ) + + env = os.environ.copy() + for key in ( + "CRAWL4AI_API_TOKEN", + "CRAWL4AI_JWT_ENABLED", + "CRAWL4AI_PORT", + "GUNICORN_BIND", + "REDIS_PASSWORD", + ): + env.pop(key, None) + env.update(extra_env) + env["PATH"] = f"{tmp_path}{os.pathsep}{env['PATH']}" + + return subprocess.run( + ["bash", os.path.join(DOCKER_DIR, "entrypoint.sh")], + cwd=DOCKER_DIR, + env=env, + text=True, + capture_output=True, + timeout=10, + check=False, + ) + + def test_entrypoint_preserves_ipv6_wildcard_when_token_allows_exposure(self, tmp_path): + result = self._run_entrypoint( + tmp_path, + { + "CRAWL4AI_API_TOKEN": "test-token", + "CRAWL4AI_JWT_ENABLED": "false", + "CRAWL4AI_PORT": "11235", + }, + ) + assert result.returncode == 0, result.stderr + assert "GUNICORN_BIND=[::]:11235" in result.stdout + + def test_entrypoint_ignores_kubernetes_service_link_port_env(self, tmp_path): + result = self._run_entrypoint( + tmp_path, + { + "CRAWL4AI_API_TOKEN": "test-token", + "CRAWL4AI_JWT_ENABLED": "false", + "CRAWL4AI_PORT": "tcp://10.0.101.25:80", + }, + ) + assert result.returncode == 0, result.stderr + assert "GUNICORN_BIND=[::]:11235" in result.stdout + assert "ignoring non-numeric CRAWL4AI_PORT" in result.stderr + + def test_entrypoint_ignores_service_link_port_env_without_token(self, tmp_path): + result = self._run_entrypoint( + tmp_path, + { + "CRAWL4AI_JWT_ENABLED": "false", + "CRAWL4AI_PORT": "tcp://10.0.101.25:80", + }, + ) + assert result.returncode == 0, result.stderr + assert "GUNICORN_BIND=127.0.0.1:11235" in result.stdout + assert "ignoring non-numeric CRAWL4AI_PORT" in result.stderr + + def test_entrypoint_preserves_explicit_gunicorn_bind(self, tmp_path): + result = self._run_entrypoint( + tmp_path, + { + "CRAWL4AI_API_TOKEN": "test-token", + "CRAWL4AI_JWT_ENABLED": "false", + "CRAWL4AI_PORT": "not-a-port", + "GUNICORN_BIND": "0.0.0.0:9999", + }, + ) + assert result.returncode == 0, result.stderr + assert "GUNICORN_BIND=0.0.0.0:9999" in result.stdout + + def test_entrypoint_rejects_other_non_numeric_port_env(self, tmp_path): + result = self._run_entrypoint( + tmp_path, + { + "CRAWL4AI_API_TOKEN": "test-token", + "CRAWL4AI_JWT_ENABLED": "false", + "CRAWL4AI_PORT": "http://10.0.101.25:80", + }, + ) + assert result.returncode == 1 + assert "CRAWL4AI_PORT must be numeric" in result.stderr + class TestSandboxOptOut: def test_default_keeps_no_sandbox(self, server_module, monkeypatch):