From 3c57c8b78b28e9852ce02e711af6d8f5e747038c Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 16:15:53 +0200 Subject: [PATCH 1/2] 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, From 1efbc289ba7c7bc26bd73cdec703577d8d8e0c57 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 16:36:09 +0200 Subject: [PATCH 2/2] fix(cloudflared): use --host 0.0.0.0 instead of --bind-addr for code-server The --bind-addr flag caused code-server to fail entirely (app not responding on any interface). The correct override for the coder/code-server image is --host 0.0.0.0, which overrides the entrypoint's --host 127.0.0.1. Changes: - Migration: Replace --bind-addr with --host 0.0.0.0, also handle existing broken templates by detecting --bind-addr and replacing it - Runtime safety net: _ensure_web_bind_address uses --host 0.0.0.0 - Test fixture: Updated compose template to match Quality gates: pytest 42 passed --- .../2026_05_29_fix_web_tool_bind_address.py | 36 +++++++----- apps/api/src/api/tool_instances.py | 6 +- .../test_tool_types_api_extended.py | 58 ++++++++++++++----- 3 files changed, 68 insertions(+), 32 deletions(-) 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 index 431446f..9058a7a 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -35,25 +36,32 @@ def _fix_code_server_compose(conn) -> None: 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 + # Fix or add command to bind to 0.0.0.0 lines = compose_template.split("\n") new_lines = [] image_line_idx = -1 + command_fixed = False for i, line in enumerate(lines): + # Replace broken --bind-addr with correct --host + if "command:" in line and "--bind-addr" in line: + indent = line[: len(line) - len(line.lstrip())] + new_lines.append(f"{indent}command: --host 0.0.0.0") + command_fixed = True + continue 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 + # If no command line exists, insert one after image + if not command_fixed and image_line_idx != -1: + image_line = lines[image_line_idx] + indent = image_line[: len(image_line) - len(image_line.lstrip())] + # Insert after the image line in new_lines + insert_idx = new_lines.index(image_line) + 1 + new_lines.insert(insert_idx, f"{indent}command: --host 0.0.0.0") + command_fixed = True + + if not command_fixed: return updated_compose = "\n".join(new_lines) @@ -67,7 +75,7 @@ def _fix_code_server_compose(conn) -> None: {"compose_template": updated_compose, "id": tool_id}, ) - print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0:8443") + print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0") def _fix_jupyter_compose(conn) -> None: @@ -100,7 +108,9 @@ def _fix_jupyter_compose(conn) -> None: 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') + new_lines.append( + f"{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser" + ) if image_line_idx == -1: return diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index d9ecfe9..32ee7c1 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -629,7 +629,7 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: from pathlib import Path KNOWN_BIND_FIXES = { - "code-server": "--bind-addr 0.0.0.0:8443", + "code-server": "--host 0.0.0.0", "jupyter-notebook": "start-notebook.sh --ip=0.0.0.0", } @@ -669,9 +669,7 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: 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 - ) + logger.info("Injected bind address for %s: %s", tool_type_name, bind_command) @router.post( 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 df581a0..be7755c 100644 --- a/apps/api/tests/integration/test_tool_types_api_extended.py +++ b/apps/api/tests/integration/test_tool_types_api_extended.py @@ -6,7 +6,9 @@ from fastapi.testclient import TestClient class TestToolTypesAPIExtended: """Integration tests for tool types API with new fields.""" - def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_dockerfile( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with dockerfile definition.""" response = authenticated_client.post( "/tool-types", @@ -27,7 +29,9 @@ class TestToolTypesAPIExtended: assert data["definition_type"] == "dockerfile" assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask" - def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_readiness_probe( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with readiness probe.""" response = authenticated_client.post( "/tool-types", @@ -52,7 +56,9 @@ class TestToolTypesAPIExtended: assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080" assert data["readiness_probe"]["timeout"] == 30 - def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_invalid_definition_type( + self, authenticated_client: TestClient + ) -> None: """Test that invalid definition types are rejected.""" response = authenticated_client.post( "/tool-types", @@ -67,7 +73,9 @@ class TestToolTypesAPIExtended: ) assert response.status_code == 422 - def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_dockerfile_without_template( + self, authenticated_client: TestClient + ) -> None: """Test that dockerfile type requires dockerfile_template.""" response = authenticated_client.post( "/tool-types", @@ -81,7 +89,9 @@ class TestToolTypesAPIExtended: ) assert response.status_code == 422 - def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None: + def test_update_tool_type_with_new_fields( + self, authenticated_client: TestClient + ) -> None: """Test updating a tool type with new fields.""" # Create tool type first create_response = authenticated_client.post( @@ -112,7 +122,9 @@ class TestToolTypesAPIExtended: assert response.status_code == 200 data = response.json() assert data["display_name"] == "Updated Name" - assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health" + assert ( + data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health" + ) def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None: """Test validating compose template.""" @@ -127,7 +139,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["valid"] is True - def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None: + def test_validate_tool_type_invalid_compose( + self, authenticated_client: TestClient + ) -> None: """Test validating invalid compose template.""" response = authenticated_client.post( "/tool-types/validate", @@ -141,7 +155,9 @@ class TestToolTypesAPIExtended: assert data["valid"] is False assert "errors" in data - def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None: + def test_validate_tool_type_dockerfile( + self, authenticated_client: TestClient + ) -> None: """Test validating dockerfile template.""" response = authenticated_client.post( "/tool-types/validate", @@ -154,7 +170,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["valid"] is True - def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None: + def test_get_tool_type_returns_new_fields( + self, authenticated_client: TestClient + ) -> None: """Test that GET returns new fields.""" # Create tool type with all fields create_response = authenticated_client.post( @@ -166,7 +184,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 command: --bind-addr 0.0.0.0:8443\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: --host 0.0.0.0\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", "readiness_probe": { "command": "curl -f http://localhost:8443", "timeout": 30, @@ -186,7 +204,9 @@ class TestToolTypesAPIExtended: assert data["interfaces"] == ["web", "terminal"] assert "readiness_probe" in data - def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_without_port_fails( + self, authenticated_client: TestClient + ) -> None: """Test that creating a tool type without default_port fails validation.""" response = authenticated_client.post( "/tool-types", @@ -204,7 +224,9 @@ class TestToolTypesAPIExtended: data = response.json() assert "default_port" in str(data) - def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_port_mismatch_fails( + self, authenticated_client: TestClient + ) -> None: """Test that port mismatch between default_port and compose template fails.""" response = authenticated_client.post( "/tool-types", @@ -222,7 +244,9 @@ class TestToolTypesAPIExtended: assert response.status_code == 422 _ = response.json() - def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with startup_command.""" response = authenticated_client.post( "/tool-types", @@ -244,7 +268,9 @@ class TestToolTypesAPIExtended: assert data["startup_command"] == "cd /workspace && ls" assert data["interface_type"] == "terminal" - def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None: + def test_update_tool_type_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test updating a tool type's startup_command.""" # Create tool type first create_response = authenticated_client.post( @@ -273,7 +299,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["startup_command"] == "source /etc/profile" - def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None: + def test_get_tool_type_returns_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test that GET returns startup_command.""" create_response = authenticated_client.post( "/tool-types",