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.
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/PKG-INFO +81 -15
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/README.md +80 -14
- ctf_attackapi-0.3.0/pyproject.toml +89 -0
- ctf_attackapi-0.1.1/pyproject.toml → ctf_attackapi-0.3.0/pyproject.toml.orig +1 -1
- ctf_attackapi-0.3.0/src/attackapi/__init__.py +4 -0
- ctf_attackapi-0.3.0/src/attackapi/async_api/__init__.py +5 -0
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/async_api/api.py +47 -24
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/async_api/decoders.py +37 -7
- ctf_attackapi-0.3.0/src/attackapi/models.py +189 -0
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/server.py +14 -14
- ctf_attackapi-0.1.1/src/attackapi/__init__.py +0 -4
- ctf_attackapi-0.1.1/src/attackapi/async_api/__init__.py +0 -4
- ctf_attackapi-0.1.1/src/attackapi/models.py +0 -134
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/async_api/filelock.py +0 -0
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/functional.py +0 -0
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/py.typed +0 -0
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/__init__.py +0 -0
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/__main__.py +0 -0
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/docs.py +0 -0
- {ctf_attackapi-0.1.1 → ctf_attackapi-0.3.0}/src/attackapi/server/worker.py +0 -0
- {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.
|
|
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
|
[](https://opensource.org/license/mit)
|
|
39
|
-

|
|
40
|
+

|
|
41
41
|
[](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml)
|
|
42
|
-
[](https://pypi.org/project/ctf-attackapi)
|
|
43
|
+

|
|
44
44
|

|
|
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)
|
|
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().
|
|
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
|
-
#
|
|
176
|
-
|
|
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
|
-
#
|
|
182
|
-
|
|
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
|
[](https://opensource.org/license/mit)
|
|
5
|
-

|
|
6
|
+

|
|
7
7
|
[](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml)
|
|
8
|
-
[](https://pypi.org/project/ctf-attackapi)
|
|
9
|
+

|
|
10
10
|

|
|
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)
|
|
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().
|
|
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
|
-
#
|
|
142
|
-
|
|
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
|
-
#
|
|
148
|
-
|
|
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
|
+
]
|
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
134
|
-
|
|
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 :=
|
|
139
|
-
return
|
|
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
|
|
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.
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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/
|
|
25
|
-
web.get("/api/v1/
|
|
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.
|
|
42
|
-
flat = info.
|
|
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
|
|
72
|
-
|
|
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
|
|
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
|
-
{"
|
|
82
|
+
{"flag_ids": flag_ids}
|
|
83
83
|
)
|
|
84
84
|
|
|
85
|
-
async def
|
|
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.
|
|
87
|
+
return info.flag_ids(service, team)
|
|
88
88
|
|
|
89
|
-
return await self.
|
|
89
|
+
return await self._flag_ids_common(request, _cb)
|
|
90
90
|
|
|
91
|
-
async def
|
|
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.
|
|
93
|
+
return info.flag_ids_raw(service, team)
|
|
94
94
|
|
|
95
|
-
return await self.
|
|
95
|
+
return await self._flag_ids_common(request, _cb)
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
async def create_app() -> web.Application:
|
|
@@ -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)"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|