ctfy.sdk.resources.awd

client.awd — classic-AWD attack submissions.

  1"""``client.awd`` — classic-AWD attack submissions."""
  2
  3from __future__ import annotations
  4
  5import builtins
  6
  7from ctfy.sdk._helpers import _raise_for_status
  8from ctfy.sdk.base import BaseHttpClient
  9from ctfy.server.models import (
 10    AwdAttackMatrixResponse,
 11    AwdBoardResponse,
 12    AwdDefenceTimelineResponse,
 13    AwdEventsResponse,
 14    AwdFlagSubmitResponse,
 15    AwdMatchStatus,
 16    AwdRoundHistoryResponse,
 17    AwdTargetsResponse,
 18    AwdVpnPeer,
 19    AwdVpnStatus,
 20    PublicTranscriptResponse,
 21    SshCredential,
 22)
 23
 24
 25class AwdResource:
 26    """A classic-AWD match: submit captured flags, read the board.
 27
 28    ⚠️ **Submit in batches.** A round hands a working exploit hundreds of
 29    flags, and one request per flag is not a slow client — it is an
 30    unplayable event on a single-process platform. The rate limit counts
 31    requests, so a team's whole round fits in one or two calls.
 32    """
 33
 34    def __init__(self, http: BaseHttpClient) -> None:
 35        self._http = http
 36
 37    def submit_flags(self, competition_id: str, flags: builtins.list[str]) -> AwdFlagSubmitResponse:
 38        """Submit captured flags; get one verdict per value, in order.
 39
 40        Results are **positional**: a value the caller cannot match back
 41        (a typo, a truncated read) still occupies its slot, so a client
 42        may zip the reply against what it sent.
 43
 44        Resubmitting is free — a capture is keyed by (attacker, victim,
 45        service, round), so a retried batch reports ``already_captured``
 46        rather than scoring twice.
 47        """
 48        resp = self._http.request(
 49            "POST",
 50            f"/competitions/{competition_id}/awd/flags",
 51            json={"flags": flags},
 52        )
 53        _raise_for_status(resp)
 54        return AwdFlagSubmitResponse.model_validate(resp.json())
 55
 56    def board(self, competition_id: str, *, tick: int | None = None) -> AwdBoardResponse:
 57        """One round's standings, plus each team's service matrix.
 58
 59        Defaults to the **last settled** round, not the one in progress:
 60        settlement runs at a round's end, so the current round has no
 61        scores and asking for it renders an empty board. Pass ``tick``
 62        for history — a settled round's rows never change again.
 63        """
 64        params = {} if tick is None else {"tick": tick}
 65        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/board", params=params)
 66        _raise_for_status(resp)
 67        return AwdBoardResponse.model_validate(resp.json())
 68
 69    def history(self, competition_id: str, *, top: int = 12) -> AwdRoundHistoryResponse:
 70        """Every settled round's standings — the match as a ladder.
 71
 72        A round-based match's time is discrete: scores exist only at
 73        settlement and jump there. :meth:`board` answers "who is ahead
 74        now"; this answers "who has been climbing", which one round
 75        cannot.
 76
 77        ``rank`` is recomputed **per round**, so a crossing shows as a
 78        crossing. ``top`` is a display depth, not an exclusion — the
 79        teams it keeps carry their real place in the field, so a team
 80        that was second reads 2 even when it is the only series returned.
 81        """
 82        resp = self._http.request(
 83            "GET", f"/competitions/{competition_id}/awd/history", params={"top": top}
 84        )
 85        _raise_for_status(resp)
 86        return AwdRoundHistoryResponse.model_validate(resp.json())
 87
 88    def events(self, competition_id: str, *, limit: int = 50) -> AwdEventsResponse:
 89        """Recent settled captures — the spectator "赛况" feed.
 90
 91        Newest first, **settled rounds only**: a capture in the round in
 92        progress carries a flag still worth points, so it is never here.
 93        Each row is tagged for the match-first capture (first blood) and a
 94        victim's first breach; a platform anchor is labelled, never
 95        counted. ``last_tick`` is ``-1`` before the first settlement, when
 96        the list is legitimately empty.
 97        """
 98        resp = self._http.request(
 99            "GET", f"/competitions/{competition_id}/awd/events", params={"limit": limit}
100        )
101        _raise_for_status(resp)
102        return AwdEventsResponse.model_validate(resp.json())
103
104    def transcript(self, competition_id: str, call_id: str) -> PublicTranscriptResponse:
105        """One entered agent's public, scrubbed conversation (B2).
106
107        The spectator counterpart to the operator's transcript drawer, and
108        far more guarded: it returns only after the author's **second**
109        consent (``transcript_published_at``), never for an anonymous
110        entrant, and never while the flag the round captured is still
111        live — an organiser reads live, a spectator waits for the flag to
112        expire. ``build_public_transcript`` emits **only** the agent's own
113        turns, scrubbed; the system prompt and every tool result are
114        dropped structurally, so ``had_system`` / ``had_tool_result`` say
115        they existed without carrying a byte of them.
116
117        ``available`` is ``False`` when the run's body was never kept or
118        has been reclaimed — the row still resolves, so a spectator learns
119        the conversation is gone rather than meeting a 404.
120        """
121        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/transcript/{call_id}")
122        _raise_for_status(resp)
123        return PublicTranscriptResponse.model_validate(resp.json())
124
125    def attack_matrix(
126        self,
127        competition_id: str,
128        *,
129        at_tick: int | None = None,
130        through_tick: int | None = None,
131    ) -> AwdAttackMatrixResponse:
132        """Who has taken what from whom.
133
134        :meth:`board` carries `attack` as one number per team, which
135        cannot say *whose* box you broke or who keeps breaking yours.
136        This is the same rows and columns as the roster, one cell per
137        ordered pair. A pair that never captured is **absent** from
138        ``captures``, never a present zero.
139
140        With neither selector the grid is cumulative through the **last
141        settled round** — the running total. ``at_tick`` isolates one
142        round (a per-round slider), ``through_tick`` moves the cumulative
143        bound to a chosen round; passing both is refused (a matrix is one
144        round or a running total, never both). The response echoes
145        ``at_tick`` so a one-round fold is distinguishable from a
146        cumulative one stopping at the same round.
147        """
148        params: dict[str, int] = {}
149        if at_tick is not None:
150            params["at_tick"] = at_tick
151        if through_tick is not None:
152            params["through_tick"] = through_tick
153        resp = self._http.request(
154            "GET",
155            f"/competitions/{competition_id}/awd/attack-matrix",
156            params=params or None,
157        )
158        _raise_for_status(resp)
159        return AwdAttackMatrixResponse.model_validate(resp.json())
160
161    def defence_timeline(self, competition_id: str) -> AwdDefenceTimelineResponse:
162        """The per-round "did they fix it?" verdict, one row per entrant.
163
164        :meth:`board` carries ``defence`` as one number and cannot say
165        *which rounds* a team held their live box with the service still
166        up. This emits the per-``(team, tick)`` verdict — ``fixed``,
167        ``not_fixed``, ``functionality_broken`` or ``inconclusive`` — for
168        the whole match.
169
170        SHELL-mode matches only for now: a ``patch``-mode match answers
171        ``applicable: false`` with no rows (the round signal lands in a
172        later phase). An entrant the reference exploit never reached is a
173        row with an empty ``cells`` list.
174        """
175        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/defence-timeline")
176        _raise_for_status(resp)
177        return AwdDefenceTimelineResponse.model_validate(resp.json())
178
179    def targets(self, competition_id: str) -> AwdTargetsResponse:
180        """Every gamebox in this match and the address it answers on.
181
182        The attack surface. A gamebox publishes no ports and the match
183        network is internal, so the address here is the only way to
184        reach one — there is no second place to look it up and no way to
185        derive it.
186
187        Your own box is in the list, marked ``is_own``: you need it to
188        know what to defend, and a flag taken from it is refused.
189        """
190        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/targets")
191        _raise_for_status(resp)
192        return AwdTargetsResponse.model_validate(resp.json())
193
194    def ssh_credential(self, competition_id: str) -> SshCredential:
195        """Mint a short-lived SSH certificate for **your own box in this
196        match**, addressed by the match rather than by an instance id.
197
198        ``instances.ssh_credential`` is the same credential and the same
199        authorisation — *(competition, team)* either way. What differs is
200        the key that finds the box: a match identifies a gamebox by its
201        **address** and its owning team, which is what :meth:`targets`
202        publishes and which carries no instance id at all. The id exists
203        only because the platform minted the box; §4.5 has the organiser
204        provision the arena before kickoff, so nobody who plays the match
205        created the thing they are being asked to name.
206
207        The credential expires in minutes — it only has to cover
208        *connecting*, and the session it opens outlives it. Ask again per
209        connect rather than caching one.
210        """
211        resp = self._http.request("POST", f"/competitions/{competition_id}/awd/ssh")
212        _raise_for_status(resp)
213        return SshCredential.model_validate(resp.json())
214
215    def arena_peer(self, competition_id: str) -> AwdVpnStatus:
216        """Whether this account already holds a peer, without rotating it.
217
218        ``vpn()`` cannot answer this: it is idempotent in its *address*
219        and deliberately not in its *key*, so calling it to find out
220        whether you are connected invalidates the ``wg-quick`` file you
221        already have. This is the read — ``peer`` is ``None`` when you
222        hold none, and the config it returns carries a placeholder where
223        the private half goes, because the platform stored none.
224        """
225        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/vpn")
226        _raise_for_status(resp)
227        return AwdVpnStatus.model_validate(resp.json())
228
229    def probe_arena_peer(self, competition_id: str) -> AwdVpnStatus:
230        """Ask the worker, now, whether this machine's tunnel is carrying.
231
232        Same three questions the round asks every peer each tick —
233        handshake, a small ping, a full-size one — but on demand, because
234        the moment you need them is the thirty seconds after
235        ``wg-quick up`` and the next round may be minutes away.
236
237        It probes your own peer and takes no address, so it cannot be
238        pointed anywhere else. Rate limited: it makes the platform dial
239        out to a worker node.
240
241        Refuses with ``awd_probe_unavailable`` when the arena's tunnel is
242        not up on that worker — which is deliberately not the same answer
243        as "your link is down", because the repair is on a different
244        machine.
245        """
246        resp = self._http.request("POST", f"/competitions/{competition_id}/awd/vpn/probe")
247        _raise_for_status(resp)
248        return AwdVpnStatus.model_validate(resp.json())
249
250    def vpn(self, competition_id: str, *, public_key: str = "") -> AwdVpnPeer:
251        """Put this machine on the arena, and get back a ``wg-quick`` file.
252
253        The classic arena is reachable over the tunnel and nowhere else —
254        a gamebox publishes no ports and the match network is an internal
255        bridge — so this is how anything that is not a platform-dispatched
256        container joins it. The peer terminates on the node holding your
257        own team's box.
258
259        **Two key-custody modes.** Pass *public_key* and this platform
260        never sees your private half; the file comes back with a
261        placeholder you fill in. Leave it empty and the platform mints a
262        pair and returns the private half **once** — ``config`` is then
263        complete, and ``private_key_included`` says which of the two you
264        got rather than making you scan the text for a placeholder.
265
266        Idempotent per person and safe to re-run: the address survives —
267        it is what the file on your machine already says — and only the
268        key rotates, which stops the previous file working.
269        """
270        resp = self._http.request(
271            "POST",
272            f"/competitions/{competition_id}/awd/vpn",
273            json={"public_key": public_key},
274        )
275        _raise_for_status(resp)
276        return AwdVpnPeer.model_validate(resp.json())
277
278    def revoke_vpn(self, competition_id: str) -> bool:
279        """Take this machine off the arena. ``True`` if there was a peer.
280
281        ⚠️ **It lands on the next round, not on this call.** The peer
282        lives on a worker node's WireGuard device and the platform
283        reaches it only through the tick's authoritative re-push; what
284        this does is remove the row the next push is built from.
285        """
286        resp = self._http.request("DELETE", f"/competitions/{competition_id}/awd/vpn")
287        _raise_for_status(resp)
288        return bool(resp.json().get("revoked", False))
289
290    def match(self, competition_id: str) -> AwdMatchStatus:
291        """The match's clock and rules — which round it is, and when it ends.
292
293        Public, like the board. ``tick_ends_at`` comes from the server
294        rather than being derived from ``starts_at`` + ``tick_seconds``:
295        the round arithmetic lives in exactly one place, and a client
296        that recomputed it would be the copy that disagrees.
297
298        ``current_tick`` is ``None`` before the first round and after the
299        last — not clamped, because "no round is in progress" is a real
300        state and a submission made then is judged against nothing.
301        """
302        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/match")
303        _raise_for_status(resp)
304        return AwdMatchStatus.model_validate(resp.json())
class AwdResource:
 26class AwdResource:
 27    """A classic-AWD match: submit captured flags, read the board.
 28
 29    ⚠️ **Submit in batches.** A round hands a working exploit hundreds of
 30    flags, and one request per flag is not a slow client — it is an
 31    unplayable event on a single-process platform. The rate limit counts
 32    requests, so a team's whole round fits in one or two calls.
 33    """
 34
 35    def __init__(self, http: BaseHttpClient) -> None:
 36        self._http = http
 37
 38    def submit_flags(self, competition_id: str, flags: builtins.list[str]) -> AwdFlagSubmitResponse:
 39        """Submit captured flags; get one verdict per value, in order.
 40
 41        Results are **positional**: a value the caller cannot match back
 42        (a typo, a truncated read) still occupies its slot, so a client
 43        may zip the reply against what it sent.
 44
 45        Resubmitting is free — a capture is keyed by (attacker, victim,
 46        service, round), so a retried batch reports ``already_captured``
 47        rather than scoring twice.
 48        """
 49        resp = self._http.request(
 50            "POST",
 51            f"/competitions/{competition_id}/awd/flags",
 52            json={"flags": flags},
 53        )
 54        _raise_for_status(resp)
 55        return AwdFlagSubmitResponse.model_validate(resp.json())
 56
 57    def board(self, competition_id: str, *, tick: int | None = None) -> AwdBoardResponse:
 58        """One round's standings, plus each team's service matrix.
 59
 60        Defaults to the **last settled** round, not the one in progress:
 61        settlement runs at a round's end, so the current round has no
 62        scores and asking for it renders an empty board. Pass ``tick``
 63        for history — a settled round's rows never change again.
 64        """
 65        params = {} if tick is None else {"tick": tick}
 66        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/board", params=params)
 67        _raise_for_status(resp)
 68        return AwdBoardResponse.model_validate(resp.json())
 69
 70    def history(self, competition_id: str, *, top: int = 12) -> AwdRoundHistoryResponse:
 71        """Every settled round's standings — the match as a ladder.
 72
 73        A round-based match's time is discrete: scores exist only at
 74        settlement and jump there. :meth:`board` answers "who is ahead
 75        now"; this answers "who has been climbing", which one round
 76        cannot.
 77
 78        ``rank`` is recomputed **per round**, so a crossing shows as a
 79        crossing. ``top`` is a display depth, not an exclusion — the
 80        teams it keeps carry their real place in the field, so a team
 81        that was second reads 2 even when it is the only series returned.
 82        """
 83        resp = self._http.request(
 84            "GET", f"/competitions/{competition_id}/awd/history", params={"top": top}
 85        )
 86        _raise_for_status(resp)
 87        return AwdRoundHistoryResponse.model_validate(resp.json())
 88
 89    def events(self, competition_id: str, *, limit: int = 50) -> AwdEventsResponse:
 90        """Recent settled captures — the spectator "赛况" feed.
 91
 92        Newest first, **settled rounds only**: a capture in the round in
 93        progress carries a flag still worth points, so it is never here.
 94        Each row is tagged for the match-first capture (first blood) and a
 95        victim's first breach; a platform anchor is labelled, never
 96        counted. ``last_tick`` is ``-1`` before the first settlement, when
 97        the list is legitimately empty.
 98        """
 99        resp = self._http.request(
100            "GET", f"/competitions/{competition_id}/awd/events", params={"limit": limit}
101        )
102        _raise_for_status(resp)
103        return AwdEventsResponse.model_validate(resp.json())
104
105    def transcript(self, competition_id: str, call_id: str) -> PublicTranscriptResponse:
106        """One entered agent's public, scrubbed conversation (B2).
107
108        The spectator counterpart to the operator's transcript drawer, and
109        far more guarded: it returns only after the author's **second**
110        consent (``transcript_published_at``), never for an anonymous
111        entrant, and never while the flag the round captured is still
112        live — an organiser reads live, a spectator waits for the flag to
113        expire. ``build_public_transcript`` emits **only** the agent's own
114        turns, scrubbed; the system prompt and every tool result are
115        dropped structurally, so ``had_system`` / ``had_tool_result`` say
116        they existed without carrying a byte of them.
117
118        ``available`` is ``False`` when the run's body was never kept or
119        has been reclaimed — the row still resolves, so a spectator learns
120        the conversation is gone rather than meeting a 404.
121        """
122        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/transcript/{call_id}")
123        _raise_for_status(resp)
124        return PublicTranscriptResponse.model_validate(resp.json())
125
126    def attack_matrix(
127        self,
128        competition_id: str,
129        *,
130        at_tick: int | None = None,
131        through_tick: int | None = None,
132    ) -> AwdAttackMatrixResponse:
133        """Who has taken what from whom.
134
135        :meth:`board` carries `attack` as one number per team, which
136        cannot say *whose* box you broke or who keeps breaking yours.
137        This is the same rows and columns as the roster, one cell per
138        ordered pair. A pair that never captured is **absent** from
139        ``captures``, never a present zero.
140
141        With neither selector the grid is cumulative through the **last
142        settled round** — the running total. ``at_tick`` isolates one
143        round (a per-round slider), ``through_tick`` moves the cumulative
144        bound to a chosen round; passing both is refused (a matrix is one
145        round or a running total, never both). The response echoes
146        ``at_tick`` so a one-round fold is distinguishable from a
147        cumulative one stopping at the same round.
148        """
149        params: dict[str, int] = {}
150        if at_tick is not None:
151            params["at_tick"] = at_tick
152        if through_tick is not None:
153            params["through_tick"] = through_tick
154        resp = self._http.request(
155            "GET",
156            f"/competitions/{competition_id}/awd/attack-matrix",
157            params=params or None,
158        )
159        _raise_for_status(resp)
160        return AwdAttackMatrixResponse.model_validate(resp.json())
161
162    def defence_timeline(self, competition_id: str) -> AwdDefenceTimelineResponse:
163        """The per-round "did they fix it?" verdict, one row per entrant.
164
165        :meth:`board` carries ``defence`` as one number and cannot say
166        *which rounds* a team held their live box with the service still
167        up. This emits the per-``(team, tick)`` verdict — ``fixed``,
168        ``not_fixed``, ``functionality_broken`` or ``inconclusive`` — for
169        the whole match.
170
171        SHELL-mode matches only for now: a ``patch``-mode match answers
172        ``applicable: false`` with no rows (the round signal lands in a
173        later phase). An entrant the reference exploit never reached is a
174        row with an empty ``cells`` list.
175        """
176        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/defence-timeline")
177        _raise_for_status(resp)
178        return AwdDefenceTimelineResponse.model_validate(resp.json())
179
180    def targets(self, competition_id: str) -> AwdTargetsResponse:
181        """Every gamebox in this match and the address it answers on.
182
183        The attack surface. A gamebox publishes no ports and the match
184        network is internal, so the address here is the only way to
185        reach one — there is no second place to look it up and no way to
186        derive it.
187
188        Your own box is in the list, marked ``is_own``: you need it to
189        know what to defend, and a flag taken from it is refused.
190        """
191        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/targets")
192        _raise_for_status(resp)
193        return AwdTargetsResponse.model_validate(resp.json())
194
195    def ssh_credential(self, competition_id: str) -> SshCredential:
196        """Mint a short-lived SSH certificate for **your own box in this
197        match**, addressed by the match rather than by an instance id.
198
199        ``instances.ssh_credential`` is the same credential and the same
200        authorisation — *(competition, team)* either way. What differs is
201        the key that finds the box: a match identifies a gamebox by its
202        **address** and its owning team, which is what :meth:`targets`
203        publishes and which carries no instance id at all. The id exists
204        only because the platform minted the box; §4.5 has the organiser
205        provision the arena before kickoff, so nobody who plays the match
206        created the thing they are being asked to name.
207
208        The credential expires in minutes — it only has to cover
209        *connecting*, and the session it opens outlives it. Ask again per
210        connect rather than caching one.
211        """
212        resp = self._http.request("POST", f"/competitions/{competition_id}/awd/ssh")
213        _raise_for_status(resp)
214        return SshCredential.model_validate(resp.json())
215
216    def arena_peer(self, competition_id: str) -> AwdVpnStatus:
217        """Whether this account already holds a peer, without rotating it.
218
219        ``vpn()`` cannot answer this: it is idempotent in its *address*
220        and deliberately not in its *key*, so calling it to find out
221        whether you are connected invalidates the ``wg-quick`` file you
222        already have. This is the read — ``peer`` is ``None`` when you
223        hold none, and the config it returns carries a placeholder where
224        the private half goes, because the platform stored none.
225        """
226        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/vpn")
227        _raise_for_status(resp)
228        return AwdVpnStatus.model_validate(resp.json())
229
230    def probe_arena_peer(self, competition_id: str) -> AwdVpnStatus:
231        """Ask the worker, now, whether this machine's tunnel is carrying.
232
233        Same three questions the round asks every peer each tick —
234        handshake, a small ping, a full-size one — but on demand, because
235        the moment you need them is the thirty seconds after
236        ``wg-quick up`` and the next round may be minutes away.
237
238        It probes your own peer and takes no address, so it cannot be
239        pointed anywhere else. Rate limited: it makes the platform dial
240        out to a worker node.
241
242        Refuses with ``awd_probe_unavailable`` when the arena's tunnel is
243        not up on that worker — which is deliberately not the same answer
244        as "your link is down", because the repair is on a different
245        machine.
246        """
247        resp = self._http.request("POST", f"/competitions/{competition_id}/awd/vpn/probe")
248        _raise_for_status(resp)
249        return AwdVpnStatus.model_validate(resp.json())
250
251    def vpn(self, competition_id: str, *, public_key: str = "") -> AwdVpnPeer:
252        """Put this machine on the arena, and get back a ``wg-quick`` file.
253
254        The classic arena is reachable over the tunnel and nowhere else —
255        a gamebox publishes no ports and the match network is an internal
256        bridge — so this is how anything that is not a platform-dispatched
257        container joins it. The peer terminates on the node holding your
258        own team's box.
259
260        **Two key-custody modes.** Pass *public_key* and this platform
261        never sees your private half; the file comes back with a
262        placeholder you fill in. Leave it empty and the platform mints a
263        pair and returns the private half **once** — ``config`` is then
264        complete, and ``private_key_included`` says which of the two you
265        got rather than making you scan the text for a placeholder.
266
267        Idempotent per person and safe to re-run: the address survives —
268        it is what the file on your machine already says — and only the
269        key rotates, which stops the previous file working.
270        """
271        resp = self._http.request(
272            "POST",
273            f"/competitions/{competition_id}/awd/vpn",
274            json={"public_key": public_key},
275        )
276        _raise_for_status(resp)
277        return AwdVpnPeer.model_validate(resp.json())
278
279    def revoke_vpn(self, competition_id: str) -> bool:
280        """Take this machine off the arena. ``True`` if there was a peer.
281
282        ⚠️ **It lands on the next round, not on this call.** The peer
283        lives on a worker node's WireGuard device and the platform
284        reaches it only through the tick's authoritative re-push; what
285        this does is remove the row the next push is built from.
286        """
287        resp = self._http.request("DELETE", f"/competitions/{competition_id}/awd/vpn")
288        _raise_for_status(resp)
289        return bool(resp.json().get("revoked", False))
290
291    def match(self, competition_id: str) -> AwdMatchStatus:
292        """The match's clock and rules — which round it is, and when it ends.
293
294        Public, like the board. ``tick_ends_at`` comes from the server
295        rather than being derived from ``starts_at`` + ``tick_seconds``:
296        the round arithmetic lives in exactly one place, and a client
297        that recomputed it would be the copy that disagrees.
298
299        ``current_tick`` is ``None`` before the first round and after the
300        last — not clamped, because "no round is in progress" is a real
301        state and a submission made then is judged against nothing.
302        """
303        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/match")
304        _raise_for_status(resp)
305        return AwdMatchStatus.model_validate(resp.json())

