ctfy.sdk.resources.instances

client.instances — launch, control, status, attachments, traffic.

Flat /instances/{id} ops plus the per-competition scoped twins (*_scoped) for callers who play in more than one competition and must address an instance unambiguously.

  1"""``client.instances`` — launch, control, status, attachments, traffic.
  2
  3Flat ``/instances/{id}`` ops plus the per-competition scoped twins
  4(``*_scoped``) for callers who play in more than one competition and must
  5address an instance unambiguously.
  6"""
  7
  8from __future__ import annotations
  9
 10from collections.abc import Iterator
 11from typing import Any
 12
 13from ctfy.sdk._helpers import (
 14    InstanceReadyResult,
 15    PagedList,
 16    _extract_items,
 17    _poll_instance_ready,
 18    _raise_for_status,
 19)
 20from ctfy.sdk.base import BaseHttpClient
 21from ctfy.server.models import (
 22    AttachmentList,
 23    InstanceInfo,
 24    InstanceQuestionInfo,
 25    InstanceStatusResponse,
 26    PlayerShellTicket,
 27    RenewResponse,
 28    SshCredential,
 29    StartResponse,
 30)
 31
 32
 33class InstancesResource:
 34    """Start / inspect / tear down challenge instances."""
 35
 36    def __init__(self, http: BaseHttpClient) -> None:
 37        self._http = http
 38
 39    def start(
 40        self,
 41        challenge_id: str,
 42        ttl: int | None = None,
 43        *,
 44        competition_id: str = "",
 45        timeout: int = 300,
 46        poll_interval: float = 2.0,
 47    ) -> InstanceReadyResult:
 48        """Start instance and wait until ready.
 49
 50        Returns InstanceReadyResult with attack surface + sandbox network info.
 51        The .surface attribute provides backward compatibility.
 52
 53        Args:
 54            challenge_id: Challenge ID (e.g., "XBOW-047").
 55            ttl: Per-instance TTL override in seconds. ``None`` (the
 56                default) delegates to the platform's
 57                ``default_instance_ttl_s`` setting (admin-tunable,
 58                currently 24h). Explicit values are clamped to the
 59                platform's ``max_instance_ttl_s``.
 60            competition_id: Which competition to spin the instance up
 61                under. Required when the calling user is on more than
 62                one team — agents tied to a single comp can leave it
 63                empty and the server picks the unambiguous team.
 64            timeout: Max seconds to wait for ready.
 65            poll_interval: Seconds between status polls.
 66
 67        ⚠️ **There is no ``proxy_output_dir`` here, and its absence is
 68        the feature.** It used to be a field on the player's own start
 69        request, passed through the platform untouched and landing on
 70        ``output_path.mkdir(parents=True, exist_ok=True)`` followed by
 71        ``output_path.chmod(0o777)`` on a worker node, as root. Where a
 72        capture goes is the *node's* decision — it reads
 73        ``proxy_output_host_root`` from its own config, which the
 74        rendered compose already sets — so a caller naming a host path
 75        on somebody else's worker was never answering a question that
 76        was theirs to answer.
 77        """
 78        body: dict[str, Any] = {
 79            "challenge_id": challenge_id,
 80            "competition_id": competition_id,
 81        }
 82        if ttl is not None:
 83            body["ttl"] = ttl
 84        resp = self._http.request("POST", "/instances", json=body)
 85        _raise_for_status(resp)
 86        start = StartResponse.model_validate(resp.json())
 87
 88        return _poll_instance_ready(
 89            self._http._client, start.id, timeout=timeout, poll_interval=poll_interval
 90        )
 91
 92    def get(self, instance_id: str) -> InstanceInfo:
 93        """Get the full ``InstanceInfo`` for one running instance.
 94
 95        Carries the per-instance ``questions`` list with each question's
 96        ``unlocked`` / ``answered_correctly`` / ``attempts_remaining``
 97        state — the surface multi-milestone solvers iterate on. The
 98        prompt is empty for still-locked questions so the route hint
 99        doesn't leak before the prerequisite is solved.
100
101        See :meth:`status` for the lighter status-only payload (no
102        questions, no spec metadata) used by polling loops.
103        """
104        resp = self._http.request("GET", f"/instances/{instance_id}")
105        _raise_for_status(resp)
106        return InstanceInfo.model_validate(resp.json())
107
108    def iter_pending_questions(self, instance_id: str) -> Iterator[InstanceQuestionInfo]:
109        """Yield each currently-pending question on a running instance.
110
111        "Pending" = unlocked (the ``requires:`` chain is satisfied)
112        and not yet correctly answered by the calling team. After
113        every batch is exhausted, re-fetches ``InstanceInfo`` so
114        newly-unlocked questions (whose prerequisite was just solved
115        by the consumer) flow through on the next iteration.
116
117        Terminates on either:
118
119        * no pending questions left — the natural "challenge fully
120          solved" exit;
121        * **no progress** — the same set of pending question ids
122          comes back twice in a row, meaning the consumer's solver
123          isn't capturing anything. Stops silently rather than
124          infinite-looping; inspect the caller's last :meth:`get` to
125          see what's still stuck.
126
127        This is the ergonomic helper for the common multi-milestone
128        loop::
129
130            for q in client.instances.iter_pending_questions(instance_id):
131                ans = my_solver(q.prompt)
132                client.submissions.submit(instance_id, ans, question_id=q.id)
133
134        Args:
135            instance_id: The instance to walk.
136        """
137        previous_pending_ids: frozenset[str] | None = None
138        while True:
139            info = self.get(instance_id)
140            pending = [q for q in info.questions if q.unlocked and not q.answered_correctly]
141            if not pending:
142                return
143
144            pending_ids = frozenset(q.id for q in pending)
145            if pending_ids == previous_pending_ids:
146                # Solver made no progress against the last batch — stop
147                # rather than spin. The consumer can re-call this method
148                # later (after fixing their solver) to pick up where
149                # they left off.
150                return
151            previous_pending_ids = pending_ids
152
153            yield from pending
154
155    def status(self, instance_id: str) -> InstanceStatusResponse:
156        """Get instance status and attack surface."""
157        resp = self._http.request("GET", f"/instances/{instance_id}/status")
158        _raise_for_status(resp)
159        return InstanceStatusResponse.model_validate(resp.json())
160
161    def stop(self, instance_id: str) -> None:
162        """Tear down ``instance_id``. Idempotent: a 404 from the server
163        means the instance is already gone (auto-stopped after solve,
164        TTL expiry, admin teardown) and is treated as success."""
165        resp = self._http.request("DELETE", f"/instances/{instance_id}")
166        if resp.status_code != 404:
167            _raise_for_status(resp)
168
169    def reset(self, instance_id: str, ttl: int | None = None) -> StartResponse:
170        """Swap ``instance_id`` for a freshly-built one, atomically.
171
172        Returns the **new** instance id — the old one is gone. Prefer
173        this over ``stop`` + ``start``: the team's slot is never
174        released between the two, so a full platform can still reset,
175        and every refusal (unknown challenge, no node with room) is
176        decided before the running environment is torn down.
177
178        Minted answers are new; solves, the ``requires:`` reveal chain
179        and the per-question wrong-attempt counters are keyed
180        ``(team, challenge)`` and carry over untouched.
181        """
182        params: dict[str, Any] = {}
183        if ttl is not None:
184            params["ttl"] = ttl
185        resp = self._http.request("POST", f"/instances/{instance_id}/reset", params=params)
186        _raise_for_status(resp)
187        return StartResponse.model_validate(resp.json())
188
189    def renew(self, instance_id: str, ttl: int | None = None) -> RenewResponse:
190        """Extend the TTL of a running instance.
191
192        ``ttl`` is an **increment**, not a new lifetime: it is added to
193        whatever time the box has left, and the resulting deadline is
194        capped at ``max_instance_ttl_s`` seconds from now. So a renewal
195        can never move a deadline backwards, and one on a box already
196        at the ceiling is a no-op rather than a refusal.
197
198        ``ttl=None`` (the default) delegates to the platform's
199        ``default_instance_ttl_s`` setting.
200
201        Read the granted deadline off ``RenewResponse.expires_at``
202        rather than computing it — see
203        :func:`ctfy.core.instance_ttl.plan_renewal`. ``0.0`` there means
204        the instance has no deadline at all (an AWD match gamebox), so
205        there was nothing to extend.
206        """
207        params: dict[str, Any] = {}
208        if ttl is not None:
209            params["ttl"] = ttl
210        resp = self._http.request("POST", f"/instances/{instance_id}/renew", params=params)
211        _raise_for_status(resp)
212        return RenewResponse.model_validate(resp.json())
213
214    def shell_session(self, instance_id: str, *, shell: str = "bash") -> PlayerShellTicket:
215        """Mint a single-use ticket for a shell into your own AWD+ box.
216
217        This wraps the *mint* only. Opening the WebSocket the ticket
218        names is the caller's job, because the SDK is a sync httpx
219        client and a shell is a long-lived bidirectional stream — the
220        same reason ``events()`` is not a plain request. ``ctfy patch
221        shell`` is the reference consumer.
222
223        No container argument: the platform resolves the target from the
224        challenge's ``patch.live.service``, and answers
225        ``shell_unsupported`` for a challenge that declares none.
226        """
227        resp = self._http.request(
228            "POST",
229            f"/instances/{instance_id}/shell/sessions",
230            json={"shell": shell},
231        )
232        _raise_for_status(resp)
233        return PlayerShellTicket.model_validate(resp.json())
234
235    def ssh_credential(self, instance_id: str) -> SshCredential:
236        """Mint a short-lived SSH certificate for your own AWD+ box.
237
238        The other half of ``shell_session``: same authorisation, same
239        target, a different protocol in front of it. A defender who
240        would rather use ``ssh`` (and every agent that already drives
241        one) gets a throwaway keypair plus a certificate, and presents
242        them to the bastion — which trades the authenticated subject
243        back for the very ticket ``shell_session`` returns.
244
245        The credential expires in minutes: it only has to cover
246        *connecting*, and the session it opens outlives it. Ask again
247        rather than caching one.
248        """
249        resp = self._http.request("POST", f"/instances/{instance_id}/ssh")
250        _raise_for_status(resp)
251        return SshCredential.model_validate(resp.json())
252
253    def list(
254        self,
255        challenge_id: str = "",
256        status: str = "",
257        q: str = "",
258        offset: int = 0,
259        limit: int = 50,
260    ) -> PagedList[InstanceInfo]:
261        """List all running instances."""
262        params: dict[str, Any] = {"offset": offset, "limit": limit}
263        if challenge_id:
264            params["challenge_id"] = challenge_id
265        if status:
266            params["status"] = status
267        if q:
268            params["q"] = q
269        resp = self._http.request("GET", "/instances", params=params)
270        _raise_for_status(resp)
271        return _extract_items(resp.json(), InstanceInfo)
272
273    def attachments(self, instance_id: str) -> AttachmentList:
274        """List per-instance (post-launch) attachments.
275
276        For challenges with ``attachments/<name>.tpl`` Jinja templates
277        the names + sizes here are the *rendered* per-team-unique
278        siblings; for ones with only static attachments the list is
279        the same as :meth:`ChallengesResource.attachments`.
280        """
281        resp = self._http.request("GET", f"/instances/{instance_id}/attachments")
282        _raise_for_status(resp)
283        return AttachmentList.model_validate(resp.json())
284
285    def download_attachment(self, instance_id: str, filename: str) -> bytes:
286        """Download one per-instance attachment as raw bytes.
287
288        For challenges with per-team Jinja templates this returns the
289        team-specific render; for static attachments it's the same
290        bytes as :meth:`ChallengesResource.download_attachment` would yield.
291        """
292        resp = self._http.request("GET", f"/instances/{instance_id}/attachments/{filename}")
293        _raise_for_status(resp)
294        return resp.content
295
296    def vpn_config(self, instance_id: str) -> bytes:
297        """Download the per-instance VPN client config as raw bytes.
298
299        Only meaningful for challenges that declare
300        ``network_topology: engagement`` in metadata.yaml. The body is
301        the rendered profile the player or automated agent hands to
302        their client to land on the challenge's DMZ docker network —
303        an OpenVPN ``.ovpn`` or a WireGuard ``.conf``, per the
304        challenge's ``vpn_backend`` (read it off
305        ``InstanceInfo.vpn_backend`` to know which client to run). The
306        platform serves a 404 for simple-mode instances.
307
308        ⚠️ **On WireGuard the filename matters**, and this returns only
309        bytes. ``wg-quick`` takes the interface name from the file's
310        basename and the kernel caps that at 15 characters, so writing
311        these bytes to ``<instance-id>.conf`` produces a file
312        ``wg-quick`` refuses. The ``Content-Disposition`` on the HTTP
313        response carries a name that works; a caller saving to disk
314        should use it, or pick its own short one.
315
316        Raises a ``CTFyError`` (404) when the challenge isn't an
317        engagement-mode one, when the instance hasn't yet reached the
318        running state, or when the node's VPN container hasn't finished
319        its key bootstrap.
320        """
321        resp = self._http.request("GET", f"/instances/{instance_id}/vpn-config")
322        _raise_for_status(resp)
323        return resp.content
324
325    def tunnel(self, instance_id: str, *, mode: str = "auto", timeout: float = 30.0) -> Any:
326        """Open a userspace tunnel to this instance; yields a SOCKS5 address.
327
328        The piece that lets an **unprivileged** caller — an agent, a
329        script, a CI job — reach an engagement challenge::
330
331            with client.instances.tunnel(iid) as tun:
332                requests.get(f"http://{host}/", proxies=tun.proxies)
333
334        ⚠️ Before this, fetching :meth:`vpn_config` was the end of the
335        road for anyone who could not run ``wg-quick`` as root. That is
336        every agent: the MCP surface has no tunnel tool, so an agent
337        received a ``config_url`` it had no way to act on, and an
338        engagement challenge was simply unplayable for it.
339
340        Terminating WireGuard in userspace removes the privilege
341        requirement entirely — no ``/dev/net/tun``, no ``CAP_NET_ADMIN``,
342        no root. See :mod:`ctfy.sdk.tunnel` for which backends are
343        accepted and why none is vendored.
344
345        ``mode`` picks the transport:
346
347        ``interface``
348            A real WireGuard device via ``wg-quick``. ICMP, UDP and raw
349            sockets all work, so `ping` and `nmap` behave — needs
350            ``CAP_NET_ADMIN`` and mutates host routes and resolver.
351        ``socks``
352            The userspace tunnel, reachable as ``tun.proxies``. No
353            privileges and no host state touched; ⚠️ **TCP only**.
354        ``auto`` (default)
355            The first of those that this process can use.
356
357        ⚠️ Ask explicitly when it matters. A challenge that needs ICMP
358        will look *broken* under ``socks`` rather than unreachable, and a
359        laptop does not want its routing table rewritten by a test.
360        """
361        from ctfy.sdk.tunnel import open_tunnel, validate_mode
362
363        # Before the request, so a typo is not reported as a missing
364        # instance.
365        validate_mode(mode)
366        return open_tunnel(self.vpn_config(instance_id).decode(), mode=mode, timeout=timeout)
367
368    def openvpn_config(self, instance_id: str) -> bytes:
369        """Deprecated alias for :meth:`vpn_config`.
370
371        Kept because agents and scripts written against the
372        engagement-mode API call it by this name. It returns whatever
373        the challenge actually runs — which for a WireGuard challenge
374        is a ``.conf``, since what the instance ships is not something
375        the method name gets a vote on.
376        """
377        return self.vpn_config(instance_id)
378
379    def traffic(
380        self,
381        instance_id: str,
382        *,
383        competition_id: str = "",
384        cursor: str = "",
385        limit: int = 0,
386    ) -> dict[str, Any]:
387        """Traffic captured since *cursor*.
388
389        Answers ``{flows, cursor, truncated, capture}``.
390
391        The capture lives on the worker node; the platform proxies the
392        fetch. An absent *cursor* means the whole capture, bounded by
393        the server's page limits — so a caller that wants everything
394        polls until ``truncated`` is false, carrying each reply's
395        ``cursor`` into the next call.
396
397        ⚠️ **This has been answering ``{}`` for every caller.** The
398        route used to return a bare JSON *list* while this method was
399        annotated ``dict`` and coerced anything else to an empty one —
400        so an SDK user got "no capture" from a box with a full one, with
401        nothing raising. The envelope is what makes the annotation true.
402
403        Still returns an empty dict on a genuinely absent capture
404        (sidecar disabled, node offline, instance never ran) so callers
405        can persist conditionally without try/except.
406        """
407        params: dict[str, Any] = {"competition_id": competition_id} if competition_id else {}
408        if cursor:
409            params["cursor"] = cursor
410        # ⚠️ A caller with a *context window* wants far fewer than the
411        # server default of 500 full flows. Omitted means the default,
412        # so an existing caller is unchanged.
413        if limit > 0:
414            params["limit"] = limit
415        resp = self._http.request("GET", f"/instances/{instance_id}/traffic", params=params)
416        _raise_for_status(resp)
417        data = resp.json()
418        return data if isinstance(data, dict) else {}
class InstancesResource:
 34class InstancesResource:
 35    """Start / inspect / tear down challenge instances."""
 36
 37    def __init__(self, http: BaseHttpClient) -> None:
 38        self._http = http
 39
 40    def start(
 41        self,
 42        challenge_id: str,
 43        ttl: int | None = None,
 44        *,
 45        competition_id: str = "",
 46        timeout: int = 300,
 47        poll_interval: float = 2.0,
 48    ) -> InstanceReadyResult:
 49        """Start instance and wait until ready.
 50
 51        Returns InstanceReadyResult with attack surface + sandbox network info.
 52        The .surface attribute provides backward compatibility.
 53
 54        Args:
 55            challenge_id: Challenge ID (e.g., "XBOW-047").
 56            ttl: Per-instance TTL override in seconds. ``None`` (the
 57                default) delegates to the platform's
 58                ``default_instance_ttl_s`` setting (admin-tunable,
 59                currently 24h). Explicit values are clamped to the
 60                platform's ``max_instance_ttl_s``.
 61            competition_id: Which competition to spin the instance up
 62                under. Required when the calling user is on more than
 63                one team — agents tied to a single comp can leave it
 64                empty and the server picks the unambiguous team.
 65            timeout: Max seconds to wait for ready.
 66            poll_interval: Seconds between status polls.
 67
 68        ⚠️ **There is no ``proxy_output_dir`` here, and its absence is
 69        the feature.** It used to be a field on the player's own start
 70        request, passed through the platform untouched and landing on
 71        ``output_path.mkdir(parents=True, exist_ok=True)`` followed by
 72        ``output_path.chmod(0o777)`` on a worker node, as root. Where a
 73        capture goes is the *node's* decision — it reads
 74        ``proxy_output_host_root`` from its own config, which the
 75        rendered compose already sets — so a caller naming a host path
 76        on somebody else's worker was never answering a question that
 77        was theirs to answer.
 78        """
 79        body: dict[str, Any] = {
 80            "challenge_id": challenge_id,
 81            "competition_id": competition_id,
 82        }
 83        if ttl is not None:
 84            body["ttl"] = ttl
 85        resp = self._http.request("POST", "/instances", json=body)
 86        _raise_for_status(resp)
 87        start = StartResponse.model_validate(resp.json())
 88
 89        return _poll_instance_ready(
 90            self._http._client, start.id, timeout=timeout, poll_interval=poll_interval
 91        )
 92
 93    def get(self, instance_id: str) -> InstanceInfo:
 94        """Get the full ``InstanceInfo`` for one running instance.
 95
 96        Carries the per-instance ``questions`` list with each question's
 97        ``unlocked`` / ``answered_correctly`` / ``attempts_remaining``
 98        state — the surface multi-milestone solvers iterate on. The
 99        prompt is empty for still-locked questions so the route hint
100        doesn't leak before the prerequisite is solved.
101
102        See :meth:`status` for the lighter status-only payload (no
103        questions, no spec metadata) used by polling loops.
104        """
105        resp = self._http.request("GET", f"/instances/{instance_id}")
106        _raise_for_status(resp)
107        return InstanceInfo.model_validate(resp.json())
108
109    def iter_pending_questions(self, instance_id: str) -> Iterator[InstanceQuestionInfo]:
110        """Yield each currently-pending question on a running instance.
111
112        "Pending" = unlocked (the ``requires:`` chain is satisfied)
113        and not yet correctly answered by the calling team. After
114        every batch is exhausted, re-fetches ``InstanceInfo`` so
115        newly-unlocked questions (whose prerequisite was just solved
116        by the consumer) flow through on the next iteration.
117
118        Terminates on either:
119
120        * no pending questions left — the natural "challenge fully
121          solved" exit;
122        * **no progress** — the same set of pending question ids
123          comes back twice in a row, meaning the consumer's solver
124          isn't capturing anything. Stops silently rather than
125          infinite-looping; inspect the caller's last :meth:`get` to
126          see what's still stuck.
127
128        This is the ergonomic helper for the common multi-milestone
129        loop::
130
131            for q in client.instances.iter_pending_questions(instance_id):
132                ans = my_solver(q.prompt)
133                client.submissions.submit(instance_id, ans, question_id=q.id)
134
135        Args:
136            instance_id: The instance to walk.
137        """
138        previous_pending_ids: frozenset[str] | None = None
139        while True:
140            info = self.get(instance_id)
141            pending = [q for q in info.questions if q.unlocked and not q.answered_correctly]
142            if not pending:
143                return
144
145            pending_ids = frozenset(q.id for q in pending)
146            if pending_ids == previous_pending_ids:
147                # Solver made no progress against the last batch — stop
148                # rather than spin. The consumer can re-call this method
149                # later (after fixing their solver) to pick up where
150                # they left off.
151                return
152            previous_pending_ids = pending_ids
153
154            yield from pending
155
156    def status(self, instance_id: str) -> InstanceStatusResponse:
157        """Get instance status and attack surface."""
158        resp = self._http.request("GET", f"/instances/{instance_id}/status")
159        _raise_for_status(resp)
160        return InstanceStatusResponse.model_validate(resp.json())
161
162    def stop(self, instance_id: str) -> None:
163        """Tear down ``instance_id``. Idempotent: a 404 from the server
164        means the instance is already gone (auto-stopped after solve,
165        TTL expiry, admin teardown) and is treated as success."""
166        resp = self._http.request("DELETE", f"/instances/{instance_id}")
167        if resp.status_code != 404:
168            _raise_for_status(resp)
169
170    def reset(self, instance_id: str, ttl: int | None = None) -> StartResponse:
171        """Swap ``instance_id`` for a freshly-built one, atomically.
172
173        Returns the **new** instance id — the old one is gone. Prefer
174        this over ``stop`` + ``start``: the team's slot is never
175        released between the two, so a full platform can still reset,
176        and every refusal (unknown challenge, no node with room) is
177        decided before the running environment is torn down.
178
179        Minted answers are new; solves, the ``requires:`` reveal chain
180        and the per-question wrong-attempt counters are keyed
181        ``(team, challenge)`` and carry over untouched.
182        """
183        params: dict[str, Any] = {}
184        if ttl is not None:
185            params["ttl"] = ttl
186        resp = self._http.request("POST", f"/instances/{instance_id}/reset", params=params)
187        _raise_for_status(resp)
188        return StartResponse.model_validate(resp.json())
189
190    def renew(self, instance_id: str, ttl: int | None = None) -> RenewResponse:
191        """Extend the TTL of a running instance.
192
193        ``ttl`` is an **increment**, not a new lifetime: it is added to
194        whatever time the box has left, and the resulting deadline is
195        capped at ``max_instance_ttl_s`` seconds from now. So a renewal
196        can never move a deadline backwards, and one on a box already
197        at the ceiling is a no-op rather than a refusal.
198
199        ``ttl=None`` (the default) delegates to the platform's
200        ``default_instance_ttl_s`` setting.
201
202        Read the granted deadline off ``RenewResponse.expires_at``
203        rather than computing it — see
204        :func:`ctfy.core.instance_ttl.plan_renewal`. ``0.0`` there means
205        the instance has no deadline at all (an AWD match gamebox), so
206        there was nothing to extend.
207        """
208        params: dict[str, Any] = {}
209        if ttl is not None:
210            params["ttl"] = ttl
211        resp = self._http.request("POST", f"/instances/{instance_id}/renew", params=params)
212        _raise_for_status(resp)
213        return RenewResponse.model_validate(resp.json())
214
215    def shell_session(self, instance_id: str, *, shell: str = "bash") -> PlayerShellTicket:
216        """Mint a single-use ticket for a shell into your own AWD+ box.
217
218        This wraps the *mint* only. Opening the WebSocket the ticket
219        names is the caller's job, because the SDK is a sync httpx
220        client and a shell is a long-lived bidirectional stream — the
221        same reason ``events()`` is not a plain request. ``ctfy patch
222        shell`` is the reference consumer.
223
224        No container argument: the platform resolves the target from the
225        challenge's ``patch.live.service``, and answers
226        ``shell_unsupported`` for a challenge that declares none.
227        """
228        resp = self._http.request(
229            "POST",
230            f"/instances/{instance_id}/shell/sessions",
231            json={"shell": shell},
232        )
233        _raise_for_status(resp)
234        return PlayerShellTicket.model_validate(resp.json())
235
236    def ssh_credential(self, instance_id: str) -> SshCredential:
237        """Mint a short-lived SSH certificate for your own AWD+ box.
238
239        The other half of ``shell_session``: same authorisation, same
240        target, a different protocol in front of it. A defender who
241        would rather use ``ssh`` (and every agent that already drives
242        one) gets a throwaway keypair plus a certificate, and presents
243        them to the bastion — which trades the authenticated subject
244        back for the very ticket ``shell_session`` returns.
245
246        The credential expires in minutes: it only has to cover
247        *connecting*, and the session it opens outlives it. Ask again
248        rather than caching one.
249        """
250        resp = self._http.request("POST", f"/instances/{instance_id}/ssh")
251        _raise_for_status(resp)
252        return SshCredential.model_validate(resp.json())
253
254    def list(
255        self,
256        challenge_id: str = "",
257        status: str = "",
258        q: str = "",
259        offset: int = 0,
260        limit: int = 50,
261    ) -> PagedList[InstanceInfo]:
262        """List all running instances."""
263        params: dict[str, Any] = {"offset": offset, "limit": limit}
264        if challenge_id:
265            params["challenge_id"] = challenge_id
266        if status:
267            params["status"] = status
268        if q:
269            params["q"] = q
270        resp = self._http.request("GET", "/instances", params=params)
271        _raise_for_status(resp)
272        return _extract_items(resp.json(), InstanceInfo)
273
274    def attachments(self, instance_id: str) -> AttachmentList:
275        """List per-instance (post-launch) attachments.
276
277        For challenges with ``attachments/<name>.tpl`` Jinja templates
278        the names + sizes here are the *rendered* per-team-unique
279        siblings; for ones with only static attachments the list is
280        the same as :meth:`ChallengesResource.attachments`.
281        """
282        resp = self._http.request("GET", f"/instances/{instance_id}/attachments")
283        _raise_for_status(resp)
284        return AttachmentList.model_validate(resp.json())
285
286    def download_attachment(self, instance_id: str, filename: str) -> bytes:
287        """Download one per-instance attachment as raw bytes.
288
289        For challenges with per-team Jinja templates this returns the
290        team-specific render; for static attachments it's the same
291        bytes as :meth:`ChallengesResource.download_attachment` would yield.
292        """
293        resp = self._http.request("GET", f"/instances/{instance_id}/attachments/{filename}")
294        _raise_for_status(resp)
295        return resp.content
296
297    def vpn_config(self, instance_id: str) -> bytes:
298        """Download the per-instance VPN client config as raw bytes.
299
300        Only meaningful for challenges that declare
301        ``network_topology: engagement`` in metadata.yaml. The body is
302        the rendered profile the player or automated agent hands to
303        their client to land on the challenge's DMZ docker network —
304        an OpenVPN ``.ovpn`` or a WireGuard ``.conf``, per the
305        challenge's ``vpn_backend`` (read it off
306        ``InstanceInfo.vpn_backend`` to know which client to run). The
307        platform serves a 404 for simple-mode instances.
308
309        ⚠️ **On WireGuard the filename matters**, and this returns only
310        bytes. ``wg-quick`` takes the interface name from the file's
311        basename and the kernel caps that at 15 characters, so writing
312        these bytes to ``<instance-id>.conf`` produces a file
313        ``wg-quick`` refuses. The ``Content-Disposition`` on the HTTP
314        response carries a name that works; a caller saving to disk
315        should use it, or pick its own short one.
316
317        Raises a ``CTFyError`` (404) when the challenge isn't an
318        engagement-mode one, when the instance hasn't yet reached the
319        running state, or when the node's VPN container hasn't finished
320        its key bootstrap.
321        """
322        resp = self._http.request("GET", f"/instances/{instance_id}/vpn-config")
323        _raise_for_status(resp)
324        return resp.content
325
326    def tunnel(self, instance_id: str, *, mode: str = "auto", timeout: float = 30.0) -> Any:
327        """Open a userspace tunnel to this instance; yields a SOCKS5 address.
328
329        The piece that lets an **unprivileged** caller — an agent, a
330        script, a CI job — reach an engagement challenge::
331
332            with client.instances.tunnel(iid) as tun:
333                requests.get(f"http://{host}/", proxies=tun.proxies)
334
335        ⚠️ Before this, fetching :meth:`vpn_config` was the end of the
336        road for anyone who could not run ``wg-quick`` as root. That is
337        every agent: the MCP surface has no tunnel tool, so an agent
338        received a ``config_url`` it had no way to act on, and an
339        engagement challenge was simply unplayable for it.
340
341        Terminating WireGuard in userspace removes the privilege
342        requirement entirely — no ``/dev/net/tun``, no ``CAP_NET_ADMIN``,
343        no root. See :mod:`ctfy.sdk.tunnel` for which backends are
344        accepted and why none is vendored.
345
346        ``mode`` picks the transport:
347
348        ``interface``
349            A real WireGuard device via ``wg-quick``. ICMP, UDP and raw
350            sockets all work, so `ping` and `nmap` behave — needs
351            ``CAP_NET_ADMIN`` and mutates host routes and resolver.
352        ``socks``
353            The userspace tunnel, reachable as ``tun.proxies``. No
354            privileges and no host state touched; ⚠️ **TCP only**.
355        ``auto`` (default)
356            The first of those that this process can use.
357
358        ⚠️ Ask explicitly when it matters. A challenge that needs ICMP
359        will look *broken* under ``socks`` rather than unreachable, and a
360        laptop does not want its routing table rewritten by a test.
361        """
362        from ctfy.sdk.tunnel import open_tunnel, validate_mode
363
364        # Before the request, so a typo is not reported as a missing
365        # instance.
366        validate_mode(mode)
367        return open_tunnel(self.vpn_config(instance_id).decode(), mode=mode, timeout=timeout)
368
369    def openvpn_config(self, instance_id: str) -> bytes:
370        """Deprecated alias for :meth:`vpn_config`.
371
372        Kept because agents and scripts written against the
373        engagement-mode API call it by this name. It returns whatever
374        the challenge actually runs — which for a WireGuard challenge
375        is a ``.conf``, since what the instance ships is not something
376        the method name gets a vote on.
377        """
378        return self.vpn_config(instance_id)
379
380    def traffic(
381        self,
382        instance_id: str,
383        *,
384        competition_id: str = "",
385        cursor: str = "",
386        limit: int = 0,
387    ) -> dict[str, Any]:
388        """Traffic captured since *cursor*.
389
390        Answers ``{flows, cursor, truncated, capture}``.
391
392        The capture lives on the worker node; the platform proxies the
393        fetch. An absent *cursor* means the whole capture, bounded by
394        the server's page limits — so a caller that wants everything
395        polls until ``truncated`` is false, carrying each reply's
396        ``cursor`` into the next call.
397
398        ⚠️ **This has been answering ``{}`` for every caller.** The
399        route used to return a bare JSON *list* while this method was
400        annotated ``dict`` and coerced anything else to an empty one —
401        so an SDK user got "no capture" from a box with a full one, with
402        nothing raising. The envelope is what makes the annotation true.
403
404        Still returns an empty dict on a genuinely absent capture
405        (sidecar disabled, node offline, instance never ran) so callers
406        can persist conditionally without try/except.
407        """
408        params: dict[str, Any] = {"competition_id": competition_id} if competition_id else {}
409        if cursor:
410            params["cursor"] = cursor
411        # ⚠️ A caller with a *context window* wants far fewer than the
412        # server default of 500 full flows. Omitted means the default,
413        # so an existing caller is unchanged.
414        if limit > 0:
415            params["limit"] = limit
416        resp = self._http.request("GET", f"/instances/{instance_id}/traffic", params=params)
417        _raise_for_status(resp)
418        data = resp.json()
419        return data if isinstance(data, dict) else {}

Start / inspect / tear down challenge instances.

InstancesResource(http: ctfy.sdk.base.BaseHttpClient)
37    def __init__(self, http: BaseHttpClient) -> None:
38        self._http = http
def start( self, challenge_id: str, ttl: int | None = None, *, competition_id: str = '', timeout: int = 300, poll_interval: float = 2.0) -> ctfy.sdk._helpers.InstanceReadyResult:
40    def start(
41        self,
42        challenge_id: str,
43        ttl: int | None = None,
44        *,
45        competition_id: str = "",
46        timeout: int = 300,
47        poll_interval: float = 2.0,
48    ) -> InstanceReadyResult:
49        """Start instance and wait until ready.
50
51        Returns InstanceReadyResult with attack surface + sandbox network info.
52        The .surface attribute provides backward compatibility.
53
54        Args:
55            challenge_id: Challenge ID (e.g., "XBOW-047").
56            ttl: Per-instance TTL override in seconds. ``None`` (the
57                default) delegates to the platform's
58                ``default_instance_ttl_s`` setting (admin-tunable,
59                currently 24h). Explicit values are clamped to the
60                platform's ``max_instance_ttl_s``.
61            competition_id: Which competition to spin the instance up
62                under. Required when the calling user is on more than
63                one team — agents tied to a single comp can leave it
64                empty and the server picks the unambiguous team.
65            timeout: Max seconds to wait for ready.
66            poll_interval: Seconds between status polls.
67
68        ⚠️ **There is no ``proxy_output_dir`` here, and its absence is
69        the feature.** It used to be a field on the player's own start
70        request, passed through the platform untouched and landing on
71        ``output_path.mkdir(parents=True, exist_ok=True)`` followed by
72        ``output_path.chmod(0o777)`` on a worker node, as root. Where a
73        capture goes is the *node's* decision — it reads
74        ``proxy_output_host_root`` from its own config, which the
75        rendered compose already sets — so a caller naming a host path
76        on somebody else's worker was never answering a question that
77        was theirs to answer.
78        """
79        body: dict[str, Any] = {
80            "challenge_id": challenge_id,
81            "competition_id": competition_id,
82        }
83        if ttl is not None:
84            body["ttl"] = ttl
85        resp = self._http.request("POST", "/instances", json=body)
86        _raise_for_status(resp)
87        start = StartResponse.model_validate(resp.json())
88
89        return _poll_instance_ready(
90            self._http._client, start.id, timeout=timeout, poll_interval=poll_interval
91        )

Start instance and wait until ready.

Returns InstanceReadyResult with attack surface + sandbox network info. The .surface attribute provides backward compatibility.

Arguments:
  • challenge_id: Challenge ID (e.g., "XBOW-047").
  • ttl: Per-instance TTL override in seconds. None (the default) delegates to the platform's default_instance_ttl_s setting (admin-tunable, currently 24h). Explicit values are clamped to the platform's max_instance_ttl_s.
  • competition_id: Which competition to spin the instance up under. Required when the calling user is on more than one team — agents tied to a single comp can leave it empty and the server picks the unambiguous team.
  • timeout: Max seconds to wait for ready.
  • poll_interval: Seconds between status polls.

⚠️ There is no proxy_output_dir here, and its absence is the feature. It used to be a field on the player's own start request, passed through the platform untouched and landing on output_path.mkdir(parents=True, exist_ok=True) followed by output_path.chmod(0o777) on a worker node, as root. Where a capture goes is the node's decision — it reads proxy_output_host_root from its own config, which the rendered compose already sets — so a caller naming a host path on somebody else's worker was never answering a question that was theirs to answer.

def get(self, instance_id: str) -> ctfy.server.models.InstanceInfo:
 93    def get(self, instance_id: str) -> InstanceInfo:
 94        """Get the full ``InstanceInfo`` for one running instance.
 95
 96        Carries the per-instance ``questions`` list with each question's
 97        ``unlocked`` / ``answered_correctly`` / ``attempts_remaining``
 98        state — the surface multi-milestone solvers iterate on. The
 99        prompt is empty for still-locked questions so the route hint
100        doesn't leak before the prerequisite is solved.
101
102        See :meth:`status` for the lighter status-only payload (no
103        questions, no spec metadata) used by polling loops.
104        """
105        resp = self._http.request("GET", f"/instances/{instance_id}")
106        _raise_for_status(resp)
107        return InstanceInfo.model_validate(resp.json())

Get the full InstanceInfo for one running instance.

Carries the per-instance questions list with each question's unlocked / answered_correctly / attempts_remaining state — the surface multi-milestone solvers iterate on. The prompt is empty for still-locked questions so the route hint doesn't leak before the prerequisite is solved.

See status() for the lighter status-only payload (no questions, no spec metadata) used by polling loops.

def iter_pending_questions( self, instance_id: str) -> Iterator[ctfy.server.models.InstanceQuestionInfo]:
109    def iter_pending_questions(self, instance_id: str) -> Iterator[InstanceQuestionInfo]:
110        """Yield each currently-pending question on a running instance.
111
112        "Pending" = unlocked (the ``requires:`` chain is satisfied)
113        and not yet correctly answered by the calling team. After
114        every batch is exhausted, re-fetches ``InstanceInfo`` so
115        newly-unlocked questions (whose prerequisite was just solved
116        by the consumer) flow through on the next iteration.
117
118        Terminates on either:
119
120        * no pending questions left — the natural "challenge fully
121          solved" exit;
122        * **no progress** — the same set of pending question ids
123          comes back twice in a row, meaning the consumer's solver
124          isn't capturing anything. Stops silently rather than
125          infinite-looping; inspect the caller's last :meth:`get` to
126          see what's still stuck.
127
128        This is the ergonomic helper for the common multi-milestone
129        loop::
130
131            for q in client.instances.iter_pending_questions(instance_id):
132                ans = my_solver(q.prompt)
133                client.submissions.submit(instance_id, ans, question_id=q.id)
134
135        Args:
136            instance_id: The instance to walk.
137        """
138        previous_pending_ids: frozenset[str] | None = None
139        while True:
140            info = self.get(instance_id)
141            pending = [q for q in info.questions if q.unlocked and not q.answered_correctly]
142            if not pending:
143                return
144
145            pending_ids = frozenset(q.id for q in pending)
146            if pending_ids == previous_pending_ids:
147                # Solver made no progress against the last batch — stop
148                # rather than spin. The consumer can re-call this method
149                # later (after fixing their solver) to pick up where
150                # they left off.
151                return
152            previous_pending_ids = pending_ids
153
154            yield from pending

