Files
ServiceMap-Public/backend/app/docker_provider.py
T

335 lines
13 KiB
Python

"""Docker inventory providers with an explicit security boundary.
The legacy Unix-socket provider is retained for compatibility. The broker
provider is opt-in and deliberately never falls back to the socket: a failed
broker configuration must be visible to the operator rather than silently
re-expanding ServiceMap's Docker privilege boundary.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
import docker
import httpx
class DockerProviderError(ValueError):
pass
_BROKER_CONNECT_TIMEOUT_SECONDS = 2.0
_BROKER_OPERATION_TIMEOUT_SECONDS = 5.0
_BROKER_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
_BROKER_MAX_CONTAINER_ITEMS = 10_000
_BROKER_PATH = "/v1/containers"
_BROKER_BEARER_TOKEN = re.compile(r"[^\x21-\x7e]")
def _invalid_inventory() -> DockerProviderError:
return DockerProviderError("Docker broker returned an invalid inventory response")
def _contains_ascii_control(value: str) -> bool:
return any(ord(char) < 32 or ord(char) == 127 for char in value)
@dataclass
class BrokerImage:
tags: list[str]
@dataclass
class BrokerContainer:
id: str
name: str
status: str
attrs: dict[str, Any]
image: BrokerImage
class BrokerContainers:
def __init__(self, provider: BrokerDockerClient) -> None: # type: ignore[name-defined]
self.provider = provider
def list(self, *, all: bool = True) -> list[BrokerContainer]:
del all # Broker registration scope is deliberate; it returns all allowed targets.
payload = self.provider._get(_BROKER_PATH)
items = payload.get("items")
if not isinstance(items, list) or len(items) > _BROKER_MAX_CONTAINER_ITEMS:
raise _invalid_inventory()
if any(not isinstance(item, dict) for item in items):
raise _invalid_inventory()
return [self.provider._container(item) for item in items]
class BrokerDockerClient:
"""Minimal Docker SDK-shaped client over the registered read broker."""
def __init__(self, base_url: str, token: str) -> None:
self.base_url = self._validated_base_url(base_url)
if (
not isinstance(token, str)
or not token
or len(token) > 8192
or _BROKER_BEARER_TOKEN.search(token) is not None
):
raise DockerProviderError("Docker broker token is invalid")
# Keep the credential out of client-wide headers: health checks are
# public and only the one fixed inventory request needs authorization.
self._authorization = f"Bearer {token}"
self.http = httpx.Client(
timeout=httpx.Timeout(
_BROKER_OPERATION_TIMEOUT_SECONDS,
connect=_BROKER_CONNECT_TIMEOUT_SECONDS,
pool=_BROKER_CONNECT_TIMEOUT_SECONDS,
),
follow_redirects=False,
trust_env=False,
)
self.containers = BrokerContainers(self)
@staticmethod
def _validated_base_url(base_url: str) -> str:
if (
not isinstance(base_url, str)
or not base_url
or any(ord(char) < 33 for char in base_url)
):
raise DockerProviderError("Docker broker URL is invalid")
try:
parsed = urlsplit(base_url)
# Accessing .port also rejects malformed or out-of-range ports.
_ = parsed.port
except ValueError:
raise DockerProviderError("Docker broker URL is invalid") from None
if (
parsed.scheme.casefold() not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or "\\" in parsed.path
or any(part == ".." for part in parsed.path.split("/"))
):
raise DockerProviderError("Docker broker URL is invalid")
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", ""))
@classmethod
def from_environment(cls) -> BrokerDockerClient:
# Never accept a request-provided Docker host as a broker destination:
# that could otherwise send the dedicated bearer token to an attacker-
# controlled endpoint. Broker identity is deployment configuration only.
url = (os.getenv("SERVICEMAP_DOCKER_BROKER_URL") or "").strip()
token_file = (os.getenv("SERVICEMAP_DOCKER_BROKER_TOKEN_FILE") or "").strip()
if not url or not token_file:
raise DockerProviderError(
"Broker mode requires SERVICEMAP_DOCKER_BROKER_URL and SERVICEMAP_DOCKER_BROKER_TOKEN_FILE"
)
path = Path(token_file)
if not path.is_file():
raise DockerProviderError("Docker broker token file is unavailable")
try:
token = path.read_text(encoding="utf-8").strip()
except OSError:
raise DockerProviderError("Docker broker token file cannot be read") from None
if not token or len(token) > 8192 or _BROKER_BEARER_TOKEN.search(token) is not None:
raise DockerProviderError("Docker broker token file is invalid")
return cls(url, token)
def _get(self, path: str) -> dict[str, Any]:
if path != _BROKER_PATH:
raise DockerProviderError("Docker broker operation is unsupported")
try:
with self.http.stream(
"GET",
f"{self.base_url}{path}",
headers={"Authorization": self._authorization},
) as response:
response.raise_for_status()
if (
response.headers.get("content-type", "").split(";", 1)[0].strip().casefold()
!= "application/json"
):
raise _invalid_inventory()
length = response.headers.get("content-length")
if length is not None:
try:
if int(length) < 0 or int(length) > _BROKER_MAX_RESPONSE_BYTES:
raise _invalid_inventory()
except ValueError:
raise _invalid_inventory() from None
raw = bytearray()
for chunk in response.iter_bytes():
if len(raw) + len(chunk) > _BROKER_MAX_RESPONSE_BYTES:
raise _invalid_inventory()
raw.extend(chunk)
except httpx.HTTPError:
raise DockerProviderError("Docker broker request failed") from None
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise DockerProviderError("Docker broker returned invalid JSON") from None
if not isinstance(payload, dict):
raise _invalid_inventory()
return payload
def ping(self) -> bool:
# /healthz is intentionally unprivileged; an authenticated inventory
# request immediately follows and remains the actual authorization test.
try:
with self.http.stream("GET", f"{self.base_url}/healthz") as response:
response.raise_for_status()
except httpx.HTTPError:
raise DockerProviderError("Docker broker health check failed") from None
return True
@staticmethod
def _container(item: dict[str, Any]) -> BrokerContainer:
def text_field(field: str, *, optional_default: str | None = None) -> str:
if field not in item:
if optional_default is not None:
return optional_default
raise _invalid_inventory()
value = item[field]
if optional_default is not None and value is None:
return optional_default
if (
not isinstance(value, str)
or not value
or len(value) > 4096
or _contains_ascii_control(value)
):
raise _invalid_inventory()
return value
ref = text_field("containerRef")
name = text_field("name").lstrip("/")
if not name:
raise _invalid_inventory()
state = text_field("state")
health = text_field("health", optional_default="none")
image = text_field("image")
labels_value = item.get("labels")
if not isinstance(labels_value, dict) or any(
not isinstance(key, str)
or not isinstance(value, str)
or not key
or len(key) > 4096
or len(value) > 4096
or _contains_ascii_control(key + value)
for key, value in labels_value.items()
):
raise _invalid_inventory()
networks_value = item.get("networks")
if not isinstance(networks_value, list) or any(
not isinstance(name, str)
or not name
or len(name) > 4096
or _contains_ascii_control(name)
for name in networks_value
):
raise _invalid_inventory()
# The deployed broker reports Docker health but does not yet emit an
# explicit healthcheckPresent flag. A concrete health status itself
# proves a healthcheck exists; preserve that discovery signal.
healthcheck_present = item.get("healthcheckPresent", health.casefold() not in {"none", "unknown"})
if not isinstance(healthcheck_present, bool):
raise _invalid_inventory()
ports: dict[str, list[dict[str, str]]] = {}
raw_ports = item.get("ports")
if not isinstance(raw_ports, dict):
raise _invalid_inventory()
for key, bindings in raw_ports.items():
if (
not isinstance(key, str)
or not key
or len(key) > 128
or _contains_ascii_control(key)
):
raise _invalid_inventory()
if bindings is None:
ports[key] = []
continue
if not isinstance(bindings, list):
raise _invalid_inventory()
ports[key] = []
for binding in bindings:
if not isinstance(binding, dict):
raise _invalid_inventory()
host_ip = binding.get("hostIp", "")
host_port = binding.get("hostPort", "")
if host_ip is None:
host_ip = ""
if host_port is None:
host_port = ""
if (
not isinstance(host_ip, str)
or not isinstance(host_port, (str, int))
or isinstance(host_port, bool)
):
raise _invalid_inventory()
host_port = str(host_port)
if (
len(host_ip) > 128
or _contains_ascii_control(host_ip + host_port)
or (
host_port
and (
len(host_port) > 5
or not all("0" <= char <= "9" for char in host_port)
or not 1 <= int(host_port) <= 65535
)
)
):
raise _invalid_inventory()
ports[key].append({"HostIp": host_ip, "HostPort": host_port})
return BrokerContainer(
id=ref,
name=name,
status=state,
attrs={
"State": {
"Status": state,
"Health": {"Status": health} if health != "none" else {},
},
"Config": {
"Image": image,
"Labels": labels_value,
"Env": [],
**({"Healthcheck": {"BrokerReported": True}} if healthcheck_present else {}),
},
"NetworkSettings": {
"Networks": {name: {} for name in networks_value},
"Ports": ports,
},
},
image=BrokerImage(tags=[image]),
)
def close(self) -> None:
self.http.close()
def docker_client_for_inventory(docker_host: str):
provider = os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold()
if provider == "socket":
return docker.DockerClient(base_url=docker_host, timeout=5)
if provider == "broker":
return BrokerDockerClient.from_environment()
raise DockerProviderError("SERVICEMAP_DOCKER_PROVIDER must be 'socket' or 'broker'")
def docker_provider_mode() -> str:
return os.getenv("SERVICEMAP_DOCKER_PROVIDER", "socket").strip().casefold() or "socket"