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