A classic-AWD match: submit captured flags, read the board.

⚠️ Submit in batches. A round hands a working exploit hundreds of flags, and one request per flag is not a slow client — it is an unplayable event on a single-process platform. The rate limit counts requests, so a team's whole round fits in one or two calls.

AwdResource(http: ctfy.sdk.base.BaseHttpClient)
35    def __init__(self, http: BaseHttpClient) -> None:
36        self._http = http
def submit_flags( self, competition_id: str, flags: list[str]) -> ctfy.server.models.AwdFlagSubmitResponse:
38    def submit_flags(self, competition_id: str, flags: builtins.list[str]) -> AwdFlagSubmitResponse:
39        """Submit captured flags; get one verdict per value, in order.
40
41        Results are **positional**: a value the caller cannot match back
42        (a typo, a truncated read) still occupies its slot, so a client
43        may zip the reply against what it sent.
44
45        Resubmitting is free — a capture is keyed by (attacker, victim,
46        service, round), so a retried batch reports ``already_captured``
47        rather than scoring twice.
48        """
49        resp = self._http.request(
50            "POST",
51            f"/competitions/{competition_id}/awd/flags",
52            json={"flags": flags},
53        )
54        _raise_for_status(resp)
55        return AwdFlagSubmitResponse.model_validate(resp.json())

Submit captured flags; get one verdict per value, in order.

Results are positional: a value the caller cannot match back (a typo, a truncated read) still occupies its slot, so a client may zip the reply against what it sent.

Resubmitting is free — a capture is keyed by (attacker, victim, service, round), so a retried batch reports already_captured rather than scoring twice.

def board( self, competition_id: str, *, tick: int | None = None) -> ctfy.server.models.AwdBoardResponse:
57    def board(self, competition_id: str, *, tick: int | None = None) -> AwdBoardResponse:
58        """One round's standings, plus each team's service matrix.
59
60        Defaults to the **last settled** round, not the one in progress:
61        settlement runs at a round's end, so the current round has no
62        scores and asking for it renders an empty board. Pass ``tick``
63        for history — a settled round's rows never change again.
64        """
65        params = {} if tick is None else {"tick": tick}
66        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/board", params=params)
67        _raise_for_status(resp)
68        return AwdBoardResponse.model_validate(resp.json())

One round's standings, plus each team's service matrix.

Defaults to the last settled round, not the one in progress: settlement runs at a round's end, so the current round has no scores and asking for it renders an empty board. Pass tick for history — a settled round's rows never change again.

def history( self, competition_id: str, *, top: int = 12) -> ctfy.server.models.AwdRoundHistoryResponse:
70    def history(self, competition_id: str, *, top: int = 12) -> AwdRoundHistoryResponse:
71        """Every settled round's standings — the match as a ladder.
72
73        A round-based match's time is discrete: scores exist only at
74        settlement and jump there. :meth:`board` answers "who is ahead
75        now"; this answers "who has been climbing", which one round
76        cannot.
77
78        ``rank`` is recomputed **per round**, so a crossing shows as a
79        crossing. ``top`` is a display depth, not an exclusion — the
80        teams it keeps carry their real place in the field, so a team
81        that was second reads 2 even when it is the only series returned.
82        """
83        resp = self._http.request(
84            "GET", f"/competitions/{competition_id}/awd/history", params={"top": top}
85        )
86        _raise_for_status(resp)
87        return AwdRoundHistoryResponse.model_validate(resp.json())

