Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,20 @@
|
||||
"""Purpose-built Docker read broker; the sole socket consumer."""
|
||||
|
||||
from packages.hub_runtime import issue_broker_service_token as issue_service_token
|
||||
|
||||
from .broker import (
|
||||
BrokerApp,
|
||||
BrokerError,
|
||||
FakeDockerBackend,
|
||||
RegistrationStore,
|
||||
ServiceTokenVerifier,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BrokerApp",
|
||||
"BrokerError",
|
||||
"FakeDockerBackend",
|
||||
"RegistrationStore",
|
||||
"ServiceTokenVerifier",
|
||||
"issue_service_token",
|
||||
]
|
||||
@@ -0,0 +1,677 @@
|
||||
"""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
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Docker broker process entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from packages.hub_runtime import SecretFileError, read_secret_file
|
||||
|
||||
from .broker import (
|
||||
BrokerApp,
|
||||
BrokerError,
|
||||
DockerBackend,
|
||||
DockerEngineBackend,
|
||||
Registration,
|
||||
RegistrationStore,
|
||||
ServiceTokenVerifier,
|
||||
make_handler,
|
||||
)
|
||||
|
||||
|
||||
def _read_secret() -> str:
|
||||
path = Path(os.getenv("BROKER_TOKEN_FILE", "/run/secrets/docker_broker_service_token"))
|
||||
try:
|
||||
return read_secret_file(
|
||||
path,
|
||||
expected_uid=(
|
||||
int(os.environ["SECRET_EXPECTED_UID"]) if os.getenv("SECRET_EXPECTED_UID") else None
|
||||
),
|
||||
expected_gid=(
|
||||
int(os.environ["SECRET_EXPECTED_GID"]) if os.getenv("SECRET_EXPECTED_GID") else None
|
||||
),
|
||||
)
|
||||
except (SecretFileError, ValueError):
|
||||
raise SystemExit("required broker secret validation failed") from None
|
||||
|
||||
|
||||
def _labels(container: Mapping[str, object]) -> Mapping[str, object]:
|
||||
labels = container.get("Labels")
|
||||
if isinstance(labels, Mapping):
|
||||
return {str(key): value for key, value in labels.items()}
|
||||
config = container.get("Config")
|
||||
nested = config.get("Labels") if isinstance(config, Mapping) else None
|
||||
if isinstance(nested, Mapping):
|
||||
return {str(key): value for key, value in nested.items()}
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_container_id(backend: DockerBackend, compose_project: str, service: str) -> str:
|
||||
matches = []
|
||||
for container in backend.containers():
|
||||
labels = _labels(container)
|
||||
if (
|
||||
labels.get("com.docker.compose.project") == compose_project
|
||||
and labels.get("com.docker.compose.service") == service
|
||||
):
|
||||
matches.append(str(container.get("Id", "")))
|
||||
if len(matches) != 1 or not matches[0]:
|
||||
raise ValueError("registration target must resolve to exactly one container")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _load_registrations(backend: DockerBackend) -> RegistrationStore:
|
||||
path = Path(os.getenv("BROKER_REGISTRATIONS_FILE", "/etc/itworx/registered-targets.json"))
|
||||
if not path.is_file():
|
||||
return RegistrationStore(())
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
if document.get("defaultDecision") != "deny":
|
||||
raise ValueError("broker registration default must deny")
|
||||
registrations = []
|
||||
for item in document.get("containers", []):
|
||||
match = item["match"]
|
||||
compose_project = str(match["composeProject"])
|
||||
service = str(match["service"])
|
||||
registrations.append(
|
||||
Registration(
|
||||
project=str(item["project"]),
|
||||
environment=str(item["environment"]),
|
||||
registered_id=str(item["registeredId"]),
|
||||
container_id=_resolve_container_id(backend, compose_project, service),
|
||||
compose_project=compose_project,
|
||||
service=service,
|
||||
allowed_reads=frozenset(str(value) for value in item.get("allowedReads", [])),
|
||||
)
|
||||
)
|
||||
return RegistrationStore(registrations)
|
||||
except (BrokerError, json.JSONDecodeError, KeyError, OSError, TypeError, ValueError):
|
||||
raise SystemExit("broker registration validation failed") from None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
backend = DockerEngineBackend()
|
||||
application = BrokerApp(
|
||||
backend,
|
||||
_load_registrations(backend),
|
||||
ServiceTokenVerifier(_read_secret()),
|
||||
)
|
||||
ThreadingHTTPServer(
|
||||
("0.0.0.0", int(os.getenv("BROKER_PORT", "8080"))), # noqa: S104
|
||||
make_handler(application), # noqa: S104
|
||||
).serve_forever()
|
||||
Reference in new issue
Block a user