diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c2035..67a08c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi remain observable as queued jobs and fail clearly if no slot becomes available within 15 minutes. - **Fish.audio Cache Eviction.** Offline search responses are now evicted by configurable file-count and byte-size limits, preventing an unbounded cache from consuming the configuration volume. +- **Docker Socket Isolation.** Compose deployments now place the host Docker socket behind a dedicated network proxy; + the web container no longer mounts the socket or joins the host Docker group. The client retains direct Unix-socket + compatibility for deployments that intentionally configure it. - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL rejection and oversized JSON request rejection. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. diff --git a/TODO.md b/TODO.md index 20547bf..57a085b 100644 --- a/TODO.md +++ b/TODO.md @@ -34,9 +34,9 @@ the first block before investing in architecture or polish. malformed records at API boundaries. - [x] **L — Add database migrations and prompt/generation history.** Preserve old record shapes and allow bad regenerations to be reverted. -- [ ] **L — Harden container and Docker access.** The app now runs non-root with - dropped capabilities, no-new-privileges and a read-only root filesystem; isolate - the remaining Docker socket access behind a minimal privileged service. +- [x] **L — Harden container and Docker access.** The app runs non-root with dropped capabilities, + no-new-privileges and a read-only root filesystem; Compose Docker access is isolated behind a minimal container + proxy with only container inspection/control enabled. ### P1 — next, highest user impact @@ -132,7 +132,7 @@ the first block before investing in architecture or polish. - [ ] Strictly validate custom URLs to prevent SSRF. - [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints. - [ ] Run containers as non-root with dropped capabilities and read-only filesystems where practical. -- [ ] Isolate Docker socket access behind a minimal privileged service. +- [x] Isolate Docker socket access behind a minimal privileged service. ## P0 — Data integrity and recovery diff --git a/core/docker_client.py b/core/docker_client.py index 7e3d0bb..9c1039e 100644 --- a/core/docker_client.py +++ b/core/docker_client.py @@ -6,6 +6,7 @@ import os import re import socket from pathlib import Path +from urllib.parse import urlparse def _decode_chunked_bytes(data: bytes) -> bytes: @@ -27,17 +28,37 @@ def _decode_chunked_bytes(data: bytes) -> bytes: return bytes(result) +def _docker_available() -> bool: + endpoint = os.environ.get("DOCKER_HOST", "") + if endpoint.startswith("tcp://"): + return bool(urlparse(endpoint).hostname and urlparse(endpoint).port) + return Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() + + +def _docker_connection() -> socket.socket: + endpoint = os.environ.get("DOCKER_HOST", "") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if endpoint.startswith("tcp://") else socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + if endpoint.startswith("tcp://"): + parsed = urlparse(endpoint) + if not parsed.hostname or not parsed.port: + sock.close() + raise RuntimeError("Invalid DOCKER_HOST; expected tcp://host:port") + sock.connect((parsed.hostname, parsed.port)) + else: + sock.connect(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")) + return sock + + def _docker_get_json(path: str) -> tuple[int, dict | list | None]: - sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") - if not Path(sock_path).exists(): - raise RuntimeError(f"Docker socket not found: {sock_path}") + if not _docker_available(): + raise RuntimeError("Docker control endpoint is unavailable") request = ( f"GET {path} HTTP/1.1\r\n" "Host: docker\r\n" "Connection: close\r\n\r\n" ).encode("utf-8") chunks: list[bytes] = [] - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + with _docker_connection() as sock: sock.settimeout(10) sock.connect(sock_path) sock.sendall(request) @@ -65,9 +86,8 @@ def _docker_get_json(path: str) -> tuple[int, dict | list | None]: def _docker_post(path: str) -> tuple[int, str]: - sock_path = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock") - if not Path(sock_path).exists(): - raise RuntimeError(f"Docker socket not found: {sock_path}") + if not _docker_available(): + raise RuntimeError("Docker control endpoint is unavailable") request = ( f"POST {path} HTTP/1.1\r\n" @@ -77,7 +97,7 @@ def _docker_post(path: str) -> tuple[int, str]: ).encode("utf-8") chunks = [] - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + with _docker_connection() as sock: sock.settimeout(20) sock.connect(sock_path) sock.sendall(request) diff --git a/docker-compose.yml b/docker-compose.yml index 026ebaf..a450ffa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,30 @@ services: + # The web process talks to this narrowly configured proxy over the Compose + # network; the host Docker socket is never mounted into the web container. + docker-socket-proxy: + image: tecnativa/docker-socket-proxy:0.3.0 + container_name: tts-voice-creator-docker-proxy + restart: unless-stopped + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + environment: + CONTAINERS: "1" + POST: "1" + IMAGES: "0" + NETWORKS: "0" + VOLUMES: "0" + SERVICES: "0" + TASKS: "0" + SWARM: "0" + SYSTEM: "0" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - dgx_net + tts-voice-creator-clone-and-design-2: build: context: . @@ -26,16 +52,14 @@ services: ports: - "7890:7890" - # Give the app user access to the Docker socket (GID of docker group on host) - group_add: - - "${DOCKER_GID:-988}" + depends_on: + - docker-socket-proxy volumes: - ${VOICE_HOST_DIR:-/home/sparky/Media/_Sounds/TTS_Voices}:/voices:rw - ./config:/home/app/.config/tts-voice-creator:rw # settings, presets, routes - ./logs:/logs:rw # application logs - /home/sparky/Docker/tts-dgx-spark-faster-qwen3-tts/config:/tts-config:rw - - /var/run/docker.sock:/var/run/docker.sock:ro - ./static:/app/static:ro - ./server.py:/app/server.py:ro - ./core:/app/core:ro @@ -52,6 +76,7 @@ services: - TTS_CONTAINER_NAME=tts-faster-qwen3-tts-voiceclone - TTS_CONTAINER_NAMES=tts-faster-qwen3-tts-voiceclone,tts-faster-qwen3-tts-streaming - VOICE_DESIGN_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign + - DOCKER_HOST=tcp://docker-socket-proxy:2375 extra_hosts: - "host.docker.internal:host-gateway" diff --git a/routes/docker.py b/routes/docker.py index 4e59b2d..34e2bba 100644 --- a/routes/docker.py +++ b/routes/docker.py @@ -9,7 +9,7 @@ from urllib.parse import quote import requests from fastapi import APIRouter, Header, HTTPException -from core.docker_client import _docker_get_json, _docker_post +from core.docker_client import _docker_available, _docker_get_json, _docker_post from core.config import _load_settings from core.validation import _normalize_service_url @@ -66,7 +66,7 @@ def _container_status(name: str) -> dict: @router.get("/api/local-containers") async def get_local_containers(): - sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() + sock_ok = _docker_available() async def _check(defn: dict) -> dict: entry = {k: v for k, v in defn.items()} diff --git a/routes/tts.py b/routes/tts.py index ba87202..8c8000f 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -48,7 +48,7 @@ from core.tts_helpers import ( _fishspeech_request_audio, ) from core.audio import _duration, _export_normalized_wav -from core.docker_client import _docker_post +from core.docker_client import _docker_available, _docker_post logger = logging.getLogger("uvicorn.error") @@ -528,7 +528,7 @@ async def restart_tts_container(): else: results.append(container) except PermissionError: - raise HTTPException(502, "No permission to access /var/run/docker.sock — is the socket mounted in docker-compose.yml?") + raise HTTPException(502, "Docker control proxy is unavailable — check docker-socket-proxy in docker-compose.yml") except Exception as e: errors.append(f"{container}: {e}") @@ -552,7 +552,7 @@ async def restart_tts_container(): @router.get("/api/tts/restart-info") async def tts_restart_info(): containers = _tts_container_names() - sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists() + sock_ok = _docker_available() return {"containers": containers, "socket_available": sock_ok} diff --git a/tests/test_security.py b/tests/test_security.py index 41012a3..f58c79b 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -95,6 +95,12 @@ class SecurityValidationTests(unittest.TestCase): finally: sources._FISH_CACHE_MAX_FILES, sources._FISH_CACHE_MAX_BYTES = old_count, old_bytes + def test_docker_client_accepts_network_proxy_without_socket_mount(self): + from unittest.mock import patch + from core.docker_client import _docker_available + with patch.dict(os.environ, {"DOCKER_HOST": "tcp://docker-socket-proxy:2375", "DOCKER_SOCKET": "/missing/socket"}): + self.assertTrue(_docker_available()) + if __name__ == "__main__": unittest.main()