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
@@ -0,0 +1,130 @@
"""fix web tool bind address to 0.0.0.0
Revision ID: 2026_05_29_fix_web_tool_bind_address
Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest
Create Date: 2026-05-29 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_29_fix_web_tool_bind_address"
down_revision: Union[str, None] = "2026_05_29_remove_ssh_keys_mount_from_manifest"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _fix_code_server_compose(conn) -> None:
"""Update code-server compose template to bind to 0.0.0.0."""
result = conn.execute(
sa.text("""
SELECT id, compose_template, definition_type
FROM tool_types
WHERE name = 'code-server'
""")
).fetchone()
if result is None:
return
tool_id, compose_template, definition_type = result
if definition_type != "compose" or not compose_template:
return
# Add command to bind to 0.0.0.0 if not already present
if "command:" in compose_template:
# Already has a command override, skip
return
# Insert command line after the image line
lines = compose_template.split("\n")
new_lines = []
image_line_idx = -1
for i, line in enumerate(lines):
new_lines.append(line)
if "image:" in line and image_line_idx == -1:
image_line_idx = i
# Insert command with proper indentation (same as image line)
indent = line[: len(line) - len(line.lstrip())]
new_lines.append(f"{indent}command: --bind-addr 0.0.0.0:8443")
if image_line_idx == -1:
# No image line found, can't safely modify
return
updated_compose = "\n".join(new_lines)
conn.execute(
sa.text("""
UPDATE tool_types
SET compose_template = :compose_template
WHERE id = :id
"""),
{"compose_template": updated_compose, "id": tool_id},
)
print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0:8443")
def _fix_jupyter_compose(conn) -> None:
"""Update jupyter-notebook compose template to bind to 0.0.0.0."""
result = conn.execute(
sa.text("""
SELECT id, compose_template, definition_type
FROM tool_types
WHERE name = 'jupyter-notebook'
""")
).fetchone()
if result is None:
return
tool_id, compose_template, definition_type = result
if definition_type != "compose" or not compose_template:
return
if "command:" in compose_template:
return
lines = compose_template.split("\n")
new_lines = []
image_line_idx = -1
for i, line in enumerate(lines):
new_lines.append(line)
if "image:" in line and image_line_idx == -1:
image_line_idx = i
indent = line[: len(line) - len(line.lstrip())]
# Jupyter needs --ip=0.0.0.0 to bind to all interfaces
new_lines.append(f'{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser')
if image_line_idx == -1:
return
updated_compose = "\n".join(new_lines)
conn.execute(
sa.text("""
UPDATE tool_types
SET compose_template = :compose_template
WHERE id = :id
"""),
{"compose_template": updated_compose, "id": tool_id},
)
print(f"Updated jupyter-notebook tool type ({tool_id}) to bind to 0.0.0.0:8888")
def upgrade() -> None:
conn = op.get_bind()
_fix_code_server_compose(conn)
_fix_jupyter_compose(conn)
def downgrade() -> None:
# Cannot safely downgrade without knowing the original compose_template
pass
+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
@@ -166,7 +166,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web", "terminal"],
"default_port": 8443,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"readiness_probe": {
"command": "curl -f http://localhost:8443",
"timeout": 30,