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