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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/PKG-INFO +45 -11
  2. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/README.md +44 -10
  3. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/pyproject.toml +1 -1
  4. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/pyproject.toml.orig +1 -1
  5. ctf_attackapi-0.3.0/src/attackapi/__init__.py +4 -0
  6. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/async_api/decoders.py +1 -1
  7. ctf_attackapi-0.3.0/src/attackapi/models.py +189 -0
  8. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/server.py +14 -14
  9. ctf_attackapi-0.2.2/src/attackapi/__init__.py +0 -4
  10. ctf_attackapi-0.2.2/src/attackapi/models.py +0 -144
  11. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/async_api/__init__.py +0 -0
  12. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/async_api/api.py +0 -0
  13. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/async_api/filelock.py +0 -0
  14. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/functional.py +0 -0
  15. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/py.typed +0 -0
  16. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/__init__.py +0 -0
  17. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/__main__.py +0 -0
  18. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/docs.py +0 -0
  19. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/server/worker.py +0 -0
  20. {ctf_attackapi-0.2.2 → ctf_attackapi-0.3.0}/src/attackapi/sync_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctf-attackapi
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached.
5
5
  Keywords: Attack-Defense,CTF,Attack API,Attack Info,Flag IDs,FAUST CTF,ENOWARS,saarCTF
6
6
  Author: Markus Bauer
@@ -54,6 +54,31 @@ Downloading that file for every exploit you're firing is costing time and bandwi
54
54
 
55
55
  This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
56
56
 
57
+ Changes in 0.3.0
58
+ ----------------
59
+
60
+ - `flag_id_flat()` is now `flag_ids()`, and `flag_id_raw()` is `flag_ids_raw()`. The list of
61
+ strings is what an exploit wants essentially every time, so it gets the plain name, and only the
62
+ one that hands back the game's own structure carries a suffix. The `attack_info_*` aliases are
63
+ gone with them: flag IDs is what most games call these, so the package calls them that once.
64
+ The REST API follows -- `/api/v1/flag_ids/...` and `/api/v1/flag_ids_raw/...`, returning a
65
+ `flag_ids` key.
66
+ - Lookups never raise. `flag_ids()` returns `[]` and `flag_ids_raw()` returns `None` for
67
+ anything they cannot answer, including a `None` team -- so the common
68
+ `flag_ids(service, info.team(name))` no longer dies on a name that did not resolve.
69
+ - The mistakes that are wrong on *every* call -- an unknown service or team, a team that never
70
+ resolved, a team of a type the API never took -- now raise a `UserWarning` pointing at your
71
+ line, instead of being indistinguishable from a team that simply has no flag IDs yet. The
72
+ ordinary case stays silent: a known team with nothing published this round is not your mistake.
73
+ Silence the warnings with `warnings.simplefilter("ignore")` if your exploit prefers it.
74
+ - Both take an optional `round`, counting back from the newest published round when negative
75
+ (`-1` is the newest). The default is unchanged and still returns every round the game API
76
+ published -- it only publishes the rounds whose flags are still valid, so narrowing by default
77
+ would cost you flags. The selector is public as `attackapi.select_round()`, next to
78
+ `flatten_flag_ids()`.
79
+ - New `has_service()`, for the case-insensitive "does this game have that service?" check that
80
+ previously meant reaching into the `flag_ids` field. That field is now private.
81
+
57
82
  Changes in 0.2.0
58
83
  ----------------
59
84
 
@@ -61,7 +86,7 @@ Changes in 0.2.0
61
86
  instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
62
87
  dialects are unchanged.
63
88
  - `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
64
- - The helper behind `flag_id_flat()` is public as `attackapi.flatten_flag_ids()`, for callers that
89
+ - The helper behind `flag_id_flat()` (now `flag_ids()`) is public as `attackapi.flatten_flag_ids()`, for callers that
65
90
  flatten a subset of the raw structure themselves.
66
91
  - `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
67
92
  no ID for a round is a hole, not a value.
@@ -98,7 +123,7 @@ from attackapi import *
98
123
  # 1. Set the API URL in code (or use CTF_API environment variable)
99
124
  configure("https://scoreboard.ctf.saarland/api/attack.json")
100
125
  # 2. Get attack infos!
101
- for username in attack_info().flag_id_flat("no-service", "10.32.1.2"):
126
+ for username in attack_info().flag_ids("servicename", "10.32.1.2"):
102
127
  pwn("10.32.1.2", username)
