From f38323c3a1fa27907163311b78f6c392a832418c Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:54:51 +0100 Subject: [PATCH] fix(docker): make host Docker socket opt-in (#4902) * fix(docker): make host socket compose opt-in * fix(cookbook): gate container Docker access * fix(docker): gate socket group setup on opt-in * fix(cookbook): gate generated docker exec serve commands * fix(cookbook): narrow generated docker exec forms --- .env.example | 20 ++ docker-compose.gpu-amd.yml | 9 - docker-compose.gpu-nvidia.yml | 10 - docker-compose.yml | 10 - docker/entrypoint.sh | 10 +- docker/host-docker.yml | 12 + docs/setup.md | 27 ++ routes/cookbook_routes.py | 265 ++++++++++++++++---- routes/shell_routes.py | 33 +-- src/host_docker_access.py | 62 +++++ tests/test_cookbook_docker_access.py | 339 ++++++++++++++++++++++++++ tests/test_docker_devops_hardening.py | 33 +++ tests/test_gpu_compose_standalone.py | 73 +++++- tests/test_shell_routes.py | 61 ++++- 14 files changed, 850 insertions(+), 114 deletions(-) create mode 100644 docker/host-docker.yml create mode 100644 src/host_docker_access.py create mode 100644 tests/test_cookbook_docker_access.py diff --git a/.env.example b/.env.example index 0f4dcd449..e96dd151c 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,26 @@ SEARXNG_INSTANCE=http://localhost:8080 # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) +# ============================================================ +# Host Docker access (explicit opt-in) +# ============================================================ +# Default Docker Compose does not mount /var/run/docker.sock. Existing +# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it. +# +# Enable this only for intentional Cookbook/local Docker-daemon management. +# Raw socket access is high-trust and can grant broad control over the host +# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID. +# Put these values in .env, or export them before running docker compose. +# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml +# DOCKER_GID=963 +# docker/host-docker.yml sets this inside the container. Keep it paired +# with the socket overlay; setting it alone is not sufficient. +# ODYSSEUS_ENABLE_HOST_DOCKER=true +# +# Host Docker access can be combined with one GPU overlay: +# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml +# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml + # ============================================================ # GPU support (Docker Compose) # ============================================================ diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 5d5f8427e..82e22e440 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -28,14 +28,6 @@ services: # land under /app/.local for the odysseus user. Persist them so a # container recreate does not silently remove installed serve engines. - ${APP_DATA_DIR:-./data}/local:/app/.local:z - # Docker socket — lets Cookbook launch commands like - # `docker exec ollama-rocm ollama show ` reach the host's - # Docker daemon (and sibling containers like ollama-rocm / - # ollama-test). The in-container user needs to be in the - # socket's owning group — see `group_add` below; the GID - # there must match the host's `docker` group (defaults to 963 - # on Debian, 999 on Ubuntu — override via env if yours differs). - - /var/run/docker.sock:/var/run/docker.sock extra_hosts: # Lets the container reach local services on the Docker host, including # Ollama at http://host.docker.internal:11434. @@ -101,7 +93,6 @@ services: - /dev/kfd - /dev/dri group_add: - - "${DOCKER_GID:-963}" - video - ${RENDER_GID:-render} diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index c1f2cddb0..1b551c669 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -27,16 +27,6 @@ services: # land under /app/.local for the odysseus user. Persist them so a # container recreate does not silently remove installed serve engines. - ${APP_DATA_DIR:-./data}/local:/app/.local:z - # Docker socket — lets Cookbook launch commands like - # `docker exec ollama-rocm ollama show ` reach the host's - # Docker daemon (and sibling containers like ollama-rocm / - # ollama-test). The in-container user needs to be in the - # socket's owning group — see `group_add` below; the GID - # there must match the host's `docker` group (defaults to 963 - # on Debian, 999 on Ubuntu — override via env if yours differs). - - /var/run/docker.sock:/var/run/docker.sock - group_add: - - "${DOCKER_GID:-963}" extra_hosts: # Lets the container reach local services on the Docker host, including # Ollama at http://host.docker.internal:11434. diff --git a/docker-compose.yml b/docker-compose.yml index 77840e22b..cbeec1e37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,16 +16,6 @@ services: # land under /app/.local for the odysseus user. Persist them so a # container recreate does not silently remove installed serve engines. - ${APP_DATA_DIR:-./data}/local:/app/.local:z - # Docker socket — lets Cookbook launch commands like - # `docker exec ollama-rocm ollama show ` reach the host's - # Docker daemon (and sibling containers like ollama-rocm / - # ollama-test). The in-container user needs to be in the - # socket's owning group — see `group_add` below; the GID - # there must match the host's `docker` group (defaults to 963 - # on Debian, 999 on Ubuntu — override via env if yours differs). - - /var/run/docker.sock:/var/run/docker.sock - group_add: - - "${DOCKER_GID:-963}" extra_hosts: # Lets the container reach local services on the Docker host, including # Ollama at http://host.docker.internal:11434. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fc0e87a08..aec3b8eec 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -29,12 +29,12 @@ fi ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)" [ -z "$ODY_USER" ] && ODY_USER=odysseus -# Docker-socket group plumbing. When /var/run/docker.sock is bind-mounted -# (Cookbook uses docker exec to reach sibling containers), the socket is -# owned by root:. Add the app user to that group and later -# call gosu by username so supplementary groups are retained. +# Docker-socket group plumbing for the explicit host-Docker overlay. When +# opted in, the socket is owned by root:. Add the app user +# to that group and later call gosu by username so supplementary groups are +# retained. DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}" -if [ -S "$DOCKER_SOCK" ]; then +if [ "${ODYSSEUS_ENABLE_HOST_DOCKER:-}" = "true" ] && [ -S "$DOCKER_SOCK" ]; then SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')" if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then if ! getent group "$SOCK_GID" >/dev/null 2>&1; then diff --git a/docker/host-docker.yml b/docker/host-docker.yml new file mode 100644 index 000000000..b5b4f4968 --- /dev/null +++ b/docker/host-docker.yml @@ -0,0 +1,12 @@ +# High-trust host Docker access. Enable only when local Docker-daemon +# management from Cookbook is required and you accept that raw socket access +# grants broad control over the host Docker daemon. +# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml +# DOCKER_GID= +services: + odysseus: + volumes: + - /var/run/docker.sock:/var/run/docker.sock + group_add: ["${DOCKER_GID:-963}"] + environment: + - ODYSSEUS_ENABLE_HOST_DOCKER=true diff --git a/docs/setup.md b/docs/setup.md index 63a95f687..e66fb566b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -99,6 +99,33 @@ Odysseus SSH key and add the public key to the remote server's ssh-copy-id -i data/ssh/id_ed25519.pub user@server ``` +**Host Docker access (explicit opt-in).** Default Docker Compose intentionally +does not mount `/var/run/docker.sock`. You can still connect Odysseus to +existing Ollama, vLLM, and other OpenAI-compatible endpoints without Docker +socket access. + +Cookbook/local Docker-daemon management requires the opt-in overlay below. Raw +Docker socket access is high-trust because it can effectively grant broad +control over the host Docker daemon. Remote server Docker workflows over SSH +remain preferred. + +Place these values in `.env`, or export them in the shell before running +`docker compose`: + +```bash +COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml +DOCKER_GID= +``` + +Combine host Docker access with a GPU overlay when both are intentionally +required: + +```bash +COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml +# or +COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml +``` + **Docker GPU overlays.** CPU-only users can skip this section. Cookbook can only detect GPUs that Docker exposes to the container — if the host runtime or device passthrough is not configured, Cookbook sees the iGPU, another card, or diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index a0fb672d7..a88c11501 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -30,6 +30,13 @@ from core.platform_compat import ( which_tool, ) from routes.shell_routes import TMUX_LOG_DIR +from src.host_docker_access import ( + HOST_DOCKER_ACCESS_HINT, + HOST_DOCKER_SOCKET_PATH, + host_docker_access_enabled, + local_docker_available, + running_in_container, +) from routes.cookbook_output import ( error_aware_output_tail, classify_dead_download, HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE, @@ -62,6 +69,182 @@ _HF_TOKEN_STATUS_SNIPPET = ( 'fi' ) +_OLLAMA_SIDECAR_CONTAINERS = {"ollama-test", "ollama-rocm"} +_UNSAFE_DOCKER_EXEC_CHARS = frozenset(";&|<>$`\r\n") +_SAFE_OLLAMA_MODEL_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$") +_SAFE_OLLAMA_FILE_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + +def _is_generated_ollama_docker_exec_cmd(cmd: str | None) -> bool: + """Match only the fixed Docker exec shapes generated by Cookbook.""" + if not cmd or any(char in cmd for char in _UNSAFE_DOCKER_EXEC_CHARS): + return False + try: + parts = shlex.split(cmd) + except ValueError: + return False + if len(parts) < 4 or parts[:2] != ["docker", "exec"]: + return False + container, executable = parts[2:4] + if container not in _OLLAMA_SIDECAR_CONTAINERS: + return False + if container == "ollama-rocm" and executable == "ollama": + return ( + len(parts) == 6 + and parts[4] == "show" + and _SAFE_OLLAMA_MODEL_TOKEN_RE.fullmatch(parts[5]) is not None + ) + if container != "ollama-test" or executable != "ollama-import": + return False + if len(parts) not in {7, 8}: + return False + model, name, context_size = parts[4:7] + return ( + _SAFE_OLLAMA_MODEL_TOKEN_RE.fullmatch(model) is not None + and _SAFE_OLLAMA_FILE_TOKEN_RE.fullmatch(name) is not None + and re.fullmatch(r"[0-9]+", context_size) is not None + and ( + len(parts) == 7 + or _SAFE_OLLAMA_FILE_TOKEN_RE.fullmatch(parts[7]) is not None + ) + ) + + +def _missing_binary_message( + binary: str, + target: str, + *, + local_host_docker_blocked: bool = False, +) -> str: + if binary == "tmux": + return ( + f"tmux is required for Cookbook background downloads/serves on {target}. " + "Install it with your OS package manager, or run Cookbook server setup for that server." + ) + if binary == "docker": + if local_host_docker_blocked: + return HOST_DOCKER_ACCESS_HINT + return ( + f"Docker is required by this Cookbook launch command on {target}, but the docker CLI was not found. " + "Install Docker and make sure this user can run `docker`, then retry." + ) + return f"{binary} is required on {target}, but it was not found." + + +async def _remote_binary_available( + remote: str, + ssh_port: str | None, + binary: str, + *, + windows: bool = False, +) -> bool: + port = ssh_port or "" + port_args = ["-p", port] if port and port != "22" else [] + if windows: + check = f'powershell -NoProfile -Command "if (Get-Command {binary} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 127 }}"' + else: + check = f"command -v {shlex.quote(binary)} >/dev/null 2>&1" + try: + proc = await asyncio.create_subprocess_exec( + "ssh", + "-o", + "ConnectTimeout=6", + "-o", + "StrictHostKeyChecking=no", + *port_args, + remote, + check, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.wait_for(proc.communicate(), timeout=10) + return proc.returncode == 0 + except Exception: + return False + + +async def _binary_available( + binary: str, + remote: str | None, + ssh_port: str | None, + *, + windows: bool = False, + in_container: bool | None = None, + environ=None, + socket_path: str = HOST_DOCKER_SOCKET_PATH, +) -> bool: + if remote: + return await _remote_binary_available( + remote, + ssh_port, + binary, + windows=windows, + ) + cli_available = shutil.which(binary) is not None + if binary != "docker": + return cli_available + return local_docker_available( + cli_available=cli_available, + in_container=in_container, + environ=environ, + socket_path=socket_path, + ) + + + +def _local_ollama_docker_fallback_available( + *, + in_container: bool | None = None, + environ: dict[str, str] | None = None, + socket_path: str = HOST_DOCKER_SOCKET_PATH, +) -> bool: + return local_docker_available( + cli_available=shutil.which("docker") is not None, + in_container=in_container, + environ=environ, + socket_path=socket_path, + ) + + +def _local_ollama_docker_access_blocked( + *, + in_container: bool | None = None, + environ: dict[str, str] | None = None, + socket_path: str = HOST_DOCKER_SOCKET_PATH, +) -> bool: + containerized = running_in_container() if in_container is None else in_container + if not containerized or shutil.which("docker") is None: + return False + return not _local_ollama_docker_fallback_available( + in_container=containerized, + environ=environ, + socket_path=socket_path, + ) + + +def _append_local_ollama_download_command_lines( + lines: list[str], + ollama_cmd: str, + *, + docker_fallback_available: bool, + docker_fallback_blocked: bool, +) -> None: + lines.append('if command -v ollama >/dev/null 2>&1; then') + lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote(ollama_cmd)}') + if docker_fallback_available: + lines.append('elif command -v docker >/dev/null 2>&1; then') + lines.append(" ODYSSEUS_OLLAMA_CONTAINER=\"$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E '^(ollama-rocm|ollama-test)$' | head -1)\"") + lines.append(' if [ -n "$ODYSSEUS_OLLAMA_CONTAINER" ]; then') + lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote("docker exec ${ODYSSEUS_OLLAMA_CONTAINER} " + ollama_cmd)}') + lines.append(' fi') + elif docker_fallback_blocked: + hint = shlex.quote("ERROR: " + HOST_DOCKER_ACCESS_HINT) + lines.append('else') + lines.append(f" printf '%s\\n' {hint}; exit 127") + lines.append('fi') + lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi') + + def setup_cookbook_routes() -> APIRouter: router = APIRouter(tags=["cookbook"]) _cookbook_state_path = Path(COOKBOOK_STATE_FILE) @@ -411,43 +594,6 @@ def setup_cookbook_routes() -> APIRouter: def _needs_binary(cmd: str, binary: str) -> bool: return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or "")) - def _missing_binary_message(binary: str, target: str) -> str: - if binary == "tmux": - return ( - f"tmux is required for Cookbook background downloads/serves on {target}. " - "Install it with your OS package manager, or run Cookbook server setup for that server." - ) - if binary == "docker": - return ( - f"Docker is required by this Cookbook launch command on {target}, but the docker CLI was not found. " - "Install Docker and make sure this user can run `docker`, then retry." - ) - return f"{binary} is required on {target}, but it was not found." - - async def _remote_binary_available(remote: str, ssh_port: str | None, binary: str, *, windows: bool = False) -> bool: - _port = ssh_port or "" - _pf = ["-p", _port] if _port and _port != "22" else [] - if windows: - check = f"powershell -NoProfile -Command \"if (Get-Command {binary} -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 127 }}\"" - else: - check = f"command -v {shlex.quote(binary)} >/dev/null 2>&1" - try: - proc = await asyncio.create_subprocess_exec( - "ssh", "-o", "ConnectTimeout=6", "-o", "StrictHostKeyChecking=no", - *_pf, remote, check, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - await asyncio.wait_for(proc.communicate(), timeout=10) - return proc.returncode == 0 - except Exception: - return False - - async def _binary_available(binary: str, remote: str | None, ssh_port: str | None, *, windows: bool = False) -> bool: - if remote: - return await _remote_binary_available(remote, ssh_port, binary, windows=windows) - return shutil.which(binary) is not None - def _launch_local_detached(session_id: str, bash_lines: list[str]) -> dict: """Windows-native stand-in for a LOCAL tmux session (tmux doesn't exist on Windows). Mirrors shell_routes._generate_win_detached / bg_jobs.launch: @@ -576,15 +722,12 @@ def setup_cookbook_routes() -> APIRouter: # slower-but-reliable downloader (resumes cleanly from the .incomplete files). # Use `python3 -m pip` not `pip` — macOS has no bare `pip` command. if is_ollama_download: - lines.append('if command -v ollama >/dev/null 2>&1; then') - lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote(ollama_cmd)}') - lines.append('elif command -v docker >/dev/null 2>&1; then') - lines.append(' ODYSSEUS_OLLAMA_CONTAINER="$(docker ps --format \'{{.Names}}\' 2>/dev/null | grep -E \'^(ollama-rocm|ollama-test)$\' | head -1)"') - lines.append(' if [ -n "$ODYSSEUS_OLLAMA_CONTAINER" ]; then') - lines.append(f' ODYSSEUS_OLLAMA_PULL_CMD={shlex.quote("docker exec ${ODYSSEUS_OLLAMA_CONTAINER} " + ollama_cmd)}') - lines.append(' fi') - lines.append('fi') - lines.append('if [ -z "$ODYSSEUS_OLLAMA_PULL_CMD" ]; then echo "ERROR: Ollama not found on this server. Install Ollama or start an ollama-rocm/ollama-test container."; exit 127; fi') + _append_local_ollama_download_command_lines( + lines, + ollama_cmd, + docker_fallback_available=_local_ollama_docker_fallback_available(), + docker_fallback_blocked=_local_ollama_docker_access_blocked(), + ) else: lines.append(f"command -v hf >/dev/null 2>&1 || {_pip_install_fallback_chain('huggingface_hub', upgrade=True)}") if req.disable_hf_transfer: @@ -1322,13 +1465,18 @@ def setup_cookbook_routes() -> APIRouter: req.gpus = _validate_gpus(req.gpus) req.hf_token = req.hf_token or _load_stored_hf_token() _validate_token(req.hf_token) - # Normalize away backslash-newline continuations (multi-line pasted - # serve commands) so the cleaned single-line command is what gets - # written into the runner script and used for engine auto-detection. - # `_validate_serve_cmd` returns None for empty input; coerce to "" so the - # many downstream `"engine" in req.cmd` membership checks can't hit - # `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400). - req.cmd = _validate_serve_cmd(req.cmd) or "" + # Cookbook emits two fixed Docker exec forms for its Ollama sidecars. + # Keep Docker out of the general allowlist: only these parsed shapes may + # proceed to the target-aware Docker availability/opt-in preflight. + if _is_generated_ollama_docker_exec_cmd(req.cmd): + req.cmd = req.cmd.strip() + else: + # Normalize away backslash-newline continuations (multi-line pasted + # serve commands) so the cleaned single-line command is what gets + # written into the runner script and used for engine auto-detection. + # `_validate_serve_cmd` returns None for empty input; coerce to "" so + # downstream `"engine" in req.cmd` checks cannot raise TypeError. + req.cmd = _validate_serve_cmd(req.cmd) or "" req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or "" req.cmd = _normalize_minimax_m3_vllm_cmd(req.cmd) req.cmd = _venv_safe_local_pip_install_cmd( @@ -1406,9 +1554,18 @@ def setup_cookbook_routes() -> APIRouter: "session_id": session_id, } if _needs_binary(req.cmd, "docker") and not await _binary_available("docker", remote, req.ssh_port, windows=is_windows): + local_host_docker_blocked = ( + not remote + and running_in_container() + and not host_docker_access_enabled() + ) return { "ok": False, - "error": _missing_binary_message("docker", remote or "local server"), + "error": _missing_binary_message( + "docker", + remote or "local server", + local_host_docker_blocked=local_host_docker_blocked, + ), "session_id": session_id, } diff --git a/routes/shell_routes.py b/routes/shell_routes.py index a51da3b21..87c7fd9b7 100644 --- a/routes/shell_routes.py +++ b/routes/shell_routes.py @@ -16,6 +16,11 @@ from pathlib import Path from typing import Dict, Any from core.platform_compat import IS_APPLE_SILICON, which_tool from core.middleware import INTERNAL_TOOL_USER +from src.host_docker_access import ( + HOST_DOCKER_ACCESS_HINT, + host_docker_access_enabled as _host_docker_access_enabled, + running_in_container as _running_in_container, +) from src.optional_deps import prepare_optional_dependency_import # POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist @@ -103,32 +108,17 @@ logger = logging.getLogger(__name__) PTY_SUPPORTED = pty is not None and fcntl is not None and hasattr(os, "setsid") -DOCKER_IN_CONTAINER_HINT = ( - "Not available inside the Odysseus container by design. The image ships no " - "docker CLI and no host socket is mounted. Run Docker-backed launches on a " - "remote server, where docker is checked over SSH. Mounting /var/run/docker.sock " - "into the container would grant it host-root access, so only do that if you " - "accept that risk." -) - - -def _running_in_container(dockerenv_path="/.dockerenv", cgroup_path="/proc/1/cgroup"): - if os.path.exists(dockerenv_path): - return True - try: - with open(cgroup_path, "r", encoding="utf-8") as fh: - contents = fh.read() - except OSError: - return False - return any(token in contents for token in ("docker", "containerd", "kubepods")) +DOCKER_IN_CONTAINER_HINT = HOST_DOCKER_ACCESS_HINT DockerRowStatus = namedtuple("DockerRowStatus", ["applicable", "install_hint"]) PackageUpdateStatus = namedtuple("PackageUpdateStatus", ["available", "note"]) -def _docker_row_status(*, on_remote, in_container, installed, default_hint): - local_docker_unavailable = not on_remote and in_container and not installed +def _docker_row_status( + *, on_remote, in_container, installed, default_hint, host_docker_access=False +): + local_docker_unavailable = not on_remote and in_container and not host_docker_access if local_docker_unavailable: return DockerRowStatus(applicable=False, install_hint=DOCKER_IN_CONTAINER_HINT) return DockerRowStatus(applicable=True, install_hint=default_hint) @@ -1510,6 +1500,9 @@ def setup_shell_routes() -> APIRouter: in_container=_running_in_container() if not on_remote else False, installed=pkg["installed"], default_hint=pkg.get("install_hint"), + host_docker_access=( + _host_docker_access_enabled() if not on_remote else False + ), ) pkg["applicable"] = status.applicable pkg["install_hint"] = status.install_hint diff --git a/src/host_docker_access.py b/src/host_docker_access.py new file mode 100644 index 000000000..e0e6698c5 --- /dev/null +++ b/src/host_docker_access.py @@ -0,0 +1,62 @@ +"""Policy checks for explicit host Docker access from a container.""" + +import os +import stat +from collections.abc import Mapping + + +HOST_DOCKER_ENV_VAR = "ODYSSEUS_ENABLE_HOST_DOCKER" +HOST_DOCKER_SOCKET_PATH = "/var/run/docker.sock" + +HOST_DOCKER_ACCESS_HINT = ( + "Local Docker daemon access is disabled inside the Odysseus container; a " + "Docker CLI alone is not enough. Default Docker Compose intentionally does " + "not mount the host Docker socket. Raw socket access is high-trust and can " + "grant broad control over the host Docker daemon. If you accept that risk, " + "enable docker/host-docker.yml. Remote server Docker workflows over SSH " + "remain preferred." +) + + +def running_in_container( + dockerenv_path: str = "/.dockerenv", + cgroup_path: str = "/proc/1/cgroup", +) -> bool: + if os.path.exists(dockerenv_path): + return True + try: + with open(cgroup_path, "r", encoding="utf-8") as handle: + contents = handle.read() + except OSError: + return False + return any(token in contents for token in ("docker", "containerd", "kubepods")) + + +def host_docker_access_enabled( + socket_path: str = HOST_DOCKER_SOCKET_PATH, + *, + environ: Mapping[str, str] | None = None, +) -> bool: + env = os.environ if environ is None else environ + if env.get(HOST_DOCKER_ENV_VAR, "").strip().lower() != "true": + return False + try: + mode = os.stat(socket_path).st_mode + except OSError: + return False + return stat.S_ISSOCK(mode) + + +def local_docker_available( + *, + cli_available: bool, + in_container: bool | None = None, + environ: Mapping[str, str] | None = None, + socket_path: str = HOST_DOCKER_SOCKET_PATH, +) -> bool: + if not cli_available: + return False + containerized = running_in_container() if in_container is None else in_container + if not containerized: + return True + return host_docker_access_enabled(socket_path, environ=environ) diff --git a/tests/test_cookbook_docker_access.py b/tests/test_cookbook_docker_access.py new file mode 100644 index 000000000..47110b04d --- /dev/null +++ b/tests/test_cookbook_docker_access.py @@ -0,0 +1,339 @@ +import socket +from unittest.mock import AsyncMock + +import pytest + +from fastapi import HTTPException +from starlette.requests import Request + +import routes.cookbook_routes as cookbook_routes +from routes.cookbook_helpers import ServeRequest, _validate_serve_cmd +from src.host_docker_access import HOST_DOCKER_ACCESS_HINT + + +def _model_serve_endpoint(): + router = cookbook_routes.setup_cookbook_routes() + for route in router.routes: + if route.path == "/api/model/serve" and "POST" in route.methods: + return route.endpoint + raise AssertionError("POST /api/model/serve route not found") + + +def _admin_request() -> Request: + request = Request( + { + "type": "http", + "method": "POST", + "path": "/api/model/serve", + "headers": [], + "state": {}, + } + ) + request.state.current_user = "admin" + return request + + +@pytest.mark.asyncio +async def test_container_cli_only_is_rejected(monkeypatch, tmp_path): + monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker") + + available = await cookbook_routes._binary_available( + "docker", + None, + None, + in_container=True, + environ={}, + socket_path=str(tmp_path / "missing.sock"), + ) + + assert available is False + message = cookbook_routes._missing_binary_message( + "docker", + "local server", + local_host_docker_blocked=True, + ) + assert message == HOST_DOCKER_ACCESS_HINT + assert "docker/host-docker.yml" in message + + +@pytest.mark.asyncio +async def test_container_opt_in_with_unix_socket_is_allowed(monkeypatch, tmp_path): + monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker") + socket_path = tmp_path / "docker.sock" + + with socket.socket(socket.AF_UNIX) as unix_socket: + unix_socket.bind(str(socket_path)) + available = await cookbook_routes._binary_available( + "docker", + None, + None, + in_container=True, + environ={"ODYSSEUS_ENABLE_HOST_DOCKER": "true"}, + socket_path=str(socket_path), + ) + + assert available is True + + +@pytest.mark.asyncio +async def test_native_local_docker_still_uses_cli_presence(monkeypatch, tmp_path): + monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker") + + available = await cookbook_routes._binary_available( + "docker", + None, + None, + in_container=False, + environ={}, + socket_path=str(tmp_path / "missing.sock"), + ) + + assert available is True + + +@pytest.mark.asyncio +async def test_remote_docker_still_uses_ssh_probe(monkeypatch): + remote_probe = AsyncMock(return_value=True) + monkeypatch.setattr(cookbook_routes, "_remote_binary_available", remote_probe) + monkeypatch.setattr( + cookbook_routes.shutil, + "which", + lambda binary: pytest.fail("remote checks must not inspect the local CLI"), + ) + + available = await cookbook_routes._binary_available( + "docker", + "gpu-server", + "2222", + windows=True, + in_container=True, + environ={}, + socket_path="/missing/docker.sock", + ) + + assert available is True + remote_probe.assert_awaited_once_with( + "gpu-server", + "2222", + "docker", + windows=True, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "cmd", + [ + "docker exec ollama-test ollama-import example/model model 8192 model.gguf", + "docker exec ollama-rocm ollama show llama3", + ], +) +async def test_local_container_serve_returns_host_docker_opt_in_hint( + monkeypatch, + tmp_path, + cmd, +): + async def binary_available(binary, remote, ssh_port, **kwargs): + assert remote is None + if binary == "tmux": + return True + assert cookbook_routes.shutil.which(binary) == "/usr/bin/docker" + return False + + monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None) + monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available) + monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True) + monkeypatch.setattr( + cookbook_routes, + "host_docker_access_enabled", + lambda: False, + ) + monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker") + monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmp_path) + monkeypatch.setattr( + cookbook_routes, + "load_stored_hf_token", + lambda **kwargs: "", + ) + + response = await _model_serve_endpoint()( + _admin_request(), + ServeRequest( + repo_id="example/model", + cmd=cmd, + ), + ) + + assert response["ok"] is False + assert response["error"] == HOST_DOCKER_ACCESS_HINT + assert "cmd binary 'docker' is not allowed" not in response["error"] + assert "docker/host-docker.yml" in response["error"] + + +@pytest.mark.asyncio +async def test_local_container_serve_allows_generated_docker_exec_when_enabled( + monkeypatch, + tmp_path, +): + checked_binaries = [] + launched_commands = [] + + async def binary_available(binary, remote, ssh_port, **kwargs): + checked_binaries.append(binary) + if binary == "docker": + assert cookbook_routes.running_in_container() is True + assert cookbook_routes.host_docker_access_enabled() is True + return True + + class _Stderr: + async def read(self): + return b"mock launch stopped" + + class _Process: + returncode = 1 + stderr = _Stderr() + + async def wait(self): + return None + + async def launch(command, **kwargs): + launched_commands.append(command) + return _Process() + + monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None) + monkeypatch.setattr(cookbook_routes, "_binary_available", binary_available) + monkeypatch.setattr(cookbook_routes, "running_in_container", lambda: True) + monkeypatch.setattr( + cookbook_routes, + "host_docker_access_enabled", + lambda: True, + ) + monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmp_path) + monkeypatch.setattr( + cookbook_routes, + "load_stored_hf_token", + lambda **kwargs: "", + ) + monkeypatch.setattr( + cookbook_routes.asyncio, + "create_subprocess_shell", + launch, + ) + + response = await _model_serve_endpoint()( + _admin_request(), + ServeRequest( + repo_id="llama3", + cmd="docker exec ollama-rocm ollama show llama3", + ), + ) + + assert checked_binaries == ["tmux", "docker"] + assert launched_commands + assert response["error"] == "mock launch stopped" + runner = next(tmp_path.glob("serve-*_run.sh")).read_text(encoding="utf-8") + assert "docker exec ollama-rocm ollama show llama3" in runner + + +@pytest.mark.parametrize( + "cmd", + [ + "docker run --rm alpine", + "docker exec random-container ollama show llama3", + "docker compose up", + "docker exec ollama-rocm ollama rm llama3", + "docker exec ollama-test ollama rm llama3", + "docker exec ollama-rocm ollama pull llama3", + "docker exec ollama-test ollama show llama3", + "docker exec ollama-test sh -c 'ollama show llama3'", + "docker exec ollama-test ollama show llama3; id", + "docker exec ollama-rocm ollama show llama3 extra", + "docker exec ollama-rocm ollama show llama3?", + "docker exec ollama-test ollama-import org/model model many model.gguf", + "docker exec ollama-test ollama-import org/model model 8192 path/model.gguf", + "docker exec ollama-rocm ollama show $(id)", + "docker exec ollama-rocm ollama show llama3 | cat", + ], +) +def test_arbitrary_docker_commands_stay_blocked(cmd): + assert cookbook_routes._is_generated_ollama_docker_exec_cmd(cmd) is False + + with pytest.raises(HTTPException) as exc: + _validate_serve_cmd(cmd) + + assert exc.value.status_code == 400 + + +def test_generated_ollama_import_shape_is_narrowly_allowed(): + assert cookbook_routes._is_generated_ollama_docker_exec_cmd( + "docker exec ollama-test ollama-import org/model model 8192 model.gguf" + ) + assert cookbook_routes._is_generated_ollama_docker_exec_cmd( + "docker exec ollama-test ollama-import org/model model 8192" + ) + assert not cookbook_routes._is_generated_ollama_docker_exec_cmd( + "docker exec ollama-rocm ollama-import org/model model 8192 model.gguf" + ) + + +def test_generated_ollama_show_shape_is_narrowly_allowed(): + assert cookbook_routes._is_generated_ollama_docker_exec_cmd( + "docker exec ollama-rocm ollama show llama3:latest" + ) + + +def test_local_ollama_docker_access_blocked_in_container_cli_only(monkeypatch, tmp_path): + monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker") + + assert cookbook_routes._local_ollama_docker_access_blocked( + in_container=True, + environ={}, + socket_path=str(tmp_path / "missing.sock"), + ) is True + + +def test_local_ollama_docker_access_not_blocked_for_native_cli(monkeypatch, tmp_path): + monkeypatch.setattr(cookbook_routes.shutil, "which", lambda binary: "/usr/bin/docker") + + assert cookbook_routes._local_ollama_docker_access_blocked( + in_container=False, + environ={}, + socket_path=str(tmp_path / "missing.sock"), + ) is False + + +def test_local_ollama_download_probe_omits_docker_commands_when_blocked(): + lines = [] + + cookbook_routes._append_local_ollama_download_command_lines( + lines, + "ollama pull llama3:latest", + docker_fallback_available=False, + docker_fallback_blocked=True, + ) + + rendered = "\n".join(lines) + + assert "command -v docker" not in rendered + assert "docker ps" not in rendered + assert "docker exec" not in rendered + assert "ODYSSEUS_OLLAMA_PULL_CMD" in rendered + assert "docker/host-docker.yml" in rendered + assert "exit 127" in rendered + + +def test_local_ollama_download_probe_keeps_docker_fallback_when_allowed(): + lines = [] + + cookbook_routes._append_local_ollama_download_command_lines( + lines, + "ollama pull llama3:latest", + docker_fallback_available=True, + docker_fallback_blocked=False, + ) + + rendered = "\n".join(lines) + + assert "docker ps" in rendered + assert "docker exec ${ODYSSEUS_OLLAMA_CONTAINER}" in rendered + assert "ODYSSEUS_OLLAMA_PULL_CMD" in rendered diff --git a/tests/test_docker_devops_hardening.py b/tests/test_docker_devops_hardening.py index 95185f5f5..29d5c9955 100644 --- a/tests/test_docker_devops_hardening.py +++ b/tests/test_docker_devops_hardening.py @@ -17,6 +17,7 @@ COMPOSE_FILES = [ ROOT / "docker-compose.gpu-nvidia.yml", ROOT / "docker-compose.gpu-amd.yml", ] +HOST_DOCKER_OVERLAY = ROOT / "docker" / "host-docker.yml" TEST_DOCS = [ ROOT / "tests" / "README.md", ROOT / "tests" / "TESTING_STANDARD.md", @@ -54,6 +55,38 @@ def test_compose_files_forward_every_upload_limit_env_var(): assert expected <= _compose_env_names(path), path.name +def test_default_compose_files_do_not_mount_host_docker_socket(): + for path in COMPOSE_FILES: + text = path.read_text(encoding="utf-8") + assert "/var/run/docker.sock" not in text, path.name + + +def test_host_docker_overlay_mounts_socket_and_adds_docker_group(): + overlay = yaml.safe_load(HOST_DOCKER_OVERLAY.read_text(encoding="utf-8")) + service = overlay["services"]["odysseus"] + + assert "/var/run/docker.sock:/var/run/docker.sock" in service["volumes"] + assert "${DOCKER_GID:-963}" in service["group_add"] + assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" in service["environment"] + + +def test_docker_entrypoint_gates_socket_group_plumbing_on_explicit_opt_in(): + script = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + block_start = script.index("DOCKER_SOCK=\"${DOCKER_SOCK:-/var/run/docker.sock}\"") + block_end = script.index("\nmount_root_for()", block_start) + socket_group_block = script[block_start:block_end] + + opt_in_check = socket_group_block.index( + "[ \"${ODYSSEUS_ENABLE_HOST_DOCKER:-}\" = \"true\" ]" + ) + socket_check = socket_group_block.index("[ -S \"$DOCKER_SOCK\" ]") + stat_socket = socket_group_block.index("stat -c") + add_group = socket_group_block.index("groupadd -g") + add_user_group = socket_group_block.index("usermod -aG") + + assert opt_in_check < socket_check < stat_socket < add_group < add_user_group + + def test_docker_entrypoint_does_not_resolve_root_commands_from_app_local_path(): script = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") path_export = script.index('export PATH="/app/.local/bin:$PATH"') diff --git a/tests/test_gpu_compose_standalone.py b/tests/test_gpu_compose_standalone.py index 64c52577c..6588f4900 100644 --- a/tests/test_gpu_compose_standalone.py +++ b/tests/test_gpu_compose_standalone.py @@ -20,6 +20,7 @@ ROOT = Path(__file__).resolve().parents[1] BASE = ROOT / "docker-compose.yml" NVIDIA_OVERLAY = ROOT / "docker" / "gpu.nvidia.yml" AMD_OVERLAY = ROOT / "docker" / "gpu.amd.yml" +HOST_DOCKER_OVERLAY = ROOT / "docker" / "host-docker.yml" NVIDIA_STANDALONE = ROOT / "docker-compose.gpu-nvidia.yml" AMD_STANDALONE = ROOT / "docker-compose.gpu-amd.yml" @@ -61,6 +62,13 @@ def _merge_overlay_into_base(base: dict, overlay: dict) -> dict: return expected +def _merge_overlays_into_base(base: dict, *overlays: dict) -> dict: + merged = copy.deepcopy(base) + for overlay in overlays: + merged = _merge_overlay_into_base(merged, overlay) + return merged + + @pytest.fixture(scope="module") def base(): return _load(BASE) @@ -124,9 +132,10 @@ def test_nvidia_odysseus_adds_only_overlay(base): {"driver": "nvidia", "count": "all", "capabilities": ["gpu"]} ] - # Base Docker socket group is preserved; no AMD-only keys leaked in. + # No Docker or AMD groups are added. assert "devices" not in svc - assert svc["group_add"] == base_svc["group_add"] + assert "group_add" not in base_svc + assert "group_add" not in svc def test_amd_odysseus_adds_only_overlay(base): @@ -137,10 +146,66 @@ def test_amd_odysseus_adds_only_overlay(base): # Environment is unchanged from base for AMD. assert svc["environment"] == base_svc["environment"] - # devices are new; group_add preserves the base Docker group and appends AMD groups. + # Devices and GPU-only groups are added. assert "devices" not in base_svc assert svc["devices"] == ["/dev/kfd", "/dev/dri"] - assert svc["group_add"] == base_svc["group_add"] + ["video", "${RENDER_GID:-render}"] + assert "group_add" not in base_svc + assert svc["group_add"] == ["video", "${RENDER_GID:-render}"] # No NVIDIA-only keys leaked in. assert "deploy" not in svc + + +# --- Host Docker opt-in combinations --------------------------------------- + + +def test_base_has_no_host_docker_access(base): + service = base["services"][SERVICE] + + assert "/var/run/docker.sock:/var/run/docker.sock" not in service["volumes"] + assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" not in service["environment"] + assert "group_add" not in service + + +def test_base_plus_host_docker_overlay_has_explicit_access(base): + merged = _merge_overlays_into_base(base, _load(HOST_DOCKER_OVERLAY)) + service = merged["services"][SERVICE] + + assert "/var/run/docker.sock:/var/run/docker.sock" in service["volumes"] + assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" in service["environment"] + assert service["group_add"] == ["${DOCKER_GID:-963}"] + + +def test_nvidia_plus_host_docker_preserves_gpu_and_docker_access(base): + merged = _merge_overlays_into_base( + base, + _load(NVIDIA_OVERLAY), + _load(HOST_DOCKER_OVERLAY), + ) + service = merged["services"][SERVICE] + + devices = service["deploy"]["resources"]["reservations"]["devices"] + assert devices == [ + {"driver": "nvidia", "count": "all", "capabilities": ["gpu"]} + ] + assert "/var/run/docker.sock:/var/run/docker.sock" in service["volumes"] + assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" in service["environment"] + assert service["group_add"] == ["${DOCKER_GID:-963}"] + + +def test_amd_plus_host_docker_preserves_gpu_and_docker_groups(base): + merged = _merge_overlays_into_base( + base, + _load(AMD_OVERLAY), + _load(HOST_DOCKER_OVERLAY), + ) + service = merged["services"][SERVICE] + + assert service["devices"] == ["/dev/kfd", "/dev/dri"] + assert service["group_add"] == [ + "video", + "${RENDER_GID:-render}", + "${DOCKER_GID:-963}", + ] + assert "/var/run/docker.sock:/var/run/docker.sock" in service["volumes"] + assert "ODYSSEUS_ENABLE_HOST_DOCKER=true" in service["environment"] diff --git a/tests/test_shell_routes.py b/tests/test_shell_routes.py index 5f9ea59a3..6ee7bbe15 100644 --- a/tests/test_shell_routes.py +++ b/tests/test_shell_routes.py @@ -5,6 +5,7 @@ import importlib import importlib.util import json import os +import socket import sys from pathlib import Path from types import SimpleNamespace @@ -13,6 +14,7 @@ import pytest from routes.shell_routes import ( _find_line_break, + _host_docker_access_enabled, _import_optional_dependency_for_status, _running_in_container, _docker_row_status, @@ -216,13 +218,24 @@ class TestDockerRowStatus: assert status.applicable is False assert status.install_hint == DOCKER_IN_CONTAINER_HINT - def test_in_container_but_present_is_applicable_with_default_hint(self): + def test_in_container_cli_without_opt_in_is_not_applicable(self): status = _docker_row_status( on_remote=False, in_container=True, installed=True, default_hint=self.DEFAULT, ) + assert status.applicable is False + assert status.install_hint == DOCKER_IN_CONTAINER_HINT + + def test_in_container_opt_in_with_socket_is_applicable(self): + status = _docker_row_status( + on_remote=False, + in_container=True, + installed=True, + default_hint=self.DEFAULT, + host_docker_access=True, + ) assert status.applicable is True assert status.install_hint == self.DEFAULT @@ -260,7 +273,51 @@ class TestDockerRowStatus: lowered = DOCKER_IN_CONTAINER_HINT.lower() assert "remote" in lowered assert "socket" in lowered - assert "host-root" in lowered or "host root" in lowered + assert "high-trust" in lowered + assert "docker/host-docker.yml" in lowered + + +class TestHostDockerAccess: + def test_opt_in_without_socket_is_disabled(self, monkeypatch, tmp_path): + monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true") + + assert _host_docker_access_enabled(str(tmp_path / "missing.sock")) is False + + def test_regular_file_is_not_accepted(self, monkeypatch, tmp_path): + socket_path = tmp_path / "docker.sock" + socket_path.touch() + monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true") + + assert _host_docker_access_enabled(str(socket_path)) is False + + @pytest.mark.parametrize("flag", [None, "false"]) + def test_socket_without_explicit_opt_in_is_disabled( + self, + monkeypatch, + tmp_path, + flag, + ): + socket_path = tmp_path / "docker.sock" + with socket.socket(socket.AF_UNIX) as unix_socket: + unix_socket.bind(str(socket_path)) + if flag is None: + monkeypatch.delenv("ODYSSEUS_ENABLE_HOST_DOCKER", raising=False) + else: + monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", flag) + + assert _host_docker_access_enabled(str(socket_path)) is False + + def test_explicit_opt_in_with_unix_socket_is_enabled( + self, + monkeypatch, + tmp_path, + ): + socket_path = tmp_path / "docker.sock" + with socket.socket(socket.AF_UNIX) as unix_socket: + unix_socket.bind(str(socket_path)) + monkeypatch.setenv("ODYSSEUS_ENABLE_HOST_DOCKER", "true") + + assert _host_docker_access_enabled(str(socket_path)) is True class TestPackageProbeStatus: