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, TypeVar
  16
  17import httpx
  18from pydantic import BaseModel, Field, ValidationError
  19
  20from ctfy.challenge.deception import DeceptionSummary
  21from ctfy.core.awd_network import MatchNetwork
  22from ctfy.core.challenge import ChallengeReloadResult
  23from ctfy.core.constants import DEFAULT_CLIENT_TIMEOUT
  24from ctfy.core.exceptions import NodeRequestError
  25from ctfy.core.failure_text import tail
  26from ctfy.core.instance_disk_retention import (
  27    CAPTURE_DROPPED_BYTES_HEADER,
  28    CAPTURE_DROPPED_SEGMENTS_HEADER,
  29)
  30from ctfy.core.log import log
  31from ctfy.core.observed_contact import ContactReading
  32from ctfy.core.prebuild_tag import ChallengeFreezeResult
  33from ctfy.sdk.base import BaseHttpClient
  34from ctfy.server.models.challenge import AttachmentList
  35from ctfy.server.models.instance import InstanceTrafficPage, InstanceTrafficWitnesses
  36from ctfy.server.models.node import (
  37    AgentRuntime,
  38    AwdAttackResult,
  39    BuildAllQueued,
  40    BuildStateRows,
  41    CheckResult,
  42    ComposeContainer,
  43    FabricProbeReport,
  44    FabricStatus,
  45    HarnessLimits,
  46    HarnessRun,
  47    InstanceStarted,
  48    InstanceStatusReport,
  49    NodeAuthoredPush,
  50    NodeAuthoredResult,
  51    NodeAuthoredSmokePush,
  52    NodeAuthoredSmokeResult,
  53    NodeDiskFree,
  54    NodeInstanceList,
  55    NodeInstanceSummary,
  56    NodeRangePush,
  57    NodeRangeResult,
  58    NodeRangeVerifyPush,
  59    NodeRangeVerifyResult,
  60    PatchVerifyResult,
  61    PullAllQueued,
  62    PullStateRows,
  63)
  64from ctfy.server.node_state import ChallengeBuildState, ChallengePullState
  65from ctfy.server.traffic_tail import coerce_page
  66
  67
  68@dataclasses.dataclass(frozen=True)
  69class PcapCapture:
  70    """A node's merged capture, and what the cap dropped from it (#1714)."""
  71
  72    data: bytes
  73    #: ``None`` = the node did not say (older build); ``0`` = nothing.
  74    dropped_bytes: int | None = None
  75    dropped_segments: int | None = None
  76
  77
  78def _header_int(resp: httpx.Response, name: str) -> int | None:
  79    raw = resp.headers.get(name)
  80    if raw is None:
  81        return None
  82    try:
  83        return max(0, int(raw))
  84    except ValueError:
  85        return None
  86
  87
  88def _raise_for_node_status(resp: httpx.Response) -> None:
  89    """Like ``resp.raise_for_status()`` but preserves the node's error body.
  90
  91    On a 4xx/5xx, pull the FastAPI ``{"detail": ...}`` field (or the raw
  92    body as a fallback) and raise :class:`NodeRequestError` so the real
  93    failure reaches the caller instead of httpx's generic status string.
  94    """
  95    if not resp.is_error:
  96        return
  97    detail = ""
  98    code = ""
  99    try:
 100        body = resp.json()
 101    except (json.JSONDecodeError, ValueError):
 102        body = None
 103    if isinstance(body, dict):
 104        d = body.get("detail")
 105        if isinstance(d, str):
 106            detail = d
 107        elif isinstance(d, dict) and isinstance(d.get("code"), str):
 108            # A node naming its failure (#1835): keep the name as a field
 109            # and the sentence as the detail, so neither a reader of the
 110            # code nor a human reading the detail meets JSON.
 111            code = str(d["code"])
 112            message = d.get("message")
 113            detail = message if isinstance(message, str) else json.dumps(d)
 114        elif d is not None:
 115            detail = json.dumps(d)
 116    if not detail:
 117        detail = (resp.text or "").strip()
 118    raise NodeRequestError(resp.status_code, detail, code=code)
 119
 120
 121_M = TypeVar("_M", bound=BaseModel)
 122
 123
 124def _parsed(model: type[_M], resp: httpx.Response) -> _M:
 125    """The node's answer, as the model the node itself declares.
 126
 127    ⚠️ **A parse failure raises rather than defaulting.** Its neighbour
 128    ``instance_health`` falls back to an empty ``DeceptionSummary``, and
 129    that is right there: the summary is decoration on a liveness answer
 130    the caller already has. These carry the *verdict* — whether a checker
 131    passed, what an attack read — and a default is a verdict nobody
 132    reached. The checker's own fail direction is closed, and an answer
 133    this client cannot parse is not an answer.
 134
 135    ⚠️ Unknown fields are ignored (pydantic's default), which is the
 136    forward-compatible half: the node and the platform are deployed
 137    separately, ``ctfy/core/version.py`` exists because they skew, and a
 138    newer node sending a field this platform has never heard of must not
 139    fail the call.
 140    """
 141    try:
 142        return model.model_validate(resp.json())
 143    except (ValidationError, ValueError) as exc:
 144        # ⚠️ ``resp.status_code``, not a synthetic one: the node
 145        # *answered*, and the failure is that its body does not fit the
 146        # contract. Reporting a 502 here would say the node was
 147        # unreachable, which sends whoever reads it to the wrong host.
 148        raise NodeRequestError(
 149            resp.status_code,
 150            f"answered something this platform cannot read as {model.__name__}: {exc}",
 151        ) from exc
 152
 153
 154def _parsed_list(model: type[_M], resp: httpx.Response) -> list[_M]:
 155    """A JSON array of *model*, with the same failure direction as
 156    :func:`_parsed` — one unreadable element fails the call rather than
 157    being dropped, because a partial list read as a whole one is how a
 158    re-adoption pass decides an instance is gone."""
 159    body = resp.json()
 160    if not isinstance(body, list):
 161        raise NodeRequestError(
 162            resp.status_code, f"expected a list of {model.__name__}, got {type(body).__name__}"
 163        )
 164    try:
 165        return [model.model_validate(item) for item in body]
 166    except (ValidationError, ValueError) as exc:
 167        raise NodeRequestError(
 168            resp.status_code,
 169            f"answered something this platform cannot read as a list of {model.__name__}: {exc}",
 170        ) from exc
 171
 172
 173class HealthReading(BaseModel):
 174    """One health poll's three answers.
 175
 176    ⚠️ **A record rather than a tuple, and the reason is recorded one
 177    subsystem over.** ``competition_results`` lost ``attempts`` and
 178    ``last_solve_at`` to a hand-made ``(rank, flags, solved)`` tuple
 179    sitting between a board that produced them and a template that
 180    consumed them; this poll had two answers and now has three, so the
 181    tuple was one field away from the same fate. Growing a record is a
 182    field; growing a tuple is every call site, silently re-bound.
 183    """
 184
 185    healthy: bool = False
 186    deception: DeceptionSummary = Field(default_factory=DeceptionSummary)
 187    contact: ContactReading = Field(default_factory=ContactReading)
 188    new_processes: dict[str, list[str]] = Field(default_factory=dict)
 189
 190
 191class NodeClient(BaseHttpClient):
 192    """Thin sync HTTP client; one instance per node URL."""
 193
 194    def __init__(
 195        self,
 196        node_url: str,
 197        token: str,
 198        *,
 199        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 200        transport: httpx.BaseTransport | None = None,
 201    ) -> None:
 202        """*transport* is `BaseHttpClient`'s own seam, passed through so a
 203        test can drive a real node app over the real client — the hop the
 204        fakes on either side of it cannot see."""
 205        super().__init__(
 206            f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout, transport=transport
 207        )
 208
 209    # -- lifecycle ----------------------------------------------------------
 210
 211    def start_instance(
 212        self,
 213        *,
 214        challenge_id: str,
 215        instance_id: str,
 216        ttl: int,
 217        answers: dict[str, str],
 218        proxy_output_dir: str | None = None,
 219        env: dict[str, str] | None = None,
 220        publish_gamebox: bool = False,
 221        match_network: MatchNetwork | None = None,
 222        open_shell_password: str = "",
 223        authored_revision: str = "",
 224    ) -> InstanceStarted:
 225        resp = self.request(
 226            "POST",
 227            "/instances",
 228            json={
 229                # #1835: the authored tree's revision this start must run;
 230                # a node holding another (or none) fetches it first.
 231                "authored_revision": authored_revision,
 232                "challenge_id": challenge_id,
 233                "instance_id": instance_id,
 234                "ttl": ttl,
 235                "answers": answers,
 236                "proxy_output_dir": proxy_output_dir,
 237                "env": env or {},
 238                "publish_gamebox": publish_gamebox,
 239                # ``None`` rather than an omitted key: the node's model
 240                # defaults to None either way, and sending it explicitly
 241                # keeps the body's shape stable across both branches.
 242                "match_network": (dataclasses.asdict(match_network) if match_network else None),
 243                # Classic-AWD open shell (#1725): the box's starting root
 244                # password, empty for every managed / non-AWD start.
 245                "open_shell_password": open_shell_password,
 246            },
 247        )
 248        _raise_for_node_status(resp)
 249        return _parsed(InstanceStarted, resp)
 250
 251    def stop_instance(self, instance_id: str) -> None:
 252        resp = self.request("DELETE", f"/instances/{instance_id}")
 253        resp.raise_for_status()
 254
 255    def apply_fabric(
 256        self,
 257        *,
 258        index: int,
 259        interface: dict[str, Any],
 260        match_network: str = "",
 261    ) -> FabricStatus:
 262        """Put this node on one match's WireGuard fabric.
 263
 264        ⚠️ **Returns the model, never ``resp.json()``.** The platform
 265        reads the verdict field by field, and on a dict a renamed field
 266        is ``None`` — falsy — which reads as "the arena did not open",
 267        so a wire change would quietly report every node as broken.
 268
 269        Returns the node's verdict rather than raising on a refusal: the
 270        route answers **200 with a body** precisely so that "this node is
 271        unreachable" and "this node rejected the plan" stay different
 272        facts, and a caller fanning out over an arena needs to tell them
 273        apart — one is waited on, the other is looked at.
 274
 275        *match_network* lets the node check the tunnel landed in the
 276        namespace its boxes are in, and open the bridge's one hole. It is
 277        the platform's own fact (`awd_network.network_name`), so the node
 278        is told rather than guessing.
 279        """
 280        resp = self.request(
 281            "POST",
 282            "/fabric",
 283            json={
 284                "index": index,
 285                "interface": interface,
 286                "match_network": match_network,
 287            },
 288        )
 289        resp.raise_for_status()
 290        return _parsed(FabricStatus, resp)
 291
 292    def probe_fabric(
 293        self,
 294        *,
 295        index: int,
 296        peers: list[dict[str, str]],
 297        mtu: int = 0,
 298    ) -> FabricProbeReport:
 299        """Ask named peers the three link questions. Applies nothing.
 300
 301        ⚠️ **Returns the model, never ``resp.json()``** — same reason
 302        `apply_fabric` gives: on a dict a renamed field reads as falsy,
 303        which here means "your tunnel is down" about a wire change.
 304        """
 305        resp = self.request(
 306            "POST",
 307            "/fabric/probe",
 308            json={"index": index, "peers": peers, "mtu": mtu},
 309        )
 310        resp.raise_for_status()
 311        return _parsed(FabricProbeReport, resp)
 312
 313    def remove_fabric(self, *, index: int, match_network: str = "") -> FabricStatus:
 314        """Take this node off one match's fabric and close its hole.
 315
 316        ⚠️ *match_network* is what lets the node resolve the bridge and
 317        delete the ``DOCKER-USER`` rules by name. Without it the tunnel
 318        goes and the hole stays — open on whatever docker gives that
 319        bridge name next.
 320        """
 321        resp = self.request(
 322            "DELETE",
 323            f"/fabric/{index}",
 324            params={"match_network": match_network},
 325        )
 326        resp.raise_for_status()
 327        return _parsed(FabricStatus, resp)
 328
 329    def stop_instance_service(self, instance_id: str, service: str) -> int:
 330        """Stop one compose service, leaving the instance running.
 331
 332        The exercise format's session close (§4b.4). Returns how many
 333        containers the node stopped — 0 means the challenge declares no
 334        such service, which is a real answer rather than a failure, so
 335        the caller can distinguish it from a cut tunnel.
 336        """
 337        resp = self.request(
 338            "POST",
 339            f"/instances/{instance_id}/stop-service",
 340            json={"service": service},
 341        )
 342        resp.raise_for_status()
 343        body = resp.json()
 344        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
 345
 346    def stop_all(self) -> None:
 347        resp = self.request("POST", "/admin/stop-all")
 348        resp.raise_for_status()
 349
 350    def put_authored_challenge(self, push: NodeAuthoredPush) -> NodeAuthoredResult:
 351        """Ask the node to write an approved player upload into its catalog.
 352
 353        ⚠️ **The bytes travel here, unlike `put_generated_range`.** That
 354        one sends a scenario because the node can compile it; a
 355        player-authored tree has no compiler and no corpus entry, so the
 356        archive is what there is to send.
 357
 358        The node answers **200 with ``ok: false``** for a refusal, so a
 359        node that declined the archive and a node that could not be
 360        reached stay different facts; only the second raises here.
 361        """
 362        resp = self.request("POST", "/admin/authored", json=push.model_dump(mode="json"))
 363        resp.raise_for_status()
 364        return _parsed(NodeAuthoredResult, resp)
 365
 366    def smoke_authored_challenge(
 367        self, push: NodeAuthoredSmokePush, *, timeout: int | None = None
 368    ) -> NodeAuthoredSmokeResult:
 369        """Ask the node to run an authored challenge's own reference exploit.
 370
 371        ⚠️ **Takes its own timeout.** A smoke run brings a whole estate
 372        up; the client default is sized for a status call and would give
 373        up while the node is still doing what it was asked.
 374        """
 375        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 376        if timeout is not None:
 377            kwargs["timeout"] = timeout
 378        resp = self.request("POST", "/admin/authored/smoke", **kwargs)
 379        resp.raise_for_status()
 380        return _parsed(NodeAuthoredSmokeResult, resp)
 381
 382    def put_generated_range(self, push: NodeRangePush) -> NodeRangeResult:
 383        """Ask the node to build a generated range from its scenario.
 384
 385        ⚠️ **The scenario travels, not the estate.** The corpus is
 386        mounted on the node and the compiler ships inside it, so 70-odd
 387        rendered files never cross this API — and the corpus commit that
 388        rides along is what stops one scenario becoming two different
 389        estates under one id across a fleet.
 390
 391        The node answers **200 with ``ok: false``** for a refusal, so a
 392        node that declined and a node that could not be reached stay
 393        different facts; only the second raises here.
 394        """
 395        resp = self.request("POST", "/admin/ranges", json=push.model_dump(mode="json"))
 396        resp.raise_for_status()
 397        return _parsed(NodeRangeResult, resp)
 398
 399    def verify_generated_range(
 400        self, push: NodeRangeVerifyPush, *, timeout: int | None = None
 401    ) -> NodeRangeVerifyResult:
 402        """Ask the node to build a scenario, solve it, and take it down.
 403
 404        ⚠️ **The longest call in this API**, so the caller passes its own
 405        timeout: the node's budget is ``compose_timeout_s +
 406        solve_timeout_s`` and a client that used the default would give
 407        up while the node was still building — then retry, and start a
 408        second estate beside the first.
 409
 410        ⚠️ A client timeout does **not** leave a range running: the node
 411        tears down in its own ``finally``. What is lost is the answer,
 412        not the host.
 413
 414        The node answers **200 with an ``error``** for a refusal, the
 415        same split ``put_generated_range`` uses; only an unreachable node
 416        raises.
 417        """
 418        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 419        if timeout is not None:
 420            # httpx takes a per-request override; the client-wide value
 421            # stays whatever the caller constructed this with.
 422            kwargs["timeout"] = timeout
 423        resp = self.request("POST", "/admin/ranges/verify", **kwargs)
 424        resp.raise_for_status()
 425        return _parsed(NodeRangeVerifyResult, resp)
 426
 427    def cancel_generated_range_verify(self, run_id: str) -> bool:
 428        """Tell the node to stop a verification it is running.
 429
 430        ⚠️ **A miss is success, not a 404 to report.** The cancel races
 431        the run's own finish, so "no such run" means the run already
 432        ended — which is what the operator asked for. The node answers
 433        ``{"cancelled": false}`` for that case rather than raising, and
 434        this returns it so a caller that wants to say "it had already
 435        finished" can.
 436        """
 437        resp = self.request("POST", f"/admin/ranges/verify/{run_id}/cancel")
 438        resp.raise_for_status()
 439        body = resp.json()
 440        return bool(body.get("cancelled"))
 441
 442    def rescan_challenges(self) -> ChallengeReloadResult:
 443        """Tell the node to drop its spec cache and re-scan challenges_dir.
 444
 445        Returns ``{total, added, removed}`` so the platform can report
 446        the per-node outcome of a cluster-wide rescan."""
 447        resp = self.request("POST", "/admin/rescan-challenges")
 448        resp.raise_for_status()
 449        return _parsed(ChallengeReloadResult, resp)
 450
 451    # -- admin pre-build (image cache warming) ------------------------------
 452
 453    def build_challenge(
 454        self, challenge_id: str, *, authored_revision: str = ""
 455    ) -> ChallengeBuildState:
 456        """Ask the node to pre-build images for *challenge_id*.
 457
 458        Fires-and-returns: the node persists ``status="building"`` and
 459        spawns a daemon thread; the body returned here is that initial
 460        state row. Polling :meth:`get_build_state` is how the platform
 461        learns when it lands on ``built`` / ``failed``. *authored_revision*
 462        is #1835's: the tree the build must be of, fetched if not held.
 463        """
 464        params = {"authored_revision": authored_revision} if authored_revision else None
 465        resp = self.request("POST", f"/admin/challenges/{challenge_id}/build", params=params)
 466        _raise_for_node_status(resp)
 467        return _parsed(ChallengeBuildState, resp)
 468
 469    def build_all_challenges(self, challenge_ids: list[str] | None = None) -> BuildAllQueued:
 470        """Queue background pre-build on the node.
 471
 472        With ``challenge_ids=None`` the node builds every spec it knows.
 473        With an explicit list (the platform scoping a bulk build to a
 474        competition's challenge set) only those are built. Returns
 475        ``{queued, skipped_built, skipped_in_progress}`` so the platform
 476        can report per-node what got picked up. Sequential on the node
 477        side — no fan-out across the corpus.
 478        """
 479        kwargs: dict[str, Any] = {}
 480        if challenge_ids is not None:
 481            kwargs["json"] = {"challenge_ids": challenge_ids}
 482        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
 483        _raise_for_node_status(resp)
 484        return _parsed(BuildAllQueued, resp)
 485
 486    def build_push_challenge(
 487        self,
 488        challenge_id: str,
 489        *,
 490        push_ticket: str = "",
 491        timeout_s: int = 1800,
 492        cleanup: bool = False,
 493        cache_max_bytes: int = 0,
 494        confine_network: bool = False,
 495        authored_revision: str = "",
 496    ) -> ChallengeFreezeResult:
 497        """Build one challenge's ``build:`` services and freeze them to the registry.
 498
 499        Layer 2 producer (P0). **Blocks** until the challenge is built +
 500        pushed (or skipped as already frozen), unlike the fire-and-return
 501        :meth:`build_challenge`, so the per-call HTTP timeout sits above
 502        the node's build budget. Returns the per-service freeze result;
 503        the platform's ``challenge_build_push`` task calls this one
 504        challenge at a time. *push_ticket* is the platform's short-lived
 505        registry credential the node's ``docker push`` authenticates with
 506        — the node runs it in a container with no ambient login.
 507        *cleanup* asks the node to remove what the build left and prune its
 508        build cache to *cache_max_bytes* afterwards, and *confine_network*
 509        keeps every ``RUN`` off the host's network (both the freeze pool, #1827).
 510        """
 511        resp = self.request(
 512            "POST",
 513            f"/admin/challenges/{challenge_id}/build-push",
 514            json={
 515                "push_ticket": push_ticket,
 516                "cleanup": cleanup,
 517                "cache_max_bytes": cache_max_bytes,
 518                "confine_network": confine_network,
 519                "authored_revision": authored_revision,
 520            },
 521            timeout=timeout_s + 60,
 522        )
 523        _raise_for_node_status(resp)
 524        return _parsed(ChallengeFreezeResult, resp)
 525
 526    def get_disk_free(self) -> NodeDiskFree:
 527        """Free space on the node's data filesystem, read now (#1827).
 528
 529        Raises on any failure — including a node predating the route, which
 530        answers 404 — and the freeze reads every failure as *cannot tell*,
 531        which stops it.
 532        """
 533        resp = self.request("GET", "/admin/disk")
 534        _raise_for_node_status(resp)
 535        return _parsed(NodeDiskFree, resp)
 536
 537    def get_build_state(self) -> BuildStateRows:
 538        """Fetch every per-challenge build-state row on this node.
 539
 540        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
 541        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
 542        ``failed``; ``unbuilt`` placeholders are synthesised for specs
 543        the node has seen but never been asked to build.
 544        """
 545        resp = self.request("GET", "/admin/challenges/build-state")
 546        _raise_for_node_status(resp)
 547        return _parsed(BuildStateRows, resp)
 548
 549    def pull_challenge(self, challenge_id: str) -> ChallengePullState:
 550        """Ask the node to pre-pull registry images for *challenge_id*.
 551
 552        Pull-side twin of :meth:`build_challenge`: the node persists
 553        ``status="pulling"`` and spawns a daemon thread; poll
 554        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
 555        """
 556        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
 557        _raise_for_node_status(resp)
 558        return _parsed(ChallengePullState, resp)
 559
 560    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> PullAllQueued:
 561        """Queue background pre-pull on the node.
 562
 563        With ``challenge_ids=None`` the node pulls every spec it knows;
 564        with an explicit list only those (the platform scoping to a
 565        competition). Returns ``{queued, skipped_pulled,
 566        skipped_in_progress}``.
 567        """
 568        kwargs: dict[str, Any] = {}
 569        if challenge_ids is not None:
 570            kwargs["json"] = {"challenge_ids": challenge_ids}
 571        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
 572        _raise_for_node_status(resp)
 573        return _parsed(PullAllQueued, resp)
 574
 575    def get_pull_state(self) -> PullStateRows:
 576        """Fetch every per-challenge pull-state row on this node.
 577
 578        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
 579        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
 580        ``failed``; ``unpulled`` placeholders are synthesised for specs
 581        the node has seen but never been asked to pull.
 582        """
 583        resp = self.request("GET", "/admin/challenges/pull-state")
 584        _raise_for_node_status(resp)
 585        return _parsed(PullStateRows, resp)
 586
 587    # -- status / health ----------------------------------------------------
 588
 589    def get_status(self, instance_id: str) -> InstanceStatusReport:
 590        resp = self.request("GET", f"/instances/{instance_id}/status")
 591        resp.raise_for_status()
 592        return _parsed(InstanceStatusReport, resp)
 593
 594    def list_instances(self) -> list[NodeInstanceSummary]:
 595        """Every instance the node still has bookkeeping for.
 596
 597        Used by the platform's boot re-adoption pass to decide which
 598        durable claims still have containers behind them. ``get_status``
 599        answers the same question one id at a time; asking it per claim
 600        would cost one round trip per instance to a node that may be
 601        slow or down, on the path that gates startup.
 602        """
 603        resp = self.request("GET", "/instances")
 604        resp.raise_for_status()
 605        return _parsed(NodeInstanceList, resp).items
 606
 607    def check_health(self, instance_id: str) -> bool:
 608        return self.check_health_verbose(instance_id).healthy
 609
 610    def check_health_verbose(self, instance_id: str) -> HealthReading:
 611        """Health plus whatever agent-deception telemetry the node read.
 612
 613        One call, because the platform already polls this endpoint for
 614        every live instance every tick and a second round trip per
 615        instance would be the whole cost of the feature. ``check_health``
 616        stays a bool for the callers that only ever wanted one.
 617
 618        Never an error, whatever arrives: this rides the health poll,
 619        and telemetry must not be able to mark a healthy instance
 620        unhealthy.
 621
 622        ⚠️ **The two ways this can come back empty are different facts
 623        and used to be one.** An older node sends no ``deception`` key
 624        at all — it *did not say*, which is ``channel=None`` and
 625        authorises nothing. A body this platform cannot parse is
 626        something else: the node did say, and we could not read it. That
 627        is ``unreadable``, the same value the reader gives for a missing
 628        file, because it is the same fact — there is no usable channel
 629        here — arriving through a different door.
 630
 631        The parse failure that matters is version skew in the other
 632        direction: a **newer** node reporting an ``ObservationKind`` this
 633        platform does not have. ``Observation.kind`` is typed, so one
 634        unknown member fails the whole model rather than dropping one
 635        row — which is the right refusal (the node's word is not ours to
 636        edit) and exactly why it must not land as "reported nothing".
 637        """
 638        resp = self.request("GET", f"/instances/{instance_id}/health")
 639        resp.raise_for_status()
 640        body = resp.json()
 641        raw = body.get("deception")
 642        if not raw:
 643            summary = DeceptionSummary()
 644        else:
 645            try:
 646                summary = DeceptionSummary.model_validate(raw)
 647            except ValidationError as exc:
 648                log.info(
 649                    "unreadable deception telemetry from node",
 650                    instance_id=instance_id,
 651                    error=tail(str(exc), limit=200),
 652                )
 653                summary = DeceptionSummary(channel="unreadable")
 654        raw_contact = body.get("contact")
 655        if not raw_contact:
 656            contact = ContactReading()
 657        else:
 658            try:
 659                contact = ContactReading.model_validate(raw_contact)
 660            except ValidationError as exc:
 661                log.info(
 662                    "unreadable contact telemetry from node",
 663                    instance_id=instance_id,
 664                    error=tail(str(exc), limit=200),
 665                )
 666                contact = ContactReading(channel="unreadable")
 667        raw_procs = body.get("new_processes")
 668        # ⚠️ Shape-checked rather than trusted: an older node sends
 669        # nothing and a newer one could send a shape this platform does
 670        # not model, and neither may reach a reader as *a container full
 671        # of intruders*. Anything unreadable is silence, which for a
 672        # signal built to under-report is the honest reading.
 673        procs: dict[str, list[str]] = {}
 674        if isinstance(raw_procs, dict):
 675            for container, names in raw_procs.items():
 676                if isinstance(container, str) and isinstance(names, list):
 677                    procs[container] = [str(n) for n in names]
 678        return HealthReading(
 679            healthy=bool(body.get("is_healthy")),
 680            deception=summary,
 681            contact=contact,
 682            new_processes=procs,
 683        )
 684
 685    def node_health(self) -> dict[str, Any]:
 686        """Liveness + ``{running, capacity}`` for heartbeat."""
 687        resp = self.request("GET", "/health")
 688        resp.raise_for_status()
 689        body: dict[str, Any] = resp.json()
 690        return body
 691
 692    # -- traffic / runtime --------------------------------------------------
 693
 694    def get_traffic_page(
 695        self, instance_id: str, *, cursor: str = "", limit: int = 0
 696    ) -> InstanceTrafficPage:
 697        """One page of mitmproxy flows; the capture lives on the node's FS.
 698
 699        ⚠️ **The one method that must not use ``_parsed``.** A node from
 700        before paging answers with a bare *list* — it sent the whole
 701        capture and understands no cursor — and a strict
 702        ``model_validate`` turns that supported skew into a raised
 703        request. So the body goes through ``coerce_page``, which already
 704        owns every decision about an unexpected shape, and the caller
 705        gets the model.
 706
 707        This is the same move ``coerce_page`` was written for, one layer
 708        down. Its docstring used to say the parsing belonged
 709        "platform-side … so a route and this client cannot come to
 710        disagree" — and there were *three* platform-side calls doing it.
 711        There is one call now, at the seam that knows the request
 712        cursor, which is what the fallback needs; the routes hold the
 713        type instead of re-deciding its shape.
 714
 715        ⚠️ **An empty ``cursor`` means "from the beginning".** Passing
 716        one on every poll re-reads the whole capture, which is the cost
 717        the paged read exists to remove — arriving through the caller
 718        rather than through the node.
 719        """
 720        params: dict[str, Any] = {}
 721        if cursor:
 722            params["cursor"] = cursor
 723        if limit > 0:
 724            params["limit"] = limit
 725        resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params)
 726        resp.raise_for_status()
 727        page = coerce_page(resp.json(), cursor)
 728        return InstanceTrafficPage(
 729            flows=page.flows,
 730            cursor=page.cursor,
 731            truncated=page.truncated,
 732            capture=page.capture,
 733        )
 734
 735    def get_traffic_witnesses(
 736        self, instance_id: str, *, cursor: str = "", limit: int = 0
 737    ) -> InstanceTrafficWitnesses:
 738        """Who spoke to this box since *cursor*, with no bodies moved.
 739
 740        §7.1's correlation reads one gamebox per team per round. Through
 741        :meth:`get_traffic_page` that is every flow, both bodies
 742        included (~100 kB apiece by ``CLAUDE.md``'s measurement),
 743        pulled across the wire to extract a handful of addresses; the
 744        node reduces instead and this carries the result.
 745
 746        ⚠️ **Strict parsing here, unlike its neighbour, and that is the
 747        difference between an old field and an old route.**
 748        ``get_traffic_page`` is lenient because a node from before
 749        paging answers the *same* path with a bare list, which is
 750        supported skew. This route did not exist on such a node at all,
 751        so the honest answer from one is a 404 — and a lenient parse of
 752        an error body would hand the round loop an empty witness set,
 753        which reads as *nobody was there*. The caller catches the
 754        request error and records the round as unknown.
 755        """
 756        params: dict[str, Any] = {}
 757        if cursor:
 758            params["cursor"] = cursor
 759        if limit > 0:
 760            params["limit"] = limit
 761        resp = self.request("GET", f"/instances/{instance_id}/traffic-witnesses", params=params)
 762        resp.raise_for_status()
 763        return _parsed(InstanceTrafficWitnesses, resp)
 764
 765    def list_containers(self, instance_id: str) -> list[ComposeContainer]:
 766        """Enumerate every container in an instance's compose project.
 767
 768        Backs the admin-shell feature: the platform forwards a
 769        ``GET /admin/instances/{id}/containers`` to the assigned node,
 770        which returns one row per container (challenge services and
 771        platform-injected sidecars alike). The WebSocket reverse-proxy
 772        is opened separately and does not go through ``NodeClient``.
 773        """
 774        resp = self.request("GET", f"/instances/{instance_id}/containers")
 775        _raise_for_node_status(resp)
 776        return _parsed_list(ComposeContainer, resp)
 777
 778    def run_checker(
 779        self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30
 780    ) -> CheckResult:
 781        """Exec an author-supplied checker in a trusted judge sidecar.
 782
 783        Backs the checker / exec-judge feature: the platform forwards a
 784        verify request to the assigned node, which runs ``cmd`` inside the
 785        named sidecar and returns
 786        ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP
 787        timeout sits above the node's exec budget so the client doesn't
 788        abandon the request before the node returns.
 789        """
 790        resp = self.request(
 791            "POST",
 792            f"/instances/{instance_id}/check",
 793            json={"service": service, "cmd": cmd, "timeout_s": timeout_s},
 794            timeout=timeout_s + 10,
 795        )
 796        _raise_for_node_status(resp)
 797        return _parsed(CheckResult, resp)
 798
 799    def run_harness(
 800        self,
 801        *,
 802        image: str,
 803        run_id: str = "",
 804        env: dict[str, str],
 805        timeout_s: int = 1800,
 806        untrusted: bool = False,
 807        network: str = "",
 808        own_network: bool = False,
 809        match_network: str = "",
 810        match_address: str = "",
 811        brief: dict[str, Any] | None = None,
 812        target_instance: str = "",
 813        target_aliases: dict[str, str] | None = None,
 814        pull_ticket: str = "",
 815        limits: HarnessLimits | None = None,
 816    ) -> HarnessRun:
 817        """Run a one-shot evaluation-harness container on the node.
 818
 819        The node ``docker run``s ``image`` with ``env`` injected, waits for it
 820        to exit (bounded by ``timeout_s``), and returns
 821        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
 822        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
 823        per-call HTTP timeout sits above the node's run budget so the client
 824        doesn't abandon the request before the (long-running) harness returns.
 825
 826        ``untrusted`` runs the image under the node's isolation profile —
 827        off host networking, bounded resources, no capabilities — for an
 828        image the platform did not build. It requires a network: the
 829        node answers 400 without one, because "isolate this" and "I will
 830        not say where the target lives" do not compose.
 831
 832        Any of ``network`` (the target instance's own, when the caller
 833        knows it), ``own_network`` or ``target_instance`` satisfies that.
 834        ⚠️ Most eval dispatches need the second: there the harness
 835        *starts* the target partway through its own run, so at ``docker
 836        run`` time no target network exists and there is no name to
 837        send. The node creates a fresh bridge and removes it afterwards.
 838
 839        ``target_instance`` reverses that ordering, and is what an
 840        `engagement` uses: the platform starts the estate first and names
 841        it here, and the node wires its published containers to the
 842        agent's bridge. ⚠️ An **id**, never a network name — the name is
 843        a fact only the node's daemon can read, and ``docker run``
 844        *creates* whatever string it is handed, so a platform guessing
 845        one starts a run that reaches nothing.
 846        """
 847        resp = self.request(
 848            "POST",
 849            "/harness/run",
 850            json={
 851                "image": image,
 852                "env": env,
 853                "timeout_s": timeout_s,
 854                "untrusted": untrusted,
 855                "network": network,
 856                "own_network": own_network,
 857                "match_network": match_network,
 858                # ⚠️ **The platform-assigned arena address.** The node
 859                # connects the agent to the match network with this
 860                # ``--ip``, so the address is known before the round loop
 861                # asks (§7.1). Empty means "let docker's IPAM pick" — an
 862                # older platform's behaviour, and what keeps a node reading
 863                # this field unchanged for every non-arena dispatch.
 864                "match_address": match_address,
 865                # ⚠️ Sent even when empty, so a node reading it gets the
 866                # same shape either way; the node's default is what makes
 867                # an older platform's dispatch unchanged.
 868                "pull_ticket": pull_ticket,
 869                # ⚠️ ``None`` leaves the node's own defaults in force, which is
 870                # what a dispatch said before the ceilings were settings.
 871                "limits": limits.model_dump() if limits is not None else None,
 872                # ⚠️ Same rule, and empty is a real answer here too: a
 873                # dispatch with no run row (the conformance probe, a dry
 874                # run) has nothing to label, and a label with an empty
 875                # value would make every one of them answer one query.
 876                "run_id": run_id,
 877                # ⚠️ Sent as the already-built manifest. The platform is
 878                # the side that knows the challenge, the competition and
 879                # the run mode; a node deriving its own would be a
 880                # second implementation of a published contract.
 881                "brief": brief,
 882                "target_instance": target_instance,
 883                # ⚠️ **The names the brief published, so they resolve on
 884                # the agent's bridge too.** An estate's hosts answer to a
 885                # per-instance FQDN minted by its own resolver; joined to
 886                # a new network a container answers only to its compose
 887                # *service* name. Sending the mapping is what keeps the
 888                # address the agent was told about true under either
 889                # placement — without it the agent is handed a hostname
 890                # that does not resolve, reaches nothing, and reports an
 891                # empty result, which reads as a model that gave up.
 892                #
 893                # ⚠️ The platform's to send: it computed the surface. A
 894                # node re-deriving it from the rendered compose would be
 895                # a second implementation of the address the brief has
 896                # already promised.
 897                "target_aliases": target_aliases or {},
 898            },
 899            timeout=timeout_s + 30,
 900        )
 901        _raise_for_node_status(resp)
 902        return _parsed(HarnessRun, resp)
 903
 904    def cancel_harness(self, *, run_id: str) -> int:
 905        """Stop whatever this node is running for ``run_id``; returns how many.
 906
 907        Until this existed a dispatched container was reclaimed by
 908        **nothing but its own timeout** — ``_harness_timeout``'s docstring
 909        says exactly that — so an operator pressing cancel on a
 910        match-long run was asking for something the platform had no way
 911        to deliver.
 912
 913        ⚠️ **Zero is an ordinary answer, not a failure.** The run may have
 914        finished on its own, been dispatched to a different node, or been
 915        lost to a node restart. A caller that read zero as an error would
 916        report a broken platform every time a cancel arrived a second
 917        late.
 918        """
 919        resp = self.request("POST", "/harness/cancel", json={"run_id": run_id})
 920        body = resp.json()
 921        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
 922
 923    def verify_patch(
 924        self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900
 925    ) -> PatchVerifyResult:
 926        """Build a submitted patch on the node and return its verdict.
 927
 928        ``files`` maps a challenge-relative path to its new bytes; base64
 929        is applied here because a patch target may legitimately be
 930        binary. Returns ``{verdict, detail, applied, steps, error}``.
 931
 932        **An empty ``verdict`` with a populated ``error`` is not a
 933        judgement** — it means the node refused the submission or the
 934        verifier crashed. The caller must retry or retire the lease
 935        rather than write a score, since failing to judge a patch is not
 936        the same as judging it unfixed.
 937
 938        The per-call HTTP timeout sits above the node's own budget so the
 939        client doesn't abandon the request while the node is still
 940        building.
 941        """
 942        resp = self.request(
 943            "POST",
 944            "/patches/verify",
 945            json={
 946                "challenge_id": challenge_id,
 947                "files": {
 948                    path: base64.b64encode(content).decode() for path, content in files.items()
 949                },
 950                "timeout_s": timeout_s,
 951            },
 952            timeout=timeout_s + 60,
 953        )
 954        _raise_for_node_status(resp)
 955        return _parsed(PatchVerifyResult, resp)
 956
 957    def collect_patch_files(
 958        self, instance_id: str, service: str, paths: list[str]
 959    ) -> dict[str, bytes]:
 960        """Read a player's SSH edits back out of their running box.
 961
 962        ``paths`` are absolute in-container paths the *platform* derived
 963        from the pristine source tree. Returns only what could be read:
 964        a path missing from the reply means the player did not change it,
 965        and the caller falls back to the shipped file rather than
 966        recording a deletion they never made.
 967        """
 968        resp = self.request(
 969            "POST",
 970            f"/instances/{instance_id}/patch-collect",
 971            json={"service": service, "paths": paths},
 972        )
 973        _raise_for_node_status(resp)
 974        body: dict[str, str] = resp.json()
 975        return {path: base64.b64decode(blob) for path, blob in body.items()}
 976
 977    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
 978        """Rotate a round's flags into every box of this node, in one call.
 979
 980        ⚠️ **One call for the whole node, never one per box.** §5.2's
 981        capacity red line is 1000 teams x 3 services injected inside
 982        120 s; per-box that is 3000 round trips, batched it is about
 983        fourteen. The signature is the enforcement — a caller cannot
 984        accidentally loop.
 985
 986        Returns ``{instance_id: ""}`` for the boxes written and a reason
 987        for the ones that were not, because one team's unreachable box
 988        must not cost every other team on the node its rotation. An
 989        unrotated box keeps serving last round's flag, which anyone
 990        holding the old value can replay.
 991        """
 992        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
 993        _raise_for_node_status(resp)
 994        result: dict[str, str] = resp.json()
 995        return result
 996
 997    def probe_awd_service(
 998        self,
 999        *,
1000        instance_id: str,
1001        service_id: str,
1002        tick: int,
1003        flag: str,
1004        previous_flag: str = "",
1005        previous_flag_id: str = "",
1006    ) -> dict[str, str]:
1007        """Run one round's SLA probes against one box, from outside it.
1008
1009        The flags travel *to* the node: a checker that sourced its
1010        expected value from the box it is checking would pass on any box
1011        that returns whatever it was handed.
1012        """
1013        resp = self.request(
1014            "POST",
1015            "/awd/probe",
1016            json={
1017                "instance_id": instance_id,
1018                "service_id": service_id,
1019                "tick": tick,
1020                "flag": flag,
1021                "previous_flag": previous_flag,
1022                "previous_flag_id": previous_flag_id,
1023            },
1024        )
1025        _raise_for_node_status(resp)
1026        result: dict[str, str] = resp.json()
1027        return result
1028
1029    def attack_awd_box(
1030        self,
1031        *,
1032        instance_id: str,
1033        tick: int,
1034        timeout_s: int = 60,
1035        agent_image: str = "",
1036        counter_flag: str = "",
1037        forensic_marker: str = "",
1038        llm_api_key: str = "",
1039    ) -> AwdAttackResult:
1040        """Run this box's own reference exploit against it, once.
1041
1042        Per **box**, not per service: one `run_exploit` discovers every
1043        answer the challenge declares and prints them all.
1044
1045        ``timeout_s`` is sent because the platform is what knows how long
1046        a round is; the node clamps it, because a container held open for
1047        an hour is the node's problem whoever asked for it.
1048
1049        ``forensic_marker`` is §4.3.1's, carried only by the agent
1050        branch: `build_attack_argv` passes no environment at all, so a
1051        marker on the reference exploit would be the one value that
1052        voids `EXPLOIT007`'s guarantee.
1053
1054        ``agent_image`` + ``counter_flag`` switch the node to §4.2b's
1055        agent attacker. ⚠️ **Both or neither** — the node refuses a half
1056        request rather than falling back to the reference exploit, which
1057        would run a different format than the one configured while the
1058        board looked entirely normal.
1059        """
1060        resp = self.request(
1061            "POST",
1062            "/awd/attack",
1063            json={
1064                "instance_id": instance_id,
1065                "tick": tick,
1066                "timeout_s": timeout_s,
1067                "agent_image": agent_image,
1068                "counter_flag": counter_flag,
1069                "forensic_marker": forensic_marker,
1070                "llm_api_key": llm_api_key,
1071            },
1072        )
1073        _raise_for_node_status(resp)
1074        return _parsed(AwdAttackResult, resp)
1075
1076    def get_container_logs(self, instance_id: str) -> str:
1077        """Fetch combined container stdout/stderr for archival."""
1078        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
1079        resp.raise_for_status()
1080        return str(resp.json().get("logs") or "")
1081
1082    def get_pcap(self, instance_id: str) -> PcapCapture:
1083        """Fetch the merged tcpdump capture for *instance_id*.
1084
1085        ``data`` is empty when no capture exists (sidecar disabled,
1086        instance never reached the running state, etc.) so callers can
1087        persist conditionally without try/except.
1088
1089        ⚠️ **It also says what the per-instance cap already dropped**
1090        (#1714), off the node's response headers, and ``None`` when the
1091        node did not say — an older node, which is *not reported*, never
1092        a clean capture.
1093        """
1094        resp = self.request("GET", f"/instances/{instance_id}/pcap")
1095        if resp.status_code == 404:
1096            return PcapCapture(data=b"")
1097        resp.raise_for_status()
1098        return PcapCapture(
1099            data=resp.content,
1100            dropped_bytes=_header_int(resp, CAPTURE_DROPPED_BYTES_HEADER),
1101            dropped_segments=_header_int(resp, CAPTURE_DROPPED_SEGMENTS_HEADER),
1102        )
1103
1104    def get_harness_artifact(self, artifact_id: str) -> bytes:
1105        """One finished run's collected archive, or ``b""``.
1106
1107        ⚠️ **Empty rather than an exception for a 404**, following
1108        :meth:`get_pcap`. The node reaps uncollected archives, and a
1109        platform that comes back after a restart is exactly the caller
1110        that meets one that is gone — a state its own record already
1111        describes, not a transport failure.
1112        """
1113        resp = self.request("GET", f"/harness/artifacts/{artifact_id}")
1114        if resp.status_code == 404:
1115            return b""
1116        resp.raise_for_status()
1117        return resp.content
1118
1119    def delete_harness_artifact(self, artifact_id: str) -> bool:
1120        """Tell the node the archive has been stored and can go.
1121
1122        ⚠️ **Never raises on a transport failure**, because it is called
1123        after the bytes are safely in object storage: turning a cleanup
1124        error into the run's error would replace a success with a
1125        janitorial complaint. The node's reaper collects whatever this
1126        misses.
1127        """
1128        try:
1129            resp = self.request("DELETE", f"/harness/artifacts/{artifact_id}")
1130        except Exception:
1131            return False
1132        return resp.status_code == 200
1133
1134    def get_agent_runtime(self, instance_id: str) -> AgentRuntime:
1135        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
1136        CA PEM, optional Docker-specific names). Replaces the old
1137        sandbox-network shape."""
1138        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
1139        resp.raise_for_status()
1140        return _parsed(AgentRuntime, resp)
1141
1142    # -- per-instance rendered attachments ---------------------------------
1143
1144    def list_instance_attachments(self, instance_id: str) -> AttachmentList:
1145        """List per-instance attachments rendered into the node's workdir.
1146
1147        Backs the platform's per-instance attachment endpoint, which
1148        surfaces the team-specific rendered file listing.
1149
1150        ⚠️ This used to hand back the raw dict, under a docstring saying
1151        "caller projects through :class:`AttachmentList` for typing" —
1152        the node's route already declared that model, so the type existed
1153        and this method's signature was the one place it was dropped.
1154        Projecting *here* means there is one place a malformed answer is
1155        caught rather than one per caller."""
1156        resp = self.request("GET", f"/instances/{instance_id}/attachments")
1157        resp.raise_for_status()
1158        return _parsed(AttachmentList, resp)
1159
1160    def get_vpn_config(self, instance_id: str) -> bytes:
1161        """Fetch the per-instance VPN client config from the node.
1162
1163        The node reads the backend's client config out of the
1164        instance's VPN service container (generated on first boot by
1165        the base image's bootstrap) and rewrites its endpoint to the
1166        node's public host:port. Returns the body verbatim — the
1167        platform-side route adds the ``Content-Disposition`` header
1168        before re-emitting to the player, because only it knows the
1169        backend and therefore the filename the player needs.
1170
1171        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1172        proxy route catches 404 and re-emits to the player, anything
1173        else is treated as a 502.
1174        """
1175        resp = self.request("GET", f"/instances/{instance_id}/vpn-config")
1176        resp.raise_for_status()
1177        return resp.content
1178
1179    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1180        """Download one per-instance attachment as ``(bytes, content_type)``.
1181
1182        Buffers the whole body — attachments are bounded (typical case
1183        is a 10 KB binary or a small text file). For huge captures the
1184        caller should hand the player a CDN URL instead.
1185
1186        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1187        proxy route catches 404 and re-emits to the player, anything
1188        else is a 502."""
1189        resp = self.request(
1190            "GET",
1191            f"/instances/{instance_id}/attachments/{filename}",
1192        )
1193        resp.raise_for_status()
1194        return resp.content, resp.headers.get("content-type", "application/octet-stream")
@dataclasses.dataclass(frozen=True)
class PcapCapture:
69@dataclasses.dataclass(frozen=True)
70class PcapCapture:
71    """A node's merged capture, and what the cap dropped from it (#1714)."""
72
73    data: bytes
74    #: ``None`` = the node did not say (older build); ``0`` = nothing.
75    dropped_bytes: int | None = None
76    dropped_segments: int | None = None

