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