ctf-attackapi 0.1.0__tar.gz → 0.2.2__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.0 → ctf_attackapi-0.2.2}/PKG-INFO +38 -6
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/README.md +37 -5
- ctf_attackapi-0.2.2/pyproject.toml +89 -0
- ctf_attackapi-0.1.0/pyproject.toml → ctf_attackapi-0.2.2/pyproject.toml.orig +1 -1
- ctf_attackapi-0.2.2/src/attackapi/__init__.py +4 -0
- ctf_attackapi-0.2.2/src/attackapi/async_api/__init__.py +5 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/async_api/api.py +47 -24
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/async_api/decoders.py +36 -6
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/models.py +16 -6
- ctf_attackapi-0.1.0/src/attackapi/__init__.py +0 -4
- ctf_attackapi-0.1.0/src/attackapi/async_api/__init__.py +0 -4
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/async_api/filelock.py +0 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/functional.py +0 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/py.typed +0 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/server/__init__.py +0 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/server/__main__.py +0 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/server/docs.py +0 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/server/server.py +0 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/src/attackapi/server/worker.py +0 -0
- {ctf_attackapi-0.1.0 → ctf_attackapi-0.2.2}/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.2.2
|
|
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,20 @@ 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.2.0
|
|
58
|
+
----------------
|
|
59
|
+
|
|
60
|
+
- New `atklab` dialect for the ATKLAB gameserver (ECSC 2026): attack info under `attack_info`
|
|
61
|
+
instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
|
|
62
|
+
dialects are unchanged.
|
|
63
|
+
- `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
|
|
64
|
+
- The helper behind `flag_id_flat()` is public as `attackapi.flatten_flag_ids()`, for callers that
|
|
65
|
+
flatten a subset of the raw structure themselves.
|
|
66
|
+
- `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
|
|
67
|
+
no ID for a round is a hole, not a value.
|
|
68
|
+
- `GenericAdCtfApiAsync` accepts a plain callable decoder, a `progress` hook, and an injectable
|
|
69
|
+
`memory_cache`, so it can back a whole game API rather than just `attack.json`.
|
|
70
|
+
|
|
57
71
|
Features
|
|
58
72
|
--------
|
|
59
73
|
|
|
@@ -63,7 +77,8 @@ Features
|
|
|
63
77
|
- Unifies team, IP, and flag info lookup between different CTFs:
|
|
64
78
|
- Supports [ENOWARS](https://enowars.com)
|
|
65
79
|
- Supports [FAUST CTF](https://faustctf.net)
|
|
66
|
-
- Supports [saarCTF](https://ctf.saarland)
|
|
80
|
+
- Supports [saarCTF](https://ctf.saarland)
|
|
81
|
+
- Supports the [Attacking-Lab](https://attacking-lab.com) gameserver (ECSC 2026)
|
|
67
82
|
|
|
68
83
|
Quick-Start
|
|
69
84
|
-----------
|
|
@@ -172,6 +187,9 @@ print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
|
|
|
172
187
|
# set of service names
|
|
173
188
|
print(info.services)
|
|
174
189
|
|
|
190
|
+
# flag format and the round this info was generated for, where the game reports them
|
|
191
|
+
print(info.flag_regex, info.current_round)
|
|
192
|
+
|
|
175
193
|
# raw flag IDs for a service and team.
|
|
176
194
|
# team can be ID, IP, or name.
|
|
177
195
|
# Return data format is determined by game API.
|
|
@@ -229,3 +247,17 @@ from attackapi.async_api import JsonAdCtfApiAsync
|
|
|
229
247
|
|
|
230
248
|
info = await JsonAdCtfApiAsync("https://scoreboard.ctf.saarland/api/scoreboard_current.json").retrieve()
|
|
231
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.
|
|
@@ -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,20 @@ 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.2.0
|
|
24
|
+
----------------
|
|
25
|
+
|
|
26
|
+
- New `atklab` dialect for the ATKLAB gameserver (ECSC 2026): attack info under `attack_info`
|
|
27
|
+
instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
|
|
28
|
+
dialects are unchanged.
|
|
29
|
+
- `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
|
|
30
|
+
- The helper behind `flag_id_flat()` is public as `attackapi.flatten_flag_ids()`, for callers that
|
|
31
|
+
flatten a subset of the raw structure themselves.
|
|
32
|
+
- `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
|
|
33
|
+
no ID for a round is a hole, not a value.
|
|
34
|
+
- `GenericAdCtfApiAsync` accepts a plain callable decoder, a `progress` hook, and an injectable
|
|
35
|
+
`memory_cache`, so it can back a whole game API rather than just `attack.json`.
|
|
36
|
+
|
|
23
37
|
Features
|
|
24
38
|
--------
|
|
25
39
|
|
|
@@ -29,7 +43,8 @@ Features
|
|
|
29
43
|
- Unifies team, IP, and flag info lookup between different CTFs:
|
|
30
44
|
- Supports [ENOWARS](https://enowars.com)
|
|
31
45
|
- Supports [FAUST CTF](https://faustctf.net)
|
|
32
|
-
- Supports [saarCTF](https://ctf.saarland)
|
|
46
|
+
- Supports [saarCTF](https://ctf.saarland)
|
|
47
|
+
- Supports the [Attacking-Lab](https://attacking-lab.com) gameserver (ECSC 2026)
|
|
33
48
|
|
|
34
49
|
Quick-Start
|
|
35
50
|
-----------
|
|
@@ -138,6 +153,9 @@ print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
|
|
|
138
153
|
# set of service names
|
|
139
154
|
print(info.services)
|
|
140
155
|
|
|
156
|
+
# flag format and the round this info was generated for, where the game reports them
|
|
157
|
+
print(info.flag_regex, info.current_round)
|
|
158
|
+
|
|
141
159
|
# raw flag IDs for a service and team.
|
|
142
160
|
# team can be ID, IP, or name.
|
|
143
161
|
# Return data format is determined by game API.
|
|
@@ -195,3 +213,17 @@ from attackapi.async_api import JsonAdCtfApiAsync
|
|
|
195
213
|
|
|
196
214
|
info = await JsonAdCtfApiAsync("https://scoreboard.ctf.saarland/api/scoreboard_current.json").retrieve()
|
|
197
215
|
```
|
|
216
|
+
|
|
217
|
+
To get parsed objects instead of raw dicts, `GenericAdCtfApiAsync` takes any `bytes -> object` callable:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from attackapi.async_api import GenericAdCtfApiAsync
|
|
221
|
+
|
|
222
|
+
scoreboard = await GenericAdCtfApiAsync(
|
|
223
|
+
parse_scoreboard, "https://scoreboard.ctf.saarland/api/scoreboard_round_237.json"
|
|
224
|
+
).retrieve()
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Both accept `progress=`, a context-manager factory called with the URL around remote fetches (to drive a
|
|
228
|
+
spinner, for example), and `memory_cache=`, an own `GlobalCache` instead of the process-wide one. The
|
|
229
|
+
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.2.2"
|
|
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"])
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
from dataclasses import dataclass, field, asdict
|
|
2
|
-
from typing import Optional, Union, Any
|
|
2
|
+
from typing import Optional, Union, Any
|
|
3
3
|
from typing_extensions import TypeAlias
|
|
4
4
|
|
|
5
5
|
RawFlagIds: TypeAlias = Union[list, dict[str, Union[str, list, dict]]]
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def
|
|
8
|
+
def flatten_flag_ids(flag_ids: Any) -> list[str]:
|
|
9
|
+
"""
|
|
10
|
+
Collect every non-null scalar flag ID out of an arbitrarily nested flag-ID structure.
|
|
11
|
+
Independent of the game API's exact nesting, but loses the round / flag-store structure.
|
|
12
|
+
"""
|
|
13
|
+
if flag_ids is None: # a flag store with no ID for this round/team
|
|
14
|
+
return []
|
|
15
|
+
if isinstance(flag_ids, str):
|
|
16
|
+
return [flag_ids]
|
|
9
17
|
if isinstance(flag_ids, list):
|
|
10
18
|
result = []
|
|
11
19
|
for value in flag_ids:
|
|
12
|
-
result +=
|
|
20
|
+
result += flatten_flag_ids(value)
|
|
13
21
|
return result
|
|
14
22
|
if isinstance(flag_ids, dict):
|
|
15
23
|
result = []
|
|
16
24
|
for value in flag_ids.values():
|
|
17
|
-
result +=
|
|
25
|
+
result += flatten_flag_ids(value)
|
|
18
26
|
return result
|
|
19
|
-
return [
|
|
27
|
+
return [str(flag_ids)]
|
|
20
28
|
|
|
21
29
|
|
|
22
30
|
@dataclass(frozen=True)
|
|
@@ -46,6 +54,8 @@ class AttackInfo:
|
|
|
46
54
|
team_lookup: dict[str, Team] = field(default_factory=dict)
|
|
47
55
|
services: set[str] = field(default_factory=set)
|
|
48
56
|
flag_ids: dict[str, dict[str, RawFlagIds]] = field(default_factory=dict)
|
|
57
|
+
flag_regex: Optional[str] = None
|
|
58
|
+
current_round: Optional[int] = None
|
|
49
59
|
raw: bytes = b"" # everything, as given by the game API
|
|
50
60
|
|
|
51
61
|
def team(self, name: Union[str, int]) -> Optional[Team]:
|
|
@@ -100,7 +110,7 @@ class AttackInfo:
|
|
|
100
110
|
:return:
|
|
101
111
|
"""
|
|
102
112
|
flag_ids = self.flag_id_raw(service, team)
|
|
103
|
-
return
|
|
113
|
+
return flatten_flag_ids(flag_ids) if flag_ids is not None else []
|
|
104
114
|
|
|
105
115
|
def attack_info_raw(self, service: str, team: Union[str, int, Team]) -> Optional[RawFlagIds]:
|
|
106
116
|
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|