ctf-attackapi 0.1.0__py3-none-any.whl
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.
- attackapi/__init__.py +4 -0
- attackapi/async_api/__init__.py +4 -0
- attackapi/async_api/api.py +225 -0
- attackapi/async_api/decoders.py +173 -0
- attackapi/async_api/filelock.py +30 -0
- attackapi/functional.py +46 -0
- attackapi/models.py +134 -0
- attackapi/py.typed +0 -0
- attackapi/server/__init__.py +2 -0
- attackapi/server/__main__.py +29 -0
- attackapi/server/docs.py +76 -0
- attackapi/server/server.py +109 -0
- attackapi/server/worker.py +19 -0
- attackapi/sync_api.py +29 -0
- ctf_attackapi-0.1.0.dist-info/METADATA +231 -0
- ctf_attackapi-0.1.0.dist-info/RECORD +18 -0
- ctf_attackapi-0.1.0.dist-info/WHEEL +4 -0
- ctf_attackapi-0.1.0.dist-info/entry_points.txt +3 -0
attackapi/__init__.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Union, Generic, TypeVar, AsyncContextManager, Any
|
|
9
|
+
|
|
10
|
+
import aiologic
|
|
11
|
+
from aiohttp import ClientSession, ClientTimeout
|
|
12
|
+
from filelock import FileLock
|
|
13
|
+
|
|
14
|
+
from attackapi.async_api.decoders import Decoder, GenericDecoder, JSONDecoder
|
|
15
|
+
from attackapi.async_api.filelock import acquire_filelock
|
|
16
|
+
from attackapi.models import AttackInfo
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GlobalCache(Generic[T]):
|
|
22
|
+
"""In-memory cache for API responses. Shared between threads/loops, not shared between processes."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
# take the lock before modifying the cache.
|
|
26
|
+
# thanks to GIL this is not necessary for thread safety, but it avoids multiple concurrent loads
|
|
27
|
+
self.lock = aiologic.Lock()
|
|
28
|
+
self._cache: dict[str, tuple[float, T]] = {}
|
|
29
|
+
|
|
30
|
+
def age(self, key: str) -> Optional[float]:
|
|
31
|
+
if key in self._cache:
|
|
32
|
+
return time.time() - self._cache[key][0]
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
def get(self, key: str) -> T:
|
|
36
|
+
return self._cache[key][1]
|
|
37
|
+
|
|
38
|
+
def set(self, key: str, value: T) -> None:
|
|
39
|
+
self._cache[key] = (time.time(), value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_api_response_cache: GlobalCache[Any] = GlobalCache()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FileCache:
|
|
46
|
+
def __init__(self, path: Path, lock: Path) -> None:
|
|
47
|
+
self._path = path
|
|
48
|
+
self._lock = lock
|
|
49
|
+
|
|
50
|
+
def age(self) -> Optional[float]:
|
|
51
|
+
try:
|
|
52
|
+
return time.time() - self._path.stat().st_mtime
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def get(self) -> Optional[bytes]:
|
|
57
|
+
"""
|
|
58
|
+
On Linux: not necessary to have the lock()
|
|
59
|
+
On Windows: Must only be called while the lock() is taken
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
return self._path.read_bytes()
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
pass
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def set(self, value: bytes) -> None:
|
|
68
|
+
"""Must only be called while the lock() is taken"""
|
|
69
|
+
_atomic_write(self._path, value)
|
|
70
|
+
|
|
71
|
+
def lock(self) -> AsyncContextManager[None]:
|
|
72
|
+
return acquire_filelock(FileLock(self._lock))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _atomic_write(p: Path, raw: bytes) -> None:
|
|
76
|
+
tmpfile = p.with_suffix(f".json.tmp")
|
|
77
|
+
with tmpfile.open("wb") as f:
|
|
78
|
+
f.write(raw)
|
|
79
|
+
f.flush()
|
|
80
|
+
os.fsync(f.fileno())
|
|
81
|
+
tmpfile.replace(p)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class GenericAdCtfApiAsync(Generic[T]):
|
|
85
|
+
def __init__(self, decoder: GenericDecoder[T], url: str = "",
|
|
86
|
+
tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
|
|
87
|
+
lifetime: float = 30.0, timeout: float = 10.0,
|
|
88
|
+
aiohttp_arguments: Optional[dict] = None) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Create a new API client.
|
|
91
|
+
|
|
92
|
+
:param decoder: A decoder for API responses
|
|
93
|
+
:param url: URL of your game's API
|
|
94
|
+
:param tmp_directory: where to store cache files
|
|
95
|
+
:param lifetime: How long to cache data for (in seconds)
|
|
96
|
+
:param timeout: How long to wait for API calls (in seconds)
|
|
97
|
+
:param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
|
|
98
|
+
"""
|
|
99
|
+
if timeout < 1:
|
|
100
|
+
raise ValueError("Timeout must be at least 1 second")
|
|
101
|
+
if lifetime < timeout:
|
|
102
|
+
raise ValueError("Lifetime must be at least as long as timeout")
|
|
103
|
+
|
|
104
|
+
self._url = url
|
|
105
|
+
self._lifetime = lifetime
|
|
106
|
+
self._timeout = timeout
|
|
107
|
+
self._decoder = decoder
|
|
108
|
+
self._aiohttp_arguments = aiohttp_arguments or {
|
|
109
|
+
"headers": {"User-Agent": "python/attackapi " + version("ctf-attackapi")}
|
|
110
|
+
}
|
|
111
|
+
self._cache_key = hashlib.sha256(url.encode()).hexdigest()[:12]
|
|
112
|
+
self._file_cache = FileCache(
|
|
113
|
+
Path(tmp_directory) / f"ctf-{self._cache_key}.json",
|
|
114
|
+
Path(tmp_directory) / f"ctf-{self._cache_key}.json.lock"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def retrieve(self) -> T:
|
|
118
|
+
"""
|
|
119
|
+
Get the current attack info. Either from cache, from disk, or from game API.
|
|
120
|
+
This method might fail with aiohttp exceptions.
|
|
121
|
+
"""
|
|
122
|
+
# Step 1: check in-process cache
|
|
123
|
+
info = self._check_memory_cache()
|
|
124
|
+
if info is not None:
|
|
125
|
+
return info
|
|
126
|
+
# not found? lock it to avoid concurrent loads
|
|
127
|
+
async with _api_response_cache.lock:
|
|
128
|
+
# check again to avoid race conditions
|
|
129
|
+
info = self._check_memory_cache()
|
|
130
|
+
if info is not None:
|
|
131
|
+
return info
|
|
132
|
+
# not found => load from file or API
|
|
133
|
+
info = await self._attack_info_from_file()
|
|
134
|
+
_api_response_cache.set(self._cache_key, info)
|
|
135
|
+
return info
|
|
136
|
+
|
|
137
|
+
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)
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def _check_file_cache(self) -> Optional[T]:
|
|
143
|
+
if (age := self._file_cache.age()) is not None and age <= self._lifetime:
|
|
144
|
+
raw = self._file_cache.get()
|
|
145
|
+
if raw is not None:
|
|
146
|
+
return self._decoder.parse(raw)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
async def _attack_info_from_file(self) -> T:
|
|
150
|
+
# Step 2: try to load from file
|
|
151
|
+
# this does not work on Windows, because concurrent read + replacing files is not possible.
|
|
152
|
+
# for better performance please use Linux
|
|
153
|
+
if sys.platform != "win32":
|
|
154
|
+
info = self._check_file_cache()
|
|
155
|
+
if info is not None:
|
|
156
|
+
return info
|
|
157
|
+
# not found? lock it to avoid concurrent API requests
|
|
158
|
+
async with self._file_cache.lock():
|
|
159
|
+
# recheck to avoid race conditions
|
|
160
|
+
info = self._check_file_cache()
|
|
161
|
+
if info is not None:
|
|
162
|
+
return info
|
|
163
|
+
# not found => load from API
|
|
164
|
+
raw = await self._attack_info_from_remote()
|
|
165
|
+
info = self._decoder.parse(raw)
|
|
166
|
+
# and save to file (atomic)
|
|
167
|
+
self._file_cache.set(raw)
|
|
168
|
+
return info
|
|
169
|
+
|
|
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()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class JsonAdCtfApiAsync(GenericAdCtfApiAsync[dict]):
|
|
178
|
+
def __init__(self, url: str, tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
|
|
179
|
+
lifetime: float = 30.0, timeout: float = 10.0,
|
|
180
|
+
aiohttp_arguments: Optional[dict] = None) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Create a new API client.
|
|
183
|
+
|
|
184
|
+
:param url: URL of any of your game's APIs
|
|
185
|
+
:param tmp_directory: where to store cache files
|
|
186
|
+
:param lifetime: How long to cache data for (in seconds)
|
|
187
|
+
: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
|
+
:param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
|
|
190
|
+
"""
|
|
191
|
+
super().__init__(decoder=JSONDecoder(), url=url, tmp_directory=tmp_directory, lifetime=lifetime,
|
|
192
|
+
timeout=timeout, aiohttp_arguments=aiohttp_arguments)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class AdCtfApiAsync(GenericAdCtfApiAsync[AttackInfo]):
|
|
196
|
+
"""
|
|
197
|
+
Async API to retrieve AD team / flag information with as much caching as possible.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(self, url: str = "", tmp_directory: Union[str, Path] = tempfile.gettempdir(), *,
|
|
201
|
+
lifetime: float = 30.0, timeout: float = 10.0, decoder: Optional[Decoder] = None,
|
|
202
|
+
aiohttp_arguments: Optional[dict] = None) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Create a new API client.
|
|
205
|
+
|
|
206
|
+
:param url: URL of your game's API (defaults to environment variable CTF_API)
|
|
207
|
+
:param tmp_directory: where to store cache files
|
|
208
|
+
:param lifetime: How long to cache data for (in seconds)
|
|
209
|
+
:param timeout: How long to wait for API calls (in seconds)
|
|
210
|
+
:param decoder: A custom decoder for API responses, if the default one doesn't work for your game
|
|
211
|
+
:param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
|
|
212
|
+
"""
|
|
213
|
+
if not url:
|
|
214
|
+
if "CTF_API" not in os.environ:
|
|
215
|
+
raise Exception("Please call configure() or set CTF_API environment variable!")
|
|
216
|
+
url = os.environ["CTF_API"]
|
|
217
|
+
super().__init__(decoder=decoder or Decoder(), url=url, tmp_directory=tmp_directory, lifetime=lifetime,
|
|
218
|
+
timeout=timeout, aiohttp_arguments=aiohttp_arguments)
|
|
219
|
+
|
|
220
|
+
async def attack_info(self) -> AttackInfo:
|
|
221
|
+
"""
|
|
222
|
+
Get the current attack info. Either from cache, from disk, or from game API.
|
|
223
|
+
This method might fail with aiohttp exceptions.
|
|
224
|
+
"""
|
|
225
|
+
return await self.retrieve()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
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]}}
|
|
6
|
+
|
|
7
|
+
Additional team list:
|
|
8
|
+
saarctf: teams => [0 => {id: ..., name: ..., logo: ...}]
|
|
9
|
+
faust: teams => [ID1, ID2, ...]
|
|
10
|
+
enowars: availableTeams: [IP1, IP2, ...]
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
from abc import abstractmethod, ABC
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Optional, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from attackapi.models import AttackInfo, Team
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Dialect(ABC):
|
|
22
|
+
"""ID/IP foo for the different games."""
|
|
23
|
+
name: str
|
|
24
|
+
ip_pattern: Optional[str] = None # format string to get IP from ID
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def matches(self, data: dict) -> bool:
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def id_from_ip(self, ip: str) -> int:
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
def ip_from_id(self, team_id: int) -> str:
|
|
34
|
+
if self.ip_pattern is None:
|
|
35
|
+
raise Exception(f"ID to IP conversion required, but no IP pattern defined for dialect {self.name!r}")
|
|
36
|
+
return self.ip_pattern.format(team_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DefaultDialect(Dialect):
|
|
40
|
+
def matches(self, data: dict) -> bool:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SaarctfDialect(Dialect):
|
|
45
|
+
def matches(self, data: dict) -> bool:
|
|
46
|
+
return "flag_ids" in data and "teams" in data and len(data["teams"]) > 0 and isinstance(data["teams"][0], dict)
|
|
47
|
+
|
|
48
|
+
def id_from_ip(self, ip: str) -> int:
|
|
49
|
+
return int(ip.split(".")[2]) # actually not needed, saarCTF API exposes full team info
|
|
50
|
+
|
|
51
|
+
def ip_from_id(self, team_id: int) -> str:
|
|
52
|
+
return f"10.{32 + (team_id // 200)}.{team_id % 200}.2" # actually not needed, saarCTF API exposes full team info
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FaustDialect(Dialect):
|
|
56
|
+
def matches(self, data: dict) -> bool:
|
|
57
|
+
return "flag_ids" in data and "teams" in data and len(data["teams"]) > 0 and isinstance(data["teams"][0], int)
|
|
58
|
+
|
|
59
|
+
def id_from_ip(self, ip: str) -> int:
|
|
60
|
+
return int(ip.split(":")[2])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EnowarsDialect(Dialect):
|
|
64
|
+
def matches(self, data: dict) -> bool:
|
|
65
|
+
return "services" in data and "availableTeams" in data
|
|
66
|
+
|
|
67
|
+
def id_from_ip(self, ip: str) -> int:
|
|
68
|
+
"""Actually, IDs don't matter for enowars format"""
|
|
69
|
+
return int(ip.split(".")[2])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
DIALECTS = [
|
|
73
|
+
SaarctfDialect("saarctf"),
|
|
74
|
+
FaustDialect("faustctf", "fd66:666:{:d}::2"),
|
|
75
|
+
EnowarsDialect("enowars", "10.1.{:d}.1"),
|
|
76
|
+
DefaultDialect("none")
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
T = TypeVar("T")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GenericDecoder(ABC, Generic[T]):
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def parse(self, raw: bytes) -> T:
|
|
85
|
+
raise NotImplementedError()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class JSONDecoder(GenericDecoder[dict]):
|
|
89
|
+
def parse(self, raw: bytes) -> dict:
|
|
90
|
+
return json.loads(raw)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Decoder(GenericDecoder[AttackInfo]):
|
|
94
|
+
"""
|
|
95
|
+
A decoder converts the game APIs response (in bytes) into teams, services, and attack information.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, dialect: Optional[Dialect] = None) -> None:
|
|
99
|
+
"""
|
|
100
|
+
:param dialect: auto-inferred by default
|
|
101
|
+
"""
|
|
102
|
+
self._dialect = dialect
|
|
103
|
+
|
|
104
|
+
def parse(self, raw: bytes) -> AttackInfo:
|
|
105
|
+
"""
|
|
106
|
+
Converts game API response to attack infos.
|
|
107
|
+
:param raw:
|
|
108
|
+
:raises ValueError: If data is invalid
|
|
109
|
+
:return:
|
|
110
|
+
"""
|
|
111
|
+
info = AttackInfo(raw=raw)
|
|
112
|
+
data = json.loads(raw)
|
|
113
|
+
dialect = self._get_dialect(data)
|
|
114
|
+
|
|
115
|
+
if "flag_ids" in data:
|
|
116
|
+
self._parse_services(info, data["flag_ids"])
|
|
117
|
+
elif "services" in data:
|
|
118
|
+
self._parse_services(info, data["services"])
|
|
119
|
+
else:
|
|
120
|
+
raise ValueError("Unknown format - no flag_ids or services key found")
|
|
121
|
+
|
|
122
|
+
if "teams" in data:
|
|
123
|
+
self._parse_teams(dialect, info, data["teams"])
|
|
124
|
+
elif "availableTeams" in data:
|
|
125
|
+
self._parse_teams(dialect, info, data["availableTeams"])
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError("Unknown format - no teams or availableTeams key found")
|
|
128
|
+
|
|
129
|
+
return info
|
|
130
|
+
|
|
131
|
+
def _get_dialect(self, data: dict) -> Dialect:
|
|
132
|
+
if self._dialect is not None:
|
|
133
|
+
return self._dialect
|
|
134
|
+
for dialect in DIALECTS:
|
|
135
|
+
if dialect.matches(data):
|
|
136
|
+
return dialect
|
|
137
|
+
return DefaultDialect("none")
|
|
138
|
+
|
|
139
|
+
def _parse_services(self, info: AttackInfo, services: dict) -> None:
|
|
140
|
+
for service_name, flag_ids in services.items():
|
|
141
|
+
if not isinstance(flag_ids, dict):
|
|
142
|
+
raise ValueError(f"Invalid flag_ids format for service {service_name!r}: {type(flag_ids)}")
|
|
143
|
+
info.services.add(service_name)
|
|
144
|
+
info.flag_ids[service_name.lower()] = flag_ids
|
|
145
|
+
|
|
146
|
+
def _parse_teams(self, dialect: Dialect, info: AttackInfo, teams: list) -> None:
|
|
147
|
+
for team in teams:
|
|
148
|
+
if isinstance(team, int):
|
|
149
|
+
team = Team(
|
|
150
|
+
id=team,
|
|
151
|
+
ip=dialect.ip_from_id(team),
|
|
152
|
+
name=f"Team #{team}"
|
|
153
|
+
)
|
|
154
|
+
elif isinstance(team, str):
|
|
155
|
+
team = Team(id=dialect.id_from_ip(team), ip=team, name=team)
|
|
156
|
+
elif isinstance(team, dict):
|
|
157
|
+
if not team.get("online", True):
|
|
158
|
+
continue
|
|
159
|
+
team = Team(
|
|
160
|
+
id=team["id"],
|
|
161
|
+
ip=team["ip"] if "ip" in team else dialect.ip_from_id(team["id"]),
|
|
162
|
+
name=team["name"]
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
raise ValueError(f"Unknown team type: {type(team)}: {json.dumps(team)}")
|
|
166
|
+
|
|
167
|
+
info.teams.append(team)
|
|
168
|
+
if team.id is not None:
|
|
169
|
+
info.team_lookup[str(team.id)] = team
|
|
170
|
+
if team.ip is not None:
|
|
171
|
+
info.team_lookup[team.ip.lower()] = team
|
|
172
|
+
if team.name is not None:
|
|
173
|
+
info.team_lookup[team.name.lower()] = team
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import AsyncGenerator
|
|
4
|
+
|
|
5
|
+
from filelock import BaseFileLock, Timeout
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@asynccontextmanager
|
|
9
|
+
async def acquire_filelock(fl: BaseFileLock, timeout: float = 10, interval: float = 0.01) -> AsyncGenerator[None, None]:
|
|
10
|
+
"""
|
|
11
|
+
Acquire filelock, async_api implementation.
|
|
12
|
+
Adapted from https://github.com/tox-dev/filelock/issues/78#issuecomment-1966513766
|
|
13
|
+
"""
|
|
14
|
+
for _ in range(int(timeout / interval)):
|
|
15
|
+
try:
|
|
16
|
+
fl.acquire(blocking=False)
|
|
17
|
+
except Timeout:
|
|
18
|
+
await asyncio.sleep(interval)
|
|
19
|
+
continue
|
|
20
|
+
# We want to have the exclusive lock
|
|
21
|
+
if fl.lock_counter <= 1:
|
|
22
|
+
try:
|
|
23
|
+
yield
|
|
24
|
+
finally:
|
|
25
|
+
fl.release()
|
|
26
|
+
return
|
|
27
|
+
# reentrant hold by an outer acquire: back off and retry
|
|
28
|
+
fl.release()
|
|
29
|
+
await asyncio.sleep(interval)
|
|
30
|
+
raise TimeoutError("Could not obtain file lock")
|
attackapi/functional.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
from typing import Optional, Any
|
|
3
|
+
|
|
4
|
+
from attackapi import AttackInfo
|
|
5
|
+
from attackapi.async_api import AdCtfApiAsync
|
|
6
|
+
from attackapi.sync_api import AdCtfApiSync
|
|
7
|
+
|
|
8
|
+
_sync_api: Optional[AdCtfApiSync] = None
|
|
9
|
+
_async_api: Optional[AdCtfApiAsync] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configure(url: str = "", tmp_directory: str = tempfile.gettempdir(), **kwargs: Any) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Configure the caching API. Only URL is required.
|
|
15
|
+
|
|
16
|
+
:param url: URL of your game's API (defaults to environment variable CTF_API)
|
|
17
|
+
:param tmp_directory: where to store cache files
|
|
18
|
+
:param lifetime: How long to cache data for (in seconds)
|
|
19
|
+
:param timeout: How long to wait for API calls (in seconds)
|
|
20
|
+
:return:
|
|
21
|
+
"""
|
|
22
|
+
global _sync_api, _async_api
|
|
23
|
+
_sync_api = AdCtfApiSync(url, tmp_directory, **kwargs)
|
|
24
|
+
_async_api = AdCtfApiAsync(url, tmp_directory, **kwargs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def attack_info() -> AttackInfo:
|
|
28
|
+
"""
|
|
29
|
+
Get the current attack info. Either from cache, from disk, or from game API.
|
|
30
|
+
This method might fail with aiohttp exceptions.
|
|
31
|
+
"""
|
|
32
|
+
global _sync_api
|
|
33
|
+
if _sync_api is None:
|
|
34
|
+
_sync_api = AdCtfApiSync()
|
|
35
|
+
return _sync_api.attack_info()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def attack_info_async() -> AttackInfo:
|
|
39
|
+
"""
|
|
40
|
+
Get the current attack info. Either from cache, from disk, or from game API.
|
|
41
|
+
This method might fail with aiohttp exceptions.
|
|
42
|
+
"""
|
|
43
|
+
global _async_api
|
|
44
|
+
if _async_api is None:
|
|
45
|
+
_async_api = AdCtfApiAsync()
|
|
46
|
+
return await _async_api.attack_info()
|
attackapi/models.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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)"
|
attackapi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import tempfile
|
|
3
|
+
|
|
4
|
+
from aiohttp import web
|
|
5
|
+
from aiohttp.web_app import Application
|
|
6
|
+
|
|
7
|
+
from attackapi.async_api import AdCtfApiAsync
|
|
8
|
+
from attackapi.server.server import AttackApiViews
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser()
|
|
13
|
+
parser.add_argument("--port", type=int, default=14320)
|
|
14
|
+
parser.add_argument("--url", type=str, default="",
|
|
15
|
+
help="API url to get CTF info from.")
|
|
16
|
+
parser.add_argument("--tmp-directory", type=str, default=tempfile.gettempdir(),
|
|
17
|
+
help="Cache directory")
|
|
18
|
+
parser.add_argument("--lifetime", type=float, default=30.0, help="Lifetime of cached data in seconds")
|
|
19
|
+
parser.add_argument("--timeout", type=float, default=10.0, help="Timeout for API calls in seconds")
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
|
|
22
|
+
api = AdCtfApiAsync(args.url, args.tmp_directory, lifetime=args.lifetime, timeout=args.timeout)
|
|
23
|
+
app = Application()
|
|
24
|
+
app.add_routes(AttackApiViews(api).routes())
|
|
25
|
+
web.run_app(app, port=args.port)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == '__main__':
|
|
29
|
+
main()
|
attackapi/server/docs.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def openapi_path() -> Path:
|
|
9
|
+
return Path(__file__).absolute().parent.parent.parent.parent / "api.yaml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@lru_cache(maxsize=1)
|
|
13
|
+
def docs_json() -> dict:
|
|
14
|
+
return yaml.safe_load(openapi_path().read_text())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@lru_cache(maxsize=1)
|
|
18
|
+
def docs_html() -> str:
|
|
19
|
+
return TEMPLATE % json.dumps(docs_json())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# FROM: https://github.com/yousan/swagger-yaml-to-html/blob/master/swagger-yaml-to-html.py
|
|
23
|
+
TEMPLATE = """
|
|
24
|
+
<!DOCTYPE html>
|
|
25
|
+
<html lang="en">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="UTF-8">
|
|
28
|
+
<title>AD CTF API Wrapper</title>
|
|
29
|
+
<link href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700" rel="stylesheet">
|
|
30
|
+
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.43.0/swagger-ui.css" >
|
|
31
|
+
<style>
|
|
32
|
+
html
|
|
33
|
+
{
|
|
34
|
+
box-sizing: border-box;
|
|
35
|
+
overflow: -moz-scrollbars-vertical;
|
|
36
|
+
overflow-y: scroll;
|
|
37
|
+
}
|
|
38
|
+
*,
|
|
39
|
+
*:before,
|
|
40
|
+
*:after
|
|
41
|
+
{
|
|
42
|
+
box-sizing: inherit;
|
|
43
|
+
}
|
|
44
|
+
body {
|
|
45
|
+
margin:0;
|
|
46
|
+
background: #fafafa;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div id="swagger-ui"></div>
|
|
52
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.43.0/swagger-ui-bundle.js"> </script>
|
|
53
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.43.0/swagger-ui-standalone-preset.js"> </script>
|
|
54
|
+
<script>
|
|
55
|
+
window.onload = function() {
|
|
56
|
+
var spec = %s;
|
|
57
|
+
// Build a system
|
|
58
|
+
const ui = SwaggerUIBundle({
|
|
59
|
+
spec: spec,
|
|
60
|
+
dom_id: '#swagger-ui',
|
|
61
|
+
deepLinking: true,
|
|
62
|
+
presets: [
|
|
63
|
+
SwaggerUIBundle.presets.apis,
|
|
64
|
+
SwaggerUIStandalonePreset
|
|
65
|
+
],
|
|
66
|
+
plugins: [
|
|
67
|
+
SwaggerUIBundle.plugins.DownloadUrl
|
|
68
|
+
],
|
|
69
|
+
layout: "StandaloneLayout"
|
|
70
|
+
})
|
|
71
|
+
window.ui = ui
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
</body>
|
|
75
|
+
</html>
|
|
76
|
+
""".strip()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from aiohttp import web
|
|
7
|
+
|
|
8
|
+
from attackapi import AttackInfo
|
|
9
|
+
from attackapi.async_api import AdCtfApiAsync
|
|
10
|
+
from attackapi.server.docs import docs_html, openapi_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AttackApiViews:
|
|
14
|
+
def __init__(self, api: AdCtfApiAsync) -> None:
|
|
15
|
+
self._api = api
|
|
16
|
+
|
|
17
|
+
def routes(self) -> list[web.RouteDef]:
|
|
18
|
+
return [
|
|
19
|
+
web.get("/", self.docs),
|
|
20
|
+
web.get("/api.yaml", self.api_spec),
|
|
21
|
+
web.get("/api/v1/raw", self.get_raw),
|
|
22
|
+
web.get("/api/v1/services", self.get_services),
|
|
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),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
async def docs(self, request: web.Request) -> web.Response:
|
|
29
|
+
docs = docs_html()
|
|
30
|
+
docs = docs.replace('"http://localhost:14320"', json.dumps("http://" + request.host))
|
|
31
|
+
try:
|
|
32
|
+
info = await self._api.attack_info()
|
|
33
|
+
if len(info.services) > 0:
|
|
34
|
+
docs = docs.replace('"RCEaaS"', json.dumps(list(info.services)[0]))
|
|
35
|
+
if len(info.teams) > 0:
|
|
36
|
+
docs = docs.replace('"10.32.1.2"', json.dumps(info.teams[0].ip))
|
|
37
|
+
docs = docs.replace('"example": 1', '"example": ' + json.dumps(info.teams[0].id))
|
|
38
|
+
docs = docs.replace('"NOP"', json.dumps(info.teams[0].name))
|
|
39
|
+
if len(info.services) > 0:
|
|
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])
|
|
43
|
+
if raw:
|
|
44
|
+
docs = docs.replace(
|
|
45
|
+
json.dumps({"227": "username1", "228": "username2", "229": "username3"}),
|
|
46
|
+
json.dumps(raw)
|
|
47
|
+
)
|
|
48
|
+
docs = docs.replace(
|
|
49
|
+
json.dumps(["username1", "username2", "username3"]),
|
|
50
|
+
json.dumps(flat)
|
|
51
|
+
)
|
|
52
|
+
except: # noqa
|
|
53
|
+
pass
|
|
54
|
+
return web.Response(text=docs, content_type="text/html")
|
|
55
|
+
|
|
56
|
+
async def api_spec(self, request: web.Request) -> web.FileResponse:
|
|
57
|
+
return web.FileResponse(openapi_path())
|
|
58
|
+
|
|
59
|
+
async def get_raw(self, request: web.Request) -> web.Response:
|
|
60
|
+
info = await self._api.attack_info()
|
|
61
|
+
return web.Response(body=info.raw, content_type="application/json")
|
|
62
|
+
|
|
63
|
+
async def get_teams(self, request: web.Request) -> web.Response:
|
|
64
|
+
info = await self._api.attack_info()
|
|
65
|
+
return web.json_response({"teams": [team.to_dict() for team in info.teams]})
|
|
66
|
+
|
|
67
|
+
async def get_services(self, request: web.Request) -> web.Response:
|
|
68
|
+
info = await self._api.attack_info()
|
|
69
|
+
return web.json_response({"services": list(info.services)})
|
|
70
|
+
|
|
71
|
+
async def _attack_info_common(self, request: web.Request,
|
|
72
|
+
cb: Callable[[AttackInfo, str, str], Any]) -> web.Response:
|
|
73
|
+
service = request.match_info["service"]
|
|
74
|
+
team = request.match_info["team"]
|
|
75
|
+
if not service or not team:
|
|
76
|
+
raise web.HTTPBadRequest(reason="Invalid team or service")
|
|
77
|
+
info = await self._api.attack_info()
|
|
78
|
+
if service.lower() not in info.flag_ids:
|
|
79
|
+
raise web.HTTPBadRequest(reason=f"Unknown service, or service has no attack info: {service}")
|
|
80
|
+
flag_ids = cb(info, service, team)
|
|
81
|
+
return web.json_response(
|
|
82
|
+
{"attack_info": flag_ids}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def get_attack_info(self, request: web.Request) -> web.Response:
|
|
86
|
+
def _cb(info: AttackInfo, service: str, team: str) -> Any:
|
|
87
|
+
return info.flag_id_flat(service, team)
|
|
88
|
+
|
|
89
|
+
return await self._attack_info_common(request, _cb)
|
|
90
|
+
|
|
91
|
+
async def get_attack_info_raw(self, request: web.Request) -> web.Response:
|
|
92
|
+
def _cb(info: AttackInfo, service: str, team: str) -> Any:
|
|
93
|
+
return info.flag_id_raw(service, team)
|
|
94
|
+
|
|
95
|
+
return await self._attack_info_common(request, _cb)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def create_app() -> web.Application:
|
|
99
|
+
"""Create the app for gunicorn"""
|
|
100
|
+
api = AdCtfApiAsync(
|
|
101
|
+
url=os.environ["CTF_API"],
|
|
102
|
+
tmp_directory=os.environ.get("CTF_API_TMP_DIR", tempfile.gettempdir()),
|
|
103
|
+
lifetime=float(os.environ.get("CTF_API_LIFETIME", 30.0)),
|
|
104
|
+
timeout=float(os.environ.get("CTF_API_TIMEOUT", 10.0))
|
|
105
|
+
)
|
|
106
|
+
views = AttackApiViews(api)
|
|
107
|
+
app = web.Application()
|
|
108
|
+
app.add_routes(views.routes())
|
|
109
|
+
return app
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from aiohttp import GunicornWebWorker
|
|
4
|
+
from gunicorn.workers import base # type: ignore
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MyGunicornWebWorker(GunicornWebWorker):
|
|
8
|
+
"""
|
|
9
|
+
Hot-patches an issue in aiohttp.
|
|
10
|
+
https://github.com/aio-libs/aiohttp/issues/11701
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def init_process(self) -> None:
|
|
14
|
+
try:
|
|
15
|
+
super().init_process()
|
|
16
|
+
except RuntimeError:
|
|
17
|
+
self.loop = asyncio.new_event_loop()
|
|
18
|
+
asyncio.set_event_loop(self.loop)
|
|
19
|
+
base.Worker.init_process(self)
|
attackapi/sync_api.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Union
|
|
5
|
+
|
|
6
|
+
from attackapi import AttackInfo
|
|
7
|
+
from attackapi.async_api import AdCtfApiAsync
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AdCtfApiSync:
|
|
11
|
+
def __init__(self, url: str = "", tmp_directory: Union[str, Path] = tempfile.gettempdir(), **kwargs: Any) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Create a new API client for synchronous use.
|
|
14
|
+
|
|
15
|
+
:param url: URL of your game's API (defaults to environment variable CTF_API)
|
|
16
|
+
:param tmp_directory: where to store cache files
|
|
17
|
+
:param lifetime: How long to cache data for (in seconds)
|
|
18
|
+
:param timeout: How long to wait for API calls (in seconds)
|
|
19
|
+
:param decoder: A custom decoder for API responses, if the default one doesn't work for your game
|
|
20
|
+
:param aiohttp_arguments: Optional arguments to pass to aiohttp.ClientSession
|
|
21
|
+
"""
|
|
22
|
+
self._api = AdCtfApiAsync(url, tmp_directory, **kwargs)
|
|
23
|
+
|
|
24
|
+
def attack_info(self) -> AttackInfo:
|
|
25
|
+
"""
|
|
26
|
+
Get the current attack info. Either from cache, from disk, or from game API.
|
|
27
|
+
This method might fail with aiohttp exceptions.
|
|
28
|
+
"""
|
|
29
|
+
return asyncio.run(self._api.attack_info())
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ctf-attackapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached.
|
|
5
|
+
Keywords: Attack-Defense,CTF,Attack API,Attack Info,Flag IDs,FAUST CTF,ENOWARS,saarCTF
|
|
6
|
+
Author: Markus Bauer
|
|
7
|
+
Author-email: Markus Bauer <markus.bauer@cispa.saarland>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Education
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: aiohttp>=3.13.3
|
|
22
|
+
Requires-Dist: aiologic>=0.16.0
|
|
23
|
+
Requires-Dist: filelock>=3.19.1
|
|
24
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
25
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
26
|
+
Requires-Dist: gunicorn>=23.0.0 ; extra == 'server'
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Project-URL: Homepage, https://github.com/Attacking-Lab/ctf-attackapi
|
|
29
|
+
Project-URL: Repository, https://github.com/Attacking-Lab/ctf-attackapi
|
|
30
|
+
Project-URL: Issues, https://github.com/Attacking-Lab/ctf-attackapi/issues
|
|
31
|
+
Project-URL: Background, https://wiki.attacking-lab.com/attack-defense/
|
|
32
|
+
Provides-Extra: server
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
CTF AttackAPI - Cached and Unified!
|
|
36
|
+
===================================
|
|
37
|
+
|
|
38
|
+
[](https://opensource.org/license/mit)
|
|
39
|
+

|
|
40
|
+

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

|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Gather attack information quickly in your attack-defense CTF exploits!
|
|
48
|
+
|
|
49
|
+
During [attack-defense CTF competitions](https://wiki.attacking-lab.com/attack-defense/), you have to write exploits
|
|
50
|
+
quickly and run them on a large scale.
|
|
51
|
+
These exploits often require information about the targets to attack (teams and sometimes usernames).
|
|
52
|
+
This attack info is available as a big JSON file which is updated every few minutes.
|
|
53
|
+
Downloading that file for every exploit you're firing is costing time and bandwidth.
|
|
54
|
+
|
|
55
|
+
This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
|
|
56
|
+
|
|
57
|
+
Features
|
|
58
|
+
--------
|
|
59
|
+
|
|
60
|
+
- Efficient caching between threads, processes, or containers
|
|
61
|
+
- Direct access from your Python exploits ([sync](./examples/basic.py) or [async](./examples/basic_async.py))
|
|
62
|
+
- Optional REST API for exploits in other languages (with [OpenAPI spec](./api.yaml))
|
|
63
|
+
- Unifies team, IP, and flag info lookup between different CTFs:
|
|
64
|
+
- Supports [ENOWARS](https://enowars.com)
|
|
65
|
+
- Supports [FAUST CTF](https://faustctf.net)
|
|
66
|
+
- Supports [saarCTF](https://ctf.saarland) (including ECSC gameserver)
|
|
67
|
+
|
|
68
|
+
Quick-Start
|
|
69
|
+
-----------
|
|
70
|
+
See [examples](./examples) directory for more full scripts.
|
|
71
|
+
|
|
72
|
+
Install the package (possibly in a virtual environment):
|
|
73
|
+
|
|
74
|
+
```shell
|
|
75
|
+
pip install ctf-attackapi
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Get attack infos for your python exploit:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from attackapi import *
|
|
82
|
+
|
|
83
|
+
# 1. Set the API URL in code (or use CTF_API environment variable)
|
|
84
|
+
configure("https://scoreboard.ctf.saarland/api/attack.json")
|
|
85
|
+
# 2. Get attack infos!
|
|
86
|
+
for username in attack_info().flag_id_flat("no-service", "10.32.1.2"):
|
|
87
|
+
pwn("10.32.1.2", username)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
List all teams that you can attack:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from attackapi import *
|
|
94
|
+
|
|
95
|
+
configure("https://scoreboard.ctf.saarland/api/attack.json")
|
|
96
|
+
for team in attack_info().teams:
|
|
97
|
+
print(team.id, team.ip, team.name)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Get attack infos from REST API if you're not pwning in Python:
|
|
101
|
+
|
|
102
|
+
```shell
|
|
103
|
+
python -m attackapi.server --url "https://scoreboard.ctf.saarland/api/attack.json"
|
|
104
|
+
curl "http://localhost:14320/api/v1/teams"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The server has documentation on its frontpage, and here is [the OpenAPI specification](./api.yaml).
|
|
108
|
+
|
|
109
|
+
If you're not pwning in Python and dislike pip, try docker:
|
|
110
|
+
```shell
|
|
111
|
+
# edit compose.yaml and insert your CTF API URL
|
|
112
|
+
docker compose up -d
|
|
113
|
+
# visit http://localhost:14320/
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
Structure
|
|
118
|
+
---------
|
|
119
|
+
|
|
120
|
+
- Attack info data is retrieved and cached twice: in-memory and on disk (`/tmp` by default)
|
|
121
|
+
- Each request goes to the caches. If the cached data is outdated, it is refreshed in the background.
|
|
122
|
+
- No concurrent requests are made to the game API.
|
|
123
|
+
- Game-specific decoders process the game APIs data and make it accessible.
|
|
124
|
+
- You can query the data via python API from your exploits, or via REST API from other languages.
|
|
125
|
+
- Relying on the disk cache is good enough for typical exploitation scenarios.
|
|
126
|
+
|
|
127
|
+
Python Library Documentation
|
|
128
|
+
----------------------------
|
|
129
|
+
There are different ways to get an `AttackInfo` object:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# 1. Functional
|
|
133
|
+
from attackapi import *
|
|
134
|
+
|
|
135
|
+
# Set the API URL in code (or use CTF_API environment variable)
|
|
136
|
+
configure("https://scoreboard.ctf.saarland/api/attack.json")
|
|
137
|
+
# sync:
|
|
138
|
+
info: AttackInfo = attack_info()
|
|
139
|
+
# async
|
|
140
|
+
info: AttackInfo = await attack_info_async()
|
|
141
|
+
|
|
142
|
+
# 2. By manually using the classes
|
|
143
|
+
from attackapi.sync_api import AdCtfApiSync
|
|
144
|
+
from attackapi.async_api import AdCtfApiAsync
|
|
145
|
+
|
|
146
|
+
api = AdCtfApiSync("https://scoreboard.ctf.saarland/api/attack.json")
|
|
147
|
+
info = api.attack_info()
|
|
148
|
+
api2 = AdCtfApiAsync("https://scoreboard.ctf.saarland/api/attack.json")
|
|
149
|
+
info = await api2.attack_info()
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Optional parameters can be passed to the `configure` function or the API constructors:
|
|
153
|
+
|
|
154
|
+
- `url: str` (default: `CTF_API` environment variable)
|
|
155
|
+
- `tmp_directory: str | Path` (default: `/tmp` or OS-specific alternative)
|
|
156
|
+
- `lifetime: float` (default: 30 seconds) - after this time, cached data is invalidated and refreshed
|
|
157
|
+
- `timeout: float` (default: 10 seconds) - abort game API requests after this duration
|
|
158
|
+
- `decoder: Decoder` (default: generic decoder) - custom decoder, if your game's format is different from what we've
|
|
159
|
+
seen so far
|
|
160
|
+
- `aiohttp_arguments: dict` - additional arguments passed to the aiohttp Session which contacts the game API
|
|
161
|
+
|
|
162
|
+
The `AttackInfo` class itself has these methods:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
info: AttackInfo
|
|
166
|
+
|
|
167
|
+
# Get attackable teams
|
|
168
|
+
print(info.teams) # list of Team objects
|
|
169
|
+
print(info.teams[0].id, info.teams[0].ip, info.teams[0].name) # Team is ID, IP, and optional name
|
|
170
|
+
print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
|
|
171
|
+
|
|
172
|
+
# set of service names
|
|
173
|
+
print(info.services)
|
|
174
|
+
|
|
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", ...}
|
|
180
|
+
|
|
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"))
|
|
183
|
+
# => ["abc", "def"]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Server Documentation
|
|
187
|
+
--------------------
|
|
188
|
+
|
|
189
|
+
```shell
|
|
190
|
+
# Simple usage:
|
|
191
|
+
python -m attackapi.server --help
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Options:
|
|
195
|
+
|
|
196
|
+
- `--port PORT`
|
|
197
|
+
- `--url URL`: API url to get CTF info from.
|
|
198
|
+
- `--tmp-directory TMP_DIRECTORY`: Cache directory
|
|
199
|
+
- `--lifetime LIFETIME`: Lifetime of cached data in seconds
|
|
200
|
+
- `--timeout TIMEOUT`: Timeout for API calls in seconds
|
|
201
|
+
|
|
202
|
+
```shell
|
|
203
|
+
# Usage for higher load scenarios:
|
|
204
|
+
pip install ctf-attackapi[gunicorn]
|
|
205
|
+
gunicorn attackapi.server:create_app --bind :14320 --worker-class attackapi.server.worker.MyGunicornWebWorker --workers 4
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Environment variables:
|
|
209
|
+
|
|
210
|
+
- `CTF_API`: URL to get CTF info from.
|
|
211
|
+
- `CTF_API_TMP_DIR`: Cache directory (gunicorn only)
|
|
212
|
+
- `CTF_API_LIFETIME`: Lifetime of cached data in seconds (gunicorn only)
|
|
213
|
+
- `CTF_API_TIMEOUT`: Timeout for API calls in seconds (gunicorn only)
|
|
214
|
+
|
|
215
|
+
You can also use docker to run the server:
|
|
216
|
+
```shell
|
|
217
|
+
# edit compose.yaml and insert your CTF API URL before!
|
|
218
|
+
docker compose up -d
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
Using attackapi for other information (scoreboard etc.)
|
|
223
|
+
-------------------------------------------------------
|
|
224
|
+
Feel free to re-use the caching layers for other information, like the current scoreboard.
|
|
225
|
+
The class `JsonAdCtfApiAsync` accepts arbitrary JSON endpoints:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from attackapi.async_api import JsonAdCtfApiAsync
|
|
229
|
+
|
|
230
|
+
info = await JsonAdCtfApiAsync("https://scoreboard.ctf.saarland/api/scoreboard_current.json").retrieve()
|
|
231
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
attackapi/__init__.py,sha256=7EtXsvf8MzdEn4U41fXqx3IvMY6dhs-4_yno6onmZW4,186
|
|
2
|
+
attackapi/async_api/__init__.py,sha256=2Mj1sMy57zzieoI4fNq1vvphi_Oj8u1TMy2uN4xD5v0,161
|
|
3
|
+
attackapi/async_api/api.py,sha256=YbhICNMr_T4WWehEZ940wwRrIbqxWhT7YBm3NLw1EDE,8867
|
|
4
|
+
attackapi/async_api/decoders.py,sha256=EOas8JexiyZTmAu9k2MVjabXFtdHZLioAsqUNhZZ0l4,5832
|
|
5
|
+
attackapi/async_api/filelock.py,sha256=mG7agPkkQjpExJNYASigfrVCuiRmAoKGkiZv6bU2vU0,978
|
|
6
|
+
attackapi/functional.py,sha256=9oqgL8XiEt1_k2hFi6yUEo00uf_A9TEJV5HVwiD3AhM,1478
|
|
7
|
+
attackapi/models.py,sha256=4dG_yKGQwY7ny2VR6wSP4AHEyBAP8U3N8P9iP6QxbiY,4983
|
|
8
|
+
attackapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
attackapi/server/__init__.py,sha256=_1uqd49SZm0KlW_jsxHBguKfA9TZdaazTzhP47rMS6Q,58
|
|
10
|
+
attackapi/server/__main__.py,sha256=0B7lIdoRt86KugnmxY5ZABG1hnTRPM9Cc8K5Fy0c2co,1057
|
|
11
|
+
attackapi/server/docs.py,sha256=9EKawP0PYQKbqd-gAZlEXqX3Z3QUcLXB1E0JZGxe9Yo,1810
|
|
12
|
+
attackapi/server/server.py,sha256=8JNIURfjlylq_a1p4xkyrPSlig3SQ7p65pSOtvntGtY,4613
|
|
13
|
+
attackapi/server/worker.py,sha256=z6_QS4vaJYK2fDC35Q4W6eSmBiYr4au8Oabpb0iLvFM,510
|
|
14
|
+
attackapi/sync_api.py,sha256=ws8pkgzeVkj5lJGGoqLuwO5vm7cQYXj3sRsa1MEsStc,1176
|
|
15
|
+
ctf_attackapi-0.1.0.dist-info/WHEEL,sha256=e_m4S054HL0hyR3CpOk-b7Q7fDX6BuFkgL5OjAExXas,80
|
|
16
|
+
ctf_attackapi-0.1.0.dist-info/entry_points.txt,sha256=HOPX0b5DyTBBgRDZINsNdoeYLyHxoH798Mi9VDMzuyQ,64
|
|
17
|
+
ctf_attackapi-0.1.0.dist-info/METADATA,sha256=V2OZ2LQGdZwbfP2yFMhBb_dWNyH2K2y6JLvmVQ44mCY,8691
|
|
18
|
+
ctf_attackapi-0.1.0.dist-info/RECORD,,
|