103
128
  ```
104
129
 
@@ -184,23 +209,32 @@ print(info.teams) # list of Team objects
184
209
  print(info.teams[0].id, info.teams[0].ip, info.teams[0].name) # Team is ID, IP, and optional name
185
210
  print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
186
211
 
187
- # set of service names
212
+ # set of service names, and a case-insensitive check for one
188
213
  print(info.services)
214
+ print(info.has_service("servicename"))
189
215
 
190
216
  # flag format and the round this info was generated for, where the game reports them
191
217
  print(info.flag_regex, info.current_round)
192
218
 
193
- # raw flag IDs for a service and team.
194
- # team can be ID, IP, or name.
219
+ # flag IDs for a service and team, as a string list -- the same shape whatever game you play.
220
+ # team can be ID, IP, or name.
221
+ print(info.flag_ids("servicename", "10.32.1.2"))
222
+ # => ["abc", "def"]
223
+
224
+ # One round only, for the games that report rounds. -1 is the newest published round.
225
+ print(info.flag_ids("servicename", "10.32.1.2", -1))
226
+ # => ["def"]
227
+
228
+ # the same flag IDs in the game API's own format, when you need the structure it keeps.
195
229
  # Return data format is determined by game API.
196
- print(info.flag_id_raw("servicename", "10.32.1.2"))
230
+ print(info.flag_ids_raw("servicename", "10.32.1.2"))
197
231
  # => {"227": "abc", "228": "def", ...}
198
-
199
- # Get flag IDs as string list (independent of game API format, but less precise)
200
- print(info.flag_id_flat("servicename", "10.32.1.2"))
201
- # => ["abc", "def"]
202
232
  ```
203
233
 
234
+ Nothing above raises. A lookup that cannot be answered gives you `[]` (or `None` for the raw
235
+ form) and warns, so a typo in an exploit costs a line on stderr rather than the round it was in
236
+ the middle of.
237
+
204
238
  Server Documentation
205
239
  --------------------
206
240
 
@@ -20,6 +20,31 @@ Downloading that file for every exploit you're firing is costing time and bandwi
20
20
 
21
21
  This package fetches, parses, and caches attack info for you, so you can focus on writing exploits!
22
22
 
23
+ Changes in 0.3.0
24
+ ----------------
25
+
26
+ - `flag_id_flat()` is now `flag_ids()`, and `flag_id_raw()` is `flag_ids_raw()`. The list of
27
+ strings is what an exploit wants essentially every time, so it gets the plain name, and only the
28
+ one that hands back the game's own structure carries a suffix. The `attack_info_*` aliases are
29
+ gone with them: flag IDs is what most games call these, so the package calls them that once.
30
+ The REST API follows -- `/api/v1/flag_ids/...` and `/api/v1/flag_ids_raw/...`, returning a
31
+ `flag_ids` key.
32
+ - Lookups never raise. `flag_ids()` returns `[]` and `flag_ids_raw()` returns `None` for
33
+ anything they cannot answer, including a `None` team -- so the common
34
+ `flag_ids(service, info.team(name))` no longer dies on a name that did not resolve.
35
+ - The mistakes that are wrong on *every* call -- an unknown service or team, a team that never
36
+ resolved, a team of a type the API never took -- now raise a `UserWarning` pointing at your
37
+ line, instead of being indistinguishable from a team that simply has no flag IDs yet. The
38
+ ordinary case stays silent: a known team with nothing published this round is not your mistake.
39
+ Silence the warnings with `warnings.simplefilter("ignore")` if your exploit prefers it.
40
+ - Both take an optional `round`, counting back from the newest published round when negative
41
+ (`-1` is the newest). The default is unchanged and still returns every round the game API
42
+ published -- it only publishes the rounds whose flags are still valid, so narrowing by default
43
+ would cost you flags. The selector is public as `attackapi.select_round()`, next to
44
+ `flatten_flag_ids()`.
45
+ - New `has_service()`, for the case-insensitive "does this game have that service?" check that
46
+ previously meant reaching into the `flag_ids` field. That field is now private.
47
+
23
48
  Changes in 0.2.0
24
49
  ----------------
25
50
 
@@ -27,7 +52,7 @@ Changes in 0.2.0
27
52
  instead of `flag_ids`, and rounds instead of ticks. The `saarctf`, `faustctf` and `enowars`
28
53
  dialects are unchanged.
