commit 5783b4034387b6da642363ca7da04ea9c96608f7 Author: Pierre Date: Sun Feb 8 16:38:58 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6b130c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/settings.local.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8abe49c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app/ ./app/ + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5257b0 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# Light Docker API + +A Python/FastAPI REST API that provides a simplified, secure interface to Docker container operations. It runs as a Docker container itself and only manages containers it creates (tracked via labels). + +## Features + +- **Container Management**: Create, start, stop, restart, and remove containers +- **Container Isolation**: Only manages containers it creates (via labels) +- **Security Filters**: Blocks dangerous Docker options (privileged mode, host networking, etc.) +- **Resource Monitoring**: Get container logs and stats +- **Docker-like API**: Familiar endpoint structure for Docker users + +## Quick Start + +### Using Docker Compose + +```bash +docker-compose up --build +``` + +The API will be available at `http://localhost:8000`. + +### Manual Docker Build + +```bash +docker build -t light-docker-api . +docker run -p 8000:8000 -v /var/run/docker.sock:/var/run/docker.sock:ro light-docker-api +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/` | API information | +| GET | `/health` | Health check | +| GET | `/containers` | List managed containers | +| POST | `/containers/create` | Create a new container | +| GET | `/containers/{id}` | Inspect a container | +| POST | `/containers/{id}/start` | Start a container | +| POST | `/containers/{id}/stop` | Stop a container | +| POST | `/containers/{id}/restart` | Restart a container | +| DELETE | `/containers/{id}` | Remove a container | +| GET | `/containers/{id}/logs` | Get container logs | +| GET | `/containers/{id}/stats` | Get container stats | + +## Usage Examples + +### Create a container + +```bash +curl -X POST http://localhost:8000/containers/create \ + -H "Content-Type: application/json" \ + -d '{"image": "nginx", "name": "my-nginx"}' +``` + +### List containers + +```bash +curl http://localhost:8000/containers +``` + +### Start a container + +```bash +curl -X POST http://localhost:8000/containers/{id}/start +``` + +### Get container logs + +```bash +curl http://localhost:8000/containers/{id}/logs +``` + +### Remove a container + +```bash +curl -X DELETE http://localhost:8000/containers/{id} +``` + +## Security Restrictions + +The following options are blocked for security: + +- `privileged: true` - Rejected +- `network_mode: host` - Rejected +- `pid_mode: host` - Rejected +- `ipc_mode: host` - Rejected +- `devices` - Rejected +- `cap_add` - Only allowed capabilities (none by default) +- Volume mounts to sensitive paths (`/`, `/etc`, `/var/run/docker.sock`, etc.) + +## Configuration + +Environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `INSTANCE_ID` | Unique instance identifier | Random UUID | +| `LIGHT_DOCKER_API_CORS_ORIGINS` | CORS allowed origins | `["*"]` | +| `LIGHT_DOCKER_API_ALLOWED_IMAGES` | Allowed image list (empty = all) | `[]` | + +## API Documentation + +When running, interactive API documentation is available at: + +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +## Container Tracking + +Every container created through this API is labeled with: + +- `light-docker-api.managed=true` +- `light-docker-api.instance={instance_id}` + +Only containers with these labels are visible and manageable through the API. + +## Development + +### Local Development + +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +Note: Local development requires access to the Docker socket. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..00d12c8 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Light Docker API diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..4859cea --- /dev/null +++ b/app/config.py @@ -0,0 +1,50 @@ +"""Configuration settings for Light Docker API.""" + +import os +from typing import List, Set +from uuid import uuid4 + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # API settings + api_title: str = "Light Docker API" + api_version: str = "1.0.0" + cors_origins: List[str] = ["*"] + + # Instance identification + instance_id: str = os.environ.get("INSTANCE_ID", uuid4().hex[:8]) + + # Label configuration + label_prefix: str = "light-docker-api" + + # Security settings + allowed_images: List[str] = [] # Empty means all images allowed + blocked_volume_paths: Set[str] = { + "/", + "/etc", + "/var/run/docker.sock", + "/var/run", + "/proc", + "/sys", + "/dev", + "/boot", + "/root", + "/home", + } + + # Allowed capabilities (empty means none allowed) + allowed_capabilities: Set[str] = set() + + class Config: + env_prefix = "LIGHT_DOCKER_API_" + + +settings = Settings() + +# Label constants +MANAGED_LABEL = f"{settings.label_prefix}.managed" +INSTANCE_LABEL = f"{settings.label_prefix}.instance" diff --git a/app/docker_client.py b/app/docker_client.py new file mode 100644 index 0000000..01dd64a --- /dev/null +++ b/app/docker_client.py @@ -0,0 +1,346 @@ +"""Docker SDK wrapper with label-based container tracking.""" + +from datetime import datetime +from typing import Any, Dict, Generator, List, Optional, Union + +import docker +from docker.errors import APIError, ImageNotFound, NotFound +from docker.models.containers import Container +from fastapi import HTTPException, status + +from app.config import INSTANCE_LABEL, MANAGED_LABEL, settings +from app.models import ( + ContainerCreate, + ContainerCreateResponse, + ContainerLogsResponse, + ContainerResponse, + ContainerStatsResponse, +) + + +class DockerClientError(HTTPException): + """Exception raised for Docker client errors.""" + + def __init__(self, message: str, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR): + super().__init__(status_code=status_code, detail=message) + + +class DockerManager: + """Manager for Docker operations with label-based tracking.""" + + def __init__(self): + """Initialize the Docker client.""" + try: + self.client = docker.from_env() + except Exception as e: + raise RuntimeError(f"Failed to connect to Docker: {e}") + + self.instance_id = settings.instance_id + + def _get_managed_labels(self, extra_labels: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """Get the labels to apply to managed containers.""" + labels = { + MANAGED_LABEL: "true", + INSTANCE_LABEL: self.instance_id, + } + if extra_labels: + labels.update(extra_labels) + return labels + + def _container_to_response(self, container: Container) -> ContainerResponse: + """Convert a Docker container to a response model.""" + container.reload() # Ensure we have the latest state + attrs = container.attrs + + # Parse creation time + created_str = attrs.get("Created", "") + try: + # Docker uses ISO format with nanoseconds + created = datetime.fromisoformat(created_str.replace("Z", "+00:00")) + except (ValueError, AttributeError): + created = datetime.now() + + # Get port bindings + ports = {} + network_settings = attrs.get("NetworkSettings", {}) + port_bindings = network_settings.get("Ports", {}) + if port_bindings: + for container_port, bindings in port_bindings.items(): + if bindings: + ports[container_port] = [ + {"HostIp": b.get("HostIp", ""), "HostPort": b.get("HostPort", "")} + for b in bindings + ] + + # Get image name + image_name = attrs.get("Config", {}).get("Image", "") + if not image_name and container.image: + image_name = container.image.tags[0] if container.image.tags else container.image.short_id + + return ContainerResponse( + id=container.id, + short_id=container.short_id, + name=container.name, + image=image_name, + status=container.status, + created=created, + ports=ports, + labels=attrs.get("Config", {}).get("Labels", {}), + ) + + def is_managed(self, container: Container) -> bool: + """Check if a container is managed by this API.""" + labels = container.labels or {} + return labels.get(MANAGED_LABEL) == "true" + + def get_managed_container(self, container_id: str) -> Container: + """Get a container by ID, ensuring it's managed by this API.""" + try: + container = self.client.containers.get(container_id) + except NotFound: + raise DockerClientError( + f"Container '{container_id}' not found", + status_code=status.HTTP_404_NOT_FOUND, + ) + + if not self.is_managed(container): + raise DockerClientError( + f"Container '{container_id}' is not managed by this API", + status_code=status.HTTP_403_FORBIDDEN, + ) + + return container + + def list_managed(self, all_containers: bool = True) -> List[ContainerResponse]: + """List all containers managed by this API.""" + try: + containers = self.client.containers.list( + all=all_containers, + filters={"label": f"{MANAGED_LABEL}=true"}, + ) + except APIError as e: + raise DockerClientError(f"Failed to list containers: {e}") + + return [self._container_to_response(c) for c in containers] + + def create_container(self, request: ContainerCreate) -> ContainerCreateResponse: + """Create a new container with managed labels.""" + # Prepare labels + labels = self._get_managed_labels(request.labels) + + # Prepare port bindings + ports = None + if request.ports: + ports = {} + for container_port, host_binding in request.ports.items(): + if isinstance(host_binding, int): + ports[container_port] = host_binding + elif isinstance(host_binding, dict): + ports[container_port] = ( + host_binding.get("host_ip", "0.0.0.0"), + host_binding.get("host_port"), + ) + elif isinstance(host_binding, list): + ports[container_port] = [ + (b.get("host_ip", "0.0.0.0"), b.get("host_port")) + for b in host_binding + ] + + # Prepare volumes + volumes = None + if request.volumes: + volumes = { + host_path: { + "bind": config.bind, + "mode": config.mode, + } + for host_path, config in request.volumes.items() + } + + # Prepare restart policy + restart_policy = None + if request.restart_policy: + restart_policy = { + "Name": request.restart_policy.name, + "MaximumRetryCount": request.restart_policy.maximum_retry_count, + } + + # Build container options + container_options: Dict[str, Any] = { + "image": request.image, + "labels": labels, + "detach": True, + } + + if request.name: + container_options["name"] = request.name + if request.command: + container_options["command"] = request.command + if request.entrypoint: + container_options["entrypoint"] = request.entrypoint + if request.environment: + container_options["environment"] = request.environment + if ports: + container_options["ports"] = ports + if volumes: + container_options["volumes"] = volumes + if restart_policy: + container_options["restart_policy"] = restart_policy + if request.working_dir: + container_options["working_dir"] = request.working_dir + if request.user: + container_options["user"] = request.user + if request.hostname: + container_options["hostname"] = request.hostname + if request.network: + container_options["network"] = request.network + if request.mem_limit: + container_options["mem_limit"] = request.mem_limit + if request.cpu_period: + container_options["cpu_period"] = request.cpu_period + if request.cpu_quota: + container_options["cpu_quota"] = request.cpu_quota + + try: + container = self.client.containers.create(**container_options) + except ImageNotFound: + raise DockerClientError( + f"Image '{request.image}' not found", + status_code=status.HTTP_404_NOT_FOUND, + ) + except APIError as e: + raise DockerClientError(f"Failed to create container: {e}") + + return ContainerCreateResponse( + id=container.id, + name=container.name, + warnings=[], + ) + + def inspect_container(self, container_id: str) -> ContainerResponse: + """Get detailed information about a container.""" + container = self.get_managed_container(container_id) + return self._container_to_response(container) + + def start_container(self, container_id: str) -> None: + """Start a container.""" + container = self.get_managed_container(container_id) + try: + container.start() + except APIError as e: + raise DockerClientError(f"Failed to start container: {e}") + + def stop_container(self, container_id: str, timeout: int = 10) -> None: + """Stop a container.""" + container = self.get_managed_container(container_id) + try: + container.stop(timeout=timeout) + except APIError as e: + raise DockerClientError(f"Failed to stop container: {e}") + + def restart_container(self, container_id: str, timeout: int = 10) -> None: + """Restart a container.""" + container = self.get_managed_container(container_id) + try: + container.restart(timeout=timeout) + except APIError as e: + raise DockerClientError(f"Failed to restart container: {e}") + + def remove_container(self, container_id: str, force: bool = False, v: bool = False) -> None: + """Remove a container.""" + container = self.get_managed_container(container_id) + try: + container.remove(force=force, v=v) + except APIError as e: + raise DockerClientError(f"Failed to remove container: {e}") + + def get_logs( + self, + container_id: str, + stdout: bool = True, + stderr: bool = True, + tail: Union[int, str] = "all", + since: Optional[int] = None, + until: Optional[int] = None, + ) -> ContainerLogsResponse: + """Get container logs.""" + container = self.get_managed_container(container_id) + try: + logs = container.logs( + stdout=stdout, + stderr=stderr, + tail=tail, + since=since, + until=until, + ) + if isinstance(logs, bytes): + logs = logs.decode("utf-8", errors="replace") + except APIError as e: + raise DockerClientError(f"Failed to get logs: {e}") + + return ContainerLogsResponse( + logs=logs, + container_id=container_id, + ) + + def get_stats(self, container_id: str, stream: bool = False) -> ContainerStatsResponse: + """Get container stats.""" + container = self.get_managed_container(container_id) + try: + stats = container.stats(stream=stream) + if isinstance(stats, Generator): + stats = next(stats) + except APIError as e: + raise DockerClientError(f"Failed to get stats: {e}") + + # Parse CPU stats + cpu_delta = stats.get("cpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0) - \ + stats.get("precpu_stats", {}).get("cpu_usage", {}).get("total_usage", 0) + system_delta = stats.get("cpu_stats", {}).get("system_cpu_usage", 0) - \ + stats.get("precpu_stats", {}).get("system_cpu_usage", 0) + num_cpus = len(stats.get("cpu_stats", {}).get("cpu_usage", {}).get("percpu_usage", [1])) + + cpu_percent = 0.0 + if system_delta > 0 and cpu_delta > 0: + cpu_percent = (cpu_delta / system_delta) * num_cpus * 100.0 + + # Parse memory stats + memory_stats = stats.get("memory_stats", {}) + memory_usage = memory_stats.get("usage", 0) + memory_limit = memory_stats.get("limit", 1) + memory_percent = (memory_usage / memory_limit) * 100.0 if memory_limit > 0 else 0.0 + + # Parse network stats + networks = stats.get("networks", {}) + network_rx = sum(n.get("rx_bytes", 0) for n in networks.values()) + network_tx = sum(n.get("tx_bytes", 0) for n in networks.values()) + + return ContainerStatsResponse( + container_id=container_id, + cpu_percent=round(cpu_percent, 2), + memory_usage=memory_usage, + memory_limit=memory_limit, + memory_percent=round(memory_percent, 2), + network_rx_bytes=network_rx, + network_tx_bytes=network_tx, + ) + + def ping(self) -> bool: + """Check if Docker daemon is accessible.""" + try: + self.client.ping() + return True + except Exception: + return False + + +# Global instance (created on first import) +docker_manager: Optional[DockerManager] = None + + +def get_docker_manager() -> DockerManager: + """Get or create the Docker manager instance.""" + global docker_manager + if docker_manager is None: + docker_manager = DockerManager() + return docker_manager diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..079deb7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,64 @@ +"""Light Docker API - FastAPI application entry point.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.docker_client import get_docker_manager +from app.routes import containers + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + # Startup: Initialize Docker client and verify connection + docker = get_docker_manager() + if not docker.ping(): + raise RuntimeError("Cannot connect to Docker daemon") + print(f"Connected to Docker daemon, instance ID: {docker.instance_id}") + yield + # Shutdown: Cleanup if needed + print("Shutting down Light Docker API") + + +app = FastAPI( + title=settings.api_title, + version=settings.api_version, + description="A simplified, secure REST API for Docker container operations", + lifespan=lifespan, +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(containers.router) + + +@app.get("/", tags=["root"]) +async def root(): + """Root endpoint with API information.""" + return { + "name": settings.api_title, + "version": settings.api_version, + "instance_id": settings.instance_id, + } + + +@app.get("/health", tags=["health"]) +async def health(): + """Health check endpoint.""" + docker = get_docker_manager() + docker_ok = docker.ping() + return { + "status": "healthy" if docker_ok else "unhealthy", + "docker": "connected" if docker_ok else "disconnected", + } diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..c1b9751 --- /dev/null +++ b/app/models.py @@ -0,0 +1,129 @@ +"""Pydantic models for request/response validation.""" + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, Field + + +class VolumeConfig(BaseModel): + """Volume binding configuration.""" + + bind: str = Field(..., description="Container path to mount to") + mode: Literal["ro", "rw"] = Field(default="rw", description="Mount mode") + + +class PortBinding(BaseModel): + """Port binding configuration.""" + + host_ip: str = Field(default="0.0.0.0", description="Host IP to bind to") + host_port: int = Field(..., description="Host port to bind to") + + +class RestartPolicy(BaseModel): + """Container restart policy.""" + + name: Literal["no", "always", "on-failure", "unless-stopped"] = Field( + default="no", description="Restart policy name" + ) + maximum_retry_count: int = Field( + default=0, description="Max retries for on-failure policy" + ) + + +class ContainerCreate(BaseModel): + """Request model for creating a container.""" + + image: str = Field(..., description="Docker image to use") + name: Optional[str] = Field(None, description="Container name") + command: Optional[Union[str, List[str]]] = Field( + None, description="Command to run" + ) + entrypoint: Optional[Union[str, List[str]]] = Field( + None, description="Container entrypoint" + ) + environment: Optional[Dict[str, str]] = Field( + None, description="Environment variables" + ) + ports: Optional[Dict[str, Union[int, PortBinding, List[PortBinding]]]] = Field( + None, + description="Port bindings: {'80/tcp': 8080} or {'80/tcp': {'host_ip': '0.0.0.0', 'host_port': 8080}}", + ) + volumes: Optional[Dict[str, VolumeConfig]] = Field( + None, + description="Volume bindings: {'/host/path': {'bind': '/container/path', 'mode': 'rw'}}", + ) + labels: Optional[Dict[str, str]] = Field(None, description="Container labels") + restart_policy: Optional[RestartPolicy] = Field( + None, description="Restart policy configuration" + ) + working_dir: Optional[str] = Field(None, description="Working directory") + user: Optional[str] = Field(None, description="User to run as") + hostname: Optional[str] = Field(None, description="Container hostname") + network: Optional[str] = Field(None, description="Network to connect to") + mem_limit: Optional[str] = Field(None, description="Memory limit (e.g., '512m')") + cpu_period: Optional[int] = Field(None, description="CPU period in microseconds") + cpu_quota: Optional[int] = Field(None, description="CPU quota in microseconds") + + model_config = {"extra": "forbid"} + + +class ContainerResponse(BaseModel): + """Response model for container information.""" + + id: str = Field(..., description="Container ID") + short_id: str = Field(..., description="Short container ID") + name: str = Field(..., description="Container name") + image: str = Field(..., description="Image name") + status: str = Field(..., description="Container status") + created: datetime = Field(..., description="Creation timestamp") + ports: Dict[str, Any] = Field(default_factory=dict, description="Port bindings") + labels: Dict[str, str] = Field(default_factory=dict, description="Container labels") + + +class ContainerListResponse(BaseModel): + """Response model for listing containers.""" + + containers: List[ContainerResponse] + count: int + + +class ContainerCreateResponse(BaseModel): + """Response model for container creation.""" + + id: str + name: str + warnings: List[str] = Field(default_factory=list) + + +class ContainerLogsResponse(BaseModel): + """Response model for container logs.""" + + logs: str + container_id: str + + +class ContainerStatsResponse(BaseModel): + """Response model for container stats.""" + + container_id: str + cpu_percent: float + memory_usage: int + memory_limit: int + memory_percent: float + network_rx_bytes: int + network_tx_bytes: int + + +class ErrorResponse(BaseModel): + """Error response model.""" + + error: str + detail: Optional[str] = None + + +class MessageResponse(BaseModel): + """Simple message response.""" + + message: str + container_id: Optional[str] = None diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..d212dab --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +# Routes package diff --git a/app/routes/containers.py b/app/routes/containers.py new file mode 100644 index 0000000..ad510ce --- /dev/null +++ b/app/routes/containers.py @@ -0,0 +1,171 @@ +"""Container management API routes.""" + +from typing import Optional + +from fastapi import APIRouter, Body, Depends, Query + +from app.docker_client import DockerManager, get_docker_manager +from app.models import ( + ContainerCreate, + ContainerCreateResponse, + ContainerListResponse, + ContainerLogsResponse, + ContainerResponse, + ContainerStatsResponse, + MessageResponse, +) +from app.security import validate_container_request + +router = APIRouter(prefix="/containers", tags=["containers"]) + + +@router.get("", response_model=ContainerListResponse) +async def list_containers( + all: bool = Query(True, description="Include stopped containers"), + docker: DockerManager = Depends(get_docker_manager), +) -> ContainerListResponse: + """ + List all containers managed by this API. + + Only containers created through this API (with managed labels) will be returned. + """ + containers = docker.list_managed(all_containers=all) + return ContainerListResponse(containers=containers, count=len(containers)) + + +@router.post("/create", response_model=ContainerCreateResponse) +async def create_container( + request: ContainerCreate = Body(...), + docker: DockerManager = Depends(get_docker_manager), +) -> ContainerCreateResponse: + """ + Create a new container. + + The container will be labeled as managed by this API and will only be accessible + through this API's endpoints. + + Security restrictions apply: + - Privileged mode is not allowed + - Host network mode is not allowed + - Host PID mode is not allowed + - Certain volume mounts are blocked + - Device mappings are not allowed + """ + # Validate security + validate_container_request(request) + + # Create the container + return docker.create_container(request) + + +@router.get("/{container_id}", response_model=ContainerResponse) +async def inspect_container( + container_id: str, + docker: DockerManager = Depends(get_docker_manager), +) -> ContainerResponse: + """ + Get detailed information about a container. + + Only managed containers can be inspected. + """ + return docker.inspect_container(container_id) + + +@router.post("/{container_id}/start", response_model=MessageResponse) +async def start_container( + container_id: str, + docker: DockerManager = Depends(get_docker_manager), +) -> MessageResponse: + """ + Start a stopped container. + + Only managed containers can be started. + """ + docker.start_container(container_id) + return MessageResponse(message="Container started", container_id=container_id) + + +@router.post("/{container_id}/stop", response_model=MessageResponse) +async def stop_container( + container_id: str, + timeout: int = Query(10, ge=0, description="Seconds to wait before killing"), + docker: DockerManager = Depends(get_docker_manager), +) -> MessageResponse: + """ + Stop a running container. + + Only managed containers can be stopped. + """ + docker.stop_container(container_id, timeout=timeout) + return MessageResponse(message="Container stopped", container_id=container_id) + + +@router.post("/{container_id}/restart", response_model=MessageResponse) +async def restart_container( + container_id: str, + timeout: int = Query(10, ge=0, description="Seconds to wait before killing"), + docker: DockerManager = Depends(get_docker_manager), +) -> MessageResponse: + """ + Restart a container. + + Only managed containers can be restarted. + """ + docker.restart_container(container_id, timeout=timeout) + return MessageResponse(message="Container restarted", container_id=container_id) + + +@router.delete("/{container_id}", response_model=MessageResponse) +async def remove_container( + container_id: str, + force: bool = Query(False, description="Force removal of running container"), + v: bool = Query(False, description="Remove associated volumes"), + docker: DockerManager = Depends(get_docker_manager), +) -> MessageResponse: + """ + Remove a container. + + Only managed containers can be removed. + """ + docker.remove_container(container_id, force=force, v=v) + return MessageResponse(message="Container removed", container_id=container_id) + + +@router.get("/{container_id}/logs", response_model=ContainerLogsResponse) +async def get_container_logs( + container_id: str, + stdout: bool = Query(True, description="Include stdout"), + stderr: bool = Query(True, description="Include stderr"), + tail: int = Query(100, ge=0, description="Number of lines from end (0 for all)"), + since: Optional[int] = Query(None, description="Unix timestamp to start from"), + until: Optional[int] = Query(None, description="Unix timestamp to end at"), + docker: DockerManager = Depends(get_docker_manager), +) -> ContainerLogsResponse: + """ + Get logs from a container. + + Only managed containers can have their logs retrieved. + """ + tail_value = "all" if tail == 0 else tail + return docker.get_logs( + container_id, + stdout=stdout, + stderr=stderr, + tail=tail_value, + since=since, + until=until, + ) + + +@router.get("/{container_id}/stats", response_model=ContainerStatsResponse) +async def get_container_stats( + container_id: str, + docker: DockerManager = Depends(get_docker_manager), +) -> ContainerStatsResponse: + """ + Get resource usage statistics for a container. + + Returns CPU, memory, and network statistics. + Only managed containers can have their stats retrieved. + """ + return docker.get_stats(container_id) diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..3d4b81a --- /dev/null +++ b/app/security.py @@ -0,0 +1,168 @@ +"""Security validation for container creation requests.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import HTTPException, status + +from app.config import settings +from app.models import ContainerCreate + + +class SecurityValidationError(HTTPException): + """Exception raised when security validation fails.""" + + def __init__(self, message: str): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Security validation failed: {message}", + ) + + +def validate_image(image: str) -> None: + """Validate that the image is allowed.""" + if not settings.allowed_images: + return # All images allowed if list is empty + + # Check if image matches any allowed pattern + for allowed in settings.allowed_images: + if image == allowed or image.startswith(f"{allowed}:"): + return + + raise SecurityValidationError( + f"Image '{image}' is not in the allowed images list" + ) + + +def validate_volume_path(host_path: str) -> None: + """Validate that a volume mount path is safe.""" + # Normalize the path + try: + normalized = Path(host_path).resolve() + except Exception: + raise SecurityValidationError(f"Invalid volume path: {host_path}") + + # Check against blocked paths + normalized_str = str(normalized) + for blocked in settings.blocked_volume_paths: + blocked_path = Path(blocked).resolve() + # Block exact matches and subdirectories of blocked paths + if normalized == blocked_path: + raise SecurityValidationError( + f"Volume mount to '{host_path}' is not allowed" + ) + # Also block if it's trying to mount a parent of a sensitive path + try: + normalized.relative_to(blocked_path) + raise SecurityValidationError( + f"Volume mount to '{host_path}' is not allowed (overlaps with protected path)" + ) + except ValueError: + pass # Not a subdirectory, which is fine + + # Block if the blocked path is a subdirectory of the mount + try: + blocked_path.relative_to(normalized) + raise SecurityValidationError( + f"Volume mount to '{host_path}' is not allowed (would expose protected paths)" + ) + except ValueError: + pass + + +def validate_volumes(volumes: Optional[Dict[str, Any]]) -> None: + """Validate all volume mounts.""" + if not volumes: + return + + for host_path in volumes.keys(): + validate_volume_path(host_path) + + +def validate_capabilities(cap_add: Optional[List[str]]) -> None: + """Validate requested capabilities.""" + if not cap_add: + return + + if not settings.allowed_capabilities: + raise SecurityValidationError( + "Adding capabilities is not allowed" + ) + + for cap in cap_add: + if cap.upper() not in settings.allowed_capabilities: + raise SecurityValidationError( + f"Capability '{cap}' is not allowed" + ) + + +def check_dangerous_options(raw_data: Dict[str, Any]) -> None: + """Check for dangerous options that should never be allowed.""" + dangerous_options = { + "privileged": "Privileged mode is not allowed", + "network_mode": None, # Special handling + "pid_mode": "PID mode 'host' is not allowed", + "ipc_mode": "IPC mode 'host' is not allowed", + "cap_add": None, # Handled separately + "devices": "Device mappings are not allowed", + "security_opt": "Security options are not allowed", + "sysctls": "Sysctl settings are not allowed", + } + + for option, message in dangerous_options.items(): + if option not in raw_data: + continue + + value = raw_data[option] + + if option == "privileged" and value is True: + raise SecurityValidationError(message) + + if option == "network_mode" and value == "host": + raise SecurityValidationError("Network mode 'host' is not allowed") + + if option == "pid_mode" and value == "host": + raise SecurityValidationError(message) + + if option == "ipc_mode" and value == "host": + raise SecurityValidationError(message) + + if option == "devices" and value: + raise SecurityValidationError(message) + + if option == "security_opt" and value: + raise SecurityValidationError(message) + + if option == "sysctls" and value: + raise SecurityValidationError(message) + + if option == "cap_add" and value: + validate_capabilities(value) + + +def validate_container_request( + request: ContainerCreate, raw_data: Optional[Dict[str, Any]] = None +) -> None: + """ + Validate a container creation request for security issues. + + Args: + request: The parsed ContainerCreate model + raw_data: The raw request data (to check for extra fields) + + Raises: + SecurityValidationError: If validation fails + """ + # Validate image + validate_image(request.image) + + # Validate volumes + validate_volumes( + {k: v.model_dump() for k, v in request.volumes.items()} + if request.volumes + else None + ) + + # Check for dangerous options in raw data + if raw_data: + check_dangerous_options(raw_data) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a67acfd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + light-docker-api: + build: . + ports: + - "8000:8000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - INSTANCE_ID=light-api-1 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e7d5953 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.109.0 +uvicorn>=0.27.0 +docker>=7.0.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0