ctf-attackapi 0.1.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/PKG-INFO +81 -15
  2. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/README.md +80 -14
  3. ctf_attackapi-0.3.0/pyproject.toml +89 -0
  4. ctf_attackapi-0.1.1/pyproject.toml → ctf_attackapi-0.3.0/pyproject.toml.orig +1 -1
  5. ctf_attackapi-0.3.0/src/attackapi/__init__.py +4 -0
  6. ctf_attackapi-0.3.0/src/attackapi/async_api/__init__.py +5 -0
  7. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/async_api/api.py +47 -24
  8. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/async_api/decoders.py +37 -7
  9. ctf_attackapi-0.3.0/src/attackapi/models.py +189 -0
  10. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/server.py +14 -14
  11. ctf_attackapi-0.1.1/src/attackapi/__init__.py +0 -4
  12. ctf_attackapi-0.1.1/src/attackapi/async_api/__init__.py +0 -4
  13. ctf_attackapi-0.1.1/src/attackapi/models.py +0 -134
  14. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/async_api/filelock.py +0 -0
  15. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/functional.py +0 -0
  16. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/py.typed +0 -0
  17. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/__init__.py +0 -0
  18. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/__main__.py +0 -0
  19. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/docs.py +0 -0
  20. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/worker.py +0 -0
  21. {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/sync_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctf-attackapi
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached.
5
5
  Keywords: Attack-Defense,CTF,Attack API,Attack Info,Flag IDs,FAUST CTF,ENOWARS,saarCTF
6
6
  Author: Markus Bauer
@@ -36,11 +36,11 @@ CTF AttackAPI - Cached and Unified!
36
36
  ===================================
37
37
 
38
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/license/mit)
39
- ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
40
- ![Types](https://img.shields.io/pypi/types/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
39
+ ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi)
40
+ ![Types](https://img.shields.io/pypi/types/ctf-attackapi)
41
41
  [![Python package tests](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml/badge.svg)](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml)
42
- [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)](https://pypi.org/project/ctf-attackapi)
43
- ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
42
+ [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi)](https://pypi.org/project/ctf-attackapi)
43
+ ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi)
44
44
  ![Repo size](https://img.shields.io/github/repo-size/Attacking-Lab/ctf-attackapi)
45
45
 
46
46
 
@@ -54,6 +54,45 @@ Downloading that file for every exploit you're firing is costing time and bandwi
54
54
 
55
55
  This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
56
56
 
57
+ Changes in 0.3.0
58
+ ----------------
59
+
60
+ - `flag_id_flat()` is now `flag_ids()`, and `flag_id_raw()` is `flag_ids_raw()`. The list of
61
+ strings is what an exploit wants essentially every time, so it gets the plain name, and only the
62
+ one that hands back the game's own structure carries a suffix. The `attack_info_*` aliases are
63
+ gone with them: flag IDs is what most games call these, so the package calls them that once.
64
+ The REST API follows -- `/api/v1/flag_ids/...` and `/api/v1/flag_ids_raw/...`, returning a
65
+ `flag_ids` key.
66
+ - Lookups never raise. `flag_ids()` returns `[]` and `flag_ids_raw()` returns `None` for
67
+ anything they cannot answer, including a `None` team -- so the common
68
+ `flag_ids(service, info.team(name))` no longer dies on a name that did not resolve.
69
+ - The mistakes that are wrong on *every* call -- an unknown service or team, a team that never
70
+ resolved, a team of a type the API never took -- now raise a `UserWarning` pointing at your
71
+ line, instead of being indistinguishable from a team that simply has no flag IDs yet. The
72
+ ordinary case stays silent: a known team with nothing published this round is not your mistake.
73
+ Silence the warnings with `warnings.simplefilter("ignore")` if your exploit prefers it.
74
+ - Both take an optional `round`, counting back from the newest published round when negative
75
+ (`-1` is the newest). The default is unchanged and still returns every round the game API
76
+ published -- it only publishes the rounds whose flags are still valid, so narrowing by default
77
+ would cost you flags. The selector is public as `attackapi.select_round()`, next to
78
+ `flatten_flag_ids()`.
79
+ - New `has_service()`, for the case-insensitive "does this game have that service?" check that
80
+ previously meant reaching into the `flag_ids` field. That field is now private.
81
+
82
+ Changes in 0.2.0
83
+ ----------------
84
+
85
+ - New `atklab` dialect for the ATKLAB gameserver (ECSC 2026): attack info under `attack_info`
86
+ instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
87
+ dialects are unchanged.
88
+ - `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
89
+ - The helper behind `flag_id_flat()` (now `flag_ids()`) is public as `attackapi.flatten_flag_ids()`, for callers that
90
+ flatten a subset of the raw structure themselves.
91
+ - `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
92
+ no ID for a round is a hole, not a value.
93
+ - `GenericAdCtfApiAsync` accepts a plain callable decoder, a `progress` hook, and an injectable
94
+ `memory_cache`, so it can back a whole game API rather than just `attack.json`.
95
+
57
96
  Features
58
97
  --------
59
98
 
@@ -63,7 +102,8 @@ Features
63
102
  - Unifies team, IP, and flag info lookup between different CTFs:
64
103
  - Supports [ENOWARS](https://enowars.com)
65
104
  - Supports [FAUST CTF](https://faustctf.net)
66
- - Supports [saarCTF](https://ctf.saarland) (including ECSC gameserver)
105
+ - Supports [saarCTF](https://ctf.saarland)
106
+ - Supports the [Attacking-Lab](https://attacking-lab.com) gameserver (ECSC 2026)
67
107
 
68
108
  Quick-Start
69
109
  -----------
@@ -83,7 +123,7 @@ from attackapi import *
83
123
  # 1. Set the API URL in code (or use CTF_API environment variable)
84
124
  configure("https://scoreboard.ctf.saarland/api/attack.json")
85
125
  # 2. Get attack infos!
86
- for username in attack_info().flag_id_flat("no-service", "10.32.1.2"):
126
+ for username in attack_info().flag_ids("servicename", "10.32.1.2"):
87
127
  pwn("10.32.1.2", username)
88
128
  ```
89
129
 
@@ -169,20 +209,32 @@ print(info.teams) # list of Team objects
169
209
  print(info.teams[0].id, info.teams[0].ip, info.teams[0].name) # Team is ID, IP, and optional name
170
210
  print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
171
211
 
172
- # set of service names
212
+ # set of service names, and a case-insensitive check for one
173
213
  print(info.services)
214
+ print(info.has_service("servicename"))
174
215
 
175
- # raw flag IDs for a service and team.
176
- # team can be ID, IP, or name.
177
- # Return data format is determined by game API.
178
- print(info.flag_id_raw("servicename", "10.32.1.2"))
179
- # => {"227": "abc", "228": "def", ...}
216
+ # flag format and the round this info was generated for, where the game reports them
217
+ print(info.flag_regex, info.current_round)
180
218
 
181
- # Get flag IDs as string list (independent of game API format, but less precise)
182
- print(info.flag_id_flat("servicename", "10.32.1.2"))
219
+ # flag IDs for a service and team, as a string list -- the same shape whatever game you play.
220
+ # team can be ID, IP, or name.
221
+ print(info.flag_ids("servicename", "10.32.1.2"))
183
222
  # => ["abc", "def"]
223
+
224
+ # One round only, for the games that report rounds. -1 is the newest published round.
225
+ print(info.flag_ids("servicename", "10.32.1.2", -1))
226
+ # => ["def"]
227
+
228
+ # the same flag IDs in the game API's own format, when you need the structure it keeps.
229
+ # Return data format is determined by game API.
230
+ print(info.flag_ids_raw("servicename", "10.32.1.2"))
231
+ # => {"227": "abc", "228": "def", ...}
184
232
  ```
185
233
 
234
+ Nothing above raises. A lookup that cannot be answered gives you `[]` (or `None` for the raw
235
+ form) and warns, so a typo in an exploit costs a line on stderr rather than the round it was in
236
+ the middle of.
237
+
186
238
  Server Documentation
187
239
  --------------------
188
240
 
@@ -229,3 +281,17 @@ from attackapi.async_api import JsonAdCtfApiAsync
229
281
 
230
282
  info = await JsonAdCtfApiAsync("https://scoreboard.ctf.saarland/api/scoreboard_current.json").retrieve()
231
283
  ```
284
+
285
+ To get parsed objects instead of raw dicts, `GenericAdCtfApiAsync` takes any `bytes -> object` callable:
286
+
287
+ ```python
288
+ from attackapi.async_api import GenericAdCtfApiAsync
289
+
290
+ scoreboard = await GenericAdCtfApiAsync(
291
+ parse_scoreboard, "https://scoreboard.ctf.saarland/api/scoreboard_round_237.json"
292
+ ).retrieve()
293
+ ```
294
+
295
+ Both accept `progress=`, a context-manager factory called with the URL around remote fetches (to drive a
296
+ spinner, for example), and `memory_cache=`, an own `GlobalCache` instead of the process-wide one. The
297
+ process-wide cache is keyed by URL alone, so tests that need isolation should inject their own.
@@ -2,11 +2,11 @@ CTF AttackAPI - Cached and Unified!
2
2
  ===================================
3
3
 
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/license/mit)
5
- ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
6
- ![Types](https://img.shields.io/pypi/types/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
5
+ ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi)
6
+ ![Types](https://img.shields.io/pypi/types/ctf-attackapi)
7
7
  [![Python package tests](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml/badge.svg)](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml)
8
- [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)](https://pypi.org/project/ctf-attackapi)
9
- ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
8
+ [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi)](https://pypi.org/project/ctf-attackapi)
9
+ ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi)
10
10
  ![Repo size](https://img.shields.io/github/repo-size/Attacking-Lab/ctf-attackapi)
11
11
 
12
12
 
@@ -20,6 +20,45 @@ Downloading that file for every exploit you're firing is costing time and bandwi
20
20
 
21
21
  This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
22
22
 
23
+ Changes in 0.3.0
24
+ ----------------
25
+
26
+ - `flag_id_flat()` is now `flag_ids()`, and `flag_id_raw()` is `flag_ids_raw()`. The list of
27
+ strings is what an exploit wants essentially every time, so it gets the plain name, and only the
28
+ one that hands back the game's own structure carries a suffix. The `attack_info_*` aliases are
29
+ gone with them: flag IDs is what most games call these, so the package calls them that once.
30
+ The REST API follows -- `/api/v1/flag_ids/...` and `/api/v1/flag_ids_raw/...`, returning a
31
+ `flag_ids` key.
32
+ - Lookups never raise. `flag_ids()` returns `[]` and `flag_ids_raw()` returns `None` for
33
+ anything they cannot answer, including a `None` team -- so the common
34
+ `flag_ids(service, info.team(name))` no longer dies on a name that did not resolve.
35
+ - The mistakes that are wrong on *every* call -- an unknown service or team, a team that never
36
+ resolved, a team of a type the API never took -- now raise a `UserWarning` pointing at your
37
+ line, instead of being indistinguishable from a team that simply has no flag IDs yet. The
38
+ ordinary case stays silent: a known team with nothing published this round is not your mistake.
39
+ Silence the warnings with `warnings.simplefilter("ignore")` if your exploit prefers it.
40
+ - Both take an optional `round`, counting back from the newest published round when negative
41
+ (`-1` is the newest). The default is unchanged and still returns every round the game API
42
+ published -- it only publishes the rounds whose flags are still valid, so narrowing by default
43
+ would cost you flags. The selector is public as `attackapi.select_round()`, next to
44
+ `flatten_flag_ids()`.
45
+ - New `has_service()`, for the case-insensitive "does this game have that service?" check that
46
+ previously meant reaching into the `flag_ids` field. That field is now private.
47
+
48
+ Changes in 0.2.0
49
+ ----------------
50
+
51
+ - New `atklab` dialect for the ATKLAB gameserver (ECSC 2026): attack info under `attack_info`
52
+ instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
53
+ dialects are unchanged.
54
+ - `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
55
+ - The helper behind `flag_id_flat()` (now `flag_ids()`) is public as `attackapi.flatten_flag_ids()`, for callers that
56
+ flatten a subset of the raw structure themselves.
57
+ - `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
58
+ no ID for a round is a hole, not a value.
59
+ - `GenericAdCtfApiAsync` accepts a plain callable decoder, a `progress` hook, and an injectable
60
+ `memory_cache`, so it can back a whole game API rather than just `attack.json`.
61
+
23
62
  Features
24
63
  --------
25
64
 
@@ -29,7 +68,8 @@ Features
29
68
  - Unifies team, IP, and flag info lookup between different CTFs:
30
69
  - Supports [ENOWARS](https://enowars.com)
31
70
  - Supports [FAUST CTF](https://faustctf.net)
32
- - Supports [saarCTF](https://ctf.saarland) (including ECSC gameserver)
71
+ - Supports [saarCTF](https://ctf.saarland)
72
+ - Supports the [Attacking-Lab](https://attacking-lab.com) gameserver (ECSC 2026)
33
73
 
34
74
  Quick-Start
35
75
  -----------
@@ -49,7 +89,7 @@ from attackapi import *
49
89
  # 1. Set the API URL in code (or use CTF_API environment variable)
50
90
  configure("https://scoreboard.ctf.saarland/api/attack.json")
51
91
  # 2. Get attack infos!
52
- for username in attack_info().flag_id_flat("no-service", "10.32.1.2"):
92
+ for username in attack_info().flag_ids("servicename", "10.32.1.2"):
53
93
  pwn("10.32.1.2", username)
54
94
  ```
55
95
 
@@ -135,20 +175,32 @@ print(info.teams) # list of Team objects
135
175
  print(info.teams[0].id, info.teams[0].ip, info.teams[0].name) # Team is ID, IP, and optional name
136
176
  print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
137
177
 
138
- # set of service names
178
+ # set of service names, and a case-insensitive check for one
139
179
  print(info.services)
180
+ print(info.has_service("servicename"))
140
181
 
141
- # raw flag IDs for a service and team.
142
- # team can be ID, IP, or name.
143
- # Return data format is determined by game API.
144
- print(info.flag_id_raw("servicename", "10.32.1.2"))
145
- # => {"227": "abc", "228": "def", ...}
182
+ # flag format and the round this info was generated for, where the game reports them
183
+ print(info.flag_regex, info.current_round)
146
184
 
147
- # Get flag IDs as string list (independent of game API format, but less precise)
148
- print(info.flag_id_flat("servicename", "10.32.1.2"))
185
+ # flag IDs for a service and team, as a string list -- the same shape whatever game you play.
186
+ # team can be ID, IP, or name.
187
+ print(info.flag_ids("servicename", "10.32.1.2"))
149
188
  # => ["abc", "def"]
189
+
190
+ # One round only, for the games that report rounds. -1 is the newest published round.
191
+ print(info.flag_ids("servicename", "10.32.1.2", -1))
192
+ # => ["def"]
193
+
194
+ # the same flag IDs in the game API's own format, when you need the structure it keeps.
195
+ # Return data format is determined by game API.
196
+ print(info.flag_ids_raw("servicename", "10.32.1.2"))
197
+ # => {"227": "abc", "228": "def", ...}
150
198
  ```
151
199
 
200
+ Nothing above raises. A lookup that cannot be answered gives you `[]` (or `None` for the raw
201
+ form) and warns, so a typo in an exploit costs a line on stderr rather than the round it was in
202
+ the middle of.
203
+
152
204
  Server Documentation
153
205
  --------------------
154
206
 
@@ -195,3 +247,17 @@ from attackapi.async_api import JsonAdCtfApiAsync
195
247
 
196
248
  info = await JsonAdCtfApiAsync("https://scoreboard.ctf.saarland/api/scoreboard_current.json").retrieve()
197
249
  ```
250
+
251
+ To get parsed objects instead of raw dicts, `GenericAdCtfApiAsync` takes any `bytes -> object` callable:
252
+
253
+ ```python
254
+ from attackapi.async_api import GenericAdCtfApiAsync
255
+
256
+ scoreboard = await GenericAdCtfApiAsync(
257
+ parse_scoreboard, "https://scoreboard.ctf.saarland/api/scoreboard_round_237.json"
258
+ ).retrieve()
259
+ ```
260
+
261
+ Both accept `progress=`, a context-manager factory called with the URL around remote fetches (to drive a
262
+ spinner, for example), and `memory_cache=`, an own `GlobalCache` instead of the process-wide one. The
263
+ process-wide cache is keyed by URL alone, so tests that need isolation should inject their own.
@@ -0,0 +1,89 @@
1
+ [project]
2
+ name = "ctf-attackapi"
3
+ version = "0.3.0"
4
+ description = "Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license_files = ["LICENSE.txt"]
8
+ keywords = [
9
+ "Attack-Defense",
10
+ "CTF",
11
+ "Attack API",
12
+ "Attack Info",
13
+ "Flag IDs",
14
+ "FAUST CTF",
15
+ "ENOWARS",
16
+ "saarCTF",
17
+ ]
18
+ requires-python = ">=3.9"
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Intended Audience :: Education",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Topic :: Security",
31
+ "Typing :: Typed",
32
+ ]
33
+ dependencies = [
34
+ "aiohttp>=3.13.3",
35
+ "aiologic>=0.16.0",
36
+ "filelock>=3.19.1",
37
+ "pyyaml>=6.0.3",
38
+ "typing-extensions>=4.15.0",
39
+ ]
40
+
41
+ [[project.authors]]
42
+ name = "Markus Bauer"
43
+ email = "markus.bauer@cispa.saarland"
44
+
45
+ [project.optional-dependencies]
46
+ server = ["gunicorn>=23.0.0"]
47
+
48
+ [project.urls]
49
+ Homepage = "https://github.com/Attacking-Lab/ctf-attackapi"
50
+ Repository = "https://github.com/Attacking-Lab/ctf-attackapi"
51
+ Issues = "https://github.com/Attacking-Lab/ctf-attackapi/issues"
52
+ Background = "https://wiki.attacking-lab.com/attack-defense/"
53
+
54
+ [project.scripts]
55
+ ctf-attackapi-server = "attackapi.server:main"
56
+
57
+ [build-system]
58
+ requires = ["uv_build>=0.9.27,<0.10.0"]
59
+ build-backend = "uv_build"
60
+
61
+ [dependency-groups]
62
+ dev = [
63
+ "mypy>=1.19.1",
64
+ "pytest>=8.4.2",
65
+ "types-pyyaml>=6.0.12.20250915",
66
+ ]
67
+
68
+ [tool.uv.build-backend]
69
+ module-name = "attackapi"
70
+
71
+ [[tool.uv.index]]
72
+ name = "testpypi"
73
+ url = "https://test.pypi.org/simple/"
74
+ publish-url = "https://test.pypi.org/legacy/"
75
+ explicit = true
76
+
77
+ [tool.mypy]
78
+ python_version = "3.9"
79
+ disallow_untyped_defs = true
80
+ packages = [
81
+ "attackapi",
82
+ "tests",
83
+ "examples",
84
+ ]
85
+ mypy_path = [
86
+ "./src",
87
+ "./tests",
88
+ "./examples",
89
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctf-attackapi"
3
- version = "0.1.1"
3
+ version = "0.3.0"
4
4
  description = "Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,4 @@
1
+ from .models import Team, AttackInfo, flatten_flag_ids, select_round
2
+ from .functional import configure, attack_info, attack_info_async
3
+
4
+ __all__ = ["Team", "AttackInfo", "flatten_flag_ids", "select_round", "configure", "attack_info", "attack_info_async"]
@@ -0,0 +1,5 @@
1
+ from .api import AdCtfApiAsync, GenericAdCtfApiAsync, GlobalCache, JsonAdCtfApiAsync
2
+ from .decoders import Decoder, Dialect, FunctionDecoder, GenericDecoder
3
+
4
+ __all__ = ["AdCtfApiAsync", "GenericAdCtfApiAsync", "GlobalCache", "JsonAdCtfApiAsync",
5
+ "Decoder", "Dialect", "FunctionDecoder", "GenericDecoder"]
@@ -5,13 +5,13 @@ import tempfile
5
5
  import time
6
6
  from importlib.metadata import version
7
7
  from pathlib import Path
8
- from typing import Optional, Union, Generic, TypeVar, AsyncContextManager, Any
8
+ from contextlib import nullcontext
9
+ from typing import Callable, ContextManager, Optional, Union, Generic, TypeVar, AsyncContextManager, Any
9
10
 
10
11
  import aiologic
11
- from aiohttp import ClientSession, ClientTimeout
12
12
  from filelock import FileLock
13
13
 
14
- from attackapi.async_api.decoders import Decoder, GenericDecoder, JSONDecoder
14
+ from attackapi.async_api.decoders import Decoder, FunctionDecoder, GenericDecoder, JSONDecoder
15
15
  from attackapi.async_api.filelock import acquire_filelock
16
16
  from attackapi.models import AttackInfo
17
17
 
@@ -38,6 +38,10 @@ class GlobalCache(Generic[T]):
38
38
  def set(self, key: str, value: T) -> None:
39
39
  self._cache[key] = (time.time(), value)
40
40
 
41
+ def clear(self) -> None:
42
+ """Drop every cached response. Mostly useful to isolate tests from each other."""
43
+ self._cache.clear()
44
+
41
45
 
42
46
  _api_response_cache: GlobalCache[Any] = GlobalCache()
43
47
 
@@ -82,19 +86,23 @@ def _atomic_write(p: Path, raw: bytes) -> None:
82
86
 
83
87
 
84
88
  class GenericAdCtfApiAsync(Generic[T]):
85
- def __init__(self, decoder: GenericDecoder[T], url: str = "",
89
+ def __init__(self, decoder: Union[GenericDecoder[T], Callable[[bytes], T]], url: str = "",
86
90
  tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
87
91
  lifetime: float = 30.0, timeout: float = 10.0,
88
- aiohttp_arguments: Optional[dict] = None) -> None:
92
+ aiohttp_arguments: Optional[dict] = None,
93
+ memory_cache: Optional[GlobalCache] = None,
94
+ progress: Optional[Callable[[str], ContextManager[None]]] = None) -> None:
89
95
  """
90
96
  Create a new API client.
91
97
 
92
- :param decoder: A decoder for API responses
98
+ :param decoder: A decoder for API responses, or a plain bytes -> object callable
93
99
  :param url: URL of your game's API
94
100
  :param tmp_directory: where to store cache files
95
101
  :param lifetime: How long to cache data for (in seconds)
96
102
  :param timeout: How long to wait for API calls (in seconds)
97
103
  :param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
104
+ :param memory_cache: Optional in-memory cache to use instead of the process-wide one
105
+ :param progress: Optional context-manager factory, called with the URL around remote fetches
98
106
  """
99
107
  if timeout < 1:
100
108
  raise ValueError("Timeout must be at least 1 second")
@@ -104,7 +112,9 @@ class GenericAdCtfApiAsync(Generic[T]):
104
112
  self._url = url
105
113
  self._lifetime = lifetime
106
114
  self._timeout = timeout
107
- self._decoder = decoder
115
+ self._decoder = decoder if isinstance(decoder, GenericDecoder) else FunctionDecoder(decoder)
116
+ self._memory_cache = memory_cache if memory_cache is not None else _api_response_cache
117
+ self._progress = progress
108
118
  self._aiohttp_arguments = aiohttp_arguments or {
109
119
  "headers": {"User-Agent": "python/attackapi " + version("ctf-attackapi")}
110
120
  }
@@ -124,19 +134,19 @@ class GenericAdCtfApiAsync(Generic[T]):
124
134
  if info is not None:
125
135
  return info
126
136
  # not found? lock it to avoid concurrent loads
127
- async with _api_response_cache.lock:
137
+ async with self._memory_cache.lock:
128
138
  # check again to avoid race conditions
129
139
  info = self._check_memory_cache()
130
140
  if info is not None:
131
141
  return info
132
142
  # not found => load from file or API
133
- info = await self._attack_info_from_file()
134
- _api_response_cache.set(self._cache_key, info)
143
+ info = await self._from_file()
144
+ self._memory_cache.set(self._cache_key, info)
135
145
  return info
136
146
 
137
147
  def _check_memory_cache(self) -> Optional[T]:
138
- if (age := _api_response_cache.age(self._cache_key)) is not None and age <= self._lifetime:
139
- return _api_response_cache.get(self._cache_key)
148
+ if (age := self._memory_cache.age(self._cache_key)) is not None and age <= self._lifetime:
149
+ return self._memory_cache.get(self._cache_key)
140
150
  return None
141
151
 
142
152
  def _check_file_cache(self) -> Optional[T]:
@@ -146,7 +156,7 @@ class GenericAdCtfApiAsync(Generic[T]):
146
156
  return self._decoder.parse(raw)
147
157
  return None
148
158
 
149
- async def _attack_info_from_file(self) -> T:
159
+ async def _from_file(self) -> T:
150
160
  # Step 2: try to load from file
151
161
  # this does not work on Windows, because concurrent read + replacing files is not possible.
152
162
  # for better performance please use Linux
@@ -161,23 +171,29 @@ class GenericAdCtfApiAsync(Generic[T]):
161
171
  if info is not None:
162
172
  return info
163
173
  # not found => load from API
164
- raw = await self._attack_info_from_remote()
174
+ raw = await self._from_remote()
165
175
  info = self._decoder.parse(raw)
166
176
  # and save to file (atomic)
167
177
  self._file_cache.set(raw)
168
178
  return info
169
179
 
170
- async def _attack_info_from_remote(self) -> bytes:
171
- async with ClientSession(**self._aiohttp_arguments) as session:
172
- async with session.get(self._url, timeout=ClientTimeout(total=self._timeout)) as response:
173
- response.raise_for_status()
174
- return await response.read()
180
+ async def _from_remote(self) -> bytes:
181
+ # imported lazily so that cache hits do not pay for importing aiohttp
182
+ from aiohttp import ClientSession, ClientTimeout
183
+
184
+ with self._progress(self._url) if self._progress is not None else nullcontext():
185
+ async with ClientSession(**self._aiohttp_arguments) as session:
186
+ async with session.get(self._url, timeout=ClientTimeout(total=self._timeout)) as response:
187
+ response.raise_for_status()
188
+ return await response.read()
175
189
 
176
190
 
177
191
  class JsonAdCtfApiAsync(GenericAdCtfApiAsync[dict]):
178
192
  def __init__(self, url: str, tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
179
193
  lifetime: float = 30.0, timeout: float = 10.0,
180
- aiohttp_arguments: Optional[dict] = None) -> None:
194
+ aiohttp_arguments: Optional[dict] = None,
195
+ memory_cache: Optional[GlobalCache] = None,
196
+ progress: Optional[Callable[[str], ContextManager[None]]] = None) -> None:
181
197
  """
182
198
  Create a new API client.
183
199
 
@@ -185,11 +201,13 @@ class JsonAdCtfApiAsync(GenericAdCtfApiAsync[dict]):
185
201
  :param tmp_directory: where to store cache files
186
202
  :param lifetime: How long to cache data for (in seconds)
187
203
  :param timeout: How long to wait for API calls (in seconds)
188
- :param decoder: A custom decoder for API responses, if the default one doesn't work for your game
189
204
  :param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
205
+ :param memory_cache: Optional in-memory cache to use instead of the process-wide one
206
+ :param progress: Optional context-manager factory, called with the URL around remote fetches
190
207
  """
191
208
  super().__init__(decoder=JSONDecoder(), url=url, tmp_directory=tmp_directory, lifetime=lifetime,
192
- timeout=timeout, aiohttp_arguments=aiohttp_arguments)
209
+ timeout=timeout, aiohttp_arguments=aiohttp_arguments, memory_cache=memory_cache,
210
+ progress=progress)
193
211
 
194
212
 
195
213
  class AdCtfApiAsync(GenericAdCtfApiAsync[AttackInfo]):
@@ -199,7 +217,9 @@ class AdCtfApiAsync(GenericAdCtfApiAsync[AttackInfo]):
199
217
 
200
218
  def __init__(self, url: str = "", tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
201
219
  lifetime: float = 30.0, timeout: float = 10.0, decoder: Optional[Decoder] = None,
202
- aiohttp_arguments: Optional[dict] = None) -> None:
220
+ aiohttp_arguments: Optional[dict] = None,
221
+ memory_cache: Optional[GlobalCache] = None,
222
+ progress: Optional[Callable[[str], ContextManager[None]]] = None) -> None:
203
223
  """
204
224
  Create a new API client.
205
225
 
@@ -209,13 +229,16 @@ class AdCtfApiAsync(GenericAdCtfApiAsync[AttackInfo]):
209
229
  :param timeout: How long to wait for API calls (in seconds)
210
230
  :param decoder: A custom decoder for API responses, if the default one doesn't work for your game
211
231
  :param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
232
+ :param memory_cache: Optional in-memory cache to use instead of the process-wide one
233
+ :param progress: Optional context-manager factory, called with the URL around remote fetches
212
234
  """
213
235
  if not url:
214
236
  if "CTF_API" not in os.environ:
215
237
  raise Exception("Please call configure() or set CTF_API environment variable!")
216
238
  url = os.environ["CTF_API"]
217
239
  super().__init__(decoder=decoder or Decoder(), url=url, tmp_directory=tmp_directory, lifetime=lifetime,
218
- timeout=timeout, aiohttp_arguments=aiohttp_arguments)
240
+ timeout=timeout, aiohttp_arguments=aiohttp_arguments, memory_cache=memory_cache,
241
+ progress=progress)
219
242
 
220
243
  async def attack_info(self) -> AttackInfo:
221
244
  """
@@ -1,8 +1,9 @@
1
1
  """
2
2
  Summary of different API formats:
3
- saarctf: flag_ids => {service_name => {ip => data}} {tick: a, tick2: [b, c]}
4
- faust: flag_ids => {service_name => {ID => data}} [a, b]
5
- enowars: services => {service_name => {ip => data}} {tick: {X: [a], Y: [b]}}
3
+ saarctf: flag_ids => {service_name => {ip => data}} {tick: a, tick2: [b, c]}
4
+ atklab: attack_info => {service_name => {ip => data}} {round: {store_index: a}}
5
+ faust: flag_ids => {service_name => {ID => data}} [a, b]
6
+ enowars: services => {service_name => {ip => data}} {tick: {X: [a], Y: [b]}}
6
7
 
7
8
  Additional team list:
8
9
  saarctf: teams => [0 => {id: ..., name: ..., logo: ...}]
@@ -12,7 +13,7 @@ enowars: availableTeams: [IP1, IP2, ...]
12
13
  import json
13
14
  from abc import abstractmethod, ABC
14
15
  from dataclasses import dataclass
15
- from typing import Optional, Generic, TypeVar
16
+ from typing import Callable, Optional, Generic, TypeVar
16
17
 
17
18
  from attackapi.models import AttackInfo, Team
18
19
 
@@ -52,6 +53,17 @@ class SaarctfDialect(Dialect):
52
53
  return f"10.{32 + (team_id // 200)}.{team_id % 200}.2" # actually not needed, saarCTF API exposes full team info
53
54
 
54
55
 
56
+ class AtklabDialect(Dialect):
57
+ """
58
+ ATKLAB gameserver (ECSC 2026). Team entries are shaped like saarCTF's, but the attack info
59
+ sits under "attack_info" and rounds are called rounds, not ticks.
60
+ """
61
+
62
+ def matches(self, data: dict) -> bool:
63
+ return "attack_info" in data and "teams" in data \
64
+ and len(data["teams"]) > 0 and isinstance(data["teams"][0], dict)
65
+
66
+
55
67
  class FaustDialect(Dialect):
56
68
  def matches(self, data: dict) -> bool:
57
69
  return "flag_ids" in data and "teams" in data and len(data["teams"]) > 0 and isinstance(data["teams"][0], int)
@@ -70,6 +82,7 @@ class EnowarsDialect(Dialect):
70
82
 
71
83
 
72
84
  DIALECTS = [
85
+ AtklabDialect("atklab"),
73
86
  SaarctfDialect("saarctf"),
74
87
  FaustDialect("faustctf", "fd66:666:{:d}::2"),
75
88
  EnowarsDialect("enowars", "10.1.{:d}.1"),
@@ -90,6 +103,16 @@ class JSONDecoder(GenericDecoder[dict]):
90
103
  return json.loads(raw)
91
104
 
92
105
 
106
+ class FunctionDecoder(GenericDecoder[T]):
107
+ """Adapts a plain ``bytes -> T`` callable to the decoder interface."""
108
+
109
+ def __init__(self, parse: Callable[[bytes], T]) -> None:
110
+ self._parse = parse
111
+
112
+ def parse(self, raw: bytes) -> T:
113
+ return self._parse(raw)
114
+
115
+
93
116
  class Decoder(GenericDecoder[AttackInfo]):
94
117
  """
95
118
  A decoder converts the game APIs response (in bytes) into teams, services, and attack information.
@@ -108,16 +131,23 @@ class Decoder(GenericDecoder[AttackInfo]):
108
131
  :raises ValueError: If data is invalid
109
132
  :return:
110
133
  """
111
- info = AttackInfo(raw=raw)
112
134
  data = json.loads(raw)
113
135
  dialect = self._get_dialect(data)
136
+ info = AttackInfo(
137
+ flag_regex=data.get("flag_regex"),
138
+ # saarCTF counts ticks, the ATKLAB gameserver counts rounds; same number
139
+ current_round=data.get("current_round", data.get("current_tick")),
140
+ raw=raw
141
+ )
114
142
 
115
143
  if "flag_ids" in data:
116
144
  self._parse_services(info, data["flag_ids"])
145
+ elif "attack_info" in data:
146
+ self._parse_services(info, data["attack_info"])
117
147
  elif "services" in data:
118
148
  self._parse_services(info, data["services"])
119
149
  else:
120
- raise ValueError("Unknown format - no flag_ids or services key found")
150
+ raise ValueError("Unknown format - no flag_ids, attack_info or services key found")
121
151
 
122
152
  if "teams" in data:
123
153
  self._parse_teams(dialect, info, data["teams"])
@@ -141,7 +171,7 @@ class Decoder(GenericDecoder[AttackInfo]):
141
171
  if not isinstance(flag_ids, dict):
142
172
  raise ValueError(f"Invalid flag_ids format for service {service_name!r}: {type(flag_ids)}")
143
173
  info.services.add(service_name)
144
- info.flag_ids[service_name.lower()] = flag_ids
174
+ info._flag_ids[service_name.lower()] = flag_ids
145
175
 
146
176
  def _parse_teams(self, dialect: Dialect, info: AttackInfo, teams: list) -> None:
147
177
  for team in teams:
@@ -0,0 +1,189 @@
1
+ import warnings
2
+ from dataclasses import dataclass, field, asdict
3
+ from typing import Optional, Union, Any
4
+ from typing_extensions import TypeAlias
5
+
6
+ RawFlagIds: TypeAlias = Union[list, dict[str, Union[str, list, dict]]]
7
+
8
+
9
+ def flatten_flag_ids(flag_ids: Any) -> list[str]:
10
+ """
11
+ Collect every non-null scalar flag ID out of an arbitrarily nested flag-ID structure.
12
+ Independent of the game API's exact nesting, but loses the round / flag-store structure.
13
+ """
14
+ if flag_ids is None: # a flag store with no ID for this round/team
15
+ return []
16
+ if isinstance(flag_ids, str):
17
+ return [flag_ids]
18
+ if isinstance(flag_ids, list):
19
+ result = []
20
+ for value in flag_ids:
21
+ result += flatten_flag_ids(value)
22
+ return result
23
+ if isinstance(flag_ids, dict):
24
+ result = []
25
+ for value in flag_ids.values():
26
+ result += flatten_flag_ids(value)
27
+ return result
28
+ return [str(flag_ids)]
29
+
30
+
31
+ def select_round(flag_ids: Any, round: int) -> Any:
32
+ """
33
+ Restrict flag IDs to a single round. Every game that reports a round keys its per-team attack
34
+ info by it; FAUST CTF does not have the dimension at all, and its plain list is returned
35
+ unchanged rather than sliced into something that only looks like a round.
36
+
37
+ :param flag_ids: raw flag IDs for one service and team, as returned by flag_ids_raw()
38
+ :param round: a round number, or an offset from the newest published round (-1 = newest)
39
+ :return: the same structure, holding at most the requested round
40
+ """
41
+ if not isinstance(flag_ids, dict):
42
+ return flag_ids
43
+ keys = list(flag_ids.keys())
44
+ if round < 0:
45
+ # the game API publishes a window of recent rounds, in no guaranteed order
46
+ if all(isinstance(key, str) and key.lstrip("-").isdigit() for key in keys):
47
+ keys.sort(key=int)
48
+ if -round > len(keys):
49
+ return {}
50
+ key = keys[round]
51
+ else:
52
+ key = str(round)
53
+ return {key: flag_ids[key]} if key in flag_ids else {}
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Team:
58
+ """
59
+ An attackable team.
60
+ IP is given for every game, ID is given or inferred for all known CTFs.
61
+ Name is not present everywhere.
62
+ """
63
+ id: int
64
+ ip: str
65
+ name: Optional[str] = None
66
+
67
+ def to_dict(self) -> dict:
68
+ return asdict(self)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class AttackInfo:
73
+ """
74
+ Container for all attack info parsed from the game API.
75
+ Use methods team(), flag_ids(), and flag_ids_raw() to look up data.
76
+ Fields teams and services can be iterated.
77
+ """
78
+
79
+ teams: list[Team] = field(default_factory=list)
80
+ team_lookup: dict[str, Team] = field(default_factory=dict)
81
+ services: set[str] = field(default_factory=set)
82
+ _flag_ids: dict[str, dict[str, RawFlagIds]] = field(default_factory=dict)
83
+ flag_regex: Optional[str] = None
84
+ current_round: Optional[int] = None
85
+ raw: bytes = b"" # everything, as given by the game API
86
+
87
+ def team(self, name: Union[str, int]) -> Optional[Team]:
88
+ """
89
+ Find a team.
90
+
91
+ :param name: Team ID, IP, or name (as far as supported by the API)
92
+ :return:
93
+ """
94
+ return self.team_lookup.get(str(name).lower())
95
+
96
+ def has_service(self, service: str) -> bool:
97
+ """
98
+ Whether this game has a service by that name (case insensitive).
99
+
100
+ :param service: Name of a service
101
+ :return:
102
+ """
103
+ return service.lower() in self._flag_ids
104
+
105
+ def flag_ids(self, service: str, team: Union[str, int, Team, None],
106
+ round: Optional[int] = None) -> list[str]:
107
+ """
108
+ Find flag IDs for a service and team, as a simple string list -- the same list whatever
109
+ game you are playing, and what an exploit almost always wants.
110
+
111
+ Never raises: a lookup that cannot be answered warns and returns [], so a typo in an
112
+ exploit costs a warning on stderr rather than the round it was in the middle of.
113
+
114
+ :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
115
+ :param team: Team ID, IP, name, or instance (from .team(...))
116
+ :param round: Restrict the result to one round, or to an offset from the newest published
117
+ round (-1 = newest). Defaults to every round the game API published.
118
+ :return:
119
+ """
120
+ flag_ids = self._lookup(service, team, round)
121
+ return flatten_flag_ids(flag_ids) if flag_ids is not None else []
122
+
123
+ def flag_ids_raw(self, service: str, team: Union[str, int, Team, None],
124
+ round: Optional[int] = None) -> Optional[RawFlagIds]:
125
+ """
126
+ Find flag IDs for a service and team, in the game API's own format -- for callers that
127
+ need the structure flag_ids() flattens away. That format differs per game.
128
+
129
+ Never raises, and returns None for anything it cannot answer -- see flag_ids().
130
+
131
+ :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
132
+ :param team: Team ID, IP, name, or instance (from .team(...))
133
+ :param round: Restrict the result to one round, or to an offset from the newest published
134
+ round (-1 = newest). Defaults to every round the game API published.
135
+ :return:
136
+ """
137
+ return self._lookup(service, team, round)
138
+
139
+ def _lookup(self, service: str, team: Union[str, int, Team, None],
140
+ round: Optional[int], stacklevel: int = 3) -> Optional[RawFlagIds]:
141
+ """
142
+ The lookup behind both accessors. Warns rather than raises on the mistakes that are wrong
143
+ on every call -- an unknown service or team, a team that never resolved -- and stays quiet
144
+ about the ones that are a normal part of a running game.
145
+
146
+ :param stacklevel: frames between this method and the caller to blame in a warning
147
+ """
148
+ if team is None:
149
+ warnings.warn(f"No team given for service {service!r} - did an earlier team() lookup fail?",
150
+ stacklevel=stacklevel)
151
+ return None
152
+ flag_ids = self._flag_ids.get(service.lower())
153
+ if flag_ids is None:
154
+ warnings.warn(f"Unknown service {service!r}, this game has: {', '.join(sorted(self.services))}",
155
+ stacklevel=stacklevel)
156
+ return None
157
+ raw = self._team_flag_ids(service, flag_ids, team, stacklevel + 1)
158
+ if raw is None or round is None:
159
+ return raw
160
+ return select_round(raw, round)
161
+
162
+ def _team_flag_ids(self, service: str, flag_ids: dict, team: Union[str, int, Team],
163
+ stacklevel: int) -> Optional[RawFlagIds]:
164
+ """Pick one team out of a service's flag IDs, which the game API keys by ID, IP, or name."""
165
+ if isinstance(team, Team):
166
+ for key in (str(team.id), team.ip, team.name):
167
+ if key is not None and key.lower() in flag_ids:
168
+ return flag_ids[key.lower()]
169
+ # a team the game knows about but has no flag IDs for: the service may be down, or the
170
+ # round's info may not be out yet. Both are ordinary, and neither is worth a warning.
171
+ return None
172
+ if isinstance(team, (str, int)):
173
+ key = str(team).lower()
174
+ if key in flag_ids:
175
+ return flag_ids[key]
176
+ if key in self.team_lookup:
177
+ return self._team_flag_ids(service, flag_ids, self.team_lookup[key], stacklevel)
178
+ warnings.warn(f"Unknown team {team!r} for service {service!r} - not in this attack info "
179
+ f"(a typo, or a team that is offline or banned)", stacklevel=stacklevel)
180
+ return None
181
+ warnings.warn(f"Invalid team type for service {service!r}: {type(team).__name__}: {team!r}",
182
+ stacklevel=stacklevel)
183
+ return None
184
+
185
+ def __str__(self) -> str:
186
+ return repr(self)
187
+
188
+ def __repr__(self) -> str:
189
+ return f"AttackInfo(services={self.services!r}, {len(self.teams)} teams)"
@@ -21,8 +21,8 @@ class AttackApiViews:
21
21
  web.get("/api/v1/raw", self.get_raw),
22
22
  web.get("/api/v1/services", self.get_services),
23
23
  web.get("/api/v1/teams", self.get_teams),
24
- web.get("/api/v1/attack_info/{service}/{team}", self.get_attack_info),
25
- web.get("/api/v1/attack_info_raw/{service}/{team}", self.get_attack_info_raw),
24
+ web.get("/api/v1/flag_ids/{service}/{team}", self.get_flag_ids),
25
+ web.get("/api/v1/flag_ids_raw/{service}/{team}", self.get_flag_ids_raw),
26
26
  ]
27
27
 
28
28
  async def docs(self, request: web.Request) -> web.Response:
@@ -38,8 +38,8 @@ class AttackApiViews:
38
38
  docs = docs.replace('"NOP"', json.dumps(info.teams[0].name))
39
39
  if len(info.services) > 0:
40
40
  s = list(info.services)[0]
41
- raw = info.flag_id_raw(s, info.teams[0])
42
- flat = info.flag_id_flat(s, info.teams[0])
41
+ raw = info.flag_ids_raw(s, info.teams[0])
42
+ flat = info.flag_ids(s, info.teams[0])
43
43
  if raw:
44
44
  docs = docs.replace(
45
45
  json.dumps({"227": "username1", "228": "username2", "229": "username3"}),
@@ -68,31 +68,31 @@ class AttackApiViews:
68
68
  info = await self._api.attack_info()
69
69
  return web.json_response({"services": list(info.services)})
70
70
 
71
- async def _attack_info_common(self, request: web.Request,
72
- cb: Callable[[AttackInfo, str, str], Any]) -> web.Response:
71
+ async def _flag_ids_common(self, request: web.Request,
72
+ cb: Callable[[AttackInfo, str, str], Any]) -> web.Response:
73
73
  service = request.match_info["service"]
74
74
  team = request.match_info["team"]
75
75
  if not service or not team:
76
76
  raise web.HTTPBadRequest(reason="Invalid team or service")
77
77
  info = await self._api.attack_info()
78
- if service.lower() not in info.flag_ids:
78
+ if not info.has_service(service):
79
79
  raise web.HTTPBadRequest(reason=f"Unknown service, or service has no attack info: {service}")
80
80
  flag_ids = cb(info, service, team)
81
81
  return web.json_response(
82
- {"attack_info": flag_ids}
82
+ {"flag_ids": flag_ids}
83
83
  )
84
84
 
85
- async def get_attack_info(self, request: web.Request) -> web.Response:
85
+ async def get_flag_ids(self, request: web.Request) -> web.Response:
86
86
  def _cb(info: AttackInfo, service: str, team: str) -> Any:
87
- return info.flag_id_flat(service, team)
87
+ return info.flag_ids(service, team)
88
88
 
89
- return await self._attack_info_common(request, _cb)
89
+ return await self._flag_ids_common(request, _cb)
90
90
 
91
- async def get_attack_info_raw(self, request: web.Request) -> web.Response:
91
+ async def get_flag_ids_raw(self, request: web.Request) -> web.Response:
92
92
  def _cb(info: AttackInfo, service: str, team: str) -> Any:
93
- return info.flag_id_raw(service, team)
93
+ return info.flag_ids_raw(service, team)
94
94
 
95
- return await self._attack_info_common(request, _cb)
95
+ return await self._flag_ids_common(request, _cb)
96
96
 
97
97
 
98
98
  async def create_app() -> web.Application:
@@ -1,4 +0,0 @@
1
- from .models import Team, AttackInfo
2
- from .functional import configure, attack_info, attack_info_async
3
-
4
- __all__ = ["Team", "AttackInfo", "configure", "attack_info", "attack_info_async"]
@@ -1,4 +0,0 @@
1
- from .api import AdCtfApiAsync, JsonAdCtfApiAsync
2
- from .decoders import Decoder, Dialect
3
-
4
- __all__ = ["AdCtfApiAsync", "JsonAdCtfApiAsync", "Decoder", "Dialect"]
@@ -1,134 +0,0 @@
1
- from dataclasses import dataclass, field, asdict
2
- from typing import Optional, Union, Any, cast
3
- from typing_extensions import TypeAlias
4
-
5
- RawFlagIds: TypeAlias = Union[list, dict[str, Union[str, list, dict]]]
6
-
7
-
8
- def _flat(flag_ids: Any) -> list[str]:
9
- if isinstance(flag_ids, list):
10
- result = []
11
- for value in flag_ids:
12
- result += _flat(value)
13
- return result
14
- if isinstance(flag_ids, dict):
15
- result = []
16
- for value in flag_ids.values():
17
- result += _flat(value)
18
- return result
19
- return [cast(str, flag_ids)]
20
-
21
-
22
- @dataclass(frozen=True)
23
- class Team:
24
- """
25
- An attackable team.
26
- IP is given for every game, ID is given or inferred for all known CTFs.
27
- Name is not present everywhere.
28
- """
29
- id: int
30
- ip: str
31
- name: Optional[str] = None
32
-
33
- def to_dict(self) -> dict:
34
- return asdict(self)
35
-
36
-
37
- @dataclass(frozen=True)
38
- class AttackInfo:
39
- """
40
- Container for all attack info parsed from the game API.
41
- Use methods team(), flag_id_raw(), and flag_id_flat() to look up data.
42
- Fields teams and services can be iterated.
43
- """
44
-
45
- teams: list[Team] = field(default_factory=list)
46
- team_lookup: dict[str, Team] = field(default_factory=dict)
47
- services: set[str] = field(default_factory=set)
48
- flag_ids: dict[str, dict[str, RawFlagIds]] = field(default_factory=dict)
49
- raw: bytes = b"" # everything, as given by the game API
50
-
51
- def team(self, name: Union[str, int]) -> Optional[Team]:
52
- """
53
- Find a team.
54
-
55
- :param name: Team ID, IP, or name (as far as supported by the API)
56
- :return:
57
- """
58
- return self.team_lookup.get(str(name).lower())
59
-
60
- def flag_id_raw(self, service: str, team: Union[str, int, Team]) -> Optional[RawFlagIds]:
61
- """
62
- Find flag IDs for a service and team. Flag IDs are returned in the APIs raw format.
63
-
64
- :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
65
- :param team: Team ID, IP, name, or instance (from .team(...))
66
- :return:
67
- """
68
- flag_ids = self.flag_ids.get(service.lower(), {})
69
- if isinstance(team, Team):
70
- if str(team.id) in flag_ids:
71
- return flag_ids[str(team.id)]
72
- if team.ip is not None and team.ip.lower() in flag_ids:
73
- return flag_ids[team.ip.lower()]
74
- if team.name is not None and team.name.lower() in flag_ids:
75
- return flag_ids[team.name.lower()]
76
- return None
77
- elif isinstance(team, str):
78
- team = team.lower()
79
- if team in flag_ids:
80
- return flag_ids.get(team.lower())
81
- elif team in self.team_lookup:
82
- return self.flag_id_raw(service, self.team_lookup[team])
83
- return None
84
- elif isinstance(team, int):
85
- if str(team) in flag_ids:
86
- return flag_ids.get(str(team))
87
- elif str(team) in self.team_lookup:
88
- return self.flag_id_raw(service, self.team_lookup[str(team)])
89
- return None
90
-
91
- raise ValueError(f"Invalid team type: {type(team)}: {team!r}")
92
-
93
- def flag_id_flat(self, service: str, team: Union[str, int, Team]) -> list[str]:
94
- """
95
- Find flag IDs for a service and team.
96
- Flag IDs are returned as a simple string list, containing all attack info for all flag stores.
97
-
98
- :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
99
- :param team: Team ID, IP, name, or instance (from .team(...))
100
- :return:
101
- """
102
- flag_ids = self.flag_id_raw(service, team)
103
- return _flat(flag_ids) if flag_ids is not None else []
104
-
105
- def attack_info_raw(self, service: str, team: Union[str, int, Team]) -> Optional[RawFlagIds]:
106
- """
107
- Find attack info for a service and team. Attack info is returned in the APIs raw format.
108
-
109
- This is an alias for flag_id_raw(...).
110
-
111
- :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
112
- :param team: Team ID, IP, name, or instance (from .team(...))
113
- :return:
114
- """
115
- return self.flag_id_raw(service, team)
116
-
117
- def attack_info_flat(self, service: str, team: Union[str, int, Team]) -> list[str]:
118
- """
119
- Find attack info for a service and team.
120
- Attack info is returned as a simple string list, containing all attack info for all flag stores.
121
-
122
- This is an alias for flag_id_flat(...).
123
-
124
- :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
125
- :param team: Team ID, IP, name, or instance (from .team(...))
126
- :return:
127
- """
128
- return self.flag_id_flat(service, team)
129
-
130
- def __str__(self) -> str:
131
- return repr(self)
132
-
133
- def __repr__(self) -> str:
134
- return f"AttackInfo(services={self.services!r}, {len(self.teams)} teams)"