29
54
  - `AttackInfo` gained `flag_regex` and `current_round`, filled in for the games that report them.
30
- - The helper behind `flag_id_flat()` is public as `attackapi.flatten_flag_ids()`, for callers that
55
+ - The helper behind `flag_id_flat()` (now `flag_ids()`) is public as `attackapi.flatten_flag_ids()`, for callers that
31
56
  flatten a subset of the raw structure themselves.
32
57
  - `flag_id_flat()` drops `null` flag IDs instead of returning them as `None` -- a flag store with
33
58
  no ID for a round is a hole, not a value.
@@ -64,7 +89,7 @@ from attackapi import *
64
89
  # 1. Set the API URL in code (or use CTF_API environment variable)
65
90
  configure("https://scoreboard.ctf.saarland/api/attack.json")
66
91
  # 2. Get attack infos!
67
- for username in attack_info().flag_id_flat("no-service", "10.32.1.2"):
92
+ for username in attack_info().flag_ids("servicename", "10.32.1.2"):
68
93
  pwn("10.32.1.2", username)
69
94
  ```
70
95
 
@@ -150,23 +175,32 @@ print(info.teams) # list of Team objects
150
175
  print(info.teams[0].id, info.teams[0].ip, info.teams[0].name) # Team is ID, IP, and optional name
151
176
  print(info.team("10.32.1.2")) # query Team object by ID, IP, or name
152
177
 
153
- # set of service names
178
+ # set of service names, and a case-insensitive check for one
154
179
  print(info.services)
180
+ print(info.has_service("servicename"))
155
181
 
156
182
  # flag format and the round this info was generated for, where the game reports them
157
183
  print(info.flag_regex, info.current_round)
158
184
 
159
- # raw flag IDs for a service and team.
160
- # team can be ID, IP, or name.
185
+ # flag IDs for a service and team, as a string list -- the same shape whatever game you play.
186
+ # team can be ID, IP, or name.
187
+ print(info.flag_ids("servicename", "10.32.1.2"))
188
+ # => ["abc", "def"]
189
+
190
+ # One round only, for the games that report rounds. -1 is the newest published round.
191
+ print(info.flag_ids("servicename", "10.32.1.2", -1))
192
+ # => ["def"]
193
+
194
+ # the same flag IDs in the game API's own format, when you need the structure it keeps.
161
195
  # Return data format is determined by game API.
162
- print(info.flag_id_raw("servicename", "10.32.1.2"))
196
+ print(info.flag_ids_raw("servicename", "10.32.1.2"))
163
197
  # => {"227": "abc", "228": "def", ...}
164
-
165
- # Get flag IDs as string list (independent of game API format, but less precise)
166
- print(info.flag_id_flat("servicename", "10.32.1.2"))
167
- # => ["abc", "def"]
168
198
  ```
169
199
 
200
+ Nothing above raises. A lookup that cannot be answered gives you `[]` (or `None` for the raw
201
+ form) and warns, so a typo in an exploit costs a line on stderr rather than the round it was in
202
+ the middle of.
203
+
170
204
  Server Documentation
171
205
  --------------------
172
206
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctf-attackapi"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctf-attackapi"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Get attack infos in attack-defense CTFs quickly to your exploits. CTF-agnostic and cached."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,4 @@
1
+ from .models import Team, AttackInfo, flatten_flag_ids, select_round
2
+ from .functional import configure, attack_info, attack_info_async
3
+
4
+ __all__ = ["Team", "AttackInfo", "flatten_flag_ids", "select_round", "configure", "attack_info", "attack_info_async"]
@@ -171,7 +171,7 @@ class Decoder(GenericDecoder[AttackInfo]):
171
171
  if not isinstance(flag_ids, dict):
172
172
  raise ValueError(f"Invalid flag_ids format for service {service_name!r}: {type(flag_ids)}")
173
173
  info.services.add(service_name)
174
- info.flag_ids[service_name.lower()] = flag_ids
174
+ info._flag_ids[service_name.lower()] = flag_ids
175
175
 
176
176
  def _parse_teams(self, dialect: Dialect, info: AttackInfo, teams: list) -> None:
177
177
  for team in teams:
