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
 10import builtins
 11from collections.abc import Iterator
 12from typing import Any
 13
 14from ctfy.sdk._helpers import (
 15    InstanceReadyResult,
 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    ) -> builtins.list[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 openvpn_config(self, instance_id: str) -> bytes:
279        """Download the per-instance OpenVPN 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 ``.ovpn`` file the player or automated agent
284        passes to ``openvpn --config …`` to land on the challenge's
285        DMZ docker network. The platform serves a 404 for simple-mode
286        instances.
287
288        Raises a ``CTFyError`` (404) when the challenge isn't an
289        engagement-mode one, when the instance hasn't yet reached the
290        running state, or when the node's openvpn container hasn't
291        finished its PKI bootstrap.
292        """
293        resp = self._http.request("GET", f"/instances/{instance_id}/openvpn-config")
294        _raise_for_status(resp)
295        return resp.content
296
297    def traffic(self, instance_id: str, *, competition_id: str = "") -> dict[str, Any]:
298        """Parsed mitmproxy traffic for a running instance. The flow
299        file lives on the worker node; the platform proxies the fetch.
300
301        Returns an empty dict when no capture exists (sidecar disabled,
302        node offline, instance never reached running) so callers can
303        persist conditionally without try/except.
304        """
305        params = {"competition_id": competition_id} if competition_id else {}
306        resp = self._http.request("GET", f"/instances/{instance_id}/traffic", params=params)
307        _raise_for_status(resp)
308        data = resp.json()
309        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    ) -> builtins.list[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 openvpn_config(self, instance_id: str) -> bytes:
280        """Download the per-instance OpenVPN 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 ``.ovpn`` file the player or automated agent
285        passes to ``openvpn --config …`` to land on the challenge's
286        DMZ docker network. The platform serves a 404 for simple-mode
287        instances.
288
289        Raises a ``CTFyError`` (404) when the challenge isn't an
290        engagement-mode one, when the instance hasn't yet reached the
291        running state, or when the node's openvpn container hasn't
292        finished its PKI bootstrap.
293        """
294        resp = self._http.request("GET", f"/instances/{instance_id}/openvpn-config")
295        _raise_for_status(resp)
296        return resp.content
297
298    def traffic(self, instance_id: str, *, competition_id: str = "") -> dict[str, Any]:
299        """Parsed mitmproxy traffic for a running instance. The flow
300        file lives on the worker node; the platform proxies the fetch.
301
302        Returns an empty dict when no capture exists (sidecar disabled,
303        node offline, instance never reached running) so callers can
304        persist conditionally without try/except.
305        """
306        params = {"competition_id": competition_id} if competition_id else {}
307        resp = self._http.request("GET", f"/instances/{instance_id}/traffic", params=params)
308        _raise_for_status(resp)
309        data = resp.json()
310        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) -> list[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    ) -> builtins.list[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 openvpn_config(self, instance_id: str) -> bytes:
279    def openvpn_config(self, instance_id: str) -> bytes:
280        """Download the per-instance OpenVPN 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 ``.ovpn`` file the player or automated agent
285        passes to ``openvpn --config …`` to land on the challenge's
286        DMZ docker network. The platform serves a 404 for simple-mode
287        instances.
288
289        Raises a ``CTFyError`` (404) when the challenge isn't an
290        engagement-mode one, when the instance hasn't yet reached the
291        running state, or when the node's openvpn container hasn't
292        finished its PKI bootstrap.
293        """
294        resp = self._http.request("GET", f"/instances/{instance_id}/openvpn-config")
295        _raise_for_status(resp)
296        return resp.content

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

Only meaningful for challenges that declare network_topology: engagement in metadata.yaml. The body is the rendered .ovpn file the player or automated agent passes to openvpn --config … to land on the challenge's DMZ docker network. The platform serves a 404 for simple-mode instances.

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 openvpn container hasn't finished its PKI bootstrap.

def traffic( self, instance_id: str, *, competition_id: str = '') -> dict[str, typing.Any]:
298    def traffic(self, instance_id: str, *, competition_id: str = "") -> dict[str, Any]:
299        """Parsed mitmproxy traffic for a running instance. The flow
300        file lives on the worker node; the platform proxies the fetch.
301
302        Returns an empty dict when no capture exists (sidecar disabled,
303        node offline, instance never reached running) so callers can
304        persist conditionally without try/except.
305        """
306        params = {"competition_id": competition_id} if competition_id else {}
307        resp = self._http.request("GET", f"/instances/{instance_id}/traffic", params=params)
308        _raise_for_status(resp)
309        data = resp.json()
310        return data if isinstance(data, dict) else {}

Parsed mitmproxy traffic for a running instance. The flow file lives on the worker node; the platform proxies the fetch.

Returns an empty dict when no capture exists (sidecar disabled, node offline, instance never reached running) so callers can persist conditionally without try/except.