Yield each currently-pending question on a running instance.

"Pending" = unlocked (the requires: chain is satisfied) and not yet correctly answered by the calling team. After every batch is exhausted, re-fetches InstanceInfo so newly-unlocked questions (whose prerequisite was just solved by the consumer) flow through on the next iteration.

Terminates on either:

  • no pending questions left — the natural "challenge fully solved" exit;
  • no progress — the same set of pending question ids comes back twice in a row, meaning the consumer's solver isn't capturing anything. Stops silently rather than infinite-looping; inspect the caller's last get() to see what's still stuck.

This is the ergonomic helper for the common multi-milestone loop::

for q in client.instances.iter_pending_questions(instance_id):
    ans = my_solver(q.prompt)
    client.submissions.submit(instance_id, ans, question_id=q.id)
Arguments:
  • instance_id: The instance to walk.
def status( self, instance_id: str) -> ctfy.server.models.InstanceStatusResponse:
156    def status(self, instance_id: str) -> InstanceStatusResponse:
157        """Get instance status and attack surface."""
158        resp = self._http.request("GET", f"/instances/{instance_id}/status")
159        _raise_for_status(resp)
160        return InstanceStatusResponse.model_validate(resp.json())

Get instance status and attack surface.

def stop(self, instance_id: str) -> None:
162    def stop(self, instance_id: str) -> None:
163        """Tear down ``instance_id``. Idempotent: a 404 from the server
164        means the instance is already gone (auto-stopped after solve,
165        TTL expiry, admin teardown) and is treated as success."""
166        resp = self._http.request("DELETE", f"/instances/{instance_id}")
167        if resp.status_code != 404:
168            _raise_for_status(resp)