@@ -0,0 +1,189 @@
1
+ import warnings
2
+ from dataclasses import dataclass, field, asdict
3
+ from typing import Optional, Union, Any
4
+ from typing_extensions import TypeAlias
5
+
6
+ RawFlagIds: TypeAlias = Union[list, dict[str, Union[str, list, dict]]]
7
+
8
+
9
+ def flatten_flag_ids(flag_ids: Any) -> list[str]:
10
+ """
11
+ Collect every non-null scalar flag ID out of an arbitrarily nested flag-ID structure.
12
+ Independent of the game API's exact nesting, but loses the round / flag-store structure.
13
+ """
14
+ if flag_ids is None: # a flag store with no ID for this round/team
15
+ return []
16
+ if isinstance(flag_ids, str):
17
+ return [flag_ids]
18
+ if isinstance(flag_ids, list):
19
+ result = []
20
+ for value in flag_ids:
21
+ result += flatten_flag_ids(value)
22
+ return result
23
+ if isinstance(flag_ids, dict):
24
+ result = []
25
+ for value in flag_ids.values():
26
+ result += flatten_flag_ids(value)
27
+ return result
28
+ return [str(flag_ids)]
29
+
30
+
31
+ def select_round(flag_ids: Any, round: int) -> Any:
32
+ """
33
+ Restrict flag IDs to a single round. Every game that reports a round keys its per-team attack
34
+ info by it; FAUST CTF does not have the dimension at all, and its plain list is returned
35
+ unchanged rather than sliced into something that only looks like a round.
36
+
37
+ :param flag_ids: raw flag IDs for one service and team, as returned by flag_ids_raw()
38
+ :param round: a round number, or an offset from the newest published round (-1 = newest)
39
+ :return: the same structure, holding at most the requested round
40
+ """
41
+ if not isinstance(flag_ids, dict):
42
+ return flag_ids
43
+ keys = list(flag_ids.keys())
44
+ if round < 0:
45
+ # the game API publishes a window of recent rounds, in no guaranteed order
46
+ if all(isinstance(key, str) and key.lstrip("-").isdigit() for key in keys):
47
+ keys.sort(key=int)
48
+ if -round > len(keys):
49
+ return {}
50
+ key = keys[round]
51
+ else:
52
+ key = str(round)
53
+ return {key: flag_ids[key]} if key in flag_ids else {}
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Team:
58
+ """
59
+ An attackable team.
60
+ IP is given for every game, ID is given or inferred for all known CTFs.
61
+ Name is not present everywhere.
62
+ """
63
+ id: int
64
+ ip: str
65
+ name: Optional[str] = None
66
+
67
+ def to_dict(self) -> dict:
68
+ return asdict(self)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class AttackInfo:
73
+ """
74
+ Container for all attack info parsed from the game API.
75
+ Use methods team(), flag_ids(), and flag_ids_raw() to look up data.
76
+ Fields teams and services can be iterated.
77
+ """
78
+
79
+ teams: list[Team] = field(default_factory=list)
80
+ team_lookup: dict[str, Team] = field(default_factory=dict)
81
+ services: set[str] = field(default_factory=set)
82
+ _flag_ids: dict[str, dict[str, RawFlagIds]] = field(default_factory=dict)
83
+ flag_regex: Optional[str] = None
84
+ current_round: Optional[int] = None
85
+ raw: bytes = b"" # everything, as given by the game API
86
+
87
+ def team(self, name: Union[str, int]) -> Optional[Team]:
88
+ """
89
+ Find a team.
90
+
91
+ :param name: Team ID, IP, or name (as far as supported by the API)
92
+ :return:
93
+ """
94
+ return self.team_lookup.get(str(name).lower())
95
+
96
+ def has_service(self, service: str) -> bool:
97
+ """
98
+ Whether this game has a service by that name (case insensitive).
99
+
100
+ :param service: Name of a service
101
+ :return:
102
+ """
103
+ return service.lower() in self._flag_ids
104
+
105
+ def flag_ids(self, service: str, team: Union[str, int, Team, None],
106
+ round: Optional[int] = None) -> list[str]:
107
+ """
108
+ Find flag IDs for a service and team, as a simple string list -- the same list whatever
109
+ game you are playing, and what an exploit almost always wants.
110
+
111
+ Never raises: a lookup that cannot be answered warns and returns [], so a typo in an
112
+ exploit costs a warning on stderr rather than the round it was in the middle of.
113
+
114
+ :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
115
+ :param team: Team ID, IP, name, or instance (from .team(...))
116
+ :param round: Restrict the result to one round, or to an offset from the newest published
117
+ round (-1 = newest). Defaults to every round the game API published.
118
+ :return:
119
+ """
120
+ flag_ids = self._lookup(service, team, round)
121
+ return flatten_flag_ids(flag_ids) if flag_ids is not None else []
122
+
123
+ def flag_ids_raw(self, service: str, team: Union[str, int, Team, None],
124
+ round: Optional[int] = None) -> Optional[RawFlagIds]:
125
+ """
126
+ Find flag IDs for a service and team, in the game API's own format -- for callers that
127
+ need the structure flag_ids() flattens away. That format differs per game.
128
+
129
+ Never raises, and returns None for anything it cannot answer -- see flag_ids().
130
+
131
+ :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
132
+ :param team: Team ID, IP, name, or instance (from .team(...))
133
+ :param round: Restrict the result to one round, or to an offset from the newest published
134
+ round (-1 = newest). Defaults to every round the game API published.
135
+ :return:
136
+ """
137
+ return self._lookup(service, team, round)
138
+
139
+ def _lookup(self, service: str, team: Union[str, int, Team, None],
140
+ round: Optional[int], stacklevel: int = 3) -> Optional[RawFlagIds]:
141
+ """
142
+ The lookup behind both accessors. Warns rather than raises on the mistakes that are wrong
143
+ on every call -- an unknown service or team, a team that never resolved -- and stays quiet
144
+ about the ones that are a normal part of a running game.
145
+
146
+ :param stacklevel: frames between this method and the caller to blame in a warning
147
+ """
148
+ if team is None:
149
+ warnings.warn(f"No team given for service {service!r} - did an earlier team() lookup fail?",
150
+ stacklevel=stacklevel)
151
+ return None
152
+ flag_ids = self._flag_ids.get(service.lower())
153
+ if flag_ids is None:
154
+ warnings.warn(f"Unknown service {service!r}, this game has: {', '.join(sorted(self.services))}",
155
+ stacklevel=stacklevel)
156
+ return None
157
+ raw = self._team_flag_ids(service, flag_ids, team, stacklevel + 1)
158
+ if raw is None or round is None:
159
+ return raw
160
+ return select_round(raw, round)
161
+
162
+ def _team_flag_ids(self, service: str, flag_ids: dict, team: Union[str, int, Team],
163
+ stacklevel: int) -> Optional[RawFlagIds]:
164
+ """Pick one team out of a service's flag IDs, which the game API keys by ID, IP, or name."""
165
+ if isinstance(team, Team):
166
+ for key in (str(team.id), team.ip, team.name):
167
+ if key is not None and key.lower() in flag_ids:
168
+ return flag_ids[key.lower()]
169
+ # a team the game knows about but has no flag IDs for: the service may be down, or the
170
+ # round's info may not be out yet. Both are ordinary, and neither is worth a warning.
171
+ return None
172
+ if isinstance(team, (str, int)):
173
+ key = str(team).lower()
174
+ if key in flag_ids:
175
+ return flag_ids[key]
176
+ if key in self.team_lookup:
177
+ return self._team_flag_ids(service, flag_ids, self.team_lookup[key], stacklevel)
178
+ warnings.warn(f"Unknown team {team!r} for service {service!r} - not in this attack info "
179
+ f"(a typo, or a team that is offline or banned)", stacklevel=stacklevel)
180
+ return None
181
+ warnings.warn(f"Invalid team type for service {service!r}: {type(team).__name__}: {team!r}",
182
+ stacklevel=stacklevel)
183
+ return None
184
+
185
+ def __str__(self) -> str:
186
+ return repr(self)
187
+
188
+ def __repr__(self) -> str:
189
+ return f"AttackInfo(services={self.services!r}, {len(self.teams)} teams)"
@@ -21,8 +21,8 @@ class AttackApiViews:
21
21
  web.get("/api/v1/raw", self.get_raw),
22
22
  web.get("/api/v1/services", self.get_services),
23
23
  web.get("/api/v1/teams", self.get_teams),
24
- web.get("/api/v1/attack_info/{service}/{team}", self.get_attack_info),
25
- web.get("/api/v1/attack_info_raw/{service}/{team}", self.get_attack_info_raw),
24
+ web.get("/api/v1/flag_ids/{service}/{team}", self.get_flag_ids),
25
+ web.get("/api/v1/flag_ids_raw/{service}/{team}", self.get_flag_ids_raw),
26
26
  ]
27
27
 
28
28
  async def docs(self, request: web.Request) -> web.Response:
@@ -38,8 +38,8 @@ class AttackApiViews:
38
38
  docs = docs.replace('"NOP"', json.dumps(info.teams[0].name))
39
39
  if len(info.services) > 0:
40
40
  s = list(info.services)[0]
41
- raw = info.flag_id_raw(s, info.teams[0])
42
- flat = info.flag_id_flat(s, info.teams[0])
41
+ raw = info.flag_ids_raw(s, info.teams[0])
42
+ flat = info.flag_ids(s, info.teams[0])
43
43
  if raw:
44
44
  docs = docs.replace(
45
45
  json.dumps({"227": "username1", "228": "username2", "229": "username3"}),
@@ -68,31 +68,31 @@ class AttackApiViews:
68
68
  info = await self._api.attack_info()
69
69
  return web.json_response({"services": list(info.services)})
70
70
 
71
- async def _attack_info_common(self, request: web.Request,
72
- cb: Callable[[AttackInfo, str, str], Any]) -> web.Response:
71
+ async def _flag_ids_common(self, request: web.Request,
72
+ cb: Callable[[AttackInfo, str, str], Any]) -> web.Response:
73
73
  service = request.match_info["service"]
74
74
  team = request.match_info["team"]
75
75
  if not service or not team:
76
76
  raise web.HTTPBadRequest(reason="Invalid team or service")
77
77
  info = await self._api.attack_info()
78
- if service.lower() not in info.flag_ids:
78
+ if not info.has_service(service):
79
79
  raise web.HTTPBadRequest(reason=f"Unknown service, or service has no attack info: {service}")
80
80
  flag_ids = cb(info, service, team)
81
81
  return web.json_response(
82
- {"attack_info": flag_ids}
82
+ {"flag_ids": flag_ids}
83
83
  )
84
84
 
85
- async def get_attack_info(self, request: web.Request) -> web.Response:
85
+ async def get_flag_ids(self, request: web.Request) -> web.Response:
86
86
  def _cb(info: AttackInfo, service: str, team: str) -> Any:
87
- return info.flag_id_flat(service, team)
87
+ return info.flag_ids(service, team)
88
88
 
89
- return await self._attack_info_common(request, _cb)
89
+ return await self._flag_ids_common(request, _cb)
90
90
 
91
- async def get_attack_info_raw(self, request: web.Request) -> web.Response:
91
+ async def get_flag_ids_raw(self, request: web.Request) -> web.Response:
92
92
  def _cb(info: AttackInfo, service: str, team: str) -> Any:
93
- return info.flag_id_raw(service, team)
93
+ return info.flag_ids_raw(service, team)
94
94
 
95
- return await self._attack_info_common(request, _cb)
95
+ return await self._flag_ids_common(request, _cb)
96
96
 
97
97
 
98
98
  async def create_app() -> web.Application:
@@ -1,4 +0,0 @@
1
- from .models import Team, AttackInfo, flatten_flag_ids
2
- from .functional import configure, attack_info, attack_info_async
3
-
4
- __all__ = ["Team", "AttackInfo", "flatten_flag_ids", "configure", "attack_info", "attack_info_async"]
@@ -1,144 +0,0 @@
1
- from dataclasses import dataclass, field, asdict
2
- from typing import Optional, Union, Any
3
- from typing_extensions import TypeAlias
4
-
5
- RawFlagIds: TypeAlias = Union[list, dict[str, Union[str, list, dict]]]
6
-
7
-
8
- def flatten_flag_ids(flag_ids: Any) -> list[str]:
9
- """
10
- Collect every non-null scalar flag ID out of an arbitrarily nested flag-ID structure.
11
- Independent of the game API's exact nesting, but loses the round / flag-store structure.
12
- """
13
- if flag_ids is None: # a flag store with no ID for this round/team
14
- return []
15
- if isinstance(flag_ids, str):
16
- return [flag_ids]
17
- if isinstance(flag_ids, list):
18
- result = []
19
- for value in flag_ids:
20
- result += flatten_flag_ids(value)
21
- return result
22
- if isinstance(flag_ids, dict):
23
- result = []
24
- for value in flag_ids.values():
25
- result += flatten_flag_ids(value)
26
- return result
27
- return [str(flag_ids)]
28
-
29
-
30
- @dataclass(frozen=True)
31
- class Team:
32
- """
33
- An attackable team.
34
- IP is given for every game, ID is given or inferred for all known CTFs.
35
- Name is not present everywhere.
36
- """
37
- id: int
38
- ip: str
39
- name: Optional[str] = None
40
-
41
- def to_dict(self) -> dict:
42
- return asdict(self)
43
-
44
-
45
- @dataclass(frozen=True)
46
- class AttackInfo:
47
- """
48
- Container for all attack info parsed from the game API.
49
- Use methods team(), flag_id_raw(), and flag_id_flat() to look up data.
50
- Fields teams and services can be iterated.
51
- """
52
-
53
- teams: list[Team] = field(default_factory=list)
54
- team_lookup: dict[str, Team] = field(default_factory=dict)
55
- services: set[str] = field(default_factory=set)
56
- flag_ids: dict[str, dict[str, RawFlagIds]] = field(default_factory=dict)
57
- flag_regex: Optional[str] = None
58
- current_round: Optional[int] = None
59
- raw: bytes = b"" # everything, as given by the game API
60
-
61
- def team(self, name: Union[str, int]) -> Optional[Team]:
62
- """
63
- Find a team.
64
-
65
- :param name: Team ID, IP, or name (as far as supported by the API)
66
- :return:
67
- """
68
- return self.team_lookup.get(str(name).lower())
69
-
70
- def flag_id_raw(self, service: str, team: Union[str, int, Team]) -> Optional[RawFlagIds]:
71
- """
72
- Find flag IDs for a service and team. Flag IDs are returned in the APIs raw format.
73
-
74
- :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
75
- :param team: Team ID, IP, name, or instance (from .team(...))
76
- :return:
77
- """
78
- flag_ids = self.flag_ids.get(service.lower(), {})
79
- if isinstance(team, Team):
80
- if str(team.id) in flag_ids:
81
- return flag_ids[str(team.id)]
82
- if team.ip is not None and team.ip.lower() in flag_ids:
83
- return flag_ids[team.ip.lower()]
84
- if team.name is not None and team.name.lower() in flag_ids:
85
- return flag_ids[team.name.lower()]
86
- return None
87
- elif isinstance(team, str):
88
- team = team.lower()
89
- if team in flag_ids:
90
- return flag_ids.get(team.lower())
91
- elif team in self.team_lookup:
92
- return self.flag_id_raw(service, self.team_lookup[team])
93
- return None
94
- elif isinstance(team, int):
95
- if str(team) in flag_ids:
96
- return flag_ids.get(str(team))
97
- elif str(team) in self.team_lookup:
98
- return self.flag_id_raw(service, self.team_lookup[str(team)])
99
- return None
100
-
101
- raise ValueError(f"Invalid team type: {type(team)}: {team!r}")
102
-
103
- def flag_id_flat(self, service: str, team: Union[str, int, Team]) -> list[str]:
104
- """
105
- Find flag IDs for a service and team.
106
- Flag IDs are returned as a simple string list, containing all attack info for all flag stores.
107
-
108
- :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
109
- :param team: Team ID, IP, name, or instance (from .team(...))
110
- :return:
111
- """
112
- flag_ids = self.flag_id_raw(service, team)
113
- return flatten_flag_ids(flag_ids) if flag_ids is not None else []
114
-
115
- def attack_info_raw(self, service: str, team: Union[str, int, Team]) -> Optional[RawFlagIds]:
116
- """
117
- Find attack info for a service and team. Attack info is returned in the APIs raw format.
118
-
119
- This is an alias for flag_id_raw(...).
120
-
121
- :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
122
- :param team: Team ID, IP, name, or instance (from .team(...))
123
- :return:
124
- """
125
- return self.flag_id_raw(service, team)
126
-
127
- def attack_info_flat(self, service: str, team: Union[str, int, Team]) -> list[str]:
128
- """
129
- Find attack info for a service and team.
130
- Attack info is returned as a simple string list, containing all attack info for all flag stores.
131
-
132
- This is an alias for flag_id_flat(...).
133
-
134
- :param service: Name of a service (case insensitive, see field "services" for a list of valid names)
135
- :param team: Team ID, IP, name, or instance (from .team(...))
136
- :return:
137
- """
138
- return self.flag_id_flat(service, team)
139
-
140
- def __str__(self) -> str:
141
- return repr(self)
142
-
143
- def __repr__(self) -> str:
144
- return f"AttackInfo(services={self.services!r}, {len(self.teams)} teams)"