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

NodeClient(node_url: str, token: str, *, timeout: int = 600)
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)
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) -> dict[str, typing.Any]:
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
def stop_instance(self, instance_id: str) -> None:
91    def stop_instance(self, instance_id: str) -> None:
92        resp = self.request("DELETE", f"/instances/{instance_id}")
93        resp.raise_for_status()
def stop_all(self) -> None:
95    def stop_all(self) -> None:
96        resp = self.request("POST", "/admin/stop-all")
97        resp.raise_for_status()
def rescan_challenges(self) -> dict[str, typing.Any]:
 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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]:
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.

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

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

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

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

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

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