Files
ITWorx_MCP_Hub-Public/services/docker_read_broker/broker.py
T

678 lines
28 KiB
Python

"""Authenticated, registration-scoped, GET-only Docker read broker."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import re
import socket
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler
from typing import Any, Protocol, cast
from urllib.parse import parse_qs, quote, urlparse
_MAX_ENGINE_RESPONSE_BYTES = 4 * 1024 * 1024
class BrokerError(RuntimeError):
def __init__(self, code: str, message: str, status: int = 400) -> None:
self.code = code
self.status = status
super().__init__(message)
@dataclass(frozen=True)
class Registration:
project: str
environment: str
registered_id: str
container_id: str
compose_project: str
service: str
allowed_reads: frozenset[str]
def __post_init__(self) -> None:
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", self.project) or not re.fullmatch(
r"[a-z0-9][a-z0-9-]{0,31}", self.environment
):
raise ValueError("registration scope is invalid")
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,127}", self.registered_id):
raise ValueError("registered container id is invalid")
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,255}", self.container_id):
raise ValueError("Docker container id is invalid")
if not self.allowed_reads <= {"inspect", "health", "logs"}:
raise ValueError("registration contains an unsupported read capability")
class RegistrationStore:
"""Registration is the authorization boundary; names and labels are never enough."""
def __init__(self, registrations: Sequence[Registration] = ()) -> None:
self._by_scope: dict[tuple[str, str, str], Registration] = {}
for registration in registrations:
key = (registration.project, registration.environment, registration.registered_id)
if key in self._by_scope:
raise ValueError("duplicate container registration")
self._by_scope[key] = registration
def list(self, project: str, environment: str) -> list[Registration]:
return sorted(
(
item
for (item_project, item_environment, _), item in self._by_scope.items()
if item_project == project and item_environment == environment
),
key=lambda item: item.registered_id,
)
def resolve(
self, project: str, environment: str, registered_id: str, operation: str
) -> Registration:
item = self._by_scope.get((project, environment, registered_id))
if item is None or operation not in item.allowed_reads:
raise BrokerError(
"BROKER_CONTAINER_NOT_REGISTERED",
"container is not registered for this project",
403,
)
return item
class DockerBackend(Protocol):
def host(self) -> Mapping[str, Any]: ...
def storage(self) -> Sequence[Mapping[str, Any]]: ...
def containers(self, state: str | None = None) -> Sequence[Mapping[str, Any]]: ...
def inspect(self, container_id: str) -> Mapping[str, Any]: ...
def health(self, container_id: str) -> Mapping[str, Any]: ...
def logs(self, container_id: str, *, tail: int, since_seconds: int | None) -> Sequence[str]: ...
def networks(self) -> Sequence[Mapping[str, Any]]: ...
class FakeDockerBackend:
"""Deterministic backend used by unit and contract tests."""
def __init__(self) -> None:
self.calls: list[str] = []
self._containers: dict[str, dict[str, Any]] = {
"container-web-001": {
"Id": "container-web-001",
"Name": "/ludarium-web",
"Config": {
"Image": "ghcr.io/example/web:1.2.3",
"Env": ["TOKEN=secret-value"],
"Labels": {
"com.docker.compose.project": "ludarium",
"com.docker.compose.service": "web",
"com.example.owner": "itworx",
},
},
"State": {
"Status": "running",
"Running": True,
"StartedAt": "2026-08-01T10:00:00Z",
"Health": {"Status": "healthy"},
"RestartCount": 2,
},
"NetworkSettings": {
"Ports": {"8080/tcp": [{"HostPort": "18080", "HostIp": "127.0.0.1"}]},
"Networks": {"hub-connectors": {"Aliases": ["web"]}},
},
}
}
self._logs = {
"container-web-001": [
"INFO started",
"Authorization: Bearer super-secret-token",
"\x1b[31mready\x1b[0m",
]
}
def host(self) -> Mapping[str, Any]:
self.calls.append("host")
return {
"hostname": "unraid-test",
"uptimeSeconds": 3600,
"cpuPercent": 12.5,
"memoryUsedBytes": 100,
"memoryTotalBytes": 1000,
"dockerVersion": "27.0.0",
}
def storage(self) -> Sequence[Mapping[str, Any]]:
self.calls.append("storage")
return [{"name": "appdata", "driver": "local", "usageBytes": 1024, "capacityBytes": 4096}]
def containers(self, state: str | None = None) -> Sequence[Mapping[str, Any]]:
self.calls.append("containers")
values = list(self._containers.values())
return [
item for item in values if state is None or item.get("State", {}).get("Status") == state
]
def inspect(self, container_id: str) -> Mapping[str, Any]:
self.calls.append("inspect")
if container_id not in self._containers:
raise BrokerError(
"BROKER_TARGET_NOT_FOUND", "registered container no longer exists", 404
)
return self._containers[container_id]
def health(self, container_id: str) -> Mapping[str, Any]:
self.calls.append("health")
item = self.inspect(container_id)
state = item.get("State", {})
health = state.get("Health", {}) if isinstance(state, Mapping) else {}
return {
"status": health.get("Status", "unknown"),
"running": bool(state.get("Running", False)),
"restartCount": int(state.get("RestartCount", 0)),
}
def logs(self, container_id: str, *, tail: int, since_seconds: int | None) -> Sequence[str]:
self.calls.append("logs")
if container_id not in self._logs:
raise BrokerError(
"BROKER_TARGET_NOT_FOUND", "registered container logs unavailable", 404
)
return self._logs[container_id][-tail:]
def networks(self) -> Sequence[Mapping[str, Any]]:
self.calls.append("networks")
return [{"Name": "hub-connectors", "Id": "network-1", "Driver": "bridge", "Scope": "local"}]
class DockerEngineBackend:
"""Structured, allowlisted Docker Engine calls over the broker-only Unix socket."""
def __init__(self, socket_path: str = "/var/run/docker.sock") -> None:
self.socket_path = socket_path
def _request(self, path: str) -> bytes:
if not path.startswith(
("/version", "/info", "/containers/json", "/containers/", "/networks")
):
raise BrokerError("BROKER_ROUTE_DENIED", "Docker route is not allowlisted", 403)
request = f"GET {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n".encode()
sock = socket.socket(getattr(socket, "AF_UNIX", socket.AF_INET), socket.SOCK_STREAM)
try:
sock.settimeout(10)
sock.connect(self.socket_path)
sock.sendall(request)
chunks: list[bytes] = []
total = 0
while True:
chunk = sock.recv(65536)
if not chunk:
break
total += len(chunk)
if total > _MAX_ENGINE_RESPONSE_BYTES:
raise BrokerError(
"BROKER_RESPONSE_TOO_LARGE", "Docker response exceeds broker budget", 502
)
chunks.append(chunk)
except OSError as exc:
raise BrokerError(
"BROKER_SOCKET_UNAVAILABLE", "Docker socket is unavailable", 503
) from exc
finally:
sock.close()
return _decode_http_response(b"".join(chunks))
def _get(self, path: str) -> Any:
try:
return json.loads(self._request(path).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid JSON", 502
) from exc
def host(self) -> Mapping[str, Any]:
version = cast(Mapping[str, Any], self._get("/version"))
info = cast(Mapping[str, Any], self._get("/info"))
return {
"hostname": info.get("Name", ""),
"uptimeSeconds": 0,
"cpuPercent": None,
"memoryUsedBytes": 0,
"memoryTotalBytes": int(info.get("MemTotal", 0)),
"dockerVersion": version.get("Version"),
}
def storage(self) -> Sequence[Mapping[str, Any]]:
value = cast(Mapping[str, Any], self._get("/info"))
return _storage_rows(value)
def containers(self, state: str | None = None) -> Sequence[Mapping[str, Any]]:
query = "?all=1"
if state:
query += "&filters=" + _json_query({"status": [state]})
value = self._get("/containers/json" + query)
return cast(Sequence[Mapping[str, Any]], value if isinstance(value, list) else [])
def inspect(self, container_id: str) -> Mapping[str, Any]:
return cast(Mapping[str, Any], self._get(f"/containers/{container_id}/json"))
def health(self, container_id: str) -> Mapping[str, Any]:
item = self.inspect(container_id)
state_value = item.get("State")
state = state_value if isinstance(state_value, Mapping) else {}
health_value = state.get("Health")
health = health_value if isinstance(health_value, Mapping) else {}
return {
"status": str(health.get("Status", "unknown")),
"running": bool(state.get("Running", False)),
"restartCount": int(state.get("RestartCount", 0)),
}
def logs(self, container_id: str, *, tail: int, since_seconds: int | None) -> Sequence[str]:
query = f"?stdout=1&stderr=1&tail={tail}" + (
f"&since={max(int(time.time()) - since_seconds, 0)}" if since_seconds else ""
)
value = self._request(f"/containers/{container_id}/logs{query}")
return _decode_docker_log_stream(value)[-tail:]
def networks(self) -> Sequence[Mapping[str, Any]]:
value = self._get("/networks")
return cast(Sequence[Mapping[str, Any]], value if isinstance(value, list) else [])
def _json_query(value: object) -> str:
return quote(json.dumps(value, separators=(",", ":")), safe="")
def _decode_http_response(raw: bytes) -> bytes:
header_block, separator, body = raw.partition(b"\r\n\r\n")
if not separator:
raise BrokerError("BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid HTTP", 502)
lines = header_block.split(b"\r\n")
status = lines[0].split(b" ", 2) if lines else []
if len(status) < 2 or not status[0].startswith(b"HTTP/") or not status[1].isdigit():
raise BrokerError("BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid HTTP", 502)
status_code = int(status[1])
headers: dict[bytes, bytes] = {}
for line in lines[1:]:
name, marker, value = line.partition(b":")
if not marker or not name.strip():
raise BrokerError("BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid HTTP", 502)
headers[name.strip().lower()] = value.strip().lower()
transfer_encoding = headers.get(b"transfer-encoding")
if transfer_encoding:
if transfer_encoding != b"chunked":
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned unsupported encoding", 502
)
body = _decode_chunked_body(body)
elif b"content-length" in headers:
try:
expected = int(headers[b"content-length"])
except ValueError as exc:
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid length", 502
) from exc
if expected < 0 or expected > _MAX_ENGINE_RESPONSE_BYTES or len(body) != expected:
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid length", 502
)
if not 200 <= status_code < 300:
raise BrokerError("BROKER_ENGINE_ERROR", "Docker read request failed", 502)
return body
def _decode_chunked_body(value: bytes) -> bytes:
position = 0
decoded = bytearray()
while True:
line_end = value.find(b"\r\n", position)
if line_end < 0:
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid chunks", 502
)
size_text = value[position:line_end].split(b";", 1)[0].strip()
if not re.fullmatch(rb"[0-9A-Fa-f]{1,16}", size_text):
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid chunks", 502
)
size = int(size_text, 16)
position = line_end + 2
if size == 0:
trailer = value[position:]
if trailer != b"\r\n" and not trailer.endswith(b"\r\n\r\n"):
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid chunks", 502
)
return bytes(decoded)
if size > _MAX_ENGINE_RESPONSE_BYTES - len(decoded):
raise BrokerError(
"BROKER_RESPONSE_TOO_LARGE", "Docker response exceeds broker budget", 502
)
chunk_end = position + size
if chunk_end + 2 > len(value) or value[chunk_end : chunk_end + 2] != b"\r\n":
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid chunks", 502
)
decoded.extend(value[position:chunk_end])
position = chunk_end + 2
def _decode_docker_log_stream(value: bytes) -> list[str]:
decoded = bytearray()
position = 0
multiplexed = len(value) >= 8 and value[0] in (1, 2)
if not multiplexed:
decoded.extend(value)
else:
while position < len(value):
if (
position + 8 > len(value)
or value[position] not in (1, 2)
or value[position + 1 : position + 4] != b"\x00\x00\x00"
):
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid log frames", 502
)
size = int.from_bytes(value[position + 4 : position + 8], "big")
position += 8
if size > _MAX_ENGINE_RESPONSE_BYTES - len(decoded) or position + size > len(value):
raise BrokerError(
"BROKER_INVALID_ENGINE_RESPONSE", "Docker returned invalid log frames", 502
)
decoded.extend(value[position : position + size])
position += size
return decoded.decode("utf-8", errors="replace").splitlines()
def _storage_rows(value: Mapping[str, Any]) -> list[dict[str, Any]]:
return [
{
"name": "docker-root",
"driver": str(value.get("Driver", "unknown")),
"usageBytes": 0,
"capacityBytes": 0,
},
]
_ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
_SECRET = re.compile(
r"(?i)(authorization\s*[:=]\s*bearer\s+|bearer\s+|(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*)[^\s,;]+"
)
_PRIVATE_KEY = re.compile(
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", re.S
)
_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
def redact_log_line(value: str) -> tuple[str, int]:
line = _ANSI.sub("", value)
line = _CONTROL.sub("", line)
count = 0
line, private_count = _PRIVATE_KEY.subn("<redacted>", line)
count += private_count
line, secret_count = _SECRET.subn(lambda match: match.group(1) + "<redacted>", line)
count += secret_count
return line.encode("utf-8")[:4096].decode("utf-8", errors="ignore"), count
def normalize_container(raw: Mapping[str, Any], registered_id: str) -> dict[str, Any]:
config_value = raw.get("Config")
config = config_value if isinstance(config_value, Mapping) else {}
state_value = raw.get("State")
state = state_value if isinstance(state_value, Mapping) else {}
network_value = raw.get("NetworkSettings")
network_settings = network_value if isinstance(network_value, Mapping) else {}
labels_value = config.get("Labels")
labels = labels_value if isinstance(labels_value, Mapping) else {}
networks_value = network_settings.get("Networks")
networks = networks_value if isinstance(networks_value, Mapping) else {}
ports_value = network_settings.get("Ports")
ports = ports_value if isinstance(ports_value, Mapping) else {}
safe_labels = {
key: str(labels[key])[:200]
for key in ("com.docker.compose.project", "com.docker.compose.service", "com.example.owner")
if key in labels
}
health_value = state.get("Health")
health = health_value if isinstance(health_value, Mapping) else {}
safe_ports = {
str(key)[:64]: [
{
"hostIp": str(item.get("HostIp", ""))[:64],
"hostPort": str(item.get("HostPort", ""))[:16],
}
for item in value
if isinstance(item, Mapping)
][:8]
for key, value in ports.items()
if isinstance(value, list)
}
return {
"containerRef": registered_id,
"name": str(raw.get("Name", "")).lstrip("/")[:255],
"image": str(config.get("Image", ""))[:512],
"state": str(state.get("Status", "unknown"))[:32],
"health": str(health.get("Status", "none"))[:32],
"startedAt": str(state.get("StartedAt", ""))[:64],
"restartCount": int(state.get("RestartCount", 0)),
"ports": safe_ports,
"networks": sorted(str(name)[:128] for name in networks),
"labels": safe_labels,
}
class ServiceTokenVerifier:
"""Verify short-lived HMAC service tokens without exposing their values."""
def __init__(self, secret: str, audience: str = "docker-read-broker") -> None:
self._secret = secret.encode()
self.audience = audience
def verify(self, token: str) -> tuple[str, str]:
try:
encoded_payload, encoded_signature = token.split(".", 1)
expected = hmac.new(self._secret, encoded_payload.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(_b64decode(encoded_signature), expected):
raise ValueError
payload = json.loads(_b64decode(encoded_payload).decode())
if payload.get("aud") != self.audience or int(payload.get("exp", 0)) < int(time.time()):
raise ValueError
project, environment = str(payload["project"]), str(payload["environment"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", project) or not re.fullmatch(
r"[a-z0-9][a-z0-9-]{0,31}", environment
):
raise ValueError
return project, environment
except (ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
raise BrokerError(
"BROKER_AUTH_INVALID", "internal service authentication failed", 401
) from exc
def _b64decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
class BrokerApp:
def __init__(
self,
backend: DockerBackend,
registrations: RegistrationStore,
verifier: ServiceTokenVerifier,
) -> None:
self.backend = backend
self.registrations = registrations
self.verifier = verifier
def handle(
self, method: str, path: str, headers: Mapping[str, str] | None = None
) -> tuple[int, dict[str, Any]]:
if path == "/healthz" and method == "GET":
return 200, {"status": "ok", "capability": "docker-read-broker"}
if method != "GET":
return 405, {"code": "BROKER_METHOD_NOT_ALLOWED", "message": "only GET is supported"}
headers = headers or {}
auth = headers.get("authorization", headers.get("Authorization", ""))
if not auth.startswith("Bearer "):
return 401, {
"code": "BROKER_AUTH_REQUIRED",
"message": "internal service authentication required",
}
try:
project, environment = self.verifier.verify(auth.removeprefix("Bearer "))
parsed = urlparse(path)
route = parsed.path.rstrip("/")
query = parse_qs(parsed.query)
return 200, self._route(project, environment, route, query)
except BrokerError as exc:
return exc.status, {"code": exc.code, "message": str(exc)}
@staticmethod
def _assert_registered(raw: Mapping[str, Any], registration: Registration) -> None:
if str(raw.get("Id", "")) != registration.container_id:
raise BrokerError(
"BROKER_REGISTRATION_MISMATCH", "Docker identity does not match registration", 403
)
config_value = raw.get("Config")
config = config_value if isinstance(config_value, Mapping) else {}
labels_value = config.get("Labels")
labels = labels_value if isinstance(labels_value, Mapping) else {}
if (
labels.get("com.docker.compose.project") != registration.compose_project
or labels.get("com.docker.compose.service") != registration.service
):
raise BrokerError(
"BROKER_REGISTRATION_MISMATCH", "Docker labels do not match registration", 403
)
def _route(
self, project: str, environment: str, route: str, query: Mapping[str, list[str]]
) -> dict[str, Any]:
if route == "/v1/host":
raw = self.backend.host()
return {
"hostname": str(raw.get("hostname", ""))[:255],
"uptimeSeconds": max(int(raw.get("uptimeSeconds", 0)), 0),
"cpuPercent": raw.get("cpuPercent"),
"memoryUsedBytes": max(int(raw.get("memoryUsedBytes", 0)), 0),
"memoryTotalBytes": max(int(raw.get("memoryTotalBytes", 0)), 0),
"dockerVersion": str(raw.get("dockerVersion"))[:100]
if raw.get("dockerVersion") is not None
else None,
}
if route == "/v1/storage":
return {
"items": [
{
"name": str(item.get("name", ""))[:128],
"driver": str(item.get("driver", ""))[:64],
"usageBytes": max(int(item.get("usageBytes", 0)), 0),
"capacityBytes": max(int(item.get("capacityBytes", 0)), 0),
}
for item in self.backend.storage()
]
}
if route == "/v1/networks":
return {"items": [self._network(item) for item in self.backend.networks()]}
if route == "/v1/containers":
state_values = query.get("state")
state = state_values[0] if state_values else None
registered = {
item.container_id: item for item in self.registrations.list(project, environment)
}
items = []
for item in self.backend.containers(state):
if not isinstance(item, Mapping) or str(item.get("Id", "")) not in registered:
continue
registration = registered[str(item["Id"])]
inspected = self.backend.inspect(registration.container_id)
self._assert_registered(inspected, registration)
items.append(normalize_container(inspected, registration.registered_id))
return {"items": items}
match = re.fullmatch(r"/v1/containers/([a-z0-9][a-z0-9-]{0,127})(?:/(health|logs))?", route)
if not match:
raise BrokerError("BROKER_ROUTE_DENIED", "broker route is not allowlisted", 404)
registered_id, suffix = match.groups()
operation = "inspect" if suffix is None else suffix
registration = self.registrations.resolve(project, environment, registered_id, operation)
inspected = self.backend.inspect(registration.container_id)
self._assert_registered(inspected, registration)
if suffix is None:
return normalize_container(inspected, registered_id)
if suffix == "health":
raw_health = self.backend.health(registration.container_id)
return {
"status": str(raw_health.get("status", "unknown"))[:32],
"running": bool(raw_health.get("running", False)),
"restartCount": max(int(raw_health.get("restartCount", 0)), 0),
}
try:
tail = min(max(int((query.get("tail") or ["200"])[0]), 1), 500)
since_values = query.get("sinceSeconds")
since = since_values[0] if since_values else None
since_seconds = int(since) if since is not None else None
except (TypeError, ValueError) as exc:
raise BrokerError("BROKER_QUERY_INVALID", "log query is invalid") from exc
if since_seconds is not None and not 1 <= since_seconds <= 86400:
raise BrokerError("BROKER_QUERY_INVALID", "sinceSeconds is outside the allowed range")
contains_values = query.get("contains")
contains = contains_values[0] if contains_values else None
lines = []
redactions = 0
for raw_line in self.backend.logs(
registration.container_id, tail=tail, since_seconds=since_seconds
):
line, count = redact_log_line(str(raw_line))
if contains and contains not in line:
continue
lines.append(line)
redactions += count
return {
"containerRef": registered_id,
"lines": lines[-tail:],
"truncated": len(lines) > tail,
"redactionsApplied": redactions,
}
@staticmethod
def _network(raw: Mapping[str, Any]) -> dict[str, Any]:
return {
"name": str(raw.get("Name", ""))[:128],
"networkRef": str(raw.get("Id", ""))[:128],
"driver": str(raw.get("Driver", ""))[:64],
"scope": str(raw.get("Scope", ""))[:32],
}
def make_handler(app: BrokerApp) -> type[BaseHTTPRequestHandler]:
class Handler(BaseHTTPRequestHandler):
server_version = "ITWorxDockerReadBroker/1.0"
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
def do_PUT(self) -> None: # noqa: N802
self._dispatch("PUT")
def do_DELETE(self) -> None: # noqa: N802
self._dispatch("DELETE")
def _dispatch(self, method: str) -> None:
status, body = app.handle(method, self.path, dict(self.headers.items()))
encoded = json.dumps(body, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def log_message(self, _format: str, *_args: object) -> None:
return
return Handler