Every settled round's standings — the match as a ladder.

A round-based match's time is discrete: scores exist only at settlement and jump there. board() answers "who is ahead now"; this answers "who has been climbing", which one round cannot.

rank is recomputed per round, so a crossing shows as a crossing. top is a display depth, not an exclusion — the teams it keeps carry their real place in the field, so a team that was second reads 2 even when it is the only series returned.

def events( self, competition_id: str, *, limit: int = 50) -> ctfy.server.models.AwdEventsResponse:
 89    def events(self, competition_id: str, *, limit: int = 50) -> AwdEventsResponse:
 90        """Recent settled captures — the spectator "赛况" feed.
 91
 92        Newest first, **settled rounds only**: a capture in the round in
 93        progress carries a flag still worth points, so it is never here.
 94        Each row is tagged for the match-first capture (first blood) and a
 95        victim's first breach; a platform anchor is labelled, never
 96        counted. ``last_tick`` is ``-1`` before the first settlement, when
 97        the list is legitimately empty.
 98        """
 99        resp = self._http.request(
100            "GET", f"/competitions/{competition_id}/awd/events", params={"limit": limit}
101        )
102        _raise_for_status(resp)
103        return AwdEventsResponse.model_validate(resp.json())

Recent settled captures — the spectator "赛况" feed.

