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

Start / inspect / tear down challenge instances.

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

Start instance and wait until ready.

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

A shared challenge (InstanceInfo.shared / ChallengeInfo.shared) is one environment for several teams: this call returns the box your group already shares (starting it if none is running), the flag is the same value for everyone on it, and you can neither stop, renew nor reset it -- those answer 403, the organisers reset it. If it looks broken, report_issue(). Your solves, wrong attempts and unlocked questions are still your own team's.

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

Get instance status and attack surface.

def stop(self, instance_id: str) -> None:
172    def stop(self, instance_id: str) -> None:
173        """Tear down ``instance_id``. Idempotent: a 404 from the server
174        means the instance is already gone (auto-stopped after solve,
175        TTL expiry, admin teardown) and is treated as success."""
176        resp = self._http.request("DELETE", f"/instances/{instance_id}")
177        if resp.status_code != 404:
178            _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:
180    def reset(self, instance_id: str, ttl: int | None = None) -> StartResponse:
181        """Swap ``instance_id`` for a freshly-built one, atomically.
182
183        Returns the **new** instance id — the old one is gone. Prefer
184        this over ``stop`` + ``start``: the team's slot is never
185        released between the two, so a full platform can still reset,
186        and every refusal (unknown challenge, no node with room) is
187        decided before the running environment is torn down.
188
189        Minted answers are new; solves, the ``requires:`` reveal chain
190        and the per-question wrong-attempt counters are keyed
191        ``(team, challenge)`` and carry over untouched.
192        """
193        params: dict[str, Any] = {}
194        if ttl is not None:
195            params["ttl"] = ttl
196        resp = self._http.request("POST", f"/instances/{instance_id}/reset", params=params)
197        _raise_for_status(resp)
198        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:
200    def renew(self, instance_id: str, ttl: int | None = None) -> RenewResponse:
201        """Extend the TTL of a running instance.
202
203        ``ttl`` is an **increment**, not a new lifetime: it is added to
204        whatever time the box has left, and the resulting deadline is
205        capped at ``max_instance_ttl_s`` seconds from now. So a renewal
206        can never move a deadline backwards, and one on a box already
207        at the ceiling is a no-op rather than a refusal.
208
209        ``ttl=None`` (the default) delegates to the platform's
210        ``default_instance_ttl_s`` setting.
211
212        Read the granted deadline off ``RenewResponse.expires_at``
213        rather than computing it — see
214        :func:`ctfy.core.instance_ttl.plan_renewal`. ``0.0`` there means
215        the instance has no deadline at all (an AWD match gamebox), so
216        there was nothing to extend.
217        """
218        params: dict[str, Any] = {}
219        if ttl is not None:
220            params["ttl"] = ttl
221        resp = self._http.request("POST", f"/instances/{instance_id}/renew", params=params)
222        _raise_for_status(resp)
223        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 report_issue( self, instance_id: str) -> ctfy.server.models.SharedEnvReportResult:
225    def report_issue(self, instance_id: str) -> SharedEnvReportResult:
226        """Tell the organisers that a *shared* environment looks broken.
227
228        The only action a player has on a shared box: it changes nothing,
229        the organisers decide whether to reset. Limited to one report per
230        team per challenge every few minutes (429 ``shared_report_rate_limited``
231        beyond that); 409 on a box that is yours alone -- reset that one
232        yourself.
233        """
234        resp = self._http.request("POST", f"/instances/{instance_id}/report-issue")
235        _raise_for_status(resp)
236        return SharedEnvReportResult.model_validate(resp.json())

Tell the organisers that a shared environment looks broken.

The only action a player has on a shared box: it changes nothing, the organisers decide whether to reset. Limited to one report per team per challenge every few minutes (429 shared_report_rate_limited beyond that); 409 on a box that is yours alone -- reset that one yourself.

def shell_session( self, instance_id: str, *, shell: str = 'bash') -> ctfy.server.models.PlayerShellTicket:
238    def shell_session(self, instance_id: str, *, shell: str = "bash") -> PlayerShellTicket:
239        """Mint a single-use ticket for a shell into your own AWD+ box.
240
241        This wraps the *mint* only. Opening the WebSocket the ticket
242        names is the caller's job, because the SDK is a sync httpx
243        client and a shell is a long-lived bidirectional stream — the
244        same reason ``events()`` is not a plain request. ``ctfy patch
245        shell`` is the reference consumer.
246
247        No container argument: the platform resolves the target from the
248        challenge's ``patch.live.service``, and answers
249        ``shell_unsupported`` for a challenge that declares none.
250        """
251        resp = self._http.request(
252            "POST",
253            f"/instances/{instance_id}/shell/sessions",
254            json={"shell": shell},
255        )
256        _raise_for_status(resp)
257        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:
259    def ssh_credential(self, instance_id: str) -> SshCredential:
260        """Mint a short-lived SSH certificate for your own AWD+ box.
261
262        The other half of ``shell_session``: same authorisation, same
263        target, a different protocol in front of it. A defender who
264        would rather use ``ssh`` (and every agent that already drives
265        one) gets a throwaway keypair plus a certificate, and presents
266        them to the bastion — which trades the authenticated subject
267        back for the very ticket ``shell_session`` returns.
268
269        The credential expires in minutes: it only has to cover
270        *connecting*, and the session it opens outlives it. Ask again
271        rather than caching one.
272        """
273        resp = self._http.request("POST", f"/instances/{instance_id}/ssh")
274        _raise_for_status(resp)
275        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]:
277    def list(
278        self,
279        challenge_id: str = "",
280        status: str = "",
281        q: str = "",
282        offset: int = 0,
283        limit: int = 50,
284    ) -> PagedList[InstanceInfo]:
285        """List all running instances."""
286        params: dict[str, Any] = {"offset": offset, "limit": limit}
287        if challenge_id:
288            params["challenge_id"] = challenge_id
289        if status:
290            params["status"] = status
291        if q:
292            params["q"] = q
293        resp = self._http.request("GET", "/instances", params=params)
294        _raise_for_status(resp)
295        return _extract_items(resp.json(), InstanceInfo)

List all running instances.

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