initial commit

This commit is contained in:
Pierre
2026-02-08 16:38:58 +01:00
commit 5783b40343
13 changed files with 1095 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.claude/settings.local.json
+16
View File
@@ -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"]
+127
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
# Light Docker API
+50
View File
@@ -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"
+346
View File
@@ -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
+64
View File
@@ -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",
}
+129
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# Routes package
+171
View File
@@ -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)
+168
View File
@@ -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)
+16
View File
@@ -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
+5
View File
@@ -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