Newest first, settled rounds only: a capture in the round in progress carries a flag still worth points, so it is never here. Each row is tagged for the match-first capture (first blood) and a victim's first breach; a platform anchor is labelled, never counted. last_tick is -1 before the first settlement, when the list is legitimately empty.

def transcript( self, competition_id: str, call_id: str) -> ctfy.server.models.PublicTranscriptResponse:
105    def transcript(self, competition_id: str, call_id: str) -> PublicTranscriptResponse:
106        """One entered agent's public, scrubbed conversation (B2).
107
108        The spectator counterpart to the operator's transcript drawer, and
109        far more guarded: it returns only after the author's **second**
110        consent (``transcript_published_at``), never for an anonymous
111        entrant, and never while the flag the round captured is still
112        live — an organiser reads live, a spectator waits for the flag to
113        expire. ``build_public_transcript`` emits **only** the agent's own
114        turns, scrubbed; the system prompt and every tool result are
115        dropped structurally, so ``had_system`` / ``had_tool_result`` say
116        they existed without carrying a byte of them.
117
118        ``available`` is ``False`` when the run's body was never kept or
119        has been reclaimed — the row still resolves, so a spectator learns
120        the conversation is gone rather than meeting a 404.
121        """
122        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/transcript/{call_id}")
123        _raise_for_status(resp)
124        return PublicTranscriptResponse.model_validate(resp.json())