Tear down instance_id. Idempotent: a 404 from the server means the instance is already gone (auto-stopped after solve, TTL expiry, admin teardown) and is treated as success.

def reset( self, instance_id: str, ttl: int | None = None) -> ctfy.server.models.StartResponse:
170    def reset(self, instance_id: str, ttl: int | None = None) -> StartResponse:
171        """Swap ``instance_id`` for a freshly-built one, atomically.
172
173        Returns the **new** instance id — the old one is gone. Prefer
174        this over ``stop`` + ``start``: the team's slot is never
175        released between the two, so a full platform can still reset,
176        and every refusal (unknown challenge, no node with room) is
177        decided before the running environment is torn down.
178
179        Minted answers are new; solves, the ``requires:`` reveal chain
180        and the per-question wrong-attempt counters are keyed
181        ``(team, challenge)`` and carry over untouched.
182        """
183        params: dict[str, Any] = {}
184        if ttl is not None:
185            params["ttl"] = ttl
186        resp = self._http.request("POST", f"/instances/{instance_id}/reset", params=params)
187        _raise_for_status(resp)
188        return StartResponse.model_validate(resp.json())

Swap instance_id for a freshly-built one, atomically.

Returns the new instance id — the old one is gone. Prefer this over stop + start: the team's slot is never released between the two, so a full platform can still reset, and every refusal (unknown challenge, no node with room) is decided before the running environment is torn down.

Minted answers are new; solves, the requires: reveal chain and the per-question wrong-attempt counters are keyed (team, challenge) and carry over untouched.

def renew( self, instance_id: str, ttl: int | None = None) -> ctfy.server.models.RenewResponse:
190    def renew(self, instance_id: str, ttl: int | None = None) -> RenewResponse:
191        """Extend the TTL of a running instance.
192
193        ``ttl`` is an **increment**, not a new lifetime: it is added to
194        whatever time the box has left, and the resulting deadline is
195        capped at ``max_instance_ttl_s`` seconds from now. So a renewal
196        can never move a deadline backwards, and one on a box already
197        at the ceiling is a no-op rather than a refusal.
198
199        ``ttl=None`` (the default) delegates to the platform's
200        ``default_instance_ttl_s`` setting.
201
202        Read the granted deadline off ``RenewResponse.expires_at``
203        rather than computing it — see
204        :func:`ctfy.core.instance_ttl.plan_renewal`. ``0.0`` there means
205        the instance has no deadline at all (an AWD match gamebox), so
206        there was nothing to extend.
207        """
208        params: dict[str, Any] = {}
209        if ttl is not None:
210            params["ttl"] = ttl
211        resp = self._http.request("POST", f"/instances/{instance_id}/renew", params=params)
212        _raise_for_status(resp)
213        return RenewResponse.model_validate(resp.json())

