ctfy.sdk.node_client
HTTP client for platform → node machine calls.
Every call is authenticated with the target node's bearer token —
minted per node at registration and used in both directions. End users
never touch this client; they talk to the platform, which proxies to
the node. Node endpoints are defined in
server/routes/node_instances.py.
1"""HTTP client for platform → node machine calls. 2 3Every call is authenticated with the target node's bearer token — 4minted per node at registration and used in both directions. End users 5never touch this client; they talk to the platform, which proxies to 6the node. Node endpoints are defined in 7``server/routes/node_instances.py``. 8""" 9 10from __future__ import annotations 11 12import base64 13import dataclasses 14import json 15from typing import Any 16 17import httpx 18from pydantic import ValidationError 19 20from ctfy.challenge.deception import DeceptionSummary 21from ctfy.core.awd_network import MatchNetwork 22from ctfy.core.constants import DEFAULT_CLIENT_TIMEOUT 23from ctfy.core.exceptions import NodeRequestError 24from ctfy.sdk.base import BaseHttpClient 25 26 27def _raise_for_node_status(resp: httpx.Response) -> None: 28 """Like ``resp.raise_for_status()`` but preserves the node's error body. 29 30 On a 4xx/5xx, pull the FastAPI ``{"detail": ...}`` field (or the raw 31 body as a fallback) and raise :class:`NodeRequestError` so the real 32 failure reaches the caller instead of httpx's generic status string. 33 """ 34 if not resp.is_error: 35 return 36 detail = "" 37 try: 38 body = resp.json() 39 except (json.JSONDecodeError, ValueError): 40 body = None 41 if isinstance(body, dict): 42 d = body.get("detail") 43 if isinstance(d, str): 44 detail = d 45 elif d is not None: 46 detail = json.dumps(d) 47 if not detail: 48 detail = (resp.text or "").strip() 49 raise NodeRequestError(resp.status_code, detail) 50 51 52class NodeClient(BaseHttpClient): 53 """Thin sync HTTP client; one instance per node URL.""" 54 55 def __init__( 56 self, 57 node_url: str, 58 token: str, 59 *, 60 timeout: int = DEFAULT_CLIENT_TIMEOUT, 61 ) -> None: 62 super().__init__(f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout) 63 64 # -- lifecycle ---------------------------------------------------------- 65 66 def start_instance( 67 self, 68 *, 69 challenge_id: str, 70 instance_id: str, 71 ttl: int, 72 answers: dict[str, str], 73 proxy_output_dir: str | None = None, 74 env: dict[str, str] | None = None, 75 publish_gamebox: bool = False, 76 match_network: MatchNetwork | None = None, 77 ) -> dict[str, Any]: 78 resp = self.request( 79 "POST", 80 "/instances", 81 json={ 82 "challenge_id": challenge_id, 83 "instance_id": instance_id, 84 "ttl": ttl, 85 "answers": answers, 86 "proxy_output_dir": proxy_output_dir, 87 "env": env or {}, 88 "publish_gamebox": publish_gamebox, 89 # ``None`` rather than an omitted key: the node's model 90 # defaults to None either way, and sending it explicitly 91 # keeps the body's shape stable across both branches. 92 "match_network": (dataclasses.asdict(match_network) if match_network else None), 93 }, 94 ) 95 _raise_for_node_status(resp) 96 body: dict[str, Any] = resp.json() 97 return body 98 99 def stop_instance(self, instance_id: str) -> None: 100 resp = self.request("DELETE", f"/instances/{instance_id}") 101 resp.raise_for_status() 102 103 def stop_instance_service(self, instance_id: str, service: str) -> int: 104 """Stop one compose service, leaving the instance running. 105 106 The exercise format's session close (§4b.4). Returns how many 107 containers the node stopped — 0 means the challenge declares no 108 such service, which is a real answer rather than a failure, so 109 the caller can distinguish it from a cut tunnel. 110 """ 111 resp = self.request( 112 "POST", 113 f"/instances/{instance_id}/stop-service", 114 json={"service": service}, 115 ) 116 resp.raise_for_status() 117 body = resp.json() 118 return int(body.get("stopped", 0)) if isinstance(body, dict) else 0 119 120 def stop_all(self) -> None: 121 resp = self.request("POST", "/admin/stop-all") 122 resp.raise_for_status() 123 124 def rescan_challenges(self) -> dict[str, Any]: 125 """Tell the node to drop its spec cache and re-scan challenges_dir. 126 127 Returns ``{total, added, removed}`` so the platform can report 128 the per-node outcome of a cluster-wide rescan.""" 129 resp = self.request("POST", "/admin/rescan-challenges") 130 resp.raise_for_status() 131 body: dict[str, Any] = resp.json() 132 return body 133 134 # -- admin pre-build (image cache warming) ------------------------------ 135 136 def build_challenge(self, challenge_id: str) -> dict[str, Any]: 137 """Ask the node to pre-build images for *challenge_id*. 138 139 Fires-and-returns: the node persists ``status="building"`` and 140 spawns a daemon thread; the body returned here is that initial 141 state row. Polling :meth:`get_build_state` is how the platform 142 learns when it lands on ``built`` / ``failed``. 143 """ 144 resp = self.request("POST", f"/admin/challenges/{challenge_id}/build") 145 _raise_for_node_status(resp) 146 body: dict[str, Any] = resp.json() 147 return body 148 149 def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 150 """Queue background pre-build on the node. 151 152 With ``challenge_ids=None`` the node builds every spec it knows. 153 With an explicit list (the platform scoping a bulk build to a 154 competition's challenge set) only those are built. Returns 155 ``{queued, skipped_built, skipped_in_progress}`` so the platform 156 can report per-node what got picked up. Sequential on the node 157 side — no fan-out across the corpus. 158 """ 159 kwargs: dict[str, Any] = {} 160 if challenge_ids is not None: 161 kwargs["json"] = {"challenge_ids": challenge_ids} 162 resp = self.request("POST", "/admin/challenges/build-all", **kwargs) 163 _raise_for_node_status(resp) 164 body: dict[str, Any] = resp.json() 165 return body 166 167 def get_build_state(self) -> dict[str, Any]: 168 """Fetch every per-challenge build-state row on this node. 169 170 Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``. 171 ``status`` is one of ``unbuilt`` / ``building`` / ``built`` / 172 ``failed``; ``unbuilt`` placeholders are synthesised for specs 173 the node has seen but never been asked to build. 174 """ 175 resp = self.request("GET", "/admin/challenges/build-state") 176 _raise_for_node_status(resp) 177 body: dict[str, Any] = resp.json() 178 return body 179 180 def pull_challenge(self, challenge_id: str) -> dict[str, Any]: 181 """Ask the node to pre-pull registry images for *challenge_id*. 182 183 Pull-side twin of :meth:`build_challenge`: the node persists 184 ``status="pulling"`` and spawns a daemon thread; poll 185 :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing. 186 """ 187 resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull") 188 _raise_for_node_status(resp) 189 body: dict[str, Any] = resp.json() 190 return body 191 192 def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 193 """Queue background pre-pull on the node. 194 195 With ``challenge_ids=None`` the node pulls every spec it knows; 196 with an explicit list only those (the platform scoping to a 197 competition). Returns ``{queued, skipped_pulled, 198 skipped_in_progress}``. 199 """ 200 kwargs: dict[str, Any] = {} 201 if challenge_ids is not None: 202 kwargs["json"] = {"challenge_ids": challenge_ids} 203 resp = self.request("POST", "/admin/challenges/pull-all", **kwargs) 204 _raise_for_node_status(resp) 205 body: dict[str, Any] = resp.json() 206 return body 207 208 def get_pull_state(self) -> dict[str, Any]: 209 """Fetch every per-challenge pull-state row on this node. 210 211 Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``. 212 ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` / 213 ``failed``; ``unpulled`` placeholders are synthesised for specs 214 the node has seen but never been asked to pull. 215 """ 216 resp = self.request("GET", "/admin/challenges/pull-state") 217 _raise_for_node_status(resp) 218 body: dict[str, Any] = resp.json() 219 return body 220 221 # -- status / health ---------------------------------------------------- 222 223 def get_status(self, instance_id: str) -> dict[str, Any]: 224 resp = self.request("GET", f"/instances/{instance_id}/status") 225 resp.raise_for_status() 226 body: dict[str, Any] = resp.json() 227 return body 228 229 def list_instances(self) -> list[dict[str, Any]]: 230 """Every instance the node still has bookkeeping for. 231 232 Used by the platform's boot re-adoption pass to decide which 233 durable claims still have containers behind them. ``get_status`` 234 answers the same question one id at a time; asking it per claim 235 would cost one round trip per instance to a node that may be 236 slow or down, on the path that gates startup. 237 """ 238 resp = self.request("GET", "/instances") 239 resp.raise_for_status() 240 items: list[dict[str, Any]] = resp.json().get("items", []) 241 return items 242 243 def check_health(self, instance_id: str) -> bool: 244 return self.check_health_verbose(instance_id)[0] 245 246 def check_health_verbose(self, instance_id: str) -> tuple[bool, DeceptionSummary]: 247 """Health plus whatever agent-deception telemetry the node read. 248 249 One call, because the platform already polls this endpoint for 250 every live instance every tick and a second round trip per 251 instance would be the whole cost of the feature. ``check_health`` 252 stays a bool for the callers that only ever wanted one. 253 254 An older node sends no ``deception`` key, and a node whose read 255 failed sends null — both are an empty summary, never an error: 256 this rides the health poll, and telemetry must not be able to 257 mark a healthy instance unhealthy. 258 """ 259 resp = self.request("GET", f"/instances/{instance_id}/health") 260 resp.raise_for_status() 261 body = resp.json() 262 raw = body.get("deception") 263 try: 264 summary = DeceptionSummary.model_validate(raw) if raw else DeceptionSummary() 265 except ValidationError: 266 summary = DeceptionSummary() 267 return bool(body.get("is_healthy")), summary 268 269 def node_health(self) -> dict[str, Any]: 270 """Liveness + ``{running, capacity}`` for heartbeat.""" 271 resp = self.request("GET", "/health") 272 resp.raise_for_status() 273 body: dict[str, Any] = resp.json() 274 return body 275 276 # -- traffic / runtime -------------------------------------------------- 277 278 def get_traffic_page( 279 self, instance_id: str, *, cursor: str = "", limit: int = 0 280 ) -> dict[str, Any]: 281 """One page of mitmproxy flows; the capture lives on the node's FS. 282 283 Returns the node's envelope verbatim — ``{flows, cursor, 284 truncated}``. It is deliberately *not* parsed here: this is the 285 transport, and every decision about an unexpected shape belongs 286 in one place platform-side (``traffic_tail.coerce_page``), so a 287 route and this client cannot come to disagree about what a 288 missing cursor means. 289 290 ⚠️ **An empty ``cursor`` means "from the beginning".** Passing 291 one on every poll re-reads the whole capture, which is the cost 292 the paged read exists to remove — arriving through the caller 293 rather than through the node. 294 """ 295 params: dict[str, Any] = {} 296 if cursor: 297 params["cursor"] = cursor 298 if limit > 0: 299 params["limit"] = limit 300 resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params) 301 resp.raise_for_status() 302 body: dict[str, Any] = resp.json() 303 return body 304 305 def list_containers(self, instance_id: str) -> list[dict[str, Any]]: 306 """Enumerate every container in an instance's compose project. 307 308 Backs the admin-shell feature: the platform forwards a 309 ``GET /admin/instances/{id}/containers`` to the assigned node, 310 which returns one row per container (challenge services and 311 platform-injected sidecars alike). The WebSocket reverse-proxy 312 is opened separately and does not go through ``NodeClient``. 313 """ 314 resp = self.request("GET", f"/instances/{instance_id}/containers") 315 _raise_for_node_status(resp) 316 body: list[dict[str, Any]] = resp.json() 317 return body 318 319 def run_checker( 320 self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30 321 ) -> dict[str, Any]: 322 """Exec an author-supplied checker in a trusted judge sidecar. 323 324 Backs the checker / exec-judge feature: the platform forwards a 325 verify request to the assigned node, which runs ``cmd`` inside the 326 named sidecar and returns 327 ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP 328 timeout sits above the node's exec budget so the client doesn't 329 abandon the request before the node returns. 330 """ 331 resp = self.request( 332 "POST", 333 f"/instances/{instance_id}/check", 334 json={"service": service, "cmd": cmd, "timeout_s": timeout_s}, 335 timeout=timeout_s + 10, 336 ) 337 _raise_for_node_status(resp) 338 body: dict[str, Any] = resp.json() 339 return body 340 341 def run_harness( 342 self, *, image: str, env: dict[str, str], timeout_s: int = 1800 343 ) -> dict[str, Any]: 344 """Run a one-shot evaluation-harness container on the node. 345 346 The node ``docker run``s ``image`` with ``env`` injected, waits for it 347 to exit (bounded by ``timeout_s``), and returns 348 ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as 349 :meth:`run_checker`. The harness writes its JSON rollup to stdout. The 350 per-call HTTP timeout sits above the node's run budget so the client 351 doesn't abandon the request before the (long-running) harness returns. 352 """ 353 resp = self.request( 354 "POST", 355 "/harness/run", 356 json={"image": image, "env": env, "timeout_s": timeout_s}, 357 timeout=timeout_s + 30, 358 ) 359 _raise_for_node_status(resp) 360 body: dict[str, Any] = resp.json() 361 return body 362 363 def verify_patch( 364 self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900 365 ) -> dict[str, Any]: 366 """Build a submitted patch on the node and return its verdict. 367 368 ``files`` maps a challenge-relative path to its new bytes; base64 369 is applied here because a patch target may legitimately be 370 binary. Returns ``{verdict, detail, applied, steps, error}``. 371 372 **An empty ``verdict`` with a populated ``error`` is not a 373 judgement** — it means the node refused the submission or the 374 verifier crashed. The caller must retry or retire the lease 375 rather than write a score, since failing to judge a patch is not 376 the same as judging it unfixed. 377 378 The per-call HTTP timeout sits above the node's own budget so the 379 client doesn't abandon the request while the node is still 380 building. 381 """ 382 resp = self.request( 383 "POST", 384 "/patches/verify", 385 json={ 386 "challenge_id": challenge_id, 387 "files": { 388 path: base64.b64encode(content).decode() for path, content in files.items() 389 }, 390 "timeout_s": timeout_s, 391 }, 392 timeout=timeout_s + 60, 393 ) 394 _raise_for_node_status(resp) 395 body: dict[str, Any] = resp.json() 396 return body 397 398 def collect_patch_files( 399 self, instance_id: str, service: str, paths: list[str] 400 ) -> dict[str, bytes]: 401 """Read a player's SSH edits back out of their running box. 402 403 ``paths`` are absolute in-container paths the *platform* derived 404 from the pristine source tree. Returns only what could be read: 405 a path missing from the reply means the player did not change it, 406 and the caller falls back to the shipped file rather than 407 recording a deletion they never made. 408 """ 409 resp = self.request( 410 "POST", 411 f"/instances/{instance_id}/patch-collect", 412 json={"service": service, "paths": paths}, 413 ) 414 _raise_for_node_status(resp) 415 body: dict[str, str] = resp.json() 416 return {path: base64.b64decode(blob) for path, blob in body.items()} 417 418 def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]: 419 """Rotate a round's flags into every box of this node, in one call. 420 421 ⚠️ **One call for the whole node, never one per box.** §5.2's 422 capacity red line is 1000 teams x 3 services injected inside 423 120 s; per-box that is 3000 round trips, batched it is about 424 fourteen. The signature is the enforcement — a caller cannot 425 accidentally loop. 426 427 Returns ``{instance_id: ""}`` for the boxes written and a reason 428 for the ones that were not, because one team's unreachable box 429 must not cost every other team on the node its rotation. An 430 unrotated box keeps serving last round's flag, which anyone 431 holding the old value can replay. 432 """ 433 resp = self.request("POST", "/awd/answers", json={"boxes": boxes}) 434 _raise_for_node_status(resp) 435 result: dict[str, str] = resp.json() 436 return result 437 438 def probe_awd_service( 439 self, 440 *, 441 instance_id: str, 442 service_id: str, 443 tick: int, 444 flag: str, 445 previous_flag: str = "", 446 previous_flag_id: str = "", 447 ) -> dict[str, str]: 448 """Run one round's SLA probes against one box, from outside it. 449 450 The flags travel *to* the node: a checker that sourced its 451 expected value from the box it is checking would pass on any box 452 that returns whatever it was handed. 453 """ 454 resp = self.request( 455 "POST", 456 "/awd/probe", 457 json={ 458 "instance_id": instance_id, 459 "service_id": service_id, 460 "tick": tick, 461 "flag": flag, 462 "previous_flag": previous_flag, 463 "previous_flag_id": previous_flag_id, 464 }, 465 ) 466 _raise_for_node_status(resp) 467 result: dict[str, str] = resp.json() 468 return result 469 470 def get_container_logs(self, instance_id: str) -> str: 471 """Fetch combined container stdout/stderr for archival.""" 472 resp = self.request("GET", f"/instances/{instance_id}/container-logs") 473 resp.raise_for_status() 474 return str(resp.json().get("logs") or "") 475 476 def get_pcap(self, instance_id: str) -> bytes: 477 """Fetch the raw tcpdump capture for *instance_id*. 478 479 Returns an empty ``bytes`` when no capture exists (sidecar 480 disabled, instance never reached the running state, etc.) so 481 callers can persist conditionally without try/except. 482 """ 483 resp = self.request("GET", f"/instances/{instance_id}/pcap") 484 if resp.status_code == 404: 485 return b"" 486 resp.raise_for_status() 487 return resp.content 488 489 def get_agent_runtime(self, instance_id: str) -> dict[str, Any]: 490 """Provider-agnostic runtime hints for attaching an agent (proxy URL, 491 CA PEM, optional Docker-specific names). Replaces the old 492 sandbox-network shape.""" 493 resp = self.request("GET", f"/instances/{instance_id}/agent-runtime") 494 resp.raise_for_status() 495 body: dict[str, Any] = resp.json() 496 return body 497 498 # -- per-instance rendered attachments --------------------------------- 499 500 def list_instance_attachments(self, instance_id: str) -> dict[str, Any]: 501 """List per-instance attachments rendered into the node's workdir. 502 503 Returns the raw JSON dict — caller projects through 504 :class:`AttachmentList` for typing. Used by the platform's 505 per-instance attachment endpoint to surface team-specific 506 rendered file listings.""" 507 resp = self.request("GET", f"/instances/{instance_id}/attachments") 508 resp.raise_for_status() 509 body: dict[str, Any] = resp.json() 510 return body 511 512 def get_vpn_config(self, instance_id: str) -> bytes: 513 """Fetch the per-instance VPN client config from the node. 514 515 The node reads the backend's client config out of the 516 instance's VPN service container (generated on first boot by 517 the base image's bootstrap) and rewrites its endpoint to the 518 node's public host:port. Returns the body verbatim — the 519 platform-side route adds the ``Content-Disposition`` header 520 before re-emitting to the player, because only it knows the 521 backend and therefore the filename the player needs. 522 523 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 524 proxy route catches 404 and re-emits to the player, anything 525 else is treated as a 502. 526 """ 527 resp = self.request("GET", f"/instances/{instance_id}/vpn-config") 528 resp.raise_for_status() 529 return resp.content 530 531 def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]: 532 """Download one per-instance attachment as ``(bytes, content_type)``. 533 534 Buffers the whole body — attachments are bounded (typical case 535 is a 10 KB binary or a small text file). For huge captures the 536 caller should hand the player a CDN URL instead. 537 538 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 539 proxy route catches 404 and re-emits to the player, anything 540 else is a 502.""" 541 resp = self.request( 542 "GET", 543 f"/instances/{instance_id}/attachments/{filename}", 544 ) 545 resp.raise_for_status() 546 return resp.content, resp.headers.get("content-type", "application/octet-stream")
53class NodeClient(BaseHttpClient): 54 """Thin sync HTTP client; one instance per node URL.""" 55 56 def __init__( 57 self, 58 node_url: str, 59 token: str, 60 *, 61 timeout: int = DEFAULT_CLIENT_TIMEOUT, 62 ) -> None: 63 super().__init__(f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout) 64 65 # -- lifecycle ---------------------------------------------------------- 66 67 def start_instance( 68 self, 69 *, 70 challenge_id: str, 71 instance_id: str, 72 ttl: int, 73 answers: dict[str, str], 74 proxy_output_dir: str | None = None, 75 env: dict[str, str] | None = None, 76 publish_gamebox: bool = False, 77 match_network: MatchNetwork | None = None, 78 ) -> dict[str, Any]: 79 resp = self.request( 80 "POST", 81 "/instances", 82 json={ 83 "challenge_id": challenge_id, 84 "instance_id": instance_id, 85 "ttl": ttl, 86 "answers": answers, 87 "proxy_output_dir": proxy_output_dir, 88 "env": env or {}, 89 "publish_gamebox": publish_gamebox, 90 # ``None`` rather than an omitted key: the node's model 91 # defaults to None either way, and sending it explicitly 92 # keeps the body's shape stable across both branches. 93 "match_network": (dataclasses.asdict(match_network) if match_network else None), 94 }, 95 ) 96 _raise_for_node_status(resp) 97 body: dict[str, Any] = resp.json() 98 return body 99 100 def stop_instance(self, instance_id: str) -> None: 101 resp = self.request("DELETE", f"/instances/{instance_id}") 102 resp.raise_for_status() 103 104 def stop_instance_service(self, instance_id: str, service: str) -> int: 105 """Stop one compose service, leaving the instance running. 106 107 The exercise format's session close (§4b.4). Returns how many 108 containers the node stopped — 0 means the challenge declares no 109 such service, which is a real answer rather than a failure, so 110 the caller can distinguish it from a cut tunnel. 111 """ 112 resp = self.request( 113 "POST", 114 f"/instances/{instance_id}/stop-service", 115 json={"service": service}, 116 ) 117 resp.raise_for_status() 118 body = resp.json() 119 return int(body.get("stopped", 0)) if isinstance(body, dict) else 0 120 121 def stop_all(self) -> None: 122 resp = self.request("POST", "/admin/stop-all") 123 resp.raise_for_status() 124 125 def rescan_challenges(self) -> dict[str, Any]: 126 """Tell the node to drop its spec cache and re-scan challenges_dir. 127 128 Returns ``{total, added, removed}`` so the platform can report 129 the per-node outcome of a cluster-wide rescan.""" 130 resp = self.request("POST", "/admin/rescan-challenges") 131 resp.raise_for_status() 132 body: dict[str, Any] = resp.json() 133 return body 134 135 # -- admin pre-build (image cache warming) ------------------------------ 136 137 def build_challenge(self, challenge_id: str) -> dict[str, Any]: 138 """Ask the node to pre-build images for *challenge_id*. 139 140 Fires-and-returns: the node persists ``status="building"`` and 141 spawns a daemon thread; the body returned here is that initial 142 state row. Polling :meth:`get_build_state` is how the platform 143 learns when it lands on ``built`` / ``failed``. 144 """ 145 resp = self.request("POST", f"/admin/challenges/{challenge_id}/build") 146 _raise_for_node_status(resp) 147 body: dict[str, Any] = resp.json() 148 return body 149 150 def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 151 """Queue background pre-build on the node. 152 153 With ``challenge_ids=None`` the node builds every spec it knows. 154 With an explicit list (the platform scoping a bulk build to a 155 competition's challenge set) only those are built. Returns 156 ``{queued, skipped_built, skipped_in_progress}`` so the platform 157 can report per-node what got picked up. Sequential on the node 158 side — no fan-out across the corpus. 159 """ 160 kwargs: dict[str, Any] = {} 161 if challenge_ids is not None: 162 kwargs["json"] = {"challenge_ids": challenge_ids} 163 resp = self.request("POST", "/admin/challenges/build-all", **kwargs) 164 _raise_for_node_status(resp) 165 body: dict[str, Any] = resp.json() 166 return body 167 168 def get_build_state(self) -> dict[str, Any]: 169 """Fetch every per-challenge build-state row on this node. 170 171 Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``. 172 ``status`` is one of ``unbuilt`` / ``building`` / ``built`` / 173 ``failed``; ``unbuilt`` placeholders are synthesised for specs 174 the node has seen but never been asked to build. 175 """ 176 resp = self.request("GET", "/admin/challenges/build-state") 177 _raise_for_node_status(resp) 178 body: dict[str, Any] = resp.json() 179 return body 180 181 def pull_challenge(self, challenge_id: str) -> dict[str, Any]: 182 """Ask the node to pre-pull registry images for *challenge_id*. 183 184 Pull-side twin of :meth:`build_challenge`: the node persists 185 ``status="pulling"`` and spawns a daemon thread; poll 186 :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing. 187 """ 188 resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull") 189 _raise_for_node_status(resp) 190 body: dict[str, Any] = resp.json() 191 return body 192 193 def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 194 """Queue background pre-pull on the node. 195 196 With ``challenge_ids=None`` the node pulls every spec it knows; 197 with an explicit list only those (the platform scoping to a 198 competition). Returns ``{queued, skipped_pulled, 199 skipped_in_progress}``. 200 """ 201 kwargs: dict[str, Any] = {} 202 if challenge_ids is not None: 203 kwargs["json"] = {"challenge_ids": challenge_ids} 204 resp = self.request("POST", "/admin/challenges/pull-all", **kwargs) 205 _raise_for_node_status(resp) 206 body: dict[str, Any] = resp.json() 207 return body 208 209 def get_pull_state(self) -> dict[str, Any]: 210 """Fetch every per-challenge pull-state row on this node. 211 212 Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``. 213 ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` / 214 ``failed``; ``unpulled`` placeholders are synthesised for specs 215 the node has seen but never been asked to pull. 216 """ 217 resp = self.request("GET", "/admin/challenges/pull-state") 218 _raise_for_node_status(resp) 219 body: dict[str, Any] = resp.json() 220 return body 221 222 # -- status / health ---------------------------------------------------- 223 224 def get_status(self, instance_id: str) -> dict[str, Any]: 225 resp = self.request("GET", f"/instances/{instance_id}/status") 226 resp.raise_for_status() 227 body: dict[str, Any] = resp.json() 228 return body 229 230 def list_instances(self) -> list[dict[str, Any]]: 231 """Every instance the node still has bookkeeping for. 232 233 Used by the platform's boot re-adoption pass to decide which 234 durable claims still have containers behind them. ``get_status`` 235 answers the same question one id at a time; asking it per claim 236 would cost one round trip per instance to a node that may be 237 slow or down, on the path that gates startup. 238 """ 239 resp = self.request("GET", "/instances") 240 resp.raise_for_status() 241 items: list[dict[str, Any]] = resp.json().get("items", []) 242 return items 243 244 def check_health(self, instance_id: str) -> bool: 245 return self.check_health_verbose(instance_id)[0] 246 247 def check_health_verbose(self, instance_id: str) -> tuple[bool, DeceptionSummary]: 248 """Health plus whatever agent-deception telemetry the node read. 249 250 One call, because the platform already polls this endpoint for 251 every live instance every tick and a second round trip per 252 instance would be the whole cost of the feature. ``check_health`` 253 stays a bool for the callers that only ever wanted one. 254 255 An older node sends no ``deception`` key, and a node whose read 256 failed sends null — both are an empty summary, never an error: 257 this rides the health poll, and telemetry must not be able to 258 mark a healthy instance unhealthy. 259 """ 260 resp = self.request("GET", f"/instances/{instance_id}/health") 261 resp.raise_for_status() 262 body = resp.json() 263 raw = body.get("deception") 264 try: 265 summary = DeceptionSummary.model_validate(raw) if raw else DeceptionSummary() 266 except ValidationError: 267 summary = DeceptionSummary() 268 return bool(body.get("is_healthy")), summary 269 270 def node_health(self) -> dict[str, Any]: 271 """Liveness + ``{running, capacity}`` for heartbeat.""" 272 resp = self.request("GET", "/health") 273 resp.raise_for_status() 274 body: dict[str, Any] = resp.json() 275 return body 276 277 # -- traffic / runtime -------------------------------------------------- 278 279 def get_traffic_page( 280 self, instance_id: str, *, cursor: str = "", limit: int = 0 281 ) -> dict[str, Any]: 282 """One page of mitmproxy flows; the capture lives on the node's FS. 283 284 Returns the node's envelope verbatim — ``{flows, cursor, 285 truncated}``. It is deliberately *not* parsed here: this is the 286 transport, and every decision about an unexpected shape belongs 287 in one place platform-side (``traffic_tail.coerce_page``), so a 288 route and this client cannot come to disagree about what a 289 missing cursor means. 290 291 ⚠️ **An empty ``cursor`` means "from the beginning".** Passing 292 one on every poll re-reads the whole capture, which is the cost 293 the paged read exists to remove — arriving through the caller 294 rather than through the node. 295 """ 296 params: dict[str, Any] = {} 297 if cursor: 298 params["cursor"] = cursor 299 if limit > 0: 300 params["limit"] = limit 301 resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params) 302 resp.raise_for_status() 303 body: dict[str, Any] = resp.json() 304 return body 305 306 def list_containers(self, instance_id: str) -> list[dict[str, Any]]: 307 """Enumerate every container in an instance's compose project. 308 309 Backs the admin-shell feature: the platform forwards a 310 ``GET /admin/instances/{id}/containers`` to the assigned node, 311 which returns one row per container (challenge services and 312 platform-injected sidecars alike). The WebSocket reverse-proxy 313 is opened separately and does not go through ``NodeClient``. 314 """ 315 resp = self.request("GET", f"/instances/{instance_id}/containers") 316 _raise_for_node_status(resp) 317 body: list[dict[str, Any]] = resp.json() 318 return body 319 320 def run_checker( 321 self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30 322 ) -> dict[str, Any]: 323 """Exec an author-supplied checker in a trusted judge sidecar. 324 325 Backs the checker / exec-judge feature: the platform forwards a 326 verify request to the assigned node, which runs ``cmd`` inside the 327 named sidecar and returns 328 ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP 329 timeout sits above the node's exec budget so the client doesn't 330 abandon the request before the node returns. 331 """ 332 resp = self.request( 333 "POST", 334 f"/instances/{instance_id}/check", 335 json={"service": service, "cmd": cmd, "timeout_s": timeout_s}, 336 timeout=timeout_s + 10, 337 ) 338 _raise_for_node_status(resp) 339 body: dict[str, Any] = resp.json() 340 return body 341 342 def run_harness( 343 self, *, image: str, env: dict[str, str], timeout_s: int = 1800 344 ) -> dict[str, Any]: 345 """Run a one-shot evaluation-harness container on the node. 346 347 The node ``docker run``s ``image`` with ``env`` injected, waits for it 348 to exit (bounded by ``timeout_s``), and returns 349 ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as 350 :meth:`run_checker`. The harness writes its JSON rollup to stdout. The 351 per-call HTTP timeout sits above the node's run budget so the client 352 doesn't abandon the request before the (long-running) harness returns. 353 """ 354 resp = self.request( 355 "POST", 356 "/harness/run", 357 json={"image": image, "env": env, "timeout_s": timeout_s}, 358 timeout=timeout_s + 30, 359 ) 360 _raise_for_node_status(resp) 361 body: dict[str, Any] = resp.json() 362 return body 363 364 def verify_patch( 365 self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900 366 ) -> dict[str, Any]: 367 """Build a submitted patch on the node and return its verdict. 368 369 ``files`` maps a challenge-relative path to its new bytes; base64 370 is applied here because a patch target may legitimately be 371 binary. Returns ``{verdict, detail, applied, steps, error}``. 372 373 **An empty ``verdict`` with a populated ``error`` is not a 374 judgement** — it means the node refused the submission or the 375 verifier crashed. The caller must retry or retire the lease 376 rather than write a score, since failing to judge a patch is not 377 the same as judging it unfixed. 378 379 The per-call HTTP timeout sits above the node's own budget so the 380 client doesn't abandon the request while the node is still 381 building. 382 """ 383 resp = self.request( 384 "POST", 385 "/patches/verify", 386 json={ 387 "challenge_id": challenge_id, 388 "files": { 389 path: base64.b64encode(content).decode() for path, content in files.items() 390 }, 391 "timeout_s": timeout_s, 392 }, 393 timeout=timeout_s + 60, 394 ) 395 _raise_for_node_status(resp) 396 body: dict[str, Any] = resp.json() 397 return body 398 399 def collect_patch_files( 400 self, instance_id: str, service: str, paths: list[str] 401 ) -> dict[str, bytes]: 402 """Read a player's SSH edits back out of their running box. 403 404 ``paths`` are absolute in-container paths the *platform* derived 405 from the pristine source tree. Returns only what could be read: 406 a path missing from the reply means the player did not change it, 407 and the caller falls back to the shipped file rather than 408 recording a deletion they never made. 409 """ 410 resp = self.request( 411 "POST", 412 f"/instances/{instance_id}/patch-collect", 413 json={"service": service, "paths": paths}, 414 ) 415 _raise_for_node_status(resp) 416 body: dict[str, str] = resp.json() 417 return {path: base64.b64decode(blob) for path, blob in body.items()} 418 419 def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]: 420 """Rotate a round's flags into every box of this node, in one call. 421 422 ⚠️ **One call for the whole node, never one per box.** §5.2's 423 capacity red line is 1000 teams x 3 services injected inside 424 120 s; per-box that is 3000 round trips, batched it is about 425 fourteen. The signature is the enforcement — a caller cannot 426 accidentally loop. 427 428 Returns ``{instance_id: ""}`` for the boxes written and a reason 429 for the ones that were not, because one team's unreachable box 430 must not cost every other team on the node its rotation. An 431 unrotated box keeps serving last round's flag, which anyone 432 holding the old value can replay. 433 """ 434 resp = self.request("POST", "/awd/answers", json={"boxes": boxes}) 435 _raise_for_node_status(resp) 436 result: dict[str, str] = resp.json() 437 return result 438 439 def probe_awd_service( 440 self, 441 *, 442 instance_id: str, 443 service_id: str, 444 tick: int, 445 flag: str, 446 previous_flag: str = "", 447 previous_flag_id: str = "", 448 ) -> dict[str, str]: 449 """Run one round's SLA probes against one box, from outside it. 450 451 The flags travel *to* the node: a checker that sourced its 452 expected value from the box it is checking would pass on any box 453 that returns whatever it was handed. 454 """ 455 resp = self.request( 456 "POST", 457 "/awd/probe", 458 json={ 459 "instance_id": instance_id, 460 "service_id": service_id, 461 "tick": tick, 462 "flag": flag, 463 "previous_flag": previous_flag, 464 "previous_flag_id": previous_flag_id, 465 }, 466 ) 467 _raise_for_node_status(resp) 468 result: dict[str, str] = resp.json() 469 return result 470 471 def get_container_logs(self, instance_id: str) -> str: 472 """Fetch combined container stdout/stderr for archival.""" 473 resp = self.request("GET", f"/instances/{instance_id}/container-logs") 474 resp.raise_for_status() 475 return str(resp.json().get("logs") or "") 476 477 def get_pcap(self, instance_id: str) -> bytes: 478 """Fetch the raw tcpdump capture for *instance_id*. 479 480 Returns an empty ``bytes`` when no capture exists (sidecar 481 disabled, instance never reached the running state, etc.) so 482 callers can persist conditionally without try/except. 483 """ 484 resp = self.request("GET", f"/instances/{instance_id}/pcap") 485 if resp.status_code == 404: 486 return b"" 487 resp.raise_for_status() 488 return resp.content 489 490 def get_agent_runtime(self, instance_id: str) -> dict[str, Any]: 491 """Provider-agnostic runtime hints for attaching an agent (proxy URL, 492 CA PEM, optional Docker-specific names). Replaces the old 493 sandbox-network shape.""" 494 resp = self.request("GET", f"/instances/{instance_id}/agent-runtime") 495 resp.raise_for_status() 496 body: dict[str, Any] = resp.json() 497 return body 498 499 # -- per-instance rendered attachments --------------------------------- 500 501 def list_instance_attachments(self, instance_id: str) -> dict[str, Any]: 502 """List per-instance attachments rendered into the node's workdir. 503 504 Returns the raw JSON dict — caller projects through 505 :class:`AttachmentList` for typing. Used by the platform's 506 per-instance attachment endpoint to surface team-specific 507 rendered file listings.""" 508 resp = self.request("GET", f"/instances/{instance_id}/attachments") 509 resp.raise_for_status() 510 body: dict[str, Any] = resp.json() 511 return body 512 513 def get_vpn_config(self, instance_id: str) -> bytes: 514 """Fetch the per-instance VPN client config from the node. 515 516 The node reads the backend's client config out of the 517 instance's VPN service container (generated on first boot by 518 the base image's bootstrap) and rewrites its endpoint to the 519 node's public host:port. Returns the body verbatim — the 520 platform-side route adds the ``Content-Disposition`` header 521 before re-emitting to the player, because only it knows the 522 backend and therefore the filename the player needs. 523 524 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 525 proxy route catches 404 and re-emits to the player, anything 526 else is treated as a 502. 527 """ 528 resp = self.request("GET", f"/instances/{instance_id}/vpn-config") 529 resp.raise_for_status() 530 return resp.content 531 532 def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]: 533 """Download one per-instance attachment as ``(bytes, content_type)``. 534 535 Buffers the whole body — attachments are bounded (typical case 536 is a 10 KB binary or a small text file). For huge captures the 537 caller should hand the player a CDN URL instead. 538 539 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 540 proxy route catches 404 and re-emits to the player, anything 541 else is a 502.""" 542 resp = self.request( 543 "GET", 544 f"/instances/{instance_id}/attachments/{filename}", 545 ) 546 resp.raise_for_status() 547 return resp.content, resp.headers.get("content-type", "application/octet-stream")
Thin sync HTTP client; one instance per node URL.
67 def start_instance( 68 self, 69 *, 70 challenge_id: str, 71 instance_id: str, 72 ttl: int, 73 answers: dict[str, str], 74 proxy_output_dir: str | None = None, 75 env: dict[str, str] | None = None, 76 publish_gamebox: bool = False, 77 match_network: MatchNetwork | None = None, 78 ) -> dict[str, Any]: 79 resp = self.request( 80 "POST", 81 "/instances", 82 json={ 83 "challenge_id": challenge_id, 84 "instance_id": instance_id, 85 "ttl": ttl, 86 "answers": answers, 87 "proxy_output_dir": proxy_output_dir, 88 "env": env or {}, 89 "publish_gamebox": publish_gamebox, 90 # ``None`` rather than an omitted key: the node's model 91 # defaults to None either way, and sending it explicitly 92 # keeps the body's shape stable across both branches. 93 "match_network": (dataclasses.asdict(match_network) if match_network else None), 94 }, 95 ) 96 _raise_for_node_status(resp) 97 body: dict[str, Any] = resp.json() 98 return body
104 def stop_instance_service(self, instance_id: str, service: str) -> int: 105 """Stop one compose service, leaving the instance running. 106 107 The exercise format's session close (§4b.4). Returns how many 108 containers the node stopped — 0 means the challenge declares no 109 such service, which is a real answer rather than a failure, so 110 the caller can distinguish it from a cut tunnel. 111 """ 112 resp = self.request( 113 "POST", 114 f"/instances/{instance_id}/stop-service", 115 json={"service": service}, 116 ) 117 resp.raise_for_status() 118 body = resp.json() 119 return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
Stop one compose service, leaving the instance running.
The exercise format's session close (§4b.4). Returns how many containers the node stopped — 0 means the challenge declares no such service, which is a real answer rather than a failure, so the caller can distinguish it from a cut tunnel.
125 def rescan_challenges(self) -> dict[str, Any]: 126 """Tell the node to drop its spec cache and re-scan challenges_dir. 127 128 Returns ``{total, added, removed}`` so the platform can report 129 the per-node outcome of a cluster-wide rescan.""" 130 resp = self.request("POST", "/admin/rescan-challenges") 131 resp.raise_for_status() 132 body: dict[str, Any] = resp.json() 133 return body
Tell the node to drop its spec cache and re-scan challenges_dir.
Returns {total, added, removed} so the platform can report
the per-node outcome of a cluster-wide rescan.
137 def build_challenge(self, challenge_id: str) -> dict[str, Any]: 138 """Ask the node to pre-build images for *challenge_id*. 139 140 Fires-and-returns: the node persists ``status="building"`` and 141 spawns a daemon thread; the body returned here is that initial 142 state row. Polling :meth:`get_build_state` is how the platform 143 learns when it lands on ``built`` / ``failed``. 144 """ 145 resp = self.request("POST", f"/admin/challenges/{challenge_id}/build") 146 _raise_for_node_status(resp) 147 body: dict[str, Any] = resp.json() 148 return body
Ask the node to pre-build images for challenge_id.
Fires-and-returns: the node persists status="building" and
spawns a daemon thread; the body returned here is that initial
state row. Polling get_build_state() is how the platform
learns when it lands on built / failed.
150 def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 151 """Queue background pre-build on the node. 152 153 With ``challenge_ids=None`` the node builds every spec it knows. 154 With an explicit list (the platform scoping a bulk build to a 155 competition's challenge set) only those are built. Returns 156 ``{queued, skipped_built, skipped_in_progress}`` so the platform 157 can report per-node what got picked up. Sequential on the node 158 side — no fan-out across the corpus. 159 """ 160 kwargs: dict[str, Any] = {} 161 if challenge_ids is not None: 162 kwargs["json"] = {"challenge_ids": challenge_ids} 163 resp = self.request("POST", "/admin/challenges/build-all", **kwargs) 164 _raise_for_node_status(resp) 165 body: dict[str, Any] = resp.json() 166 return body
Queue background pre-build on the node.
With challenge_ids=None the node builds every spec it knows.
With an explicit list (the platform scoping a bulk build to a
competition's challenge set) only those are built. Returns
{queued, skipped_built, skipped_in_progress} so the platform
can report per-node what got picked up. Sequential on the node
side — no fan-out across the corpus.
168 def get_build_state(self) -> dict[str, Any]: 169 """Fetch every per-challenge build-state row on this node. 170 171 Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``. 172 ``status`` is one of ``unbuilt`` / ``building`` / ``built`` / 173 ``failed``; ``unbuilt`` placeholders are synthesised for specs 174 the node has seen but never been asked to build. 175 """ 176 resp = self.request("GET", "/admin/challenges/build-state") 177 _raise_for_node_status(resp) 178 body: dict[str, Any] = resp.json() 179 return body
Fetch every per-challenge build-state row on this node.
Returns {"rows": [{challenge_id, status, built_at, error}, …]}.
status is one of unbuilt / building / built /
failed; unbuilt placeholders are synthesised for specs
the node has seen but never been asked to build.
181 def pull_challenge(self, challenge_id: str) -> dict[str, Any]: 182 """Ask the node to pre-pull registry images for *challenge_id*. 183 184 Pull-side twin of :meth:`build_challenge`: the node persists 185 ``status="pulling"`` and spawns a daemon thread; poll 186 :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing. 187 """ 188 resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull") 189 _raise_for_node_status(resp) 190 body: dict[str, Any] = resp.json() 191 return body
Ask the node to pre-pull registry images for challenge_id.
Pull-side twin of build_challenge(): the node persists
status="pulling" and spawns a daemon thread; poll
get_pull_state() for the pulled / failed landing.
193 def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 194 """Queue background pre-pull on the node. 195 196 With ``challenge_ids=None`` the node pulls every spec it knows; 197 with an explicit list only those (the platform scoping to a 198 competition). Returns ``{queued, skipped_pulled, 199 skipped_in_progress}``. 200 """ 201 kwargs: dict[str, Any] = {} 202 if challenge_ids is not None: 203 kwargs["json"] = {"challenge_ids": challenge_ids} 204 resp = self.request("POST", "/admin/challenges/pull-all", **kwargs) 205 _raise_for_node_status(resp) 206 body: dict[str, Any] = resp.json() 207 return body
Queue background pre-pull on the node.
With challenge_ids=None the node pulls every spec it knows;
with an explicit list only those (the platform scoping to a
competition). Returns {queued, skipped_pulled,
skipped_in_progress}.
209 def get_pull_state(self) -> dict[str, Any]: 210 """Fetch every per-challenge pull-state row on this node. 211 212 Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``. 213 ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` / 214 ``failed``; ``unpulled`` placeholders are synthesised for specs 215 the node has seen but never been asked to pull. 216 """ 217 resp = self.request("GET", "/admin/challenges/pull-state") 218 _raise_for_node_status(resp) 219 body: dict[str, Any] = resp.json() 220 return body
Fetch every per-challenge pull-state row on this node.
Returns {"rows": [{challenge_id, status, pulled_at, error}, …]}.
status is one of unpulled / pulling / pulled /
failed; unpulled placeholders are synthesised for specs
the node has seen but never been asked to pull.
230 def list_instances(self) -> list[dict[str, Any]]: 231 """Every instance the node still has bookkeeping for. 232 233 Used by the platform's boot re-adoption pass to decide which 234 durable claims still have containers behind them. ``get_status`` 235 answers the same question one id at a time; asking it per claim 236 would cost one round trip per instance to a node that may be 237 slow or down, on the path that gates startup. 238 """ 239 resp = self.request("GET", "/instances") 240 resp.raise_for_status() 241 items: list[dict[str, Any]] = resp.json().get("items", []) 242 return items
Every instance the node still has bookkeeping for.
Used by the platform's boot re-adoption pass to decide which
durable claims still have containers behind them. get_status
answers the same question one id at a time; asking it per claim
would cost one round trip per instance to a node that may be
slow or down, on the path that gates startup.
247 def check_health_verbose(self, instance_id: str) -> tuple[bool, DeceptionSummary]: 248 """Health plus whatever agent-deception telemetry the node read. 249 250 One call, because the platform already polls this endpoint for 251 every live instance every tick and a second round trip per 252 instance would be the whole cost of the feature. ``check_health`` 253 stays a bool for the callers that only ever wanted one. 254 255 An older node sends no ``deception`` key, and a node whose read 256 failed sends null — both are an empty summary, never an error: 257 this rides the health poll, and telemetry must not be able to 258 mark a healthy instance unhealthy. 259 """ 260 resp = self.request("GET", f"/instances/{instance_id}/health") 261 resp.raise_for_status() 262 body = resp.json() 263 raw = body.get("deception") 264 try: 265 summary = DeceptionSummary.model_validate(raw) if raw else DeceptionSummary() 266 except ValidationError: 267 summary = DeceptionSummary() 268 return bool(body.get("is_healthy")), summary
Health plus whatever agent-deception telemetry the node read.
One call, because the platform already polls this endpoint for
every live instance every tick and a second round trip per
instance would be the whole cost of the feature. check_health
stays a bool for the callers that only ever wanted one.
An older node sends no deception key, and a node whose read
failed sends null — both are an empty summary, never an error:
this rides the health poll, and telemetry must not be able to
mark a healthy instance unhealthy.
270 def node_health(self) -> dict[str, Any]: 271 """Liveness + ``{running, capacity}`` for heartbeat.""" 272 resp = self.request("GET", "/health") 273 resp.raise_for_status() 274 body: dict[str, Any] = resp.json() 275 return body
Liveness + {running, capacity} for heartbeat.
279 def get_traffic_page( 280 self, instance_id: str, *, cursor: str = "", limit: int = 0 281 ) -> dict[str, Any]: 282 """One page of mitmproxy flows; the capture lives on the node's FS. 283 284 Returns the node's envelope verbatim — ``{flows, cursor, 285 truncated}``. It is deliberately *not* parsed here: this is the 286 transport, and every decision about an unexpected shape belongs 287 in one place platform-side (``traffic_tail.coerce_page``), so a 288 route and this client cannot come to disagree about what a 289 missing cursor means. 290 291 ⚠️ **An empty ``cursor`` means "from the beginning".** Passing 292 one on every poll re-reads the whole capture, which is the cost 293 the paged read exists to remove — arriving through the caller 294 rather than through the node. 295 """ 296 params: dict[str, Any] = {} 297 if cursor: 298 params["cursor"] = cursor 299 if limit > 0: 300 params["limit"] = limit 301 resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params) 302 resp.raise_for_status() 303 body: dict[str, Any] = resp.json() 304 return body
One page of mitmproxy flows; the capture lives on the node's FS.
Returns the node's envelope verbatim — {flows, cursor,
truncated}. It is deliberately not parsed here: this is the
transport, and every decision about an unexpected shape belongs
in one place platform-side (traffic_tail.coerce_page), so a
route and this client cannot come to disagree about what a
missing cursor means.
⚠️ An empty cursor means "from the beginning". Passing
one on every poll re-reads the whole capture, which is the cost
the paged read exists to remove — arriving through the caller
rather than through the node.
306 def list_containers(self, instance_id: str) -> list[dict[str, Any]]: 307 """Enumerate every container in an instance's compose project. 308 309 Backs the admin-shell feature: the platform forwards a 310 ``GET /admin/instances/{id}/containers`` to the assigned node, 311 which returns one row per container (challenge services and 312 platform-injected sidecars alike). The WebSocket reverse-proxy 313 is opened separately and does not go through ``NodeClient``. 314 """ 315 resp = self.request("GET", f"/instances/{instance_id}/containers") 316 _raise_for_node_status(resp) 317 body: list[dict[str, Any]] = resp.json() 318 return body
Enumerate every container in an instance's compose project.
Backs the admin-shell feature: the platform forwards a
GET /admin/instances/{id}/containers to the assigned node,
which returns one row per container (challenge services and
platform-injected sidecars alike). The WebSocket reverse-proxy
is opened separately and does not go through NodeClient.
320 def run_checker( 321 self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30 322 ) -> dict[str, Any]: 323 """Exec an author-supplied checker in a trusted judge sidecar. 324 325 Backs the checker / exec-judge feature: the platform forwards a 326 verify request to the assigned node, which runs ``cmd`` inside the 327 named sidecar and returns 328 ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP 329 timeout sits above the node's exec budget so the client doesn't 330 abandon the request before the node returns. 331 """ 332 resp = self.request( 333 "POST", 334 f"/instances/{instance_id}/check", 335 json={"service": service, "cmd": cmd, "timeout_s": timeout_s}, 336 timeout=timeout_s + 10, 337 ) 338 _raise_for_node_status(resp) 339 body: dict[str, Any] = resp.json() 340 return body
Exec an author-supplied checker in a trusted judge sidecar.
Backs the checker / exec-judge feature: the platform forwards a
verify request to the assigned node, which runs cmd inside the
named sidecar and returns
{exit_code, stdout, stderr, timed_out, error}. The per-call HTTP
timeout sits above the node's exec budget so the client doesn't
abandon the request before the node returns.
342 def run_harness( 343 self, *, image: str, env: dict[str, str], timeout_s: int = 1800 344 ) -> dict[str, Any]: 345 """Run a one-shot evaluation-harness container on the node. 346 347 The node ``docker run``s ``image`` with ``env`` injected, waits for it 348 to exit (bounded by ``timeout_s``), and returns 349 ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as 350 :meth:`run_checker`. The harness writes its JSON rollup to stdout. The 351 per-call HTTP timeout sits above the node's run budget so the client 352 doesn't abandon the request before the (long-running) harness returns. 353 """ 354 resp = self.request( 355 "POST", 356 "/harness/run", 357 json={"image": image, "env": env, "timeout_s": timeout_s}, 358 timeout=timeout_s + 30, 359 ) 360 _raise_for_node_status(resp) 361 body: dict[str, Any] = resp.json() 362 return body
Run a one-shot evaluation-harness container on the node.
The node docker runs image with env injected, waits for it
to exit (bounded by timeout_s), and returns
{exit_code, stdout, stderr, timed_out, error} — the same shape as
run_checker(). The harness writes its JSON rollup to stdout. The
per-call HTTP timeout sits above the node's run budget so the client
doesn't abandon the request before the (long-running) harness returns.
364 def verify_patch( 365 self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900 366 ) -> dict[str, Any]: 367 """Build a submitted patch on the node and return its verdict. 368 369 ``files`` maps a challenge-relative path to its new bytes; base64 370 is applied here because a patch target may legitimately be 371 binary. Returns ``{verdict, detail, applied, steps, error}``. 372 373 **An empty ``verdict`` with a populated ``error`` is not a 374 judgement** — it means the node refused the submission or the 375 verifier crashed. The caller must retry or retire the lease 376 rather than write a score, since failing to judge a patch is not 377 the same as judging it unfixed. 378 379 The per-call HTTP timeout sits above the node's own budget so the 380 client doesn't abandon the request while the node is still 381 building. 382 """ 383 resp = self.request( 384 "POST", 385 "/patches/verify", 386 json={ 387 "challenge_id": challenge_id, 388 "files": { 389 path: base64.b64encode(content).decode() for path, content in files.items() 390 }, 391 "timeout_s": timeout_s, 392 }, 393 timeout=timeout_s + 60, 394 ) 395 _raise_for_node_status(resp) 396 body: dict[str, Any] = resp.json() 397 return body
Build a submitted patch on the node and return its verdict.
files maps a challenge-relative path to its new bytes; base64
is applied here because a patch target may legitimately be
binary. Returns {verdict, detail, applied, steps, error}.
An empty verdict with a populated error is not a
judgement — it means the node refused the submission or the
verifier crashed. The caller must retry or retire the lease
rather than write a score, since failing to judge a patch is not
the same as judging it unfixed.
The per-call HTTP timeout sits above the node's own budget so the client doesn't abandon the request while the node is still building.
399 def collect_patch_files( 400 self, instance_id: str, service: str, paths: list[str] 401 ) -> dict[str, bytes]: 402 """Read a player's SSH edits back out of their running box. 403 404 ``paths`` are absolute in-container paths the *platform* derived 405 from the pristine source tree. Returns only what could be read: 406 a path missing from the reply means the player did not change it, 407 and the caller falls back to the shipped file rather than 408 recording a deletion they never made. 409 """ 410 resp = self.request( 411 "POST", 412 f"/instances/{instance_id}/patch-collect", 413 json={"service": service, "paths": paths}, 414 ) 415 _raise_for_node_status(resp) 416 body: dict[str, str] = resp.json() 417 return {path: base64.b64decode(blob) for path, blob in body.items()}
Read a player's SSH edits back out of their running box.
paths are absolute in-container paths the platform derived
from the pristine source tree. Returns only what could be read:
a path missing from the reply means the player did not change it,
and the caller falls back to the shipped file rather than
recording a deletion they never made.
419 def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]: 420 """Rotate a round's flags into every box of this node, in one call. 421 422 ⚠️ **One call for the whole node, never one per box.** §5.2's 423 capacity red line is 1000 teams x 3 services injected inside 424 120 s; per-box that is 3000 round trips, batched it is about 425 fourteen. The signature is the enforcement — a caller cannot 426 accidentally loop. 427 428 Returns ``{instance_id: ""}`` for the boxes written and a reason 429 for the ones that were not, because one team's unreachable box 430 must not cost every other team on the node its rotation. An 431 unrotated box keeps serving last round's flag, which anyone 432 holding the old value can replay. 433 """ 434 resp = self.request("POST", "/awd/answers", json={"boxes": boxes}) 435 _raise_for_node_status(resp) 436 result: dict[str, str] = resp.json() 437 return result
Rotate a round's flags into every box of this node, in one call.
⚠️ One call for the whole node, never one per box. §5.2's capacity red line is 1000 teams x 3 services injected inside 120 s; per-box that is 3000 round trips, batched it is about fourteen. The signature is the enforcement — a caller cannot accidentally loop.
Returns {instance_id: ""} for the boxes written and a reason
for the ones that were not, because one team's unreachable box
must not cost every other team on the node its rotation. An
unrotated box keeps serving last round's flag, which anyone
holding the old value can replay.
439 def probe_awd_service( 440 self, 441 *, 442 instance_id: str, 443 service_id: str, 444 tick: int, 445 flag: str, 446 previous_flag: str = "", 447 previous_flag_id: str = "", 448 ) -> dict[str, str]: 449 """Run one round's SLA probes against one box, from outside it. 450 451 The flags travel *to* the node: a checker that sourced its 452 expected value from the box it is checking would pass on any box 453 that returns whatever it was handed. 454 """ 455 resp = self.request( 456 "POST", 457 "/awd/probe", 458 json={ 459 "instance_id": instance_id, 460 "service_id": service_id, 461 "tick": tick, 462 "flag": flag, 463 "previous_flag": previous_flag, 464 "previous_flag_id": previous_flag_id, 465 }, 466 ) 467 _raise_for_node_status(resp) 468 result: dict[str, str] = resp.json() 469 return result
Run one round's SLA probes against one box, from outside it.
The flags travel to the node: a checker that sourced its expected value from the box it is checking would pass on any box that returns whatever it was handed.
471 def get_container_logs(self, instance_id: str) -> str: 472 """Fetch combined container stdout/stderr for archival.""" 473 resp = self.request("GET", f"/instances/{instance_id}/container-logs") 474 resp.raise_for_status() 475 return str(resp.json().get("logs") or "")
Fetch combined container stdout/stderr for archival.
477 def get_pcap(self, instance_id: str) -> bytes: 478 """Fetch the raw tcpdump capture for *instance_id*. 479 480 Returns an empty ``bytes`` when no capture exists (sidecar 481 disabled, instance never reached the running state, etc.) so 482 callers can persist conditionally without try/except. 483 """ 484 resp = self.request("GET", f"/instances/{instance_id}/pcap") 485 if resp.status_code == 404: 486 return b"" 487 resp.raise_for_status() 488 return resp.content
Fetch the raw tcpdump capture for instance_id.
Returns an empty bytes when no capture exists (sidecar
disabled, instance never reached the running state, etc.) so
callers can persist conditionally without try/except.
490 def get_agent_runtime(self, instance_id: str) -> dict[str, Any]: 491 """Provider-agnostic runtime hints for attaching an agent (proxy URL, 492 CA PEM, optional Docker-specific names). Replaces the old 493 sandbox-network shape.""" 494 resp = self.request("GET", f"/instances/{instance_id}/agent-runtime") 495 resp.raise_for_status() 496 body: dict[str, Any] = resp.json() 497 return body
Provider-agnostic runtime hints for attaching an agent (proxy URL, CA PEM, optional Docker-specific names). Replaces the old sandbox-network shape.
501 def list_instance_attachments(self, instance_id: str) -> dict[str, Any]: 502 """List per-instance attachments rendered into the node's workdir. 503 504 Returns the raw JSON dict — caller projects through 505 :class:`AttachmentList` for typing. Used by the platform's 506 per-instance attachment endpoint to surface team-specific 507 rendered file listings.""" 508 resp = self.request("GET", f"/instances/{instance_id}/attachments") 509 resp.raise_for_status() 510 body: dict[str, Any] = resp.json() 511 return body
List per-instance attachments rendered into the node's workdir.
Returns the raw JSON dict — caller projects through
AttachmentList for typing. Used by the platform's
per-instance attachment endpoint to surface team-specific
rendered file listings.
513 def get_vpn_config(self, instance_id: str) -> bytes: 514 """Fetch the per-instance VPN client config from the node. 515 516 The node reads the backend's client config out of the 517 instance's VPN service container (generated on first boot by 518 the base image's bootstrap) and rewrites its endpoint to the 519 node's public host:port. Returns the body verbatim — the 520 platform-side route adds the ``Content-Disposition`` header 521 before re-emitting to the player, because only it knows the 522 backend and therefore the filename the player needs. 523 524 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 525 proxy route catches 404 and re-emits to the player, anything 526 else is treated as a 502. 527 """ 528 resp = self.request("GET", f"/instances/{instance_id}/vpn-config") 529 resp.raise_for_status() 530 return resp.content
Fetch the per-instance VPN client config from the node.
The node reads the backend's client config out of the
instance's VPN service container (generated on first boot by
the base image's bootstrap) and rewrites its endpoint to the
node's public host:port. Returns the body verbatim — the
platform-side route adds the Content-Disposition header
before re-emitting to the player, because only it knows the
backend and therefore the filename the player needs.
Raises httpx.HTTPStatusError on non-2xx — the platform's
proxy route catches 404 and re-emits to the player, anything
else is treated as a 502.
532 def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]: 533 """Download one per-instance attachment as ``(bytes, content_type)``. 534 535 Buffers the whole body — attachments are bounded (typical case 536 is a 10 KB binary or a small text file). For huge captures the 537 caller should hand the player a CDN URL instead. 538 539 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 540 proxy route catches 404 and re-emits to the player, anything 541 else is a 502.""" 542 resp = self.request( 543 "GET", 544 f"/instances/{instance_id}/attachments/{filename}", 545 ) 546 resp.raise_for_status() 547 return resp.content, resp.headers.get("content-type", "application/octet-stream")
Download one per-instance attachment as (bytes, content_type).
Buffers the whole body — attachments are bounded (typical case is a 10 KB binary or a small text file). For huge captures the caller should hand the player a CDN URL instead.
Raises httpx.HTTPStatusError on non-2xx — the platform's
proxy route catches 404 and re-emits to the player, anything
else is a 502.