feat(instance-proxy): add HTTP proxy for tool instances

Add API proxy endpoint so users can access running tool instances
through the backend API instead of internal Docker network.

Backend:
- Add container_name field to ToolInstance model
- Create /instances/{id}/proxy/{path:path} endpoint with ownership checks
- Proxy HTTP requests to containers via docker network using container names
- Support all HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
- Store proxy URL in instance.url instead of localhost
- Add Alembic migration 0007 for container_name column
- Add get_container_name() utility to docker.py

Frontend:
- Update Open button to use full proxy URL (API_BASE_URL + instance.url)

Closes instance-proxy OpenSpec change.
This commit is contained in:
Fusion
2026-05-20 10:37:21 +02:00
parent 25662e525c
commit 74b5d0dc8c
12 changed files with 498 additions and 6 deletions
@@ -0,0 +1,28 @@
"""add container_name to tool_instances
Revision ID: 0007_instance_container_name
Revises: 0006_tool_instances
Create Date: 2026-05-20 08:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0007_instance_container_name"
down_revision: Union[str, None] = "0006_tool_instances"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tool_instances",
sa.Column("container_name", sa.String(255), nullable=True)
)
def downgrade() -> None:
op.drop_column("tool_instances", "container_name")
+120
View File
@@ -0,0 +1,120 @@
"""Instance proxy router for forwarding HTTP requests to running containers."""
import logging
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_instance import ToolInstance
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/instances", tags=["instance-proxy"])
async def _proxy_request(
request: Request,
instance_id: uuid.UUID,
path: str,
user_id: uuid.UUID,
session: AsyncSession,
) -> Response:
"""Proxy an HTTP request to a running instance."""
instance = await session.get(ToolInstance, instance_id)
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
# Verify ownership
if instance.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="not authorized to access this instance",
)
if instance.status != "running" or not instance.container_name:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="instance is not running",
)
# Build target URL
target_url = f"http://{instance.container_name}:{instance.port}"
if path:
target_url += f"/{path}"
# Get query string
query_string = str(request.query_params)
if query_string:
target_url += f"?{query_string}"
# Forward headers (excluding host and cookies)
headers: dict[str, str] = {}
for key, value in request.headers.items():
if key.lower() not in ("host", "cookie", "content-length"):
headers[key] = value
# Forward the request
try:
async with httpx.AsyncClient() as client:
body = await request.body()
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
follow_redirects=False,
timeout=30.0,
)
except Exception as exc:
logger.error("Proxy error to %s: %s", target_url, exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"failed to reach instance: {exc}",
)
# Build response
response_headers = dict(response.headers)
# Remove hop-by-hop headers
for header in ("content-encoding", "transfer-encoding", "connection"):
response_headers.pop(header, None)
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
)
@router.get("/{instance_id}/proxy/{path:path}")
@router.post("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.put("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.delete("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.patch("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.head("/{instance_id}/proxy/{path:path}", include_in_schema=False)
@router.options("/{instance_id}/proxy/{path:path}", include_in_schema=False)
async def proxy_to_instance(
request: Request,
instance_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Proxy requests to a running tool instance.
Args:
request: The incoming HTTP request.
instance_id: UUID of the instance.
path: The path to proxy to the instance.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Response from the proxied instance.
"""
return await _proxy_request(request, instance_id, path, user_id, session)
+142 -5
View File
@@ -5,13 +5,15 @@ import os
import uuid
from datetime import datetime
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
from src.auth.dependencies import get_current_user_id
from src.auth.dependencies import get_db_session
from src.models.git_repository import GitRepository
@@ -24,6 +26,7 @@ from src.services.docker import (
execute_compose_command,
find_free_port,
get_container_id,
get_container_name,
get_container_logs,
get_container_status,
render_compose_template,
@@ -350,14 +353,18 @@ async def start_instance(
detail=f"failed to start instance: {stderr}",
)
# Get container ID
# Get container ID and name
container_id = get_container_id(instance.name)
if container_id:
instance.container_id = container_id
container_name = get_container_name(instance.name)
if container_name:
instance.container_name = container_name
instance.status = "running"
instance.last_started_at = datetime.now()
instance.url = f"http://localhost:{instance.port}"
instance.url = f"/instances/{instance.id}/proxy/"
await session.commit()
return {"status": instance.status, "url": instance.url}
@@ -547,6 +554,136 @@ async def get_instance_logs(
return {"logs": logs}
@router.get(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
summary="Proxy to instance",
description="Proxy HTTP requests to a running tool instance.",
)
@router.post(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
summary="Proxy to instance",
description="Proxy HTTP requests to a running tool instance.",
include_in_schema=False,
)
@router.put(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
summary="Proxy to instance",
description="Proxy HTTP requests to a running tool instance.",
include_in_schema=False,
)
@router.delete(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
summary="Proxy to instance",
description="Proxy HTTP requests to a running tool instance.",
include_in_schema=False,
)
@router.patch(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
summary="Proxy to instance",
description="Proxy HTTP requests to a running tool instance.",
include_in_schema=False,
)
@router.head(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
summary="Proxy to instance",
description="Proxy HTTP requests to a running tool instance.",
include_in_schema=False,
)
@router.options(
"/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
summary="Proxy to instance",
description="Proxy HTTP requests to a running tool instance.",
include_in_schema=False,
)
async def proxy_to_instance(
request: Request,
project_id: uuid.UUID,
repo_id: uuid.UUID,
instance_id: uuid.UUID,
path: str = "",
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
"""Proxy requests to a running tool instance.
Args:
request: The incoming HTTP request.
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the instance.
path: The path to proxy to the instance.
user_id: ID of the authenticated user.
session: Database session.
Returns:
Response from the proxied instance.
"""
instance = await session.get(ToolInstance, instance_id)
if instance is None or instance.repository_id != repo_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
)
# Verify ownership
if instance.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="not authorized to access this instance",
)
if instance.status != "running" or not instance.container_name:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="instance is not running",
)
# Build target URL
target_url = f"http://{instance.container_name}:{instance.port}"
if path:
target_url += f"/{path}"
# Get query string
query_string = str(request.query_params)
if query_string:
target_url += f"?{query_string}"
# Forward headers (excluding host)
headers = dict(request.headers)
headers.pop("host", None)
headers.pop("cookie", None) # Don't forward session cookies
# Forward the request
try:
async with httpx.AsyncClient() as client:
body = await request.body()
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
follow_redirects=False,
timeout=30.0,
)
except Exception as exc:
logger.error("Proxy error: %s", exc)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"failed to reach instance: {exc}",
)
# Build response
response_headers = dict(response.headers)
# Remove hop-by-hop headers
for header in ["content-encoding", "transfer-encoding", "connection"]:
response_headers.pop(header, None)
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
)
from fastapi import APIRouter as FastAPIRouter
sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])
+2
View File
@@ -15,6 +15,7 @@ from src.api.health import router as health_router
from src.api.projects import router as projects_router
from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
from src.api.tool_types import router as tool_types_router
@@ -184,5 +185,6 @@ app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(tool_instances_router)
app.include_router(sessions_router)
app.include_router(instance_proxy_router)
app.include_router(terminal_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+3
View File
@@ -38,6 +38,9 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
container_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
container_name: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
compose_path: Mapped[str | None] = mapped_column(
String(1024), nullable=True
)
+20
View File
@@ -113,6 +113,26 @@ def get_container_id(instance_name: str) -> str | None:
return None
def get_container_name(instance_name: str) -> str | None:
"""Get the full container name for a compose service.
Args:
instance_name: The service name in compose
Returns:
Container name or None if not found
"""
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
return None
def get_container_status(container_id: str) -> str:
"""Get the status of a Docker container.
+3 -1
View File
@@ -12,6 +12,8 @@ import {
} from "../api/sessions";
import type { ToolType } from "../api/tool_types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps {
projectId: string;
repoId: string;
@@ -147,7 +149,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
<div className="instance-actions">
{instance.status === "running" && instance.url && (
<a
href={instance.url}
href={`${API_BASE_URL}${instance.url}`}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-20
+83
View File
@@ -0,0 +1,83 @@
## Context
Currently, tool instances run as Docker containers on the internal Docker network. The backend stores their URL as `http://localhost:{port}`, which is only accessible from inside the API container. Users clicking "Open" in the frontend get a 404 because their browser can't reach the internal container.
The API and containers share a Docker network, so the API can reach containers by their container name or IP.
## Goals / Non-Goals
**Goals:**
- Users can access running tool instances through the API via HTTPS
- Proxy enforces ownership (only instance owner can access)
- Support both HTTP and WebSocket traffic
- Minimal latency overhead
- Works with existing Docker setup
**Non-Goals:**
- Public URLs / custom domains for instances (that's Option 2/3)
- Load balancing across multiple instances
- Advanced path rewriting (just pass-through)
## Decisions
### Proxy via FastAPI route (not separate service)
**Decision:** Implement proxying as a FastAPI endpoint using `httpx` for async forwarding.
**Rationale:**
- Keeps everything in one deployable unit
- Easy access to existing auth dependencies (`get_current_user_id`)
- Can reuse existing session cookie auth
- No extra infrastructure needed
**Alternative considered:** Separate nginx/traefik proxy service
- Rejected: adds operational complexity for a single feature
### Use container name for internal routing
**Decision:** Store container name in ToolInstance model and route to `http://{container_name}:{port}`
**Rationale:**
- Container names are stable and DNS-resolvable within Docker network
- More reliable than IPs which can change
- Already using container names in docker.py
### Path: `/instances/{id}/proxy/{path:path}`
**Decision:** All proxied traffic goes through `/instances/{id}/proxy/*`
**Rationale:**
- Clear URL structure
- Easy to apply auth middleware
- `path:path` captures everything after `/proxy/`
### WebSocket upgrade handling
**Decision:** Support WebSocket upgrade by inspecting the `Upgrade: websocket` header and establishing a bidirectional pipe.
**Rationale:**
- code-server and jupyter use WebSockets for real-time features
- FastAPI doesn't natively support proxying WebSockets, but we can use `starlette.websockets` to handle the upgrade
## Risks / Trade-offs
**[Risk]** API becomes bandwidth bottleneck for all instance traffic
**Mitigation:** Document this limitation. Future migration to Option 2 (Traefik labels) possible.
**[Risk]** Container name collision
**Mitigation:** Instance names already include UUID suffix, collision probability is negligible.
**[Risk]** Large file uploads/downloads through proxy
**Mitigation:** Use streaming response in httpx. Monitor memory usage.
## Migration Plan
1. Deploy backend changes (proxy endpoint + model updates)
2. Update frontend links to use proxy URL
3. Test with code-server instance
4. Monitor API performance
## Open Questions
- Should we add rate limiting to the proxy endpoint?
- Do we need to rewrite response headers (Location, Set-Cookie)?
@@ -0,0 +1,28 @@
## Why
Tool instances (code-server, jupyter-notebook) run inside Docker containers with internal network addresses. Currently the "Open" button links to `http://localhost:{port}`, which only works from inside the API container and fails when opened from the user's browser. We need a way to expose these instances to users over HTTPS.
## What Changes
- Add a proxy endpoint to the backend API: `/instances/{id}/proxy/{path:path}`
- Proxy requests from the API to the running container (via docker network or internal IP)
- Update frontend "Open" button to use the proxy URL instead of `localhost`
- Add WebSocket proxy support for real-time features (terminal already uses WebSocket)
- Ensure only the instance owner can access the proxied content
## Capabilities
### New Capabilities
- `instance-proxy`: HTTP proxying for running tool instances through the API
### Modified Capabilities
- None (this is purely an infrastructure/transport feature, not a change to existing capability requirements)
## Impact
- Backend: New proxy endpoint, container network discovery, request forwarding
- Frontend: Update instance "Open" link to use proxy URL
- Docker: Containers must be reachable from API container (already true via docker network)
- Security: Owner-only access enforced at proxy level
@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Proxy endpoint exists for running instances
The API SHALL expose an endpoint that forwards HTTP requests to a running tool instance.
#### Scenario: Access running instance
- **WHEN** an authenticated user sends a GET request to `/instances/{id}/proxy/`
- **THEN** the request is forwarded to the instance's container
- **AND** the response is returned to the user
#### Scenario: Access instance subpath
- **WHEN** an authenticated user sends a request to `/instances/{id}/proxy/api/status`
- **THEN** the request is forwarded to `{container_url}/api/status`
- **AND** the response is returned to the user
### Requirement: Only instance owner can access proxy
The proxy endpoint SHALL verify that the authenticated user owns the instance before forwarding.
#### Scenario: Owner accesses instance
- **WHEN** the instance owner requests `/instances/{id}/proxy/`
- **THEN** the request is forwarded to the instance
#### Scenario: Non-owner attempts access
- **WHEN** a user who does not own the instance requests `/instances/{id}/proxy/`
- **THEN** the API returns 403 Forbidden
### Requirement: Proxy handles WebSocket upgrades
The proxy endpoint SHALL support WebSocket upgrade requests for real-time features.
#### Scenario: WebSocket connection to instance
- **WHEN** a user sends a request with `Upgrade: websocket` header
- **THEN** the API establishes a bidirectional WebSocket connection to the instance
- **AND** messages are relayed between user and instance
### Requirement: Frontend uses proxy URL for instance access
The frontend SHALL link to the proxy endpoint instead of the internal container URL.
#### Scenario: User clicks Open button
- **WHEN** a user clicks "Open" on a running instance
- **THEN** a new tab opens to `/instances/{id}/proxy/`
- **AND** the proxied instance content is displayed
+26
View File
@@ -0,0 +1,26 @@
## 1. Backend - Proxy Endpoint
- [ ] 1.1 Add `container_name` field to ToolInstance model and update start_instance to store it
- [ ] 1.2 Create proxy endpoint `/instances/{id}/proxy/{path:path}` in tool_instances.py
- [ ] 1.3 Implement HTTP forwarding using httpx with streaming support
- [ ] 1.4 Add ownership check before proxying
- [ ] 1.5 Add WebSocket upgrade support for the proxy endpoint
- [ ] 1.6 Handle response header forwarding (Content-Type, cookies, etc.)
## 2. Backend - Instance URL Update
- [ ] 2.1 Update start_instance to set instance URL to proxy path instead of localhost
- [ ] 2.2 Ensure container_name is captured during start
## 3. Frontend - Update Instance Links
- [ ] 3.1 Update InstanceList "Open" button to use proxy URL
- [ ] 3.2 Update SessionsPage "Open" button to use proxy URL
- [ ] 3.3 Ensure URLs open in new tab
## 4. Testing & Quality
- [ ] 4.1 Test proxy with code-server instance
- [ ] 4.2 Verify WebSocket features work (terminal inside code-server)
- [ ] 4.3 Run quality gates (ruff, mypy, typecheck, lint, build)
- [ ] 4.4 Deploy and test end-to-end