Extend the TTL of a running instance.

ttl is an increment, not a new lifetime: it is added to whatever time the box has left, and the resulting deadline is capped at max_instance_ttl_s seconds from now. So a renewal can never move a deadline backwards, and one on a box already at the ceiling is a no-op rather than a refusal.

ttl=None (the default) delegates to the platform's default_instance_ttl_s setting.

Read the granted deadline off RenewResponse.expires_at rather than computing it — see ctfy.core.instance_ttl.plan_renewal(). 0.0 there means the instance has no deadline at all (an AWD match gamebox), so there was nothing to extend.

def shell_session( self, instance_id: str, *, shell: str = 'bash') -> ctfy.server.models.PlayerShellTicket:
215    def shell_session(self, instance_id: str, *, shell: str = "bash") -> PlayerShellTicket:
216        """Mint a single-use ticket for a shell into your own AWD+ box.
217
218        This wraps the *mint* only. Opening the WebSocket the ticket
219        names is the caller's job, because the SDK is a sync httpx
220        client and a shell is a long-lived bidirectional stream — the
221        same reason ``events()`` is not a plain request. ``ctfy patch
222        shell`` is the reference consumer.
223
224        No container argument: the platform resolves the target from the
225        challenge's ``patch.live.service``, and answers
226        ``shell_unsupported`` for a challenge that declares none.
227        """
228        resp = self._http.request(
229            "POST",
230            f"/instances/{instance_id}/shell/sessions",
231            json={"shell": shell},
232        )
233        _raise_for_status(resp)
234        return PlayerShellTicket.model_validate(resp.json())

Mint a single-use ticket for a shell into your own AWD+ box.

This wraps the mint only. Opening the WebSocket the ticket names is the caller's job, because the SDK is a sync httpx client and a shell is a long-lived bidirectional stream — the same reason events() is not a plain request. ctfy patch shell is the reference consumer.

No container argument: the platform resolves the target from the challenge's patch.live.service, and answers shell_unsupported for a challenge that declares none.

def ssh_credential(self, instance_id: str) -> ctfy.server.models.SshCredential:
236    def ssh_credential(self, instance_id: str) -> SshCredential:
237        """Mint a short-lived SSH certificate for your own AWD+ box.
238
239        The other half of ``shell_session``: same authorisation, same
240        target, a different protocol in front of it. A defender who
241        would rather use ``ssh`` (and every agent that already drives
242        one) gets a throwaway keypair plus a certificate, and presents
243        them to the bastion — which trades the authenticated subject
244        back for the very ticket ``shell_session`` returns.
245
246        The credential expires in minutes: it only has to cover
247        *connecting*, and the session it opens outlives it. Ask again
248        rather than caching one.
249        """
250        resp = self._http.request("POST", f"/instances/{instance_id}/ssh")
251        _raise_for_status(resp)
252        return SshCredential.model_validate(resp.json())

Mint a short-lived SSH certificate for your own AWD+ box.

The other half of shell_session: same authorisation, same target, a different protocol in front of it. A defender who would rather use ssh (and every agent that already drives one) gets a throwaway keypair plus a certificate, and presents them to the bastion — which trades the authenticated subject back for the very ticket shell_session returns.

The credential expires in minutes: it only has to cover connecting, and the session it opens outlives it. Ask again rather than caching one.

def list( self, challenge_id: str = '', status: str = '', q: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.InstanceInfo]:
254    def list(
255        self,
256        challenge_id: str = "",
257        status: str = "",
258        q: str = "",
259        offset: int = 0,
260        limit: int = 50,
261    ) -> PagedList[InstanceInfo]:
262        """List all running instances."""
263        params: dict[str, Any] = {"offset": offset, "limit": limit}
264        if challenge_id:
265            params["challenge_id"] = challenge_id
266        if status:
267            params["status"] = status
268        if q:
269            params["q"] = q
270        resp = self._http.request("GET", "/instances", params=params)
271        _raise_for_status(resp)
272        return _extract_items(resp.json(), InstanceInfo)

List all running instances.

def attachments(self, instance_id: str) -> ctfy.server.models.AttachmentList:
274    def attachments(self, instance_id: str) -> AttachmentList:
275        """List per-instance (post-launch) attachments.
276
277        For challenges with ``attachments/<name>.tpl`` Jinja templates
278        the names + sizes here are the *rendered* per-team-unique
279        siblings; for ones with only static attachments the list is
280        the same as :meth:`ChallengesResource.attachments`.
281        """
282        resp = self._http.request("GET", f"/instances/{instance_id}/attachments")
283        _raise_for_status(resp)
284        return AttachmentList.model_validate(resp.json())

List per-instance (post-launch) attachments.

For challenges with attachments/<name>.tpl Jinja templates the names + sizes here are the rendered per-team-unique siblings; for ones with only static attachments the list is the same as ChallengesResource.attachments().

def download_attachment(self, instance_id: str, filename: str) -> bytes:
286    def download_attachment(self, instance_id: str, filename: str) -> bytes:
287        """Download one per-instance attachment as raw bytes.
288
289        For challenges with per-team Jinja templates this returns the
290        team-specific render; for static attachments it's the same
291        bytes as :meth:`ChallengesResource.download_attachment` would yield.
292        """
293        resp = self._http.request("GET", f"/instances/{instance_id}/attachments/{filename}")
294        _raise_for_status(resp)
295        return resp.content

Download one per-instance attachment as raw bytes.

For challenges with per-team Jinja templates this returns the team-specific render; for static attachments it's the same bytes as ChallengesResource.download_attachment() would yield.

def vpn_config(self, instance_id: str) -> bytes:
297    def vpn_config(self, instance_id: str) -> bytes:
298        """Download the per-instance VPN client config as raw bytes.
299
300        Only meaningful for challenges that declare
301        ``network_topology: engagement`` in metadata.yaml. The body is
302        the rendered profile the player or automated agent hands to
303        their client to land on the challenge's DMZ docker network —
304        an OpenVPN ``.ovpn`` or a WireGuard ``.conf``, per the
305        challenge's ``vpn_backend`` (read it off
306        ``InstanceInfo.vpn_backend`` to know which client to run). The
307        platform serves a 404 for simple-mode instances.
308
309        ⚠️ **On WireGuard the filename matters**, and this returns only
310        bytes. ``wg-quick`` takes the interface name from the file's
311        basename and the kernel caps that at 15 characters, so writing
312        these bytes to ``<instance-id>.conf`` produces a file
313        ``wg-quick`` refuses. The ``Content-Disposition`` on the HTTP
314        response carries a name that works; a caller saving to disk
315        should use it, or pick its own short one.
316
317        Raises a ``CTFyError`` (404) when the challenge isn't an
318        engagement-mode one, when the instance hasn't yet reached the
319        running state, or when the node's VPN container hasn't finished
320        its key bootstrap.
321        """
322        resp = self._http.request("GET", f"/instances/{instance_id}/vpn-config")
323        _raise_for_status(resp)
324        return resp.content

