runtime.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. """Runnable pull-only edge process with mTLS server-CRL fail-stop semantics."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import os
  6. import signal
  7. import stat
  8. import threading
  9. from datetime import UTC, datetime
  10. from pathlib import Path
  11. import requests
  12. from requests.adapters import HTTPAdapter
  13. from app.core.edge_gateway.policy import EdgePolicyError
  14. from app.edge_gateway.agent import EdgeAgent, EdgeRunnerAdapter
  15. from app.edge_gateway.bootstrap import (
  16. EdgeBootstrapConfig,
  17. build_server_crl_ssl_context,
  18. validate_server_crl,
  19. )
  20. from app.edge_gateway.transport import (
  21. EdgeAuthenticationStopped,
  22. EdgeTransport,
  23. EdgeTransportError,
  24. )
  25. MAX_CONFIG_BYTES = 262_144
  26. def _now() -> str:
  27. return datetime.now(UTC).isoformat().replace("+00:00", "Z")
  28. def _read_regular(path: Path, *, maximum: int, private: bool = False) -> bytes:
  29. if not path.is_absolute() or path.is_symlink():
  30. raise ValueError("runtime material path is invalid")
  31. flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
  32. if hasattr(os, "O_NOFOLLOW"):
  33. flags |= os.O_NOFOLLOW
  34. descriptor = os.open(path, flags)
  35. try:
  36. metadata = os.fstat(descriptor)
  37. if (
  38. not stat.S_ISREG(metadata.st_mode)
  39. or metadata.st_nlink != 1
  40. or metadata.st_size > maximum
  41. or (private and stat.S_IMODE(metadata.st_mode) & 0o077)
  42. ):
  43. raise ValueError("runtime material metadata is unsafe")
  44. chunks = bytearray()
  45. while len(chunks) <= maximum:
  46. chunk = os.read(descriptor, min(65_536, maximum + 1 - len(chunks)))
  47. if not chunk:
  48. break
  49. chunks.extend(chunk)
  50. if len(chunks) > maximum or os.fstat(descriptor) != metadata:
  51. raise ValueError("runtime material changed while reading")
  52. return bytes(chunks)
  53. finally:
  54. os.close(descriptor)
  55. def _read_json(path: Path) -> dict[str, object]:
  56. try:
  57. value = json.loads(_read_regular(path, maximum=MAX_CONFIG_BYTES).decode("utf-8"))
  58. except (UnicodeError, json.JSONDecodeError) as exc:
  59. raise ValueError("runtime JSON is invalid") from exc
  60. if not isinstance(value, dict):
  61. raise ValueError("runtime JSON must be an object")
  62. return value
  63. def _atomic_health(path: Path, value: dict[str, object]) -> None:
  64. encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + b"\n"
  65. path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
  66. temporary = path.parent / f".{path.name}.{os.getpid()}.tmp"
  67. flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
  68. if hasattr(os, "O_NOFOLLOW"):
  69. flags |= os.O_NOFOLLOW
  70. descriptor = os.open(temporary, flags, 0o600)
  71. try:
  72. os.write(descriptor, encoded)
  73. os.fsync(descriptor)
  74. finally:
  75. os.close(descriptor)
  76. os.replace(temporary, path)
  77. directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
  78. try:
  79. os.fsync(directory)
  80. finally:
  81. os.close(directory)
  82. class ServerCrlHttpAdapter(HTTPAdapter):
  83. def __init__(self, ca_bundle_path: str, server_crl_path: str):
  84. self._ssl_context = build_server_crl_ssl_context(
  85. ca_bundle_path, server_crl_path
  86. )
  87. super().__init__()
  88. def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
  89. pool_kwargs["ssl_context"] = self._ssl_context
  90. return super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
  91. class ServerCrlSession(requests.Session):
  92. """Reload the TLS pool whenever the operator atomically replaces the CRL."""
  93. def __init__(self, ca_bundle_path: str, server_crl_path: str):
  94. super().__init__()
  95. self.trust_env = False
  96. self._ca_bundle_path = ca_bundle_path
  97. self._server_crl_path = server_crl_path
  98. self._server_crl_digest = ""
  99. self.refresh_server_crl()
  100. @property
  101. def server_crl_digest(self) -> str:
  102. return self._server_crl_digest
  103. def refresh_server_crl(self) -> bool:
  104. validate_server_crl(self._ca_bundle_path, self._server_crl_path)
  105. digest = hashlib.sha256(
  106. _read_regular(Path(self._server_crl_path), maximum=1_048_576)
  107. ).hexdigest()
  108. if digest == self._server_crl_digest:
  109. return False
  110. replacement = ServerCrlHttpAdapter(
  111. self._ca_bundle_path, self._server_crl_path
  112. )
  113. previous = self.adapters.get("https://")
  114. self.mount("https://", replacement)
  115. self._server_crl_digest = digest
  116. if previous is not None:
  117. previous.close()
  118. return True
  119. def request(self, *args, **kwargs):
  120. self.refresh_server_crl()
  121. return super().request(*args, **kwargs)
  122. def _runtime_self_check(node, request, cancel_requested):
  123. if (
  124. node != "quality.check"
  125. or request.get("operation") != "quality"
  126. or request.get("purpose") != "quality-evaluation"
  127. or request.get("classification") != "evidence"
  128. ):
  129. raise EdgePolicyError("enterprise source runner handler is not provisioned")
  130. if cancel_requested():
  131. raise EdgePolicyError("runtime self-check was cancelled")
  132. return {
  133. "classification": "evidence",
  134. "payload": {
  135. "check_count": 1,
  136. "passed_count": 1,
  137. "scope": "edge_runtime",
  138. "status": "passed",
  139. },
  140. }
  141. def build_runtime_runner() -> EdgeRunnerAdapter:
  142. """Provide one closed engineering self-check; source handlers remain external."""
  143. return EdgeRunnerAdapter(_runtime_self_check)
  144. def build_runtime_from_environment() -> tuple[EdgeAgent, Path, float]:
  145. config_path = Path(os.environ.get("EDGE_CONFIG_PATH", ""))
  146. credential_path = Path(os.environ.get("EDGE_CREDENTIAL_FILE", ""))
  147. task_keys_path = Path(os.environ.get("EDGE_TASK_SIGNING_KEYS_PATH", ""))
  148. release_keys_path = Path(os.environ.get("EDGE_RELEASE_SIGNING_KEYS_PATH", ""))
  149. server_crl_path = Path(os.environ.get("EDGE_SERVER_CRL_PATH", ""))
  150. health_path = Path(os.environ.get("EDGE_HEALTH_PATH", "/run/edge/tmp/health.json"))
  151. if any(not path.is_absolute() for path in (
  152. config_path, credential_path, task_keys_path, release_keys_path,
  153. server_crl_path, health_path,
  154. )):
  155. raise ValueError("edge runtime paths must be absolute")
  156. raw = _read_json(config_path)
  157. credential = _read_regular(
  158. credential_path, maximum=160, private=True
  159. ).decode("utf-8").strip()
  160. task_keys = _read_json(task_keys_path)
  161. release_keys = _read_json(release_keys_path)
  162. raw.update({
  163. "credential": credential,
  164. "trusted_task_keys": task_keys,
  165. "trusted_release_keys": release_keys,
  166. "server_crl_path": str(server_crl_path),
  167. })
  168. config = EdgeBootstrapConfig.from_mapping(raw)
  169. if config.server_crl_path is None:
  170. raise ValueError("server CRL is required by the runtime")
  171. session = ServerCrlSession(config.ca_bundle_path, config.server_crl_path)
  172. transport = EdgeTransport(
  173. base_url=config.control_url,
  174. gateway_id=config.gateway_id,
  175. environment=config.environment,
  176. network_zone=config.network_zone,
  177. generation=config.generation,
  178. credential=config.credential,
  179. certificate_sha256=config.certificate_sha256,
  180. allowed_control_hosts=config.allowed_control_hosts,
  181. allowed_proxy_hosts=config.allowed_proxy_hosts,
  182. allowed_control_origins=config.allowed_control_origins or None,
  183. allowed_proxy_origins=config.allowed_proxy_origins or None,
  184. proxy_url=config.proxy_url,
  185. client_certificate_path=config.client_certificate_path,
  186. client_private_key_path=config.client_private_key_path,
  187. ca_bundle_path=config.ca_bundle_path,
  188. client=session,
  189. server_crl_path=config.server_crl_path,
  190. )
  191. runner = build_runtime_runner()
  192. interval = float(os.environ.get("EDGE_POLL_INTERVAL_SECONDS", "5"))
  193. if not 1 <= interval <= 60:
  194. raise ValueError("EDGE_POLL_INTERVAL_SECONDS must be between 1 and 60")
  195. return EdgeAgent(config, transport, runner), health_path, interval
  196. def run() -> int:
  197. stop = threading.Event()
  198. for event in (signal.SIGINT, signal.SIGTERM):
  199. signal.signal(event, lambda _signum, _frame: stop.set())
  200. try:
  201. agent, health_path, interval = build_runtime_from_environment()
  202. except Exception:
  203. return 2
  204. stopped = False
  205. while not stop.is_set():
  206. try:
  207. if agent.config.server_crl_path is None: # pragma: no cover - invariant
  208. raise EdgeAuthenticationStopped("server CRL is missing")
  209. crl_next_update = validate_server_crl(
  210. agent.config.ca_bundle_path, agent.config.server_crl_path
  211. )
  212. agent.heartbeat_once()
  213. cycle = agent.run_once()
  214. _atomic_health(health_path, {
  215. "checked_at": _now(),
  216. "crl_next_update": crl_next_update.isoformat().replace("+00:00", "Z"),
  217. "gateway_id_digest": agent.gateway_id_digest,
  218. "last_cycle": cycle,
  219. "queue": agent.safe_diagnostic(),
  220. "status": "healthy",
  221. })
  222. except EdgeAuthenticationStopped:
  223. stopped = True
  224. _atomic_health(health_path, {
  225. "checked_at": _now(), "gateway_id_digest": agent.gateway_id_digest,
  226. "status": "stopped",
  227. })
  228. except (EdgeTransportError, OSError, ValueError):
  229. _atomic_health(health_path, {
  230. "checked_at": _now(), "gateway_id_digest": agent.gateway_id_digest,
  231. "status": "degraded",
  232. })
  233. if stopped:
  234. stop.wait(interval)
  235. else:
  236. stop.wait(interval)
  237. _atomic_health(health_path, {
  238. "checked_at": _now(), "gateway_id_digest": agent.gateway_id_digest,
  239. "status": "stopped",
  240. })
  241. return 0
  242. if __name__ == "__main__":
  243. raise SystemExit(run())