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 ADDED
@@ -0,0 +1,4 @@
1
+ from .models import Team, AttackInfo
2
+ from .functional import configure, attack_info, attack_info_async
3
+
4
+ __all__ = ["Team", "AttackInfo", "configure", "attack_info", "attack_info_async"]
@@ -0,0 +1,4 @@
1
+ from .api import AdCtfApiAsync, JsonAdCtfApiAsync
2
+ from .decoders import Decoder, Dialect
3
+
4
+ __all__ = ["AdCtfApiAsync", "JsonAdCtfApiAsync", "Decoder", "Dialect"]
@@ -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")
@@ -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,2 @@
1
+ from .server import create_app
2
+ from .__main__ import main
@@ -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()
@@ -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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/license/mit)
39
+ ![Python](https://img.shields.io/pypi/pyversions/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
40
+ ![Types](https://img.shields.io/pypi/types/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
41
+ [![Python package tests](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml/badge.svg)](https://github.com/Attacking-Lab/ctf-attackapi/actions/workflows/python-package.yml)
42
+ [![PyPI version](https://img.shields.io/pypi/v/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)](https://pypi.org/project/ctf-attackapi)
43
+ ![Downloads](https://img.shields.io/pypi/dm/ctf-attackapi?pypiBaseUrl=https://test.pypi.org)
44
+ ![Repo size](https://img.shields.io/github/repo-size/Attacking-Lab/ctf-attackapi)
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.27
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ctf-attackapi-server = attackapi.server:main
3
+