Download the per-instance VPN client config as raw bytes.

Only meaningful for challenges that declare network_topology: engagement in metadata.yaml. The body is the rendered profile the player or automated agent hands to their client to land on the challenge's DMZ docker network — an OpenVPN .ovpn or a WireGuard .conf, per the challenge's vpn_backend (read it off InstanceInfo.vpn_backend to know which client to run). The platform serves a 404 for simple-mode instances.

⚠️ On WireGuard the filename matters, and this returns only bytes. wg-quick takes the interface name from the file's basename and the kernel caps that at 15 characters, so writing these bytes to <instance-id>.conf produces a file wg-quick refuses. The Content-Disposition on the HTTP response carries a name that works; a caller saving to disk should use it, or pick its own short one.

Raises a CTFyError (404) when the challenge isn't an engagement-mode one, when the instance hasn't yet reached the running state, or when the node's VPN container hasn't finished its key bootstrap.

def tunnel( self, instance_id: str, *, mode: str = 'auto', timeout: float = 30.0) -> Any:
326    def tunnel(self, instance_id: str, *, mode: str = "auto", timeout: float = 30.0) -> Any:
327        """Open a userspace tunnel to this instance; yields a SOCKS5 address.
328
329        The piece that lets an **unprivileged** caller — an agent, a
330        script, a CI job — reach an engagement challenge::
331
332            with client.instances.tunnel(iid) as tun:
333                requests.get(f"http://{host}/", proxies=tun.proxies)
334
335        ⚠️ Before this, fetching :meth:`vpn_config` was the end of the
336        road for anyone who could not run ``wg-quick`` as root. That is
337        every agent: the MCP surface has no tunnel tool, so an agent
338        received a ``config_url`` it had no way to act on, and an
339        engagement challenge was simply unplayable for it.
340
341        Terminating WireGuard in userspace removes the privilege
342        requirement entirely — no ``/dev/net/tun``, no ``CAP_NET_ADMIN``,
343        no root. See :mod:`ctfy.sdk.tunnel` for which backends are
344        accepted and why none is vendored.
345
346        ``mode`` picks the transport:
347
348        ``interface``
349            A real WireGuard device via ``wg-quick``. ICMP, UDP and raw
350            sockets all work, so `ping` and `nmap` behave — needs
351            ``CAP_NET_ADMIN`` and mutates host routes and resolver.
352        ``socks``
353            The userspace tunnel, reachable as ``tun.proxies``. No
354            privileges and no host state touched; ⚠️ **TCP only**.
355        ``auto`` (default)
356            The first of those that this process can use.
357
358        ⚠️ Ask explicitly when it matters. A challenge that needs ICMP
359        will look *broken* under ``socks`` rather than unreachable, and a
360        laptop does not want its routing table rewritten by a test.
361        """
362        from ctfy.sdk.tunnel import open_tunnel, validate_mode
363
364        # Before the request, so a typo is not reported as a missing
365        # instance.
366        validate_mode(mode)
367        return open_tunnel(self.vpn_config(instance_id).decode(), mode=mode, timeout=timeout)

Open a userspace tunnel to this instance; yields a SOCKS5 address.

The piece that lets an unprivileged caller — an agent, a script, a CI job — reach an engagement challenge::

with client.instances.tunnel(iid) as tun:
    requests.get(f"http://{host}/", proxies=tun.proxies)

⚠️ Before this, fetching vpn_config() was the end of the road for anyone who could not run wg-quick as root. That is every agent: the MCP surface has no tunnel tool, so an agent received a config_url it had no way to act on, and an engagement challenge was simply unplayable for it.

Terminating WireGuard in userspace removes the privilege requirement entirely — no /dev/net/tun, no CAP_NET_ADMIN, no root. See ctfy.sdk.tunnel for which backends are accepted and why none is vendored.

mode picks the transport:

interface A real WireGuard device via wg-quick. ICMP, UDP and raw sockets all work, so ping and nmap behave — needs CAP_NET_ADMIN and mutates host routes and resolver. socks The userspace tunnel, reachable as tun.proxies. No privileges and no host state touched; ⚠️ TCP only. auto (default) The first of those that this process can use.

⚠️ Ask explicitly when it matters. A challenge that needs ICMP will look broken under socks rather than unreachable, and a laptop does not want its routing table rewritten by a test.

def openvpn_config(self, instance_id: str) -> bytes:
369    def openvpn_config(self, instance_id: str) -> bytes:
370        """Deprecated alias for :meth:`vpn_config`.
371
372        Kept because agents and scripts written against the
373        engagement-mode API call it by this name. It returns whatever
374        the challenge actually runs — which for a WireGuard challenge
375        is a ``.conf``, since what the instance ships is not something
376        the method name gets a vote on.
377        """
378        return self.vpn_config(instance_id)

Deprecated alias for vpn_config().

Kept because agents and scripts written against the engagement-mode API call it by this name. It returns whatever the challenge actually runs — which for a WireGuard challenge is a .conf, since what the instance ships is not something the method name gets a vote on.

def traffic( self, instance_id: str, *, competition_id: str = '', cursor: str = '', limit: int = 0) -> dict[str, typing.Any]:
380    def traffic(
381        self,
382        instance_id: str,
383        *,
384        competition_id: str = "",
385        cursor: str = "",
386        limit: int = 0,
387    ) -> dict[str, Any]:
388        """Traffic captured since *cursor*.
389
390        Answers ``{flows, cursor, truncated, capture}``.
391
392        The capture lives on the worker node; the platform proxies the
393        fetch. An absent *cursor* means the whole capture, bounded by
394        the server's page limits — so a caller that wants everything
395        polls until ``truncated`` is false, carrying each reply's
396        ``cursor`` into the next call.
397
398        ⚠️ **This has been answering ``{}`` for every caller.** The
399        route used to return a bare JSON *list* while this method was
400        annotated ``dict`` and coerced anything else to an empty one —
401        so an SDK user got "no capture" from a box with a full one, with
402        nothing raising. The envelope is what makes the annotation true.
403
404        Still returns an empty dict on a genuinely absent capture
405        (sidecar disabled, node offline, instance never ran) so callers
406        can persist conditionally without try/except.
407        """
408        params: dict[str, Any] = {"competition_id": competition_id} if competition_id else {}
409        if cursor:
410            params["cursor"] = cursor
411        # ⚠️ A caller with a *context window* wants far fewer than the
412        # server default of 500 full flows. Omitted means the default,
413        # so an existing caller is unchanged.
414        if limit > 0:
415            params["limit"] = limit
416        resp = self._http.request("GET", f"/instances/{instance_id}/traffic", params=params)
417        _raise_for_status(resp)
418        data = resp.json()
419        return data if isinstance(data, dict) else {}

Traffic captured since cursor.

Answers {flows, cursor, truncated, capture}.

The capture lives on the worker node; the platform proxies the fetch. An absent cursor means the whole capture, bounded by the server's page limits — so a caller that wants everything polls until truncated is false, carrying each reply's cursor into the next call.

⚠️ This has been answering {} for every caller. The route used to return a bare JSON list while this method was annotated dict and coerced anything else to an empty one — so an SDK user got "no capture" from a box with a full one, with nothing raising. The envelope is what makes the annotation true.

Still returns an empty dict on a genuinely absent capture (sidecar disabled, node offline, instance never ran) so callers can persist conditionally without try/except.