One entered agent's public, scrubbed conversation (B2).

The spectator counterpart to the operator's transcript drawer, and far more guarded: it returns only after the author's second consent (transcript_published_at), never for an anonymous entrant, and never while the flag the round captured is still live — an organiser reads live, a spectator waits for the flag to expire. build_public_transcript emits only the agent's own turns, scrubbed; the system prompt and every tool result are dropped structurally, so had_system / had_tool_result say they existed without carrying a byte of them.

available is False when the run's body was never kept or has been reclaimed — the row still resolves, so a spectator learns the conversation is gone rather than meeting a 404.

def attack_matrix( self, competition_id: str, *, at_tick: int | None = None, through_tick: int | None = None) -> ctfy.server.models.AwdAttackMatrixResponse:
126    def attack_matrix(
127        self,
128        competition_id: str,
129        *,
130        at_tick: int | None = None,
131        through_tick: int | None = None,
132    ) -> AwdAttackMatrixResponse:
133        """Who has taken what from whom.
134
135        :meth:`board` carries `attack` as one number per team, which
136        cannot say *whose* box you broke or who keeps breaking yours.
137        This is the same rows and columns as the roster, one cell per
138        ordered pair. A pair that never captured is **absent** from
139        ``captures``, never a present zero.
140
141        With neither selector the grid is cumulative through the **last
142        settled round** — the running total. ``at_tick`` isolates one
143        round (a per-round slider), ``through_tick`` moves the cumulative
144        bound to a chosen round; passing both is refused (a matrix is one
145        round or a running total, never both). The response echoes
146        ``at_tick`` so a one-round fold is distinguishable from a
147        cumulative one stopping at the same round.
148        """
149        params: dict[str, int] = {}
150        if at_tick is not None:
151            params["at_tick"] = at_tick
152        if through_tick is not None:
153            params["through_tick"] = through_tick
154        resp = self._http.request(
155            "GET",
156            f"/competitions/{competition_id}/awd/attack-matrix",
157            params=params or None,
158        )
159        _raise_for_status(resp)
160        return AwdAttackMatrixResponse.model_validate(resp.json())

