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
+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.