A node's merged capture, and what the cap dropped from it (#1714).

PcapCapture( data: bytes, dropped_bytes: int | None = None, dropped_segments: int | None = None)
data: bytes
dropped_bytes: int | None = None
dropped_segments: int | None = None
class HealthReading(pydantic.main.BaseModel):
174class HealthReading(BaseModel):
175    """One health poll's three answers.
176
177    ⚠️ **A record rather than a tuple, and the reason is recorded one
178    subsystem over.** ``competition_results`` lost ``attempts`` and
179    ``last_solve_at`` to a hand-made ``(rank, flags, solved)`` tuple
180    sitting between a board that produced them and a template that
181    consumed them; this poll had two answers and now has three, so the
182    tuple was one field away from the same fate. Growing a record is a
183    field; growing a tuple is every call site, silently re-bound.
184    """
185
186    healthy: bool = False
187    deception: DeceptionSummary = Field(default_factory=DeceptionSummary)
188    contact: ContactReading = Field(default_factory=ContactReading)
189    new_processes: dict[str, list[str]] = Field(default_factory=dict)

One health poll's three answers.

⚠️ A record rather than a tuple, and the reason is recorded one subsystem over. competition_results lost attempts and last_solve_at to a hand-made (rank, flags, solved) tuple sitting between a board that produced them and a template that consumed them; this poll had two answers and now has three, so the tuple was one field away from the same fate. Growing a record is a field; growing a tuple is every call site, silently re-bound.

