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