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 {}
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.
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'sdefault_instance_ttl_ssetting (admin-tunable, currently 24h). Explicit values are clamped to the platform'smax_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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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.