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")
class NodeClient(ctfy.sdk.base.BaseHttpClient):
 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.

NodeClient(node_url: str, token: str, *, timeout: int = 600)
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)
def start_instance( self, *, challenge_id: str, instance_id: str, ttl: int, answers: dict[str, str], proxy_output_dir: str | None = None, env: dict[str, str] | None = None, publish_gamebox: bool = False, match_network: ctfy.core.awd_network.MatchNetwork | None = None) -> dict[str, typing.Any]:
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
def stop_instance(self, instance_id: str) -> None:
100    def stop_instance(self, instance_id: str) -> None:
101        resp = self.request("DELETE", f"/instances/{instance_id}")
102        resp.raise_for_status()
def stop_instance_service(self, instance_id: str, service: str) -> int:
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.

def stop_all(self) -> None:
121    def stop_all(self) -> None:
122        resp = self.request("POST", "/admin/stop-all")
123        resp.raise_for_status()
def rescan_challenges(self) -> dict[str, typing.Any]:
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.

def build_challenge(self, challenge_id: str) -> dict[str, typing.Any]:
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.

def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, typing.Any]:
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.

def get_build_state(self) -> dict[str, typing.Any]:
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.

def pull_challenge(self, challenge_id: str) -> dict[str, typing.Any]:
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.

def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, typing.Any]:
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}.

def get_pull_state(self) -> dict[str, typing.Any]:
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.

def get_status(self, instance_id: str) -> dict[str, typing.Any]:
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
def list_instances(self) -> list[dict[str, typing.Any]]:
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.

def check_health(self, instance_id: str) -> bool:
244    def check_health(self, instance_id: str) -> bool:
245        return self.check_health_verbose(instance_id)[0]
def check_health_verbose( self, instance_id: str) -> tuple[bool, ctfy.challenge.deception.DeceptionSummary]:
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.

def node_health(self) -> dict[str, typing.Any]:
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.

def get_traffic_page( self, instance_id: str, *, cursor: str = '', limit: int = 0) -> dict[str, typing.Any]:
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.

def list_containers(self, instance_id: str) -> list[dict[str, typing.Any]]:
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.

def run_checker( self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30) -> dict[str, typing.Any]:
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.

def run_harness( self, *, image: str, env: dict[str, str], timeout_s: int = 1800) -> dict[str, typing.Any]:
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.

def verify_patch( self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900) -> dict[str, typing.Any]:
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.

def collect_patch_files( self, instance_id: str, service: str, paths: list[str]) -> dict[str, bytes]:
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.

def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
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.

def probe_awd_service( self, *, instance_id: str, service_id: str, tick: int, flag: str, previous_flag: str = '', previous_flag_id: str = '') -> dict[str, str]:
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.

def get_container_logs(self, instance_id: str) -> str:
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.

def get_pcap(self, instance_id: str) -> bytes:
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.

def get_agent_runtime(self, instance_id: str) -> dict[str, typing.Any]:
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.

def list_instance_attachments(self, instance_id: str) -> dict[str, typing.Any]:
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.

def get_vpn_config(self, instance_id: str) -> bytes:
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.

def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
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.