Who has taken what from whom.

board() carries attack as one number per team, which cannot say whose box you broke or who keeps breaking yours. This is the same rows and columns as the roster, one cell per ordered pair. A pair that never captured is absent from captures, never a present zero.

With neither selector the grid is cumulative through the last settled round — the running total. at_tick isolates one round (a per-round slider), through_tick moves the cumulative bound to a chosen round; passing both is refused (a matrix is one round or a running total, never both). The response echoes at_tick so a one-round fold is distinguishable from a cumulative one stopping at the same round.

def defence_timeline( self, competition_id: str) -> ctfy.server.models.AwdDefenceTimelineResponse:
162    def defence_timeline(self, competition_id: str) -> AwdDefenceTimelineResponse:
163        """The per-round "did they fix it?" verdict, one row per entrant.
164
165        :meth:`board` carries ``defence`` as one number and cannot say
166        *which rounds* a team held their live box with the service still
167        up. This emits the per-``(team, tick)`` verdict — ``fixed``,
168        ``not_fixed``, ``functionality_broken`` or ``inconclusive`` — for
169        the whole match.
170
171        SHELL-mode matches only for now: a ``patch``-mode match answers
172        ``applicable: false`` with no rows (the round signal lands in a
173        later phase). An entrant the reference exploit never reached is a
174        row with an empty ``cells`` list.
175        """
176        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/defence-timeline")
177        _raise_for_status(resp)
178        return AwdDefenceTimelineResponse.model_validate(resp.json())

The per-round "did they fix it?" verdict, one row per entrant.

board() carries defence as one number and cannot say which rounds a team held their live box with the service still up. This emits the per-(team, tick) verdict — fixed, not_fixed, functionality_broken or inconclusive — for the whole match.

SHELL-mode matches only for now: a patch-mode match answers applicable: false with no rows (the round signal lands in a later phase). An entrant the reference exploit never reached is a row with an empty cells list.

def targets(self, competition_id: str) -> ctfy.server.models.AwdTargetsResponse:
180    def targets(self, competition_id: str) -> AwdTargetsResponse:
181        """Every gamebox in this match and the address it answers on.
182
183        The attack surface. A gamebox publishes no ports and the match
184        network is internal, so the address here is the only way to
185        reach one — there is no second place to look it up and no way to
186        derive it.
187
188        Your own box is in the list, marked ``is_own``: you need it to
189        know what to defend, and a flag taken from it is refused.
190        """
191        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/targets")
192        _raise_for_status(resp)
193        return AwdTargetsResponse.model_validate(resp.json())

Every gamebox in this match and the address it answers on.

The attack surface. A gamebox publishes no ports and the match network is internal, so the address here is the only way to reach one — there is no second place to look it up and no way to derive it.

Your own box is in the list, marked is_own: you need it to know what to defend, and a flag taken from it is refused.

def ssh_credential(self, competition_id: str) -> ctfy.server.models.SshCredential:
195    def ssh_credential(self, competition_id: str) -> SshCredential:
196        """Mint a short-lived SSH certificate for **your own box in this
197        match**, addressed by the match rather than by an instance id.
198
199        ``instances.ssh_credential`` is the same credential and the same
200        authorisation — *(competition, team)* either way. What differs is
201        the key that finds the box: a match identifies a gamebox by its
202        **address** and its owning team, which is what :meth:`targets`
203        publishes and which carries no instance id at all. The id exists
204        only because the platform minted the box; §4.5 has the organiser
205        provision the arena before kickoff, so nobody who plays the match
206        created the thing they are being asked to name.
207
208        The credential expires in minutes — it only has to cover
209        *connecting*, and the session it opens outlives it. Ask again per
210        connect rather than caching one.
211        """
212        resp = self._http.request("POST", f"/competitions/{competition_id}/awd/ssh")
213        _raise_for_status(resp)
214        return SshCredential.model_validate(resp.json())

Mint a short-lived SSH certificate for your own box in this match, addressed by the match rather than by an instance id.

instances.ssh_credential is the same credential and the same authorisation — (competition, team) either way. What differs is the key that finds the box: a match identifies a gamebox by its address and its owning team, which is what targets() publishes and which carries no instance id at all. The id exists only because the platform minted the box; §4.5 has the organiser provision the arena before kickoff, so nobody who plays the match created the thing they are being asked to name.

The credential expires in minutes — it only has to cover connecting, and the session it opens outlives it. Ask again per connect rather than caching one.

def arena_peer(self, competition_id: str) -> ctfy.server.models.AwdVpnStatus:
216    def arena_peer(self, competition_id: str) -> AwdVpnStatus:
217        """Whether this account already holds a peer, without rotating it.
218
219        ``vpn()`` cannot answer this: it is idempotent in its *address*
220        and deliberately not in its *key*, so calling it to find out
221        whether you are connected invalidates the ``wg-quick`` file you
222        already have. This is the read — ``peer`` is ``None`` when you
223        hold none, and the config it returns carries a placeholder where
224        the private half goes, because the platform stored none.
225        """
226        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/vpn")
227        _raise_for_status(resp)
228        return AwdVpnStatus.model_validate(resp.json())

Whether this account already holds a peer, without rotating it.

vpn() cannot answer this: it is idempotent in its address and deliberately not in its key, so calling it to find out whether you are connected invalidates the wg-quick file you already have. This is the read — peer is None when you hold none, and the config it returns carries a placeholder where the private half goes, because the platform stored none.