healthy: bool = False
deception: ctfy.core.observation.DeceptionSummary = PydanticUndefined
contact: ctfy.core.observed_contact.ContactReading = PydanticUndefined
new_processes: dict[str, list[str]] = PydanticUndefined
class NodeClient(ctfy.sdk.base.BaseHttpClient):
 192class NodeClient(BaseHttpClient):
 193    """Thin sync HTTP client; one instance per node URL."""
 194
 195    def __init__(
 196        self,
 197        node_url: str,
 198        token: str,
 199        *,
 200        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 201        transport: httpx.BaseTransport | None = None,
 202    ) -> None:
 203        """*transport* is `BaseHttpClient`'s own seam, passed through so a
 204        test can drive a real node app over the real client — the hop the
 205        fakes on either side of it cannot see."""
 206        super().__init__(
 207            f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout, transport=transport
 208        )
 209
 210    # -- lifecycle ----------------------------------------------------------
 211
 212    def start_instance(
 213        self,
 214        *,
 215        challenge_id: str,
 216        instance_id: str,
 217        ttl: int,
 218        answers: dict[str, str],
 219        proxy_output_dir: str | None = None,
 220        env: dict[str, str] | None = None,
 221        publish_gamebox: bool = False,
 222        match_network: MatchNetwork | None = None,
 223        open_shell_password: str = "",
 224        authored_revision: str = "",
 225    ) -> InstanceStarted:
 226        resp = self.request(
 227            "POST",
 228            "/instances",
 229            json={
 230                # #1835: the authored tree's revision this start must run;
 231                # a node holding another (or none) fetches it first.
 232                "authored_revision": authored_revision,
 233                "challenge_id": challenge_id,
 234                "instance_id": instance_id,
 235                "ttl": ttl,
 236                "answers": answers,
 237                "proxy_output_dir": proxy_output_dir,
 238                "env": env or {},
 239                "publish_gamebox": publish_gamebox,
 240                # ``None`` rather than an omitted key: the node's model
 241                # defaults to None either way, and sending it explicitly
 242                # keeps the body's shape stable across both branches.
 243                "match_network": (dataclasses.asdict(match_network) if match_network else None),
 244                # Classic-AWD open shell (#1725): the box's starting root
 245                # password, empty for every managed / non-AWD start.
 246                "open_shell_password": open_shell_password,
 247            },
 248        )
 249        _raise_for_node_status(resp)
 250        return _parsed(InstanceStarted, resp)
 251
 252    def stop_instance(self, instance_id: str) -> None:
 253        resp = self.request("DELETE", f"/instances/{instance_id}")
 254        resp.raise_for_status()
 255
 256    def apply_fabric(
 257        self,
 258        *,
 259        index: int,
 260        interface: dict[str, Any],
 261        match_network: str = "",
 262    ) -> FabricStatus:
 263        """Put this node on one match's WireGuard fabric.
 264
 265        ⚠️ **Returns the model, never ``resp.json()``.** The platform
 266        reads the verdict field by field, and on a dict a renamed field
 267        is ``None`` — falsy — which reads as "the arena did not open",
 268        so a wire change would quietly report every node as broken.
 269
 270        Returns the node's verdict rather than raising on a refusal: the
 271        route answers **200 with a body** precisely so that "this node is
 272        unreachable" and "this node rejected the plan" stay different
 273        facts, and a caller fanning out over an arena needs to tell them
 274        apart — one is waited on, the other is looked at.
 275
 276        *match_network* lets the node check the tunnel landed in the
 277        namespace its boxes are in, and open the bridge's one hole. It is
 278        the platform's own fact (`awd_network.network_name`), so the node
 279        is told rather than guessing.
 280        """
 281        resp = self.request(
 282            "POST",
 283            "/fabric",
 284            json={
 285                "index": index,
 286                "interface": interface,
 287                "match_network": match_network,
 288            },
 289        )
 290        resp.raise_for_status()
 291        return _parsed(FabricStatus, resp)
 292
 293    def probe_fabric(
 294        self,
 295        *,
 296        index: int,
 297        peers: list[dict[str, str]],
 298        mtu: int = 0,
 299    ) -> FabricProbeReport:
 300        """Ask named peers the three link questions. Applies nothing.
 301
 302        ⚠️ **Returns the model, never ``resp.json()``** — same reason
 303        `apply_fabric` gives: on a dict a renamed field reads as falsy,
 304        which here means "your tunnel is down" about a wire change.
 305        """
 306        resp = self.request(
 307            "POST",
 308            "/fabric/probe",
 309            json={"index": index, "peers": peers, "mtu": mtu},
 310        )
 311        resp.raise_for_status()
 312        return _parsed(FabricProbeReport, resp)
 313
 314    def remove_fabric(self, *, index: int, match_network: str = "") -> FabricStatus:
 315        """Take this node off one match's fabric and close its hole.
 316
 317        ⚠️ *match_network* is what lets the node resolve the bridge and
 318        delete the ``DOCKER-USER`` rules by name. Without it the tunnel
 319        goes and the hole stays — open on whatever docker gives that
 320        bridge name next.
 321        """
 322        resp = self.request(
 323            "DELETE",
 324            f"/fabric/{index}",
 325            params={"match_network": match_network},
 326        )
 327        resp.raise_for_status()
 328        return _parsed(FabricStatus, resp)
 329
 330    def stop_instance_service(self, instance_id: str, service: str) -> int:
 331        """Stop one compose service, leaving the instance running.
 332
 333        The exercise format's session close (§4b.4). Returns how many
 334        containers the node stopped — 0 means the challenge declares no
 335        such service, which is a real answer rather than a failure, so
 336        the caller can distinguish it from a cut tunnel.
 337        """
 338        resp = self.request(
 339            "POST",
 340            f"/instances/{instance_id}/stop-service",
 341            json={"service": service},
 342        )
 343        resp.raise_for_status()
 344        body = resp.json()
 345        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
 346
 347    def stop_all(self) -> None:
 348        resp = self.request("POST", "/admin/stop-all")
 349        resp.raise_for_status()
 350
 351    def put_authored_challenge(self, push: NodeAuthoredPush) -> NodeAuthoredResult:
 352        """Ask the node to write an approved player upload into its catalog.
 353
 354        ⚠️ **The bytes travel here, unlike `put_generated_range`.** That
 355        one sends a scenario because the node can compile it; a
 356        player-authored tree has no compiler and no corpus entry, so the
 357        archive is what there is to send.
 358
 359        The node answers **200 with ``ok: false``** for a refusal, so a
 360        node that declined the archive and a node that could not be
 361        reached stay different facts; only the second raises here.
 362        """
 363        resp = self.request("POST", "/admin/authored", json=push.model_dump(mode="json"))
 364        resp.raise_for_status()
 365        return _parsed(NodeAuthoredResult, resp)
 366
 367    def smoke_authored_challenge(
 368        self, push: NodeAuthoredSmokePush, *, timeout: int | None = None
 369    ) -> NodeAuthoredSmokeResult:
 370        """Ask the node to run an authored challenge's own reference exploit.
 371
 372        ⚠️ **Takes its own timeout.** A smoke run brings a whole estate
 373        up; the client default is sized for a status call and would give
 374        up while the node is still doing what it was asked.
 375        """
 376        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 377        if timeout is not None:
 378            kwargs["timeout"] = timeout
 379        resp = self.request("POST", "/admin/authored/smoke", **kwargs)
 380        resp.raise_for_status()
 381        return _parsed(NodeAuthoredSmokeResult, resp)
 382
 383    def put_generated_range(self, push: NodeRangePush) -> NodeRangeResult:
 384        """Ask the node to build a generated range from its scenario.
 385
 386        ⚠️ **The scenario travels, not the estate.** The corpus is
 387        mounted on the node and the compiler ships inside it, so 70-odd
 388        rendered files never cross this API — and the corpus commit that
 389        rides along is what stops one scenario becoming two different
 390        estates under one id across a fleet.
 391
 392        The node answers **200 with ``ok: false``** for a refusal, so a
 393        node that declined and a node that could not be reached stay
 394        different facts; only the second raises here.
 395        """
 396        resp = self.request("POST", "/admin/ranges", json=push.model_dump(mode="json"))
 397        resp.raise_for_status()
 398        return _parsed(NodeRangeResult, resp)
 399
 400    def verify_generated_range(
 401        self, push: NodeRangeVerifyPush, *, timeout: int | None = None
 402    ) -> NodeRangeVerifyResult:
 403        """Ask the node to build a scenario, solve it, and take it down.
 404
 405        ⚠️ **The longest call in this API**, so the caller passes its own
 406        timeout: the node's budget is ``compose_timeout_s +
 407        solve_timeout_s`` and a client that used the default would give
 408        up while the node was still building — then retry, and start a
 409        second estate beside the first.
 410
 411        ⚠️ A client timeout does **not** leave a range running: the node
 412        tears down in its own ``finally``. What is lost is the answer,
 413        not the host.
 414
 415        The node answers **200 with an ``error``** for a refusal, the
 416        same split ``put_generated_range`` uses; only an unreachable node
 417        raises.
 418        """
 419        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 420        if timeout is not None:
 421            # httpx takes a per-request override; the client-wide value
 422            # stays whatever the caller constructed this with.
 423            kwargs["timeout"] = timeout
 424        resp = self.request("POST", "/admin/ranges/verify", **kwargs)
 425        resp.raise_for_status()
 426        return _parsed(NodeRangeVerifyResult, resp)
 427
 428    def cancel_generated_range_verify(self, run_id: str) -> bool:
 429        """Tell the node to stop a verification it is running.
 430
 431        ⚠️ **A miss is success, not a 404 to report.** The cancel races
 432        the run's own finish, so "no such run" means the run already
 433        ended — which is what the operator asked for. The node answers
 434        ``{"cancelled": false}`` for that case rather than raising, and
 435        this returns it so a caller that wants to say "it had already
 436        finished" can.
 437        """
 438        resp = self.request("POST", f"/admin/ranges/verify/{run_id}/cancel")
 439        resp.raise_for_status()
 440        body = resp.json()
 441        return bool(body.get("cancelled"))
 442
 443    def rescan_challenges(self) -> ChallengeReloadResult:
 444        """Tell the node to drop its spec cache and re-scan challenges_dir.
 445
 446        Returns ``{total, added, removed}`` so the platform can report
 447        the per-node outcome of a cluster-wide rescan."""
 448        resp = self.request("POST", "/admin/rescan-challenges")
 449        resp.raise_for_status()
 450        return _parsed(ChallengeReloadResult, resp)
 451
 452    # -- admin pre-build (image cache warming) ------------------------------
 453
 454    def build_challenge(
 455        self, challenge_id: str, *, authored_revision: str = ""
 456    ) -> ChallengeBuildState:
 457        """Ask the node to pre-build images for *challenge_id*.
 458
 459        Fires-and-returns: the node persists ``status="building"`` and
 460        spawns a daemon thread; the body returned here is that initial
 461        state row. Polling :meth:`get_build_state` is how the platform
 462        learns when it lands on ``built`` / ``failed``. *authored_revision*
 463        is #1835's: the tree the build must be of, fetched if not held.
 464        """
 465        params = {"authored_revision": authored_revision} if authored_revision else None
 466        resp = self.request("POST", f"/admin/challenges/{challenge_id}/build", params=params)
 467        _raise_for_node_status(resp)
 468        return _parsed(ChallengeBuildState, resp)
 469
 470    def build_all_challenges(self, challenge_ids: list[str] | None = None) -> BuildAllQueued:
 471        """Queue background pre-build on the node.
 472
 473        With ``challenge_ids=None`` the node builds every spec it knows.
 474        With an explicit list (the platform scoping a bulk build to a
 475        competition's challenge set) only those are built. Returns
 476        ``{queued, skipped_built, skipped_in_progress}`` so the platform
 477        can report per-node what got picked up. Sequential on the node
 478        side — no fan-out across the corpus.
 479        """
 480        kwargs: dict[str, Any] = {}
 481        if challenge_ids is not None:
 482            kwargs["json"] = {"challenge_ids": challenge_ids}
 483        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
 484        _raise_for_node_status(resp)
 485        return _parsed(BuildAllQueued, resp)
 486
 487    def build_push_challenge(
 488        self,
 489        challenge_id: str,
 490        *,
 491        push_ticket: str = "",
 492        timeout_s: int = 1800,
 493        cleanup: bool = False,
 494        cache_max_bytes: int = 0,
 495        confine_network: bool = False,
 496        authored_revision: str = "",
 497    ) -> ChallengeFreezeResult:
 498        """Build one challenge's ``build:`` services and freeze them to the registry.
 499
 500        Layer 2 producer (P0). **Blocks** until the challenge is built +
 501        pushed (or skipped as already frozen), unlike the fire-and-return
 502        :meth:`build_challenge`, so the per-call HTTP timeout sits above
 503        the node's build budget. Returns the per-service freeze result;
 504        the platform's ``challenge_build_push`` task calls this one
 505        challenge at a time. *push_ticket* is the platform's short-lived
 506        registry credential the node's ``docker push`` authenticates with
 507        — the node runs it in a container with no ambient login.
 508        *cleanup* asks the node to remove what the build left and prune its
 509        build cache to *cache_max_bytes* afterwards, and *confine_network*
 510        keeps every ``RUN`` off the host's network (both the freeze pool, #1827).
 511        """
 512        resp = self.request(
 513            "POST",
 514            f"/admin/challenges/{challenge_id}/build-push",
 515            json={
 516                "push_ticket": push_ticket,
 517                "cleanup": cleanup,
 518                "cache_max_bytes": cache_max_bytes,
 519                "confine_network": confine_network,
 520                "authored_revision": authored_revision,
 521            },
 522            timeout=timeout_s + 60,
 523        )
 524        _raise_for_node_status(resp)
 525        return _parsed(ChallengeFreezeResult, resp)
 526
 527    def get_disk_free(self) -> NodeDiskFree:
 528        """Free space on the node's data filesystem, read now (#1827).
 529
 530        Raises on any failure — including a node predating the route, which
 531        answers 404 — and the freeze reads every failure as *cannot tell*,
 532        which stops it.
 533        """
 534        resp = self.request("GET", "/admin/disk")
 535        _raise_for_node_status(resp)
 536        return _parsed(NodeDiskFree, resp)
 537
 538    def get_build_state(self) -> BuildStateRows:
 539        """Fetch every per-challenge build-state row on this node.
 540
 541        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
 542        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
 543        ``failed``; ``unbuilt`` placeholders are synthesised for specs
 544        the node has seen but never been asked to build.
 545        """
 546        resp = self.request("GET", "/admin/challenges/build-state")
 547        _raise_for_node_status(resp)
 548        return _parsed(BuildStateRows, resp)
 549
 550    def pull_challenge(self, challenge_id: str) -> ChallengePullState:
 551        """Ask the node to pre-pull registry images for *challenge_id*.
 552
 553        Pull-side twin of :meth:`build_challenge`: the node persists
 554        ``status="pulling"`` and spawns a daemon thread; poll
 555        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
 556        """
 557        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
 558        _raise_for_node_status(resp)
 559        return _parsed(ChallengePullState, resp)
 560
 561    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> PullAllQueued:
 562        """Queue background pre-pull on the node.
 563
 564        With ``challenge_ids=None`` the node pulls every spec it knows;
 565        with an explicit list only those (the platform scoping to a
 566        competition). Returns ``{queued, skipped_pulled,
 567        skipped_in_progress}``.
 568        """
 569        kwargs: dict[str, Any] = {}
 570        if challenge_ids is not None:
 571            kwargs["json"] = {"challenge_ids": challenge_ids}
 572        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
 573        _raise_for_node_status(resp)
 574        return _parsed(PullAllQueued, resp)
 575
 576    def get_pull_state(self) -> PullStateRows:
 577        """Fetch every per-challenge pull-state row on this node.
 578
 579        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
 580        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
 581        ``failed``; ``unpulled`` placeholders are synthesised for specs
 582        the node has seen but never been asked to pull.
 583        """
 584        resp = self.request("GET", "/admin/challenges/pull-state")
 585        _raise_for_node_status(resp)
 586        return _parsed(PullStateRows, resp)
 587
 588    # -- status / health ----------------------------------------------------
 589
 590    def get_status(self, instance_id: str) -> InstanceStatusReport:
 591        resp = self.request("GET", f"/instances/{instance_id}/status")
 592        resp.raise_for_status()
 593        return _parsed(InstanceStatusReport, resp)
 594
 595    def list_instances(self) -> list[NodeInstanceSummary]:
 596        """Every instance the node still has bookkeeping for.
 597
 598        Used by the platform's boot re-adoption pass to decide which
 599        durable claims still have containers behind them. ``get_status``
 600        answers the same question one id at a time; asking it per claim
 601        would cost one round trip per instance to a node that may be
 602        slow or down, on the path that gates startup.
 603        """
 604        resp = self.request("GET", "/instances")
 605        resp.raise_for_status()
 606        return _parsed(NodeInstanceList, resp).items
 607
 608    def check_health(self, instance_id: str) -> bool:
 609        return self.check_health_verbose(instance_id).healthy
 610
 611    def check_health_verbose(self, instance_id: str) -> HealthReading:
 612        """Health plus whatever agent-deception telemetry the node read.
 613
 614        One call, because the platform already polls this endpoint for
 615        every live instance every tick and a second round trip per
 616        instance would be the whole cost of the feature. ``check_health``
 617        stays a bool for the callers that only ever wanted one.
 618
 619        Never an error, whatever arrives: this rides the health poll,
 620        and telemetry must not be able to mark a healthy instance
 621        unhealthy.
 622
 623        ⚠️ **The two ways this can come back empty are different facts
 624        and used to be one.** An older node sends no ``deception`` key
 625        at all — it *did not say*, which is ``channel=None`` and
 626        authorises nothing. A body this platform cannot parse is
 627        something else: the node did say, and we could not read it. That
 628        is ``unreadable``, the same value the reader gives for a missing
 629        file, because it is the same fact — there is no usable channel
 630        here — arriving through a different door.
 631
 632        The parse failure that matters is version skew in the other
 633        direction: a **newer** node reporting an ``ObservationKind`` this
 634        platform does not have. ``Observation.kind`` is typed, so one
 635        unknown member fails the whole model rather than dropping one
 636        row — which is the right refusal (the node's word is not ours to
 637        edit) and exactly why it must not land as "reported nothing".
 638        """
 639        resp = self.request("GET", f"/instances/{instance_id}/health")
 640        resp.raise_for_status()
 641        body = resp.json()
 642        raw = body.get("deception")
 643        if not raw:
 644            summary = DeceptionSummary()
 645        else:
 646            try:
 647                summary = DeceptionSummary.model_validate(raw)
 648            except ValidationError as exc:
 649                log.info(
 650                    "unreadable deception telemetry from node",
 651                    instance_id=instance_id,
 652                    error=tail(str(exc), limit=200),
 653                )
 654                summary = DeceptionSummary(channel="unreadable")
 655        raw_contact = body.get("contact")
 656        if not raw_contact:
 657            contact = ContactReading()
 658        else:
 659            try:
 660                contact = ContactReading.model_validate(raw_contact)
 661            except ValidationError as exc:
 662                log.info(
 663                    "unreadable contact telemetry from node",
 664                    instance_id=instance_id,
 665                    error=tail(str(exc), limit=200),
 666                )
 667                contact = ContactReading(channel="unreadable")
 668        raw_procs = body.get("new_processes")
 669        # ⚠️ Shape-checked rather than trusted: an older node sends
 670        # nothing and a newer one could send a shape this platform does
 671        # not model, and neither may reach a reader as *a container full
 672        # of intruders*. Anything unreadable is silence, which for a
 673        # signal built to under-report is the honest reading.
 674        procs: dict[str, list[str]] = {}
 675        if isinstance(raw_procs, dict):
 676            for container, names in raw_procs.items():
 677                if isinstance(container, str) and isinstance(names, list):
 678                    procs[container] = [str(n) for n in names]
 679        return HealthReading(
 680            healthy=bool(body.get("is_healthy")),
 681            deception=summary,
 682            contact=contact,
 683            new_processes=procs,
 684        )
 685
 686    def node_health(self) -> dict[str, Any]:
 687        """Liveness + ``{running, capacity}`` for heartbeat."""
 688        resp = self.request("GET", "/health")
 689        resp.raise_for_status()
 690        body: dict[str, Any] = resp.json()
 691        return body
 692
 693    # -- traffic / runtime --------------------------------------------------
 694
 695    def get_traffic_page(
 696        self, instance_id: str, *, cursor: str = "", limit: int = 0
 697    ) -> InstanceTrafficPage:
 698        """One page of mitmproxy flows; the capture lives on the node's FS.
 699
 700        ⚠️ **The one method that must not use ``_parsed``.** A node from
 701        before paging answers with a bare *list* — it sent the whole
 702        capture and understands no cursor — and a strict
 703        ``model_validate`` turns that supported skew into a raised
 704        request. So the body goes through ``coerce_page``, which already
 705        owns every decision about an unexpected shape, and the caller
 706        gets the model.
 707
 708        This is the same move ``coerce_page`` was written for, one layer
 709        down. Its docstring used to say the parsing belonged
 710        "platform-side … so a route and this client cannot come to
 711        disagree" — and there were *three* platform-side calls doing it.
 712        There is one call now, at the seam that knows the request
 713        cursor, which is what the fallback needs; the routes hold the
 714        type instead of re-deciding its shape.
 715
 716        ⚠️ **An empty ``cursor`` means "from the beginning".** Passing
 717        one on every poll re-reads the whole capture, which is the cost
 718        the paged read exists to remove — arriving through the caller
 719        rather than through the node.
 720        """
 721        params: dict[str, Any] = {}
 722        if cursor:
 723            params["cursor"] = cursor
 724        if limit > 0:
 725            params["limit"] = limit
 726        resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params)
 727        resp.raise_for_status()
 728        page = coerce_page(resp.json(), cursor)
 729        return InstanceTrafficPage(
 730            flows=page.flows,
 731            cursor=page.cursor,
 732            truncated=page.truncated,
 733            capture=page.capture,
 734        )
 735
 736    def get_traffic_witnesses(
 737        self, instance_id: str, *, cursor: str = "", limit: int = 0
 738    ) -> InstanceTrafficWitnesses:
 739        """Who spoke to this box since *cursor*, with no bodies moved.
 740
 741        §7.1's correlation reads one gamebox per team per round. Through
 742        :meth:`get_traffic_page` that is every flow, both bodies
 743        included (~100 kB apiece by ``CLAUDE.md``'s measurement),
 744        pulled across the wire to extract a handful of addresses; the
 745        node reduces instead and this carries the result.
 746
 747        ⚠️ **Strict parsing here, unlike its neighbour, and that is the
 748        difference between an old field and an old route.**
 749        ``get_traffic_page`` is lenient because a node from before
 750        paging answers the *same* path with a bare list, which is
 751        supported skew. This route did not exist on such a node at all,
 752        so the honest answer from one is a 404 — and a lenient parse of
 753        an error body would hand the round loop an empty witness set,
 754        which reads as *nobody was there*. The caller catches the
 755        request error and records the round as unknown.
 756        """
 757        params: dict[str, Any] = {}
 758        if cursor:
 759            params["cursor"] = cursor
 760        if limit > 0:
 761            params["limit"] = limit
 762        resp = self.request("GET", f"/instances/{instance_id}/traffic-witnesses", params=params)
 763        resp.raise_for_status()
 764        return _parsed(InstanceTrafficWitnesses, resp)
 765
 766    def list_containers(self, instance_id: str) -> list[ComposeContainer]:
 767        """Enumerate every container in an instance's compose project.
 768
 769        Backs the admin-shell feature: the platform forwards a
 770        ``GET /admin/instances/{id}/containers`` to the assigned node,
 771        which returns one row per container (challenge services and
 772        platform-injected sidecars alike). The WebSocket reverse-proxy
 773        is opened separately and does not go through ``NodeClient``.
 774        """
 775        resp = self.request("GET", f"/instances/{instance_id}/containers")
 776        _raise_for_node_status(resp)
 777        return _parsed_list(ComposeContainer, resp)
 778
 779    def run_checker(
 780        self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30
 781    ) -> CheckResult:
 782        """Exec an author-supplied checker in a trusted judge sidecar.
 783
 784        Backs the checker / exec-judge feature: the platform forwards a
 785        verify request to the assigned node, which runs ``cmd`` inside the
 786        named sidecar and returns
 787        ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP
 788        timeout sits above the node's exec budget so the client doesn't
 789        abandon the request before the node returns.
 790        """
 791        resp = self.request(
 792            "POST",
 793            f"/instances/{instance_id}/check",
 794            json={"service": service, "cmd": cmd, "timeout_s": timeout_s},
 795            timeout=timeout_s + 10,
 796        )
 797        _raise_for_node_status(resp)
 798        return _parsed(CheckResult, resp)
 799
 800    def run_harness(
 801        self,
 802        *,
 803        image: str,
 804        run_id: str = "",
 805        env: dict[str, str],
 806        timeout_s: int = 1800,
 807        untrusted: bool = False,
 808        network: str = "",
 809        own_network: bool = False,
 810        match_network: str = "",
 811        match_address: str = "",
 812        brief: dict[str, Any] | None = None,
 813        target_instance: str = "",
 814        target_aliases: dict[str, str] | None = None,
 815        pull_ticket: str = "",
 816        limits: HarnessLimits | None = None,
 817    ) -> HarnessRun:
 818        """Run a one-shot evaluation-harness container on the node.
 819
 820        The node ``docker run``s ``image`` with ``env`` injected, waits for it
 821        to exit (bounded by ``timeout_s``), and returns
 822        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
 823        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
 824        per-call HTTP timeout sits above the node's run budget so the client
 825        doesn't abandon the request before the (long-running) harness returns.
 826
 827        ``untrusted`` runs the image under the node's isolation profile —
 828        off host networking, bounded resources, no capabilities — for an
 829        image the platform did not build. It requires a network: the
 830        node answers 400 without one, because "isolate this" and "I will
 831        not say where the target lives" do not compose.
 832
 833        Any of ``network`` (the target instance's own, when the caller
 834        knows it), ``own_network`` or ``target_instance`` satisfies that.
 835        ⚠️ Most eval dispatches need the second: there the harness
 836        *starts* the target partway through its own run, so at ``docker
 837        run`` time no target network exists and there is no name to
 838        send. The node creates a fresh bridge and removes it afterwards.
 839
 840        ``target_instance`` reverses that ordering, and is what an
 841        `engagement` uses: the platform starts the estate first and names
 842        it here, and the node wires its published containers to the
 843        agent's bridge. ⚠️ An **id**, never a network name — the name is
 844        a fact only the node's daemon can read, and ``docker run``
 845        *creates* whatever string it is handed, so a platform guessing
 846        one starts a run that reaches nothing.
 847        """
 848        resp = self.request(
 849            "POST",
 850            "/harness/run",
 851            json={
 852                "image": image,
 853                "env": env,
 854                "timeout_s": timeout_s,
 855                "untrusted": untrusted,
 856                "network": network,
 857                "own_network": own_network,
 858                "match_network": match_network,
 859                # ⚠️ **The platform-assigned arena address.** The node
 860                # connects the agent to the match network with this
 861                # ``--ip``, so the address is known before the round loop
 862                # asks (§7.1). Empty means "let docker's IPAM pick" — an
 863                # older platform's behaviour, and what keeps a node reading
 864                # this field unchanged for every non-arena dispatch.
 865                "match_address": match_address,
 866                # ⚠️ Sent even when empty, so a node reading it gets the
 867                # same shape either way; the node's default is what makes
 868                # an older platform's dispatch unchanged.
 869                "pull_ticket": pull_ticket,
 870                # ⚠️ ``None`` leaves the node's own defaults in force, which is
 871                # what a dispatch said before the ceilings were settings.
 872                "limits": limits.model_dump() if limits is not None else None,
 873                # ⚠️ Same rule, and empty is a real answer here too: a
 874                # dispatch with no run row (the conformance probe, a dry
 875                # run) has nothing to label, and a label with an empty
 876                # value would make every one of them answer one query.
 877                "run_id": run_id,
 878                # ⚠️ Sent as the already-built manifest. The platform is
 879                # the side that knows the challenge, the competition and
 880                # the run mode; a node deriving its own would be a
 881                # second implementation of a published contract.
 882                "brief": brief,
 883                "target_instance": target_instance,
 884                # ⚠️ **The names the brief published, so they resolve on
 885                # the agent's bridge too.** An estate's hosts answer to a
 886                # per-instance FQDN minted by its own resolver; joined to
 887                # a new network a container answers only to its compose
 888                # *service* name. Sending the mapping is what keeps the
 889                # address the agent was told about true under either
 890                # placement — without it the agent is handed a hostname
 891                # that does not resolve, reaches nothing, and reports an
 892                # empty result, which reads as a model that gave up.
 893                #
 894                # ⚠️ The platform's to send: it computed the surface. A
 895                # node re-deriving it from the rendered compose would be
 896                # a second implementation of the address the brief has
 897                # already promised.
 898                "target_aliases": target_aliases or {},
 899            },
 900            timeout=timeout_s + 30,
 901        )
 902        _raise_for_node_status(resp)
 903        return _parsed(HarnessRun, resp)
 904
 905    def cancel_harness(self, *, run_id: str) -> int:
 906        """Stop whatever this node is running for ``run_id``; returns how many.
 907
 908        Until this existed a dispatched container was reclaimed by
 909        **nothing but its own timeout** — ``_harness_timeout``'s docstring
 910        says exactly that — so an operator pressing cancel on a
 911        match-long run was asking for something the platform had no way
 912        to deliver.
 913
 914        ⚠️ **Zero is an ordinary answer, not a failure.** The run may have
 915        finished on its own, been dispatched to a different node, or been
 916        lost to a node restart. A caller that read zero as an error would
 917        report a broken platform every time a cancel arrived a second
 918        late.
 919        """
 920        resp = self.request("POST", "/harness/cancel", json={"run_id": run_id})
 921        body = resp.json()
 922        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
 923
 924    def verify_patch(
 925        self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900
 926    ) -> PatchVerifyResult:
 927        """Build a submitted patch on the node and return its verdict.
 928
 929        ``files`` maps a challenge-relative path to its new bytes; base64
 930        is applied here because a patch target may legitimately be
 931        binary. Returns ``{verdict, detail, applied, steps, error}``.
 932
 933        **An empty ``verdict`` with a populated ``error`` is not a
 934        judgement** — it means the node refused the submission or the
 935        verifier crashed. The caller must retry or retire the lease
 936        rather than write a score, since failing to judge a patch is not
 937        the same as judging it unfixed.
 938
 939        The per-call HTTP timeout sits above the node's own budget so the
 940        client doesn't abandon the request while the node is still
 941        building.
 942        """
 943        resp = self.request(
 944            "POST",
 945            "/patches/verify",
 946            json={
 947                "challenge_id": challenge_id,
 948                "files": {
 949                    path: base64.b64encode(content).decode() for path, content in files.items()
 950                },
 951                "timeout_s": timeout_s,
 952            },
 953            timeout=timeout_s + 60,
 954        )
 955        _raise_for_node_status(resp)
 956        return _parsed(PatchVerifyResult, resp)
 957
 958    def collect_patch_files(
 959        self, instance_id: str, service: str, paths: list[str]
 960    ) -> dict[str, bytes]:
 961        """Read a player's SSH edits back out of their running box.
 962
 963        ``paths`` are absolute in-container paths the *platform* derived
 964        from the pristine source tree. Returns only what could be read:
 965        a path missing from the reply means the player did not change it,
 966        and the caller falls back to the shipped file rather than
 967        recording a deletion they never made.
 968        """
 969        resp = self.request(
 970            "POST",
 971            f"/instances/{instance_id}/patch-collect",
 972            json={"service": service, "paths": paths},
 973        )
 974        _raise_for_node_status(resp)
 975        body: dict[str, str] = resp.json()
 976        return {path: base64.b64decode(blob) for path, blob in body.items()}
 977
 978    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
 979        """Rotate a round's flags into every box of this node, in one call.
 980
 981        ⚠️ **One call for the whole node, never one per box.** §5.2's
 982        capacity red line is 1000 teams x 3 services injected inside
 983        120 s; per-box that is 3000 round trips, batched it is about
 984        fourteen. The signature is the enforcement — a caller cannot
 985        accidentally loop.
 986
 987        Returns ``{instance_id: ""}`` for the boxes written and a reason
 988        for the ones that were not, because one team's unreachable box
 989        must not cost every other team on the node its rotation. An
 990        unrotated box keeps serving last round's flag, which anyone
 991        holding the old value can replay.
 992        """
 993        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
 994        _raise_for_node_status(resp)
 995        result: dict[str, str] = resp.json()
 996        return result
 997
 998    def probe_awd_service(
 999        self,
1000        *,
1001        instance_id: str,
1002        service_id: str,
1003        tick: int,
1004        flag: str,
1005        previous_flag: str = "",
1006        previous_flag_id: str = "",
1007    ) -> dict[str, str]:
1008        """Run one round's SLA probes against one box, from outside it.
1009
1010        The flags travel *to* the node: a checker that sourced its
1011        expected value from the box it is checking would pass on any box
1012        that returns whatever it was handed.
1013        """
1014        resp = self.request(
1015            "POST",
1016            "/awd/probe",
1017            json={
1018                "instance_id": instance_id,
1019                "service_id": service_id,
1020                "tick": tick,
1021                "flag": flag,
1022                "previous_flag": previous_flag,
1023                "previous_flag_id": previous_flag_id,
1024            },
1025        )
1026        _raise_for_node_status(resp)
1027        result: dict[str, str] = resp.json()
1028        return result
1029
1030    def attack_awd_box(
1031        self,
1032        *,
1033        instance_id: str,
1034        tick: int,
1035        timeout_s: int = 60,
1036        agent_image: str = "",
1037        counter_flag: str = "",
1038        forensic_marker: str = "",
1039        llm_api_key: str = "",
1040    ) -> AwdAttackResult:
1041        """Run this box's own reference exploit against it, once.
1042
1043        Per **box**, not per service: one `run_exploit` discovers every
1044        answer the challenge declares and prints them all.
1045
1046        ``timeout_s`` is sent because the platform is what knows how long
1047        a round is; the node clamps it, because a container held open for
1048        an hour is the node's problem whoever asked for it.
1049
1050        ``forensic_marker`` is §4.3.1's, carried only by the agent
1051        branch: `build_attack_argv` passes no environment at all, so a
1052        marker on the reference exploit would be the one value that
1053        voids `EXPLOIT007`'s guarantee.
1054
1055        ``agent_image`` + ``counter_flag`` switch the node to §4.2b's
1056        agent attacker. ⚠️ **Both or neither** — the node refuses a half
1057        request rather than falling back to the reference exploit, which
1058        would run a different format than the one configured while the
1059        board looked entirely normal.
1060        """
1061        resp = self.request(
1062            "POST",
1063            "/awd/attack",
1064            json={
1065                "instance_id": instance_id,
1066                "tick": tick,
1067                "timeout_s": timeout_s,
1068                "agent_image": agent_image,
1069                "counter_flag": counter_flag,
1070                "forensic_marker": forensic_marker,
1071                "llm_api_key": llm_api_key,
1072            },
1073        )
1074        _raise_for_node_status(resp)
1075        return _parsed(AwdAttackResult, resp)
1076
1077    def get_container_logs(self, instance_id: str) -> str:
1078        """Fetch combined container stdout/stderr for archival."""
1079        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
1080        resp.raise_for_status()
1081        return str(resp.json().get("logs") or "")
1082
1083    def get_pcap(self, instance_id: str) -> PcapCapture:
1084        """Fetch the merged tcpdump capture for *instance_id*.
1085
1086        ``data`` is empty when no capture exists (sidecar disabled,
1087        instance never reached the running state, etc.) so callers can
1088        persist conditionally without try/except.
1089
1090        ⚠️ **It also says what the per-instance cap already dropped**
1091        (#1714), off the node's response headers, and ``None`` when the
1092        node did not say — an older node, which is *not reported*, never
1093        a clean capture.
1094        """
1095        resp = self.request("GET", f"/instances/{instance_id}/pcap")
1096        if resp.status_code == 404:
1097            return PcapCapture(data=b"")
1098        resp.raise_for_status()
1099        return PcapCapture(
1100            data=resp.content,
1101            dropped_bytes=_header_int(resp, CAPTURE_DROPPED_BYTES_HEADER),
1102            dropped_segments=_header_int(resp, CAPTURE_DROPPED_SEGMENTS_HEADER),
1103        )
1104
1105    def get_harness_artifact(self, artifact_id: str) -> bytes:
1106        """One finished run's collected archive, or ``b""``.
1107
1108        ⚠️ **Empty rather than an exception for a 404**, following
1109        :meth:`get_pcap`. The node reaps uncollected archives, and a
1110        platform that comes back after a restart is exactly the caller
1111        that meets one that is gone — a state its own record already
1112        describes, not a transport failure.
1113        """
1114        resp = self.request("GET", f"/harness/artifacts/{artifact_id}")
1115        if resp.status_code == 404:
1116            return b""
1117        resp.raise_for_status()
1118        return resp.content
1119
1120    def delete_harness_artifact(self, artifact_id: str) -> bool:
1121        """Tell the node the archive has been stored and can go.
1122
1123        ⚠️ **Never raises on a transport failure**, because it is called
1124        after the bytes are safely in object storage: turning a cleanup
1125        error into the run's error would replace a success with a
1126        janitorial complaint. The node's reaper collects whatever this
1127        misses.
1128        """
1129        try:
1130            resp = self.request("DELETE", f"/harness/artifacts/{artifact_id}")
1131        except Exception:
1132            return False
1133        return resp.status_code == 200
1134
1135    def get_agent_runtime(self, instance_id: str) -> AgentRuntime:
1136        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
1137        CA PEM, optional Docker-specific names). Replaces the old
1138        sandbox-network shape."""
1139        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
1140        resp.raise_for_status()
1141        return _parsed(AgentRuntime, resp)
1142
1143    # -- per-instance rendered attachments ---------------------------------
1144
1145    def list_instance_attachments(self, instance_id: str) -> AttachmentList:
1146        """List per-instance attachments rendered into the node's workdir.
1147
1148        Backs the platform's per-instance attachment endpoint, which
1149        surfaces the team-specific rendered file listing.
1150
1151        ⚠️ This used to hand back the raw dict, under a docstring saying
1152        "caller projects through :class:`AttachmentList` for typing" —
1153        the node's route already declared that model, so the type existed
1154        and this method's signature was the one place it was dropped.
1155        Projecting *here* means there is one place a malformed answer is
1156        caught rather than one per caller."""
1157        resp = self.request("GET", f"/instances/{instance_id}/attachments")
1158        resp.raise_for_status()
1159        return _parsed(AttachmentList, resp)
1160
1161    def get_vpn_config(self, instance_id: str) -> bytes:
1162        """Fetch the per-instance VPN client config from the node.
1163
1164        The node reads the backend's client config out of the
1165        instance's VPN service container (generated on first boot by
1166        the base image's bootstrap) and rewrites its endpoint to the
1167        node's public host:port. Returns the body verbatim — the
1168        platform-side route adds the ``Content-Disposition`` header
1169        before re-emitting to the player, because only it knows the
1170        backend and therefore the filename the player needs.
1171
1172        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1173        proxy route catches 404 and re-emits to the player, anything
1174        else is treated as a 502.
1175        """
1176        resp = self.request("GET", f"/instances/{instance_id}/vpn-config")
1177        resp.raise_for_status()
1178        return resp.content
1179
1180    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1181        """Download one per-instance attachment as ``(bytes, content_type)``.
1182
1183        Buffers the whole body — attachments are bounded (typical case
1184        is a 10 KB binary or a small text file). For huge captures the
1185        caller should hand the player a CDN URL instead.
1186
1187        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1188        proxy route catches 404 and re-emits to the player, anything
1189        else is a 502."""
1190        resp = self.request(
1191            "GET",
1192            f"/instances/{instance_id}/attachments/{filename}",
1193        )
1194        resp.raise_for_status()
1195        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, transport: httpx.BaseTransport | None = None)
195    def __init__(
196        self,
197        node_url: str,
198        token: str,
199        *,
200        timeout: int = DEFAULT_CLIENT_TIMEOUT,
201        transport: httpx.BaseTransport | None = None,
202    ) -> None:
203        """*transport* is `BaseHttpClient`'s own seam, passed through so a
204        test can drive a real node app over the real client — the hop the
205        fakes on either side of it cannot see."""
206        super().__init__(
207            f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout, transport=transport
208        )

transport is BaseHttpClient's own seam, passed through so a test can drive a real node app over the real client — the hop the fakes on either side of it cannot see.

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, open_shell_password: str = '', authored_revision: str = '') -> ctfy.server.models.node.InstanceStarted:
212    def start_instance(
213        self,
214        *,
215        challenge_id: str,
216        instance_id: str,
217        ttl: int,
218        answers: dict[str, str],
219        proxy_output_dir: str | None = None,
220        env: dict[str, str] | None = None,
221        publish_gamebox: bool = False,
222        match_network: MatchNetwork | None = None,
223        open_shell_password: str = "",
224        authored_revision: str = "",
225    ) -> InstanceStarted:
226        resp = self.request(
227            "POST",
228            "/instances",
229            json={
230                # #1835: the authored tree's revision this start must run;
231                # a node holding another (or none) fetches it first.
232                "authored_revision": authored_revision,
233                "challenge_id": challenge_id,
234                "instance_id": instance_id,
235                "ttl": ttl,
236                "answers": answers,
237                "proxy_output_dir": proxy_output_dir,
238                "env": env or {},
239                "publish_gamebox": publish_gamebox,
240                # ``None`` rather than an omitted key: the node's model
241                # defaults to None either way, and sending it explicitly
242                # keeps the body's shape stable across both branches.
243                "match_network": (dataclasses.asdict(match_network) if match_network else None),
244                # Classic-AWD open shell (#1725): the box's starting root
245                # password, empty for every managed / non-AWD start.
246                "open_shell_password": open_shell_password,
247            },
248        )
249        _raise_for_node_status(resp)
250        return _parsed(InstanceStarted, resp)
def stop_instance(self, instance_id: str) -> None:
252    def stop_instance(self, instance_id: str) -> None:
253        resp = self.request("DELETE", f"/instances/{instance_id}")
254        resp.raise_for_status()
def apply_fabric( self, *, index: int, interface: dict[str, typing.Any], match_network: str = '') -> ctfy.server.models.node.FabricStatus:
256    def apply_fabric(
257        self,
258        *,
259        index: int,
260        interface: dict[str, Any],
261        match_network: str = "",
262    ) -> FabricStatus:
263        """Put this node on one match's WireGuard fabric.
264
265        ⚠️ **Returns the model, never ``resp.json()``.** The platform
266        reads the verdict field by field, and on a dict a renamed field
267        is ``None`` — falsy — which reads as "the arena did not open",
268        so a wire change would quietly report every node as broken.
269
270        Returns the node's verdict rather than raising on a refusal: the
271        route answers **200 with a body** precisely so that "this node is
272        unreachable" and "this node rejected the plan" stay different
273        facts, and a caller fanning out over an arena needs to tell them
274        apart — one is waited on, the other is looked at.
275
276        *match_network* lets the node check the tunnel landed in the
277        namespace its boxes are in, and open the bridge's one hole. It is
278        the platform's own fact (`awd_network.network_name`), so the node
279        is told rather than guessing.
280        """
281        resp = self.request(
282            "POST",
283            "/fabric",
284            json={
285                "index": index,
286                "interface": interface,
287                "match_network": match_network,
288            },
289        )
290        resp.raise_for_status()
291        return _parsed(FabricStatus, resp)

Put this node on one match's WireGuard fabric.

⚠️ Returns the model, never resp.json(). The platform reads the verdict field by field, and on a dict a renamed field is None — falsy — which reads as "the arena did not open", so a wire change would quietly report every node as broken.

Returns the node's verdict rather than raising on a refusal: the route answers 200 with a body precisely so that "this node is unreachable" and "this node rejected the plan" stay different facts, and a caller fanning out over an arena needs to tell them apart — one is waited on, the other is looked at.

match_network lets the node check the tunnel landed in the namespace its boxes are in, and open the bridge's one hole. It is the platform's own fact (awd_network.network_name), so the node is told rather than guessing.

def probe_fabric( self, *, index: int, peers: list[dict[str, str]], mtu: int = 0) -> ctfy.server.models.node.FabricProbeReport:
293    def probe_fabric(
294        self,
295        *,
296        index: int,
297        peers: list[dict[str, str]],
298        mtu: int = 0,
299    ) -> FabricProbeReport:
300        """Ask named peers the three link questions. Applies nothing.
301
302        ⚠️ **Returns the model, never ``resp.json()``** — same reason
303        `apply_fabric` gives: on a dict a renamed field reads as falsy,
304        which here means "your tunnel is down" about a wire change.
305        """
306        resp = self.request(
307            "POST",
308            "/fabric/probe",
309            json={"index": index, "peers": peers, "mtu": mtu},
310        )
311        resp.raise_for_status()
312        return _parsed(FabricProbeReport, resp)

Ask named peers the three link questions. Applies nothing.

⚠️ Returns the model, never resp.json() — same reason apply_fabric gives: on a dict a renamed field reads as falsy, which here means "your tunnel is down" about a wire change.

def remove_fabric( self, *, index: int, match_network: str = '') -> ctfy.server.models.node.FabricStatus:
314    def remove_fabric(self, *, index: int, match_network: str = "") -> FabricStatus:
315        """Take this node off one match's fabric and close its hole.
316
317        ⚠️ *match_network* is what lets the node resolve the bridge and
318        delete the ``DOCKER-USER`` rules by name. Without it the tunnel
319        goes and the hole stays — open on whatever docker gives that
320        bridge name next.
321        """
322        resp = self.request(
323            "DELETE",
324            f"/fabric/{index}",
325            params={"match_network": match_network},
326        )
327        resp.raise_for_status()
328        return _parsed(FabricStatus, resp)

Take this node off one match's fabric and close its hole.

⚠️ match_network is what lets the node resolve the bridge and delete the DOCKER-USER rules by name. Without it the tunnel goes and the hole stays — open on whatever docker gives that bridge name next.

def stop_instance_service(self, instance_id: str, service: str) -> int:
330    def stop_instance_service(self, instance_id: str, service: str) -> int:
331        """Stop one compose service, leaving the instance running.
332
333        The exercise format's session close (§4b.4). Returns how many
334        containers the node stopped — 0 means the challenge declares no
335        such service, which is a real answer rather than a failure, so
336        the caller can distinguish it from a cut tunnel.
337        """
338        resp = self.request(
339            "POST",
340            f"/instances/{instance_id}/stop-service",
341            json={"service": service},
342        )
343        resp.raise_for_status()
344        body = resp.json()
345        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:
347    def stop_all(self) -> None:
348        resp = self.request("POST", "/admin/stop-all")
349        resp.raise_for_status()
def put_authored_challenge( self, push: ctfy.server.models.NodeAuthoredPush) -> ctfy.server.models.NodeAuthoredResult:
351    def put_authored_challenge(self, push: NodeAuthoredPush) -> NodeAuthoredResult:
352        """Ask the node to write an approved player upload into its catalog.
353
354        ⚠️ **The bytes travel here, unlike `put_generated_range`.** That
355        one sends a scenario because the node can compile it; a
356        player-authored tree has no compiler and no corpus entry, so the
357        archive is what there is to send.
358
359        The node answers **200 with ``ok: false``** for a refusal, so a
360        node that declined the archive and a node that could not be
361        reached stay different facts; only the second raises here.
362        """
363        resp = self.request("POST", "/admin/authored", json=push.model_dump(mode="json"))
364        resp.raise_for_status()
365        return _parsed(NodeAuthoredResult, resp)

Ask the node to write an approved player upload into its catalog.

⚠️ The bytes travel here, unlike put_generated_range. That one sends a scenario because the node can compile it; a player-authored tree has no compiler and no corpus entry, so the archive is what there is to send.

The node answers 200 with ok: false for a refusal, so a node that declined the archive and a node that could not be reached stay different facts; only the second raises here.

def smoke_authored_challenge( self, push: ctfy.server.models.NodeAuthoredSmokePush, *, timeout: int | None = None) -> ctfy.server.models.NodeAuthoredSmokeResult:
367    def smoke_authored_challenge(
368        self, push: NodeAuthoredSmokePush, *, timeout: int | None = None
369    ) -> NodeAuthoredSmokeResult:
370        """Ask the node to run an authored challenge's own reference exploit.
371
372        ⚠️ **Takes its own timeout.** A smoke run brings a whole estate
373        up; the client default is sized for a status call and would give
374        up while the node is still doing what it was asked.
375        """
376        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
377        if timeout is not None:
378            kwargs["timeout"] = timeout
379        resp = self.request("POST", "/admin/authored/smoke", **kwargs)
380        resp.raise_for_status()
381        return _parsed(NodeAuthoredSmokeResult, resp)

Ask the node to run an authored challenge's own reference exploit.

⚠️ Takes its own timeout. A smoke run brings a whole estate up; the client default is sized for a status call and would give up while the node is still doing what it was asked.

def put_generated_range( self, push: ctfy.server.models.NodeRangePush) -> ctfy.server.models.NodeRangeResult:
383    def put_generated_range(self, push: NodeRangePush) -> NodeRangeResult:
384        """Ask the node to build a generated range from its scenario.
385
386        ⚠️ **The scenario travels, not the estate.** The corpus is
387        mounted on the node and the compiler ships inside it, so 70-odd
388        rendered files never cross this API — and the corpus commit that
389        rides along is what stops one scenario becoming two different
390        estates under one id across a fleet.
391
392        The node answers **200 with ``ok: false``** for a refusal, so a
393        node that declined and a node that could not be reached stay
394        different facts; only the second raises here.
395        """
396        resp = self.request("POST", "/admin/ranges", json=push.model_dump(mode="json"))
397        resp.raise_for_status()
398        return _parsed(NodeRangeResult, resp)

Ask the node to build a generated range from its scenario.

⚠️ The scenario travels, not the estate. The corpus is mounted on the node and the compiler ships inside it, so 70-odd rendered files never cross this API — and the corpus commit that rides along is what stops one scenario becoming two different estates under one id across a fleet.

The node answers 200 with ok: false for a refusal, so a node that declined and a node that could not be reached stay different facts; only the second raises here.

def verify_generated_range( self, push: ctfy.server.models.NodeRangeVerifyPush, *, timeout: int | None = None) -> ctfy.server.models.NodeRangeVerifyResult:
400    def verify_generated_range(
401        self, push: NodeRangeVerifyPush, *, timeout: int | None = None
402    ) -> NodeRangeVerifyResult:
403        """Ask the node to build a scenario, solve it, and take it down.
404
405        ⚠️ **The longest call in this API**, so the caller passes its own
406        timeout: the node's budget is ``compose_timeout_s +
407        solve_timeout_s`` and a client that used the default would give
408        up while the node was still building — then retry, and start a
409        second estate beside the first.
410
411        ⚠️ A client timeout does **not** leave a range running: the node
412        tears down in its own ``finally``. What is lost is the answer,
413        not the host.
414
415        The node answers **200 with an ``error``** for a refusal, the
416        same split ``put_generated_range`` uses; only an unreachable node
417        raises.
418        """
419        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
420        if timeout is not None:
421            # httpx takes a per-request override; the client-wide value
422            # stays whatever the caller constructed this with.
423            kwargs["timeout"] = timeout
424        resp = self.request("POST", "/admin/ranges/verify", **kwargs)
425        resp.raise_for_status()
426        return _parsed(NodeRangeVerifyResult, resp)

Ask the node to build a scenario, solve it, and take it down.

⚠️ The longest call in this API, so the caller passes its own timeout: the node's budget is compose_timeout_s + solve_timeout_s and a client that used the default would give up while the node was still building — then retry, and start a second estate beside the first.

⚠️ A client timeout does not leave a range running: the node tears down in its own finally. What is lost is the answer, not the host.

The node answers 200 with an error for a refusal, the same split put_generated_range uses; only an unreachable node raises.

def cancel_generated_range_verify(self, run_id: str) -> bool:
428    def cancel_generated_range_verify(self, run_id: str) -> bool:
429        """Tell the node to stop a verification it is running.
430
431        ⚠️ **A miss is success, not a 404 to report.** The cancel races
432        the run's own finish, so "no such run" means the run already
433        ended — which is what the operator asked for. The node answers
434        ``{"cancelled": false}`` for that case rather than raising, and
435        this returns it so a caller that wants to say "it had already
436        finished" can.
437        """
438        resp = self.request("POST", f"/admin/ranges/verify/{run_id}/cancel")
439        resp.raise_for_status()
440        body = resp.json()
441        return bool(body.get("cancelled"))

Tell the node to stop a verification it is running.

⚠️ A miss is success, not a 404 to report. The cancel races the run's own finish, so "no such run" means the run already ended — which is what the operator asked for. The node answers {"cancelled": false} for that case rather than raising, and this returns it so a caller that wants to say "it had already finished" can.

def rescan_challenges(self) -> ctfy.core.challenge.ChallengeReloadResult:
443    def rescan_challenges(self) -> ChallengeReloadResult:
444        """Tell the node to drop its spec cache and re-scan challenges_dir.
445
446        Returns ``{total, added, removed}`` so the platform can report
447        the per-node outcome of a cluster-wide rescan."""
448        resp = self.request("POST", "/admin/rescan-challenges")
449        resp.raise_for_status()
450        return _parsed(ChallengeReloadResult, resp)

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, *, authored_revision: str = '') -> ctfy.server.node_state.ChallengeBuildState:
454    def build_challenge(
455        self, challenge_id: str, *, authored_revision: str = ""
456    ) -> ChallengeBuildState:
457        """Ask the node to pre-build images for *challenge_id*.
458
459        Fires-and-returns: the node persists ``status="building"`` and
460        spawns a daemon thread; the body returned here is that initial
461        state row. Polling :meth:`get_build_state` is how the platform
462        learns when it lands on ``built`` / ``failed``. *authored_revision*
463        is #1835's: the tree the build must be of, fetched if not held.
464        """
465        params = {"authored_revision": authored_revision} if authored_revision else None
466        resp = self.request("POST", f"/admin/challenges/{challenge_id}/build", params=params)
467        _raise_for_node_status(resp)
468        return _parsed(ChallengeBuildState, resp)

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. authored_revision is #1835's: the tree the build must be of, fetched if not held.

def build_all_challenges( self, challenge_ids: list[str] | None = None) -> ctfy.server.models.node.BuildAllQueued:
470    def build_all_challenges(self, challenge_ids: list[str] | None = None) -> BuildAllQueued:
471        """Queue background pre-build on the node.
472
473        With ``challenge_ids=None`` the node builds every spec it knows.
474        With an explicit list (the platform scoping a bulk build to a
475        competition's challenge set) only those are built. Returns
476        ``{queued, skipped_built, skipped_in_progress}`` so the platform
477        can report per-node what got picked up. Sequential on the node
478        side — no fan-out across the corpus.
479        """
480        kwargs: dict[str, Any] = {}
481        if challenge_ids is not None:
482            kwargs["json"] = {"challenge_ids": challenge_ids}
483        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
484        _raise_for_node_status(resp)
485        return _parsed(BuildAllQueued, resp)

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 build_push_challenge( self, challenge_id: str, *, push_ticket: str = '', timeout_s: int = 1800, cleanup: bool = False, cache_max_bytes: int = 0, confine_network: bool = False, authored_revision: str = '') -> ctfy.core.prebuild_tag.ChallengeFreezeResult:
487    def build_push_challenge(
488        self,
489        challenge_id: str,
490        *,
491        push_ticket: str = "",
492        timeout_s: int = 1800,
493        cleanup: bool = False,
494        cache_max_bytes: int = 0,
495        confine_network: bool = False,
496        authored_revision: str = "",
497    ) -> ChallengeFreezeResult:
498        """Build one challenge's ``build:`` services and freeze them to the registry.
499
500        Layer 2 producer (P0). **Blocks** until the challenge is built +
501        pushed (or skipped as already frozen), unlike the fire-and-return
502        :meth:`build_challenge`, so the per-call HTTP timeout sits above
503        the node's build budget. Returns the per-service freeze result;
504        the platform's ``challenge_build_push`` task calls this one
505        challenge at a time. *push_ticket* is the platform's short-lived
506        registry credential the node's ``docker push`` authenticates with
507        — the node runs it in a container with no ambient login.
508        *cleanup* asks the node to remove what the build left and prune its
509        build cache to *cache_max_bytes* afterwards, and *confine_network*
510        keeps every ``RUN`` off the host's network (both the freeze pool, #1827).
511        """
512        resp = self.request(
513            "POST",
514            f"/admin/challenges/{challenge_id}/build-push",
515            json={
516                "push_ticket": push_ticket,
517                "cleanup": cleanup,
518                "cache_max_bytes": cache_max_bytes,
519                "confine_network": confine_network,
520                "authored_revision": authored_revision,
521            },
522            timeout=timeout_s + 60,
523        )
524        _raise_for_node_status(resp)
525        return _parsed(ChallengeFreezeResult, resp)

Build one challenge's build: services and freeze them to the registry.

Layer 2 producer (P0). Blocks until the challenge is built + pushed (or skipped as already frozen), unlike the fire-and-return build_challenge(), so the per-call HTTP timeout sits above the node's build budget. Returns the per-service freeze result; the platform's challenge_build_push task calls this one challenge at a time. push_ticket is the platform's short-lived registry credential the node's docker push authenticates with — the node runs it in a container with no ambient login. cleanup asks the node to remove what the build left and prune its build cache to cache_max_bytes afterwards, and confine_network keeps every RUN off the host's network (both the freeze pool, #1827).

def get_disk_free(self) -> ctfy.server.models.node.NodeDiskFree:
527    def get_disk_free(self) -> NodeDiskFree:
528        """Free space on the node's data filesystem, read now (#1827).
529
530        Raises on any failure — including a node predating the route, which
531        answers 404 — and the freeze reads every failure as *cannot tell*,
532        which stops it.
533        """
534        resp = self.request("GET", "/admin/disk")
535        _raise_for_node_status(resp)
536        return _parsed(NodeDiskFree, resp)

Free space on the node's data filesystem, read now (#1827).

Raises on any failure — including a node predating the route, which answers 404 — and the freeze reads every failure as cannot tell, which stops it.

def get_build_state(self) -> ctfy.server.models.node.BuildStateRows:
538    def get_build_state(self) -> BuildStateRows:
539        """Fetch every per-challenge build-state row on this node.
540
541        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
542        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
543        ``failed``; ``unbuilt`` placeholders are synthesised for specs
544        the node has seen but never been asked to build.
545        """
546        resp = self.request("GET", "/admin/challenges/build-state")
547        _raise_for_node_status(resp)
548        return _parsed(BuildStateRows, resp)

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) -> ctfy.server.node_state.ChallengePullState:
550    def pull_challenge(self, challenge_id: str) -> ChallengePullState:
551        """Ask the node to pre-pull registry images for *challenge_id*.
552
553        Pull-side twin of :meth:`build_challenge`: the node persists
554        ``status="pulling"`` and spawns a daemon thread; poll
555        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
556        """
557        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
558        _raise_for_node_status(resp)
559        return _parsed(ChallengePullState, resp)

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) -> ctfy.server.models.node.PullAllQueued:
561    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> PullAllQueued:
562        """Queue background pre-pull on the node.
563
564        With ``challenge_ids=None`` the node pulls every spec it knows;
565        with an explicit list only those (the platform scoping to a
566        competition). Returns ``{queued, skipped_pulled,
567        skipped_in_progress}``.
568        """
569        kwargs: dict[str, Any] = {}
570        if challenge_ids is not None:
571            kwargs["json"] = {"challenge_ids": challenge_ids}
572        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
573        _raise_for_node_status(resp)
574        return _parsed(PullAllQueued, resp)

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) -> ctfy.server.models.node.PullStateRows:
576    def get_pull_state(self) -> PullStateRows:
577        """Fetch every per-challenge pull-state row on this node.
578
579        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
580        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
581        ``failed``; ``unpulled`` placeholders are synthesised for specs
582        the node has seen but never been asked to pull.
583        """
584        resp = self.request("GET", "/admin/challenges/pull-state")
585        _raise_for_node_status(resp)
586        return _parsed(PullStateRows, resp)

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) -> ctfy.server.models.node.InstanceStatusReport:
590    def get_status(self, instance_id: str) -> InstanceStatusReport:
591        resp = self.request("GET", f"/instances/{instance_id}/status")
592        resp.raise_for_status()
593        return _parsed(InstanceStatusReport, resp)
def list_instances(self) -> list[ctfy.server.models.node.NodeInstanceSummary]:
595    def list_instances(self) -> list[NodeInstanceSummary]:
596        """Every instance the node still has bookkeeping for.
597
598        Used by the platform's boot re-adoption pass to decide which
599        durable claims still have containers behind them. ``get_status``
600        answers the same question one id at a time; asking it per claim
601        would cost one round trip per instance to a node that may be
602        slow or down, on the path that gates startup.
603        """
604        resp = self.request("GET", "/instances")
605        resp.raise_for_status()
606        return _parsed(NodeInstanceList, resp).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:
608    def check_health(self, instance_id: str) -> bool:
609        return self.check_health_verbose(instance_id).healthy
def check_health_verbose(self, instance_id: str) -> HealthReading:
611    def check_health_verbose(self, instance_id: str) -> HealthReading:
612        """Health plus whatever agent-deception telemetry the node read.
613
614        One call, because the platform already polls this endpoint for
615        every live instance every tick and a second round trip per
616        instance would be the whole cost of the feature. ``check_health``
617        stays a bool for the callers that only ever wanted one.
618
619        Never an error, whatever arrives: this rides the health poll,
620        and telemetry must not be able to mark a healthy instance
621        unhealthy.
622
623        ⚠️ **The two ways this can come back empty are different facts
624        and used to be one.** An older node sends no ``deception`` key
625        at all — it *did not say*, which is ``channel=None`` and
626        authorises nothing. A body this platform cannot parse is
627        something else: the node did say, and we could not read it. That
628        is ``unreadable``, the same value the reader gives for a missing
629        file, because it is the same fact — there is no usable channel
630        here — arriving through a different door.
631
632        The parse failure that matters is version skew in the other
633        direction: a **newer** node reporting an ``ObservationKind`` this
634        platform does not have. ``Observation.kind`` is typed, so one
635        unknown member fails the whole model rather than dropping one
636        row — which is the right refusal (the node's word is not ours to
637        edit) and exactly why it must not land as "reported nothing".
638        """
639        resp = self.request("GET", f"/instances/{instance_id}/health")
640        resp.raise_for_status()
641        body = resp.json()
642        raw = body.get("deception")
643        if not raw:
644            summary = DeceptionSummary()
645        else:
646            try:
647                summary = DeceptionSummary.model_validate(raw)
648            except ValidationError as exc:
649                log.info(
650                    "unreadable deception telemetry from node",
651                    instance_id=instance_id,
652                    error=tail(str(exc), limit=200),
653                )
654                summary = DeceptionSummary(channel="unreadable")
655        raw_contact = body.get("contact")
656        if not raw_contact:
657            contact = ContactReading()
658        else:
659            try:
660                contact = ContactReading.model_validate(raw_contact)
661            except ValidationError as exc:
662                log.info(
663                    "unreadable contact telemetry from node",
664                    instance_id=instance_id,
665                    error=tail(str(exc), limit=200),
666                )
667                contact = ContactReading(channel="unreadable")
668        raw_procs = body.get("new_processes")
669        # ⚠️ Shape-checked rather than trusted: an older node sends
670        # nothing and a newer one could send a shape this platform does
671        # not model, and neither may reach a reader as *a container full
672        # of intruders*. Anything unreadable is silence, which for a
673        # signal built to under-report is the honest reading.
674        procs: dict[str, list[str]] = {}
675        if isinstance(raw_procs, dict):
676            for container, names in raw_procs.items():
677                if isinstance(container, str) and isinstance(names, list):
678                    procs[container] = [str(n) for n in names]
679        return HealthReading(
680            healthy=bool(body.get("is_healthy")),
681            deception=summary,
682            contact=contact,
683            new_processes=procs,
684        )

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.

Never an error, whatever arrives: this rides the health poll, and telemetry must not be able to mark a healthy instance unhealthy.

⚠️ The two ways this can come back empty are different facts and used to be one. An older node sends no deception key at all — it did not say, which is channel=None and authorises nothing. A body this platform cannot parse is something else: the node did say, and we could not read it. That is unreadable, the same value the reader gives for a missing file, because it is the same fact — there is no usable channel here — arriving through a different door.

The parse failure that matters is version skew in the other direction: a newer node reporting an ObservationKind this platform does not have. Observation.kind is typed, so one unknown member fails the whole model rather than dropping one row — which is the right refusal (the node's word is not ours to edit) and exactly why it must not land as "reported nothing".

def node_health(self) -> dict[str, typing.Any]:
686    def node_health(self) -> dict[str, Any]:
687        """Liveness + ``{running, capacity}`` for heartbeat."""
688        resp = self.request("GET", "/health")
689        resp.raise_for_status()
690        body: dict[str, Any] = resp.json()
691        return body

Liveness + {running, capacity} for heartbeat.

def get_traffic_page( self, instance_id: str, *, cursor: str = '', limit: int = 0) -> ctfy.server.models.InstanceTrafficPage:
695    def get_traffic_page(
696        self, instance_id: str, *, cursor: str = "", limit: int = 0
697    ) -> InstanceTrafficPage:
698        """One page of mitmproxy flows; the capture lives on the node's FS.
699
700        ⚠️ **The one method that must not use ``_parsed``.** A node from
701        before paging answers with a bare *list* — it sent the whole
702        capture and understands no cursor — and a strict
703        ``model_validate`` turns that supported skew into a raised
704        request. So the body goes through ``coerce_page``, which already
705        owns every decision about an unexpected shape, and the caller
706        gets the model.
707
708        This is the same move ``coerce_page`` was written for, one layer
709        down. Its docstring used to say the parsing belonged
710        "platform-side … so a route and this client cannot come to
711        disagree" — and there were *three* platform-side calls doing it.
712        There is one call now, at the seam that knows the request
713        cursor, which is what the fallback needs; the routes hold the
714        type instead of re-deciding its shape.
715
716        ⚠️ **An empty ``cursor`` means "from the beginning".** Passing
717        one on every poll re-reads the whole capture, which is the cost
718        the paged read exists to remove — arriving through the caller
719        rather than through the node.
720        """
721        params: dict[str, Any] = {}
722        if cursor:
723            params["cursor"] = cursor
724        if limit > 0:
725            params["limit"] = limit
726        resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params)
727        resp.raise_for_status()
728        page = coerce_page(resp.json(), cursor)
729        return InstanceTrafficPage(
730            flows=page.flows,
731            cursor=page.cursor,
732            truncated=page.truncated,
733            capture=page.capture,
734        )

One page of mitmproxy flows; the capture lives on the node's FS.

⚠️ The one method that must not use _parsed. A node from before paging answers with a bare list — it sent the whole capture and understands no cursor — and a strict model_validate turns that supported skew into a raised request. So the body goes through coerce_page, which already owns every decision about an unexpected shape, and the caller gets the model.

This is the same move coerce_page was written for, one layer down. Its docstring used to say the parsing belonged "platform-side … so a route and this client cannot come to disagree" — and there were three platform-side calls doing it. There is one call now, at the seam that knows the request cursor, which is what the fallback needs; the routes hold the type instead of re-deciding its shape.

⚠️ 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 get_traffic_witnesses( self, instance_id: str, *, cursor: str = '', limit: int = 0) -> ctfy.server.models.InstanceTrafficWitnesses:
736    def get_traffic_witnesses(
737        self, instance_id: str, *, cursor: str = "", limit: int = 0
738    ) -> InstanceTrafficWitnesses:
739        """Who spoke to this box since *cursor*, with no bodies moved.
740
741        §7.1's correlation reads one gamebox per team per round. Through
742        :meth:`get_traffic_page` that is every flow, both bodies
743        included (~100 kB apiece by ``CLAUDE.md``'s measurement),
744        pulled across the wire to extract a handful of addresses; the
745        node reduces instead and this carries the result.
746
747        ⚠️ **Strict parsing here, unlike its neighbour, and that is the
748        difference between an old field and an old route.**
749        ``get_traffic_page`` is lenient because a node from before
750        paging answers the *same* path with a bare list, which is
751        supported skew. This route did not exist on such a node at all,
752        so the honest answer from one is a 404 — and a lenient parse of
753        an error body would hand the round loop an empty witness set,
754        which reads as *nobody was there*. The caller catches the
755        request error and records the round as unknown.
756        """
757        params: dict[str, Any] = {}
758        if cursor:
759            params["cursor"] = cursor
760        if limit > 0:
761            params["limit"] = limit
762        resp = self.request("GET", f"/instances/{instance_id}/traffic-witnesses", params=params)
763        resp.raise_for_status()
764        return _parsed(InstanceTrafficWitnesses, resp)

Who spoke to this box since cursor, with no bodies moved.

§7.1's correlation reads one gamebox per team per round. Through get_traffic_page() that is every flow, both bodies included (~100 kB apiece by CLAUDE.md's measurement), pulled across the wire to extract a handful of addresses; the node reduces instead and this carries the result.

⚠️ Strict parsing here, unlike its neighbour, and that is the difference between an old field and an old route. get_traffic_page is lenient because a node from before paging answers the same path with a bare list, which is supported skew. This route did not exist on such a node at all, so the honest answer from one is a 404 — and a lenient parse of an error body would hand the round loop an empty witness set, which reads as nobody was there. The caller catches the request error and records the round as unknown.

def list_containers(self, instance_id: str) -> list[ctfy.server.models.node.ComposeContainer]:
766    def list_containers(self, instance_id: str) -> list[ComposeContainer]:
767        """Enumerate every container in an instance's compose project.
768
769        Backs the admin-shell feature: the platform forwards a
770        ``GET /admin/instances/{id}/containers`` to the assigned node,
771        which returns one row per container (challenge services and
772        platform-injected sidecars alike). The WebSocket reverse-proxy
773        is opened separately and does not go through ``NodeClient``.
774        """
775        resp = self.request("GET", f"/instances/{instance_id}/containers")
776        _raise_for_node_status(resp)
777        return _parsed_list(ComposeContainer, resp)

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) -> ctfy.server.models.node.CheckResult:
779    def run_checker(
780        self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30
781    ) -> CheckResult:
782        """Exec an author-supplied checker in a trusted judge sidecar.
783
784        Backs the checker / exec-judge feature: the platform forwards a
785        verify request to the assigned node, which runs ``cmd`` inside the
786        named sidecar and returns
787        ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP
788        timeout sits above the node's exec budget so the client doesn't
789        abandon the request before the node returns.
790        """
791        resp = self.request(
792            "POST",
793            f"/instances/{instance_id}/check",
794            json={"service": service, "cmd": cmd, "timeout_s": timeout_s},
795            timeout=timeout_s + 10,
796        )
797        _raise_for_node_status(resp)
798        return _parsed(CheckResult, resp)

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, run_id: str = '', env: dict[str, str], timeout_s: int = 1800, untrusted: bool = False, network: str = '', own_network: bool = False, match_network: str = '', match_address: str = '', brief: dict[str, typing.Any] | None = None, target_instance: str = '', target_aliases: dict[str, str] | None = None, pull_ticket: str = '', limits: ctfy.server.models.node.HarnessLimits | None = None) -> ctfy.server.models.node.HarnessRun:
800    def run_harness(
801        self,
802        *,
803        image: str,
804        run_id: str = "",
805        env: dict[str, str],
806        timeout_s: int = 1800,
807        untrusted: bool = False,
808        network: str = "",
809        own_network: bool = False,
810        match_network: str = "",
811        match_address: str = "",
812        brief: dict[str, Any] | None = None,
813        target_instance: str = "",
814        target_aliases: dict[str, str] | None = None,
815        pull_ticket: str = "",
816        limits: HarnessLimits | None = None,
817    ) -> HarnessRun:
818        """Run a one-shot evaluation-harness container on the node.
819
820        The node ``docker run``s ``image`` with ``env`` injected, waits for it
821        to exit (bounded by ``timeout_s``), and returns
822        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
823        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
824        per-call HTTP timeout sits above the node's run budget so the client
825        doesn't abandon the request before the (long-running) harness returns.
826
827        ``untrusted`` runs the image under the node's isolation profile —
828        off host networking, bounded resources, no capabilities — for an
829        image the platform did not build. It requires a network: the
830        node answers 400 without one, because "isolate this" and "I will
831        not say where the target lives" do not compose.
832
833        Any of ``network`` (the target instance's own, when the caller
834        knows it), ``own_network`` or ``target_instance`` satisfies that.
835        ⚠️ Most eval dispatches need the second: there the harness
836        *starts* the target partway through its own run, so at ``docker
837        run`` time no target network exists and there is no name to
838        send. The node creates a fresh bridge and removes it afterwards.
839
840        ``target_instance`` reverses that ordering, and is what an
841        `engagement` uses: the platform starts the estate first and names
842        it here, and the node wires its published containers to the
843        agent's bridge. ⚠️ An **id**, never a network name — the name is
844        a fact only the node's daemon can read, and ``docker run``
845        *creates* whatever string it is handed, so a platform guessing
846        one starts a run that reaches nothing.
847        """
848        resp = self.request(
849            "POST",
850            "/harness/run",
851            json={
852                "image": image,
853                "env": env,
854                "timeout_s": timeout_s,
855                "untrusted": untrusted,
856                "network": network,
857                "own_network": own_network,
858                "match_network": match_network,
859                # ⚠️ **The platform-assigned arena address.** The node
860                # connects the agent to the match network with this
861                # ``--ip``, so the address is known before the round loop
862                # asks (§7.1). Empty means "let docker's IPAM pick" — an
863                # older platform's behaviour, and what keeps a node reading
864                # this field unchanged for every non-arena dispatch.
865                "match_address": match_address,
866                # ⚠️ Sent even when empty, so a node reading it gets the
867                # same shape either way; the node's default is what makes
868                # an older platform's dispatch unchanged.
869                "pull_ticket": pull_ticket,
870                # ⚠️ ``None`` leaves the node's own defaults in force, which is
871                # what a dispatch said before the ceilings were settings.
872                "limits": limits.model_dump() if limits is not None else None,
873                # ⚠️ Same rule, and empty is a real answer here too: a
874                # dispatch with no run row (the conformance probe, a dry
875                # run) has nothing to label, and a label with an empty
876                # value would make every one of them answer one query.
877                "run_id": run_id,
878                # ⚠️ Sent as the already-built manifest. The platform is
879                # the side that knows the challenge, the competition and
880                # the run mode; a node deriving its own would be a
881                # second implementation of a published contract.
882                "brief": brief,
883                "target_instance": target_instance,
884                # ⚠️ **The names the brief published, so they resolve on
885                # the agent's bridge too.** An estate's hosts answer to a
886                # per-instance FQDN minted by its own resolver; joined to
887                # a new network a container answers only to its compose
888                # *service* name. Sending the mapping is what keeps the
889                # address the agent was told about true under either
890                # placement — without it the agent is handed a hostname
891                # that does not resolve, reaches nothing, and reports an
892                # empty result, which reads as a model that gave up.
893                #
894                # ⚠️ The platform's to send: it computed the surface. A
895                # node re-deriving it from the rendered compose would be
896                # a second implementation of the address the brief has
897                # already promised.
898                "target_aliases": target_aliases or {},
899            },
900            timeout=timeout_s + 30,
901        )
902        _raise_for_node_status(resp)
903        return _parsed(HarnessRun, resp)

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.

untrusted runs the image under the node's isolation profile — off host networking, bounded resources, no capabilities — for an image the platform did not build. It requires a network: the node answers 400 without one, because "isolate this" and "I will not say where the target lives" do not compose.

Any of network (the target instance's own, when the caller knows it), own_network or target_instance satisfies that. ⚠️ Most eval dispatches need the second: there the harness starts the target partway through its own run, so at docker run time no target network exists and there is no name to send. The node creates a fresh bridge and removes it afterwards.

target_instance reverses that ordering, and is what an engagement uses: the platform starts the estate first and names it here, and the node wires its published containers to the agent's bridge. ⚠️ An id, never a network name — the name is a fact only the node's daemon can read, and docker run creates whatever string it is handed, so a platform guessing one starts a run that reaches nothing.

def cancel_harness(self, *, run_id: str) -> int:
905    def cancel_harness(self, *, run_id: str) -> int:
906        """Stop whatever this node is running for ``run_id``; returns how many.
907
908        Until this existed a dispatched container was reclaimed by
909        **nothing but its own timeout** — ``_harness_timeout``'s docstring
910        says exactly that — so an operator pressing cancel on a
911        match-long run was asking for something the platform had no way
912        to deliver.
913
914        ⚠️ **Zero is an ordinary answer, not a failure.** The run may have
915        finished on its own, been dispatched to a different node, or been
916        lost to a node restart. A caller that read zero as an error would
917        report a broken platform every time a cancel arrived a second
918        late.
919        """
920        resp = self.request("POST", "/harness/cancel", json={"run_id": run_id})
921        body = resp.json()
922        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0

Stop whatever this node is running for run_id; returns how many.

Until this existed a dispatched container was reclaimed by nothing but its own timeout — _harness_timeout's docstring says exactly that — so an operator pressing cancel on a match-long run was asking for something the platform had no way to deliver.

⚠️ Zero is an ordinary answer, not a failure. The run may have finished on its own, been dispatched to a different node, or been lost to a node restart. A caller that read zero as an error would report a broken platform every time a cancel arrived a second late.

def verify_patch( self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900) -> ctfy.server.models.node.PatchVerifyResult:
924    def verify_patch(
925        self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900
926    ) -> PatchVerifyResult:
927        """Build a submitted patch on the node and return its verdict.
928
929        ``files`` maps a challenge-relative path to its new bytes; base64
930        is applied here because a patch target may legitimately be
931        binary. Returns ``{verdict, detail, applied, steps, error}``.
932
933        **An empty ``verdict`` with a populated ``error`` is not a
934        judgement** — it means the node refused the submission or the
935        verifier crashed. The caller must retry or retire the lease
936        rather than write a score, since failing to judge a patch is not
937        the same as judging it unfixed.
938
939        The per-call HTTP timeout sits above the node's own budget so the
940        client doesn't abandon the request while the node is still
941        building.
942        """
943        resp = self.request(
944            "POST",
945            "/patches/verify",
946            json={
947                "challenge_id": challenge_id,
948                "files": {
949                    path: base64.b64encode(content).decode() for path, content in files.items()
950                },
951                "timeout_s": timeout_s,
952            },
953            timeout=timeout_s + 60,
954        )
955        _raise_for_node_status(resp)
956        return _parsed(PatchVerifyResult, resp)

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]:
958    def collect_patch_files(
959        self, instance_id: str, service: str, paths: list[str]
960    ) -> dict[str, bytes]:
961        """Read a player's SSH edits back out of their running box.
962
963        ``paths`` are absolute in-container paths the *platform* derived
964        from the pristine source tree. Returns only what could be read:
965        a path missing from the reply means the player did not change it,
966        and the caller falls back to the shipped file rather than
967        recording a deletion they never made.
968        """
969        resp = self.request(
970            "POST",
971            f"/instances/{instance_id}/patch-collect",
972            json={"service": service, "paths": paths},
973        )
974        _raise_for_node_status(resp)
975        body: dict[str, str] = resp.json()
976        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]:
978    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
979        """Rotate a round's flags into every box of this node, in one call.
980
981        ⚠️ **One call for the whole node, never one per box.** §5.2's
982        capacity red line is 1000 teams x 3 services injected inside
983        120 s; per-box that is 3000 round trips, batched it is about
984        fourteen. The signature is the enforcement — a caller cannot
985        accidentally loop.
986
987        Returns ``{instance_id: ""}`` for the boxes written and a reason
988        for the ones that were not, because one team's unreachable box
989        must not cost every other team on the node its rotation. An
990        unrotated box keeps serving last round's flag, which anyone
991        holding the old value can replay.
992        """
993        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
994        _raise_for_node_status(resp)
995        result: dict[str, str] = resp.json()
996        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]:
 998    def probe_awd_service(
 999        self,
1000        *,
1001        instance_id: str,
1002        service_id: str,
1003        tick: int,
1004        flag: str,
1005        previous_flag: str = "",
1006        previous_flag_id: str = "",
1007    ) -> dict[str, str]:
1008        """Run one round's SLA probes against one box, from outside it.
1009
1010        The flags travel *to* the node: a checker that sourced its
1011        expected value from the box it is checking would pass on any box
1012        that returns whatever it was handed.
1013        """
1014        resp = self.request(
1015            "POST",
1016            "/awd/probe",
1017            json={
1018                "instance_id": instance_id,
1019                "service_id": service_id,
1020                "tick": tick,
1021                "flag": flag,
1022                "previous_flag": previous_flag,
1023                "previous_flag_id": previous_flag_id,
1024            },
1025        )
1026        _raise_for_node_status(resp)
1027        result: dict[str, str] = resp.json()
1028        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 attack_awd_box( self, *, instance_id: str, tick: int, timeout_s: int = 60, agent_image: str = '', counter_flag: str = '', forensic_marker: str = '', llm_api_key: str = '') -> ctfy.server.models.node.AwdAttackResult:
1030    def attack_awd_box(
1031        self,
1032        *,
1033        instance_id: str,
1034        tick: int,
1035        timeout_s: int = 60,
1036        agent_image: str = "",
1037        counter_flag: str = "",
1038        forensic_marker: str = "",
1039        llm_api_key: str = "",
1040    ) -> AwdAttackResult:
1041        """Run this box's own reference exploit against it, once.
1042
1043        Per **box**, not per service: one `run_exploit` discovers every
1044        answer the challenge declares and prints them all.
1045
1046        ``timeout_s`` is sent because the platform is what knows how long
1047        a round is; the node clamps it, because a container held open for
1048        an hour is the node's problem whoever asked for it.
1049
1050        ``forensic_marker`` is §4.3.1's, carried only by the agent
1051        branch: `build_attack_argv` passes no environment at all, so a
1052        marker on the reference exploit would be the one value that
1053        voids `EXPLOIT007`'s guarantee.
1054
1055        ``agent_image`` + ``counter_flag`` switch the node to §4.2b's
1056        agent attacker. ⚠️ **Both or neither** — the node refuses a half
1057        request rather than falling back to the reference exploit, which
1058        would run a different format than the one configured while the
1059        board looked entirely normal.
1060        """
1061        resp = self.request(
1062            "POST",
1063            "/awd/attack",
1064            json={
1065                "instance_id": instance_id,
1066                "tick": tick,
1067                "timeout_s": timeout_s,
1068                "agent_image": agent_image,
1069                "counter_flag": counter_flag,
1070                "forensic_marker": forensic_marker,
1071                "llm_api_key": llm_api_key,
1072            },
1073        )
1074        _raise_for_node_status(resp)
1075        return _parsed(AwdAttackResult, resp)

Run this box's own reference exploit against it, once.

Per box, not per service: one run_exploit discovers every answer the challenge declares and prints them all.

timeout_s is sent because the platform is what knows how long a round is; the node clamps it, because a container held open for an hour is the node's problem whoever asked for it.

forensic_marker is §4.3.1's, carried only by the agent branch: build_attack_argv passes no environment at all, so a marker on the reference exploit would be the one value that voids EXPLOIT007's guarantee.

agent_image + counter_flag switch the node to §4.2b's agent attacker. ⚠️ Both or neither — the node refuses a half request rather than falling back to the reference exploit, which would run a different format than the one configured while the board looked entirely normal.

def get_container_logs(self, instance_id: str) -> str:
1077    def get_container_logs(self, instance_id: str) -> str:
1078        """Fetch combined container stdout/stderr for archival."""
1079        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
1080        resp.raise_for_status()
1081        return str(resp.json().get("logs") or "")

Fetch combined container stdout/stderr for archival.

def get_pcap(self, instance_id: str) -> PcapCapture:
1083    def get_pcap(self, instance_id: str) -> PcapCapture:
1084        """Fetch the merged tcpdump capture for *instance_id*.
1085
1086        ``data`` is empty when no capture exists (sidecar disabled,
1087        instance never reached the running state, etc.) so callers can
1088        persist conditionally without try/except.
1089
1090        ⚠️ **It also says what the per-instance cap already dropped**
1091        (#1714), off the node's response headers, and ``None`` when the
1092        node did not say — an older node, which is *not reported*, never
1093        a clean capture.
1094        """
1095        resp = self.request("GET", f"/instances/{instance_id}/pcap")
1096        if resp.status_code == 404:
1097            return PcapCapture(data=b"")
1098        resp.raise_for_status()
1099        return PcapCapture(
1100            data=resp.content,
1101            dropped_bytes=_header_int(resp, CAPTURE_DROPPED_BYTES_HEADER),
1102            dropped_segments=_header_int(resp, CAPTURE_DROPPED_SEGMENTS_HEADER),
1103        )

Fetch the merged tcpdump capture for instance_id.

data is empty when no capture exists (sidecar disabled, instance never reached the running state, etc.) so callers can persist conditionally without try/except.

⚠️ It also says what the per-instance cap already dropped (#1714), off the node's response headers, and None when the node did not say — an older node, which is not reported, never a clean capture.

def get_harness_artifact(self, artifact_id: str) -> bytes:
1105    def get_harness_artifact(self, artifact_id: str) -> bytes:
1106        """One finished run's collected archive, or ``b""``.
1107
1108        ⚠️ **Empty rather than an exception for a 404**, following
1109        :meth:`get_pcap`. The node reaps uncollected archives, and a
1110        platform that comes back after a restart is exactly the caller
1111        that meets one that is gone — a state its own record already
1112        describes, not a transport failure.
1113        """
1114        resp = self.request("GET", f"/harness/artifacts/{artifact_id}")
1115        if resp.status_code == 404:
1116            return b""
1117        resp.raise_for_status()
1118        return resp.content

One finished run's collected archive, or b"".

⚠️ Empty rather than an exception for a 404, following get_pcap(). The node reaps uncollected archives, and a platform that comes back after a restart is exactly the caller that meets one that is gone — a state its own record already describes, not a transport failure.

def delete_harness_artifact(self, artifact_id: str) -> bool:
1120    def delete_harness_artifact(self, artifact_id: str) -> bool:
1121        """Tell the node the archive has been stored and can go.
1122
1123        ⚠️ **Never raises on a transport failure**, because it is called
1124        after the bytes are safely in object storage: turning a cleanup
1125        error into the run's error would replace a success with a
1126        janitorial complaint. The node's reaper collects whatever this
1127        misses.
1128        """
1129        try:
1130            resp = self.request("DELETE", f"/harness/artifacts/{artifact_id}")
1131        except Exception:
1132            return False
1133        return resp.status_code == 200

Tell the node the archive has been stored and can go.

⚠️ Never raises on a transport failure, because it is called after the bytes are safely in object storage: turning a cleanup error into the run's error would replace a success with a janitorial complaint. The node's reaper collects whatever this misses.

def get_agent_runtime(self, instance_id: str) -> ctfy.server.models.node.AgentRuntime:
1135    def get_agent_runtime(self, instance_id: str) -> AgentRuntime:
1136        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
1137        CA PEM, optional Docker-specific names). Replaces the old
1138        sandbox-network shape."""
1139        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
1140        resp.raise_for_status()
1141        return _parsed(AgentRuntime, resp)

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) -> ctfy.server.models.AttachmentList:
1145    def list_instance_attachments(self, instance_id: str) -> AttachmentList:
1146        """List per-instance attachments rendered into the node's workdir.
1147
1148        Backs the platform's per-instance attachment endpoint, which
1149        surfaces the team-specific rendered file listing.
1150
1151        ⚠️ This used to hand back the raw dict, under a docstring saying
1152        "caller projects through :class:`AttachmentList` for typing" —
1153        the node's route already declared that model, so the type existed
1154        and this method's signature was the one place it was dropped.
1155        Projecting *here* means there is one place a malformed answer is
1156        caught rather than one per caller."""
1157        resp = self.request("GET", f"/instances/{instance_id}/attachments")
1158        resp.raise_for_status()
1159        return _parsed(AttachmentList, resp)

List per-instance attachments rendered into the node's workdir.

Backs the platform's per-instance attachment endpoint, which surfaces the team-specific rendered file listing.

⚠️ This used to hand back the raw dict, under a docstring saying "caller projects through AttachmentList for typing" — the node's route already declared that model, so the type existed and this method's signature was the one place it was dropped. Projecting here means there is one place a malformed answer is caught rather than one per caller.

def get_vpn_config(self, instance_id: str) -> bytes:
1161    def get_vpn_config(self, instance_id: str) -> bytes:
1162        """Fetch the per-instance VPN client config from the node.
1163
1164        The node reads the backend's client config out of the
1165        instance's VPN service container (generated on first boot by
1166        the base image's bootstrap) and rewrites its endpoint to the
1167        node's public host:port. Returns the body verbatim — the
1168        platform-side route adds the ``Content-Disposition`` header
1169        before re-emitting to the player, because only it knows the
1170        backend and therefore the filename the player needs.
1171
1172        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1173        proxy route catches 404 and re-emits to the player, anything
1174        else is treated as a 502.
1175        """
1176        resp = self.request("GET", f"/instances/{instance_id}/vpn-config")
1177        resp.raise_for_status()
1178        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]:
1180    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1181        """Download one per-instance attachment as ``(bytes, content_type)``.
1182
1183        Buffers the whole body — attachments are bounded (typical case
1184        is a 10 KB binary or a small text file). For huge captures the
1185        caller should hand the player a CDN URL instead.
1186
1187        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1188        proxy route catches 404 and re-emits to the player, anything
1189        else is a 502."""
1190        resp = self.request(
1191            "GET",
1192            f"/instances/{instance_id}/attachments/{filename}",
1193        )
1194        resp.raise_for_status()
1195        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.