Initial curated ServiceMap source from 7a0a61232d05ba9097432abef6f7f4102a64d0b8
This commit is contained in:
commit
105251b9c8
96 files changed
+23587
No files matched your search
@@ -0,0 +1,334 @@
|
||||
"""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"
|
||||
Reference in new issue
Block a user