From 3c57c8b78b28e9852ce02e711af6d8f5e747038c Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 16:15:53 +0200 Subject: [PATCH] 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) --- .../2026_05_29_fix_web_tool_bind_address.py | 130 ++++++++++++++++++ apps/api/src/api/tool_instances.py | 62 ++++++++- apps/api/src/services/docker.py | 4 +- .../test_tool_types_api_extended.py | 2 +- 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py diff --git a/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py b/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py new file mode 100644 index 0000000..431446f --- /dev/null +++ b/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py @@ -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 diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 05e9d54..d9ecfe9 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -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)", diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index fd05903..f583c1e 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -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 diff --git a/apps/api/tests/integration/test_tool_types_api_extended.py b/apps/api/tests/integration/test_tool_types_api_extended.py index 974f815..df581a0 100644 --- a/apps/api/tests/integration/test_tool_types_api_extended.py +++ b/apps/api/tests/integration/test_tool_types_api_extended.py @@ -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,