"""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("", line) count += private_count line, secret_count = _SECRET.subn(lambda match: match.group(1) + "", 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