def probe_arena_peer(self, competition_id: str) -> ctfy.server.models.AwdVpnStatus:
230    def probe_arena_peer(self, competition_id: str) -> AwdVpnStatus:
231        """Ask the worker, now, whether this machine's tunnel is carrying.
232
233        Same three questions the round asks every peer each tick —
234        handshake, a small ping, a full-size one — but on demand, because
235        the moment you need them is the thirty seconds after
236        ``wg-quick up`` and the next round may be minutes away.
237
238        It probes your own peer and takes no address, so it cannot be
239        pointed anywhere else. Rate limited: it makes the platform dial
240        out to a worker node.
241
242        Refuses with ``awd_probe_unavailable`` when the arena's tunnel is
243        not up on that worker — which is deliberately not the same answer
244        as "your link is down", because the repair is on a different
245        machine.
246        """
247        resp = self._http.request("POST", f"/competitions/{competition_id}/awd/vpn/probe")
248        _raise_for_status(resp)
249        return AwdVpnStatus.model_validate(resp.json())

Ask the worker, now, whether this machine's tunnel is carrying.

Same three questions the round asks every peer each tick — handshake, a small ping, a full-size one — but on demand, because the moment you need them is the thirty seconds after wg-quick up and the next round may be minutes away.

It probes your own peer and takes no address, so it cannot be pointed anywhere else. Rate limited: it makes the platform dial out to a worker node.

Refuses with awd_probe_unavailable when the arena's tunnel is not up on that worker — which is deliberately not the same answer as "your link is down", because the repair is on a different machine.

def vpn( self, competition_id: str, *, public_key: str = '') -> ctfy.server.models.AwdVpnPeer:
251    def vpn(self, competition_id: str, *, public_key: str = "") -> AwdVpnPeer:
252        """Put this machine on the arena, and get back a ``wg-quick`` file.
253
254        The classic arena is reachable over the tunnel and nowhere else —
255        a gamebox publishes no ports and the match network is an internal
256        bridge — so this is how anything that is not a platform-dispatched
257        container joins it. The peer terminates on the node holding your
258        own team's box.
259
260        **Two key-custody modes.** Pass *public_key* and this platform
261        never sees your private half; the file comes back with a
262        placeholder you fill in. Leave it empty and the platform mints a
263        pair and returns the private half **once** — ``config`` is then
264        complete, and ``private_key_included`` says which of the two you
265        got rather than making you scan the text for a placeholder.
266
267        Idempotent per person and safe to re-run: the address survives —
268        it is what the file on your machine already says — and only the
269        key rotates, which stops the previous file working.
270        """
271        resp = self._http.request(
272            "POST",
273            f"/competitions/{competition_id}/awd/vpn",
274            json={"public_key": public_key},
275        )
276        _raise_for_status(resp)
277        return AwdVpnPeer.model_validate(resp.json())

Put this machine on the arena, and get back a wg-quick file.

The classic arena is reachable over the tunnel and nowhere else — a gamebox publishes no ports and the match network is an internal bridge — so this is how anything that is not a platform-dispatched container joins it. The peer terminates on the node holding your own team's box.

Two key-custody modes. Pass public_key and this platform never sees your private half; the file comes back with a placeholder you fill in. Leave it empty and the platform mints a pair and returns the private half once — config is then complete, and private_key_included says which of the two you got rather than making you scan the text for a placeholder.

Idempotent per person and safe to re-run: the address survives — it is what the file on your machine already says — and only the key rotates, which stops the previous file working.

def revoke_vpn(self, competition_id: str) -> bool:
279    def revoke_vpn(self, competition_id: str) -> bool:
280        """Take this machine off the arena. ``True`` if there was a peer.
281
282        ⚠️ **It lands on the next round, not on this call.** The peer
283        lives on a worker node's WireGuard device and the platform
284        reaches it only through the tick's authoritative re-push; what
285        this does is remove the row the next push is built from.
286        """
287        resp = self._http.request("DELETE", f"/competitions/{competition_id}/awd/vpn")
288        _raise_for_status(resp)
289        return bool(resp.json().get("revoked", False))

Take this machine off the arena. True if there was a peer.

⚠️ It lands on the next round, not on this call. The peer lives on a worker node's WireGuard device and the platform reaches it only through the tick's authoritative re-push; what this does is remove the row the next push is built from.

def match(self, competition_id: str) -> ctfy.server.models.AwdMatchStatus:
291    def match(self, competition_id: str) -> AwdMatchStatus:
292        """The match's clock and rules — which round it is, and when it ends.
293
294        Public, like the board. ``tick_ends_at`` comes from the server
295        rather than being derived from ``starts_at`` + ``tick_seconds``:
296        the round arithmetic lives in exactly one place, and a client
297        that recomputed it would be the copy that disagrees.
298
299        ``current_tick`` is ``None`` before the first round and after the
300        last — not clamped, because "no round is in progress" is a real
301        state and a submission made then is judged against nothing.
302        """
303        resp = self._http.request("GET", f"/competitions/{competition_id}/awd/match")
304        _raise_for_status(resp)
305        return AwdMatchStatus.model_validate(resp.json())

The match's clock and rules — which round it is, and when it ends.

Public, like the board. tick_ends_at comes from the server rather than being derived from starts_at + tick_seconds: the round arithmetic lives in exactly one place, and a client that recomputed it would be the copy that disagrees.

current_tick is None before the first round and after the last — not clamped, because "no round is in progress" is a real state and a submission made then is judged against nothing.