fix(cloudflared): code-server binds to 127.0.0.1 causing tunnel app error 0

Root cause: code-server (and similar web tools) default to binding to
127.0.0.1 (localhost) inside their containers. This makes them unreachable
from the Docker network and from cloudflared, which connects via the
container's Docker network name.

Changes:
- Migration: Update code-server compose_template to include
  --bind-addr 0.0.0.0:8443 command override
- Migration: Update jupyter-notebook compose_template to include
  --ip=0.0.0.0 flag
- Runtime safety net: _ensure_web_bind_address() auto-injects bind
  address for known web tools (code-server, jupyter-notebook) when
  compose doesn't already specify a command
- Diagnostics: _check_app_binding() compares internal vs external
  connectivity to detect 127.0.0.1 binding issues
- Improved readiness check: 30s timeout, checks HTTP status codes,
  logs curl stderr for debugging

Files:
- apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py
- apps/api/src/services/docker.py
- apps/api/src/api/tool_instances.py
- apps/api/tests/integration/test_tool_types_api_extended.py

Quality gates: pytest 42 passed (5 pre-existing unrelated failures)
This commit is contained in:
2026-05-29 16:15:53 +02:00
parent 9c4500f9cb
commit 3c57c8b78b
4 changed files with 193 additions and 5 deletions
+61 -1
View File
@@ -618,6 +618,62 @@ def _modify_compose_file(
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
"""Auto-inject bind address for known web tools that default to 127.0.0.1.
Many web tools (code-server, jupyter) bind to localhost by default,
making them inaccessible from the Docker network. This function detects
known tool images and injects the correct --bind-addr or --ip flag.
"""
import yaml
from pathlib import Path
KNOWN_BIND_FIXES = {
"code-server": "--bind-addr 0.0.0.0:8443",
"jupyter-notebook": "start-notebook.sh --ip=0.0.0.0",
}
bind_command = KNOWN_BIND_FIXES.get(tool_type_name)
if not bind_command:
return
compose_file = Path(compose_path)
if not compose_file.exists():
return
content = compose_file.read_text()
compose_data = yaml.safe_load(content)
if not compose_data or "services" not in compose_data:
return
for service_config in compose_data["services"].values():
# Skip if command is already overridden
if "command" in service_config:
return
image = service_config.get("image", "")
if not image:
return
# Check if the image matches a known tool
if tool_type_name == "code-server" and (
"code-server" in image or "coder" in image
):
service_config["command"] = bind_command
break
if tool_type_name == "jupyter-notebook" and (
"jupyter" in image or "notebook" in image
):
service_config["command"] = bind_command
break
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
logger.info(
"Injected bind address for %s: %s", tool_type_name, bind_command
)
@router.post(
"/{project_id}/repositories/{repo_id}/instances",
summary="Create tool instance",
@@ -854,7 +910,7 @@ services:
)
# Determine home directory for path expansion
home_dir = get_manifest_home_dir(manifest)
_home_dir = get_manifest_home_dir(manifest)
image_tag = compute_image_tag(tool_type.name, manifest)
@@ -1550,6 +1606,10 @@ async def start_instance(
# Sanitize compose file to remove invalid port mappings from old instances
_sanitize_compose_file(instance.compose_path)
# Auto-fix bind address for known web tools that default to localhost
if tool_type and tool_type.interface_type == "web":
_ensure_web_bind_address(instance.compose_path, tool_type.name)
# Execute docker compose up with env file
logger.debug(
"Running docker compose up for instance %s (compose_path=%s)",
+1 -3
View File
@@ -384,9 +384,7 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
raise RuntimeError(f"No free port found in range {start}-{end}")
def _check_app_binding(
container_name: str, port: int
) -> dict[str, str | bool]:
def _check_app_binding(container_name: str, port: int) -> dict[str, str | bool]:
"""Diagnose whether the app is bound to 127.0.0.1 or 0.0.0.0.
Checks from both inside the container (localhost) and outside