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

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.
  • proxy_output_dir: Host path to store proxy traffic captures.
def get(self, instance_id: str) -> ctfy.server.models.InstanceInfo:
 86    def get(self, instance_id: str) -> InstanceInfo:
 87        """Get the full ``InstanceInfo`` for one running instance.
 88
 89        Carries the per-instance ``questions`` list with each question's
 90        ``unlocked`` / ``answered_correctly`` / ``attempts_remaining``
 91        state — the surface multi-milestone solvers iterate on. The
 92        prompt is empty for still-locked questions so the route hint
 93        doesn't leak before the prerequisite is solved.
 94
 95        See :meth:`status` for the lighter status-only payload (no
 96        questions, no spec metadata) used by polling loops.
 97        """
 98        resp = self._http.request("GET", f"/instances/{instance_id}")
 99        _raise_for_status(resp)
100        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]:
102    def iter_pending_questions(self, instance_id: str) -> Iterator[InstanceQuestionInfo]:
103        """Yield each currently-pending question on a running instance.
104
105        "Pending" = unlocked (the ``requires:`` chain is satisfied)
106        and not yet correctly answered by the calling team. After
107        every batch is exhausted, re-fetches ``InstanceInfo`` so
108        newly-unlocked questions (whose prerequisite was just solved
109        by the consumer) flow through on the next iteration.
110
111        Terminates on either:
112
113        * no pending questions left — the natural "challenge fully
114          solved" exit;
115        * **no progress** — the same set of pending question ids
116          comes back twice in a row, meaning the consumer's solver
117          isn't capturing anything. Stops silently rather than
118          infinite-looping; inspect the caller's last :meth:`get` to
119          see what's still stuck.
120
121        This is the ergonomic helper for the common multi-milestone
122        loop::
123
124            for q in client.instances.iter_pending_questions(instance_id):
125                ans = my_solver(q.prompt)
126                client.submissions.submit(instance_id, ans, question_id=q.id)
127
128        Args:
129            instance_id: The instance to walk.
130        """
131        previous_pending_ids: frozenset[str] | None = None
132        while True:
133            info = self.get(instance_id)
134            pending = [q for q in info.questions if q.unlocked and not q.answered_correctly]
135            if not pending:
136                return
137
138            pending_ids = frozenset(q.id for q in pending)
139            if pending_ids == previous_pending_ids:
140                # Solver made no progress against the last batch — stop
141                # rather than spin. The consumer can re-call this method
142                # later (after fixing their solver) to pick up where
143                # they left off.
144                return
145            previous_pending_ids = pending_ids
146
147            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:
149    def status(self, instance_id: str) -> InstanceStatusResponse:
150        """Get instance status and attack surface."""
151        resp = self._http.request("GET", f"/instances/{instance_id}/status")
152        _raise_for_status(resp)
153        return InstanceStatusResponse.model_validate(resp.json())

Get instance status and attack surface.

def stop(self, instance_id: str) -> None:
155    def stop(self, instance_id: str) -> None:
156        """Tear down ``instance_id``. Idempotent: a 404 from the server
157        means the instance is already gone (auto-stopped after solve,
158        TTL expiry, admin teardown) and is treated as success."""
159        resp = self._http.request("DELETE", f"/instances/{instance_id}")
160        if resp.status_code != 404:
161            _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:
163    def reset(self, instance_id: str, ttl: int | None = None) -> StartResponse:
164        """Swap ``instance_id`` for a freshly-built one, atomically.
165
166        Returns the **new** instance id — the old one is gone. Prefer
167        this over ``stop`` + ``start``: the team's slot is never
168        released between the two, so a full platform can still reset,
169        and every refusal (unknown challenge, no node with room) is
170        decided before the running environment is torn down.
171
172        Minted answers are new; solves, the ``requires:`` reveal chain
173        and the per-question wrong-attempt counters are keyed
174        ``(team, challenge)`` and carry over untouched.
175        """
176        params: dict[str, Any] = {}
177        if ttl is not None:
178            params["ttl"] = ttl
179        resp = self._http.request("POST", f"/instances/{instance_id}/reset", params=params)
180        _raise_for_status(resp)
181        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:
183    def renew(self, instance_id: str, ttl: int | None = None) -> RenewResponse:
184        """Extend the TTL of a running instance.
185
186        ``ttl=None`` (the default) delegates to the platform's
187        ``default_instance_ttl_s`` setting. Explicit values are
188        clamped to ``max_instance_ttl_s``.
189        """
190        params: dict[str, Any] = {}
191        if ttl is not None:
192            params["ttl"] = ttl
193        resp = self._http.request("POST", f"/instances/{instance_id}/renew", params=params)
194        _raise_for_status(resp)
195        return RenewResponse.model_validate(resp.json())

Extend the TTL of a running instance.

ttl=None (the default) delegates to the platform's default_instance_ttl_s setting. Explicit values are clamped to max_instance_ttl_s.

def shell_session( self, instance_id: str, *, shell: str = 'bash') -> ctfy.server.models.PlayerShellTicket:
197    def shell_session(self, instance_id: str, *, shell: str = "bash") -> PlayerShellTicket:
198        """Mint a single-use ticket for a shell into your own AWD+ box.
199
200        This wraps the *mint* only. Opening the WebSocket the ticket
201        names is the caller's job, because the SDK is a sync httpx
202        client and a shell is a long-lived bidirectional stream — the
203        same reason ``events()`` is not a plain request. ``ctfy patch
204        shell`` is the reference consumer.
205
206        No container argument: the platform resolves the target from the
207        challenge's ``patch.live.service``, and answers
208        ``shell_unsupported`` for a challenge that declares none.
209        """
210        resp = self._http.request(
211            "POST",
212            f"/instances/{instance_id}/shell/sessions",
213            json={"shell": shell},
214        )
215        _raise_for_status(resp)
216        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:
218    def ssh_credential(self, instance_id: str) -> SshCredential:
219        """Mint a short-lived SSH certificate for your own AWD+ box.
220
221        The other half of ``shell_session``: same authorisation, same
222        target, a different protocol in front of it. A defender who
223        would rather use ``ssh`` (and every agent that already drives
224        one) gets a throwaway keypair plus a certificate, and presents
225        them to the bastion — which trades the authenticated subject
226        back for the very ticket ``shell_session`` returns.
227
228        The credential expires in minutes: it only has to cover
229        *connecting*, and the session it opens outlives it. Ask again
230        rather than caching one.
231        """
232        resp = self._http.request("POST", f"/instances/{instance_id}/ssh")
233        _raise_for_status(resp)
234        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]:
236    def list(
237        self,
238        challenge_id: str = "",
239        status: str = "",
240        q: str = "",
241        offset: int = 0,
242        limit: int = 50,
243    ) -> PagedList[InstanceInfo]:
244        """List all running instances."""
245        params: dict[str, Any] = {"offset": offset, "limit": limit}
246        if challenge_id:
247            params["challenge_id"] = challenge_id
248        if status:
249            params["status"] = status
250        if q:
251            params["q"] = q
252        resp = self._http.request("GET", "/instances", params=params)
253        _raise_for_status(resp)
254        return _extract_items(resp.json(), InstanceInfo)

List all running instances.

def attachments(self, instance_id: str) -> ctfy.server.models.AttachmentList:
256    def attachments(self, instance_id: str) -> AttachmentList:
257        """List per-instance (post-launch) attachments.
258
259        For challenges with ``attachments/<name>.tpl`` Jinja templates
260        the names + sizes here are the *rendered* per-team-unique
261        siblings; for ones with only static attachments the list is
262        the same as :meth:`ChallengesResource.attachments`.
263        """
264        resp = self._http.request("GET", f"/instances/{instance_id}/attachments")
265        _raise_for_status(resp)
266        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:
268    def download_attachment(self, instance_id: str, filename: str) -> bytes:
269        """Download one per-instance attachment as raw bytes.
270
271        For challenges with per-team Jinja templates this returns the
272        team-specific render; for static attachments it's the same
273        bytes as :meth:`ChallengesResource.download_attachment` would yield.
274        """
275        resp = self._http.request("GET", f"/instances/{instance_id}/attachments/{filename}")
276        _raise_for_status(resp)
277        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:
279    def vpn_config(self, instance_id: str) -> bytes:
280        """Download the per-instance VPN client config as raw bytes.
281
282        Only meaningful for challenges that declare
283        ``network_topology: engagement`` in metadata.yaml. The body is
284        the rendered profile the player or automated agent hands to
285        their client to land on the challenge's DMZ docker network —
286        an OpenVPN ``.ovpn`` or a WireGuard ``.conf``, per the
287        challenge's ``vpn_backend`` (read it off
288        ``InstanceInfo.vpn_backend`` to know which client to run). The
289        platform serves a 404 for simple-mode instances.
290
291        ⚠️ **On WireGuard the filename matters**, and this returns only
292        bytes. ``wg-quick`` takes the interface name from the file's
293        basename and the kernel caps that at 15 characters, so writing
294        these bytes to ``<instance-id>.conf`` produces a file
295        ``wg-quick`` refuses. The ``Content-Disposition`` on the HTTP
296        response carries a name that works; a caller saving to disk
297        should use it, or pick its own short one.
298
299        Raises a ``CTFyError`` (404) when the challenge isn't an
300        engagement-mode one, when the instance hasn't yet reached the
301        running state, or when the node's VPN container hasn't finished
302        its key bootstrap.
303        """
304        resp = self._http.request("GET", f"/instances/{instance_id}/vpn-config")
305        _raise_for_status(resp)
306        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:
308    def tunnel(self, instance_id: str, *, mode: str = "auto", timeout: float = 30.0) -> Any:
309        """Open a userspace tunnel to this instance; yields a SOCKS5 address.
310
311        The piece that lets an **unprivileged** caller — an agent, a
312        script, a CI job — reach an engagement challenge::
313
314            with client.instances.tunnel(iid) as tun:
315                requests.get(f"http://{host}/", proxies=tun.proxies)
316
317        ⚠️ Before this, fetching :meth:`vpn_config` was the end of the
318        road for anyone who could not run ``wg-quick`` as root. That is
319        every agent: the MCP surface has no tunnel tool, so an agent
320        received a ``config_url`` it had no way to act on, and an
321        engagement challenge was simply unplayable for it.
322
323        Terminating WireGuard in userspace removes the privilege
324        requirement entirely — no ``/dev/net/tun``, no ``CAP_NET_ADMIN``,
325        no root. See :mod:`ctfy.sdk.tunnel` for which backends are
326        accepted and why none is vendored.
327
328        ``mode`` picks the transport:
329
330        ``interface``
331            A real WireGuard device via ``wg-quick``. ICMP, UDP and raw
332            sockets all work, so `ping` and `nmap` behave — needs
333            ``CAP_NET_ADMIN`` and mutates host routes and resolver.
334        ``socks``
335            The userspace tunnel, reachable as ``tun.proxies``. No
336            privileges and no host state touched; ⚠️ **TCP only**.
337        ``auto`` (default)
338            The first of those that this process can use.
339
340        ⚠️ Ask explicitly when it matters. A challenge that needs ICMP
341        will look *broken* under ``socks`` rather than unreachable, and a
342        laptop does not want its routing table rewritten by a test.
343        """
344        from ctfy.sdk.tunnel import open_tunnel, validate_mode
345
346        # Before the request, so a typo is not reported as a missing
347        # instance.
348        validate_mode(mode)
349        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:
351    def openvpn_config(self, instance_id: str) -> bytes:
352        """Deprecated alias for :meth:`vpn_config`.
353
354        Kept because agents and scripts written against the
355        engagement-mode API call it by this name. It returns whatever
356        the challenge actually runs — which for a WireGuard challenge
357        is a ``.conf``, since what the instance ships is not something
358        the method name gets a vote on.
359        """
360        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]:
362    def traffic(
363        self,
364        instance_id: str,
365        *,
366        competition_id: str = "",
367        cursor: str = "",
368        limit: int = 0,
369    ) -> dict[str, Any]:
370        """Traffic captured since *cursor*.
371
372        Answers ``{flows, cursor, truncated, capture}``.
373
374        The capture lives on the worker node; the platform proxies the
375        fetch. An absent *cursor* means the whole capture, bounded by
376        the server's page limits — so a caller that wants everything
377        polls until ``truncated`` is false, carrying each reply's
378        ``cursor`` into the next call.
379
380        ⚠️ **This has been answering ``{}`` for every caller.** The
381        route used to return a bare JSON *list* while this method was
382        annotated ``dict`` and coerced anything else to an empty one —
383        so an SDK user got "no capture" from a box with a full one, with
384        nothing raising. The envelope is what makes the annotation true.
385
386        Still returns an empty dict on a genuinely absent capture
387        (sidecar disabled, node offline, instance never ran) so callers
388        can persist conditionally without try/except.
389        """
390        params: dict[str, Any] = {"competition_id": competition_id} if competition_id else {}
391        if cursor:
392            params["cursor"] = cursor
393        # ⚠️ A caller with a *context window* wants far fewer than the
394        # server default of 500 full flows. Omitted means the default,
395        # so an existing caller is unchanged.
396        if limit > 0:
397            params["limit"] = limit
398        resp = self._http.request("GET", f"/instances/{instance_id}/traffic", params=params)
399        _raise_for_status(resp)
400        data = resp.json()
401        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.