echoact 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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""Section 4.1's request limits, as sliding windows.
|
|
2
|
+
|
|
3
|
+
Three limits live here, and 4.1 keeps them separate on purpose: 10 generation
|
|
4
|
+
requests per minute per client, 120 other requests per minute per client, and
|
|
5
|
+
authentication failures, which are counted per connection origin rather than
|
|
6
|
+
per client because a failed authentication has not identified a client yet.
|
|
7
|
+
|
|
8
|
+
Two requirements shape the implementation more than the numbers do.
|
|
9
|
+
|
|
10
|
+
N-23 forbids an unbounded queue: a request over the limit is refused now, so
|
|
11
|
+
nothing here sleeps, blocks, or holds a caller. Every rejection carries a
|
|
12
|
+
retry-after computed from the window -- the moment the oldest counted request
|
|
13
|
+
falls out of it, or the moment a lockout ends -- because F-57 promises the
|
|
14
|
+
hint exists so a client need not guess a backoff. A made-up constant would
|
|
15
|
+
satisfy the letter and mislead the caller.
|
|
16
|
+
|
|
17
|
+
Memory is bounded on both axes. Windows are trimmed on every touch, empty
|
|
18
|
+
buckets are dropped, and the number of tracked keys is capped; at the cap a
|
|
19
|
+
key that is not already tracked is refused rather than admitted untracked or
|
|
20
|
+
swapped in over an existing one. Untracked admission would make the cap a way
|
|
21
|
+
to bypass the limit, and eviction would make it a way to clear one's own
|
|
22
|
+
lockout, both by inventing keys.
|
|
23
|
+
|
|
24
|
+
No web framework is imported: F-69 and F-71 show the same state in the GUI,
|
|
25
|
+
and 4.1's authentication lockout must never take the service or the GUI down
|
|
26
|
+
with it -- it refuses authentication attempts from one origin and nothing else.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import threading
|
|
32
|
+
from collections import deque
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from enum import StrEnum
|
|
36
|
+
from typing import Final
|
|
37
|
+
|
|
38
|
+
from ..errors import Code, EchoActError
|
|
39
|
+
from ..policy import (
|
|
40
|
+
AUTH_FAILURES_PER_MIN,
|
|
41
|
+
AUTH_LOCKOUT_S,
|
|
42
|
+
RATE_GENERATION_PER_MIN,
|
|
43
|
+
RATE_OTHER_PER_MIN,
|
|
44
|
+
)
|
|
45
|
+
from ..util.ids import monotonic
|
|
46
|
+
|
|
47
|
+
#: The "per minute" in every one of policy's ``*_PER_MIN`` figures. A sliding
|
|
48
|
+
#: window rather than a calendar minute: a fixed bucket would let a client
|
|
49
|
+
#: spend two full allowances back to back across the boundary.
|
|
50
|
+
WINDOW_S: Final = 60.0
|
|
51
|
+
|
|
52
|
+
#: How many distinct keys each limiter will track -- for authentication that
|
|
53
|
+
#: is origins, counted once each whether the origin is accumulating failures
|
|
54
|
+
#: or serving a lockout. 4.1 fixes no figure, because
|
|
55
|
+
#: on a loopback-only service the real bound is the number of issued
|
|
56
|
+
#: credentials; this is the backstop that keeps the table finite anyway. Both
|
|
57
|
+
#: are far above any plausible legitimate count -- 4.1 allows one credential
|
|
58
|
+
#: per integration, not thousands -- so reaching either means something is
|
|
59
|
+
#: wrong, and being refused is the right answer to that.
|
|
60
|
+
MAX_TRACKED_CLIENTS: Final = 1024
|
|
61
|
+
MAX_TRACKED_ORIGINS: Final = 1024
|
|
62
|
+
|
|
63
|
+
Clock = Callable[[], float]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RequestClass(StrEnum):
|
|
67
|
+
"""4.1 counts generation separately from everything else.
|
|
68
|
+
|
|
69
|
+
A generation request consumes only the generation allowance: the two
|
|
70
|
+
limits are stated as separate sentences, and charging a generation request
|
|
71
|
+
to both would silently make the "other" limit 110 for a busy client.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
GENERATION = "generation"
|
|
75
|
+
OTHER = "other"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_LIMITS: Final[dict[RequestClass, int]] = {
|
|
79
|
+
RequestClass.GENERATION: RATE_GENERATION_PER_MIN,
|
|
80
|
+
RequestClass.OTHER: RATE_OTHER_PER_MIN,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class Decision:
|
|
86
|
+
"""The answer to one rate-limit question.
|
|
87
|
+
|
|
88
|
+
``retry_after_s`` is 0.0 when allowed and strictly positive when not, so a
|
|
89
|
+
caller can pass it straight into F-57's hint without a special case.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
allowed: bool
|
|
93
|
+
request_class: RequestClass
|
|
94
|
+
limit: int
|
|
95
|
+
remaining: int
|
|
96
|
+
retry_after_s: float
|
|
97
|
+
|
|
98
|
+
def raise_if_limited(self) -> None:
|
|
99
|
+
if self.allowed:
|
|
100
|
+
return
|
|
101
|
+
raise EchoActError(
|
|
102
|
+
Code.RATE_LIMITED,
|
|
103
|
+
retry_after_s=self.retry_after_s,
|
|
104
|
+
detail={
|
|
105
|
+
"limit": self.limit,
|
|
106
|
+
"window_s": WINDOW_S,
|
|
107
|
+
"request_class": self.request_class.value,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class Lockout:
|
|
114
|
+
"""The authentication state of one connection origin (4.1).
|
|
115
|
+
|
|
116
|
+
``locked`` is what blocks an attempt; ``failures`` is how many are inside
|
|
117
|
+
the current window, which F-69's service screen can show without the owner
|
|
118
|
+
having to trigger a lockout to find out.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
locked: bool
|
|
122
|
+
failures: int
|
|
123
|
+
limit: int
|
|
124
|
+
retry_after_s: float
|
|
125
|
+
|
|
126
|
+
def raise_if_locked(self) -> None:
|
|
127
|
+
if not self.locked:
|
|
128
|
+
return
|
|
129
|
+
raise EchoActError(
|
|
130
|
+
Code.AUTH_LOCKED_OUT,
|
|
131
|
+
retry_after_s=self.retry_after_s,
|
|
132
|
+
detail={"limit": self.limit, "window_s": WINDOW_S},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _Window:
|
|
137
|
+
"""Timestamps of the events counted in one sliding window."""
|
|
138
|
+
|
|
139
|
+
__slots__ = ("_hits",)
|
|
140
|
+
|
|
141
|
+
def __init__(self) -> None:
|
|
142
|
+
self._hits: deque[float] = deque()
|
|
143
|
+
|
|
144
|
+
def trim(self, now: float) -> None:
|
|
145
|
+
cutoff = now - WINDOW_S
|
|
146
|
+
hits = self._hits
|
|
147
|
+
while hits and hits[0] <= cutoff:
|
|
148
|
+
hits.popleft()
|
|
149
|
+
|
|
150
|
+
def __len__(self) -> int:
|
|
151
|
+
return len(self._hits)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def empty(self) -> bool:
|
|
155
|
+
return not self._hits
|
|
156
|
+
|
|
157
|
+
def add(self, now: float) -> None:
|
|
158
|
+
self._hits.append(now)
|
|
159
|
+
|
|
160
|
+
def retry_after(self, now: float) -> float:
|
|
161
|
+
"""When the oldest counted event leaves the window.
|
|
162
|
+
|
|
163
|
+
This is the earliest instant at which the same request could succeed,
|
|
164
|
+
which is what F-57's hint is supposed to mean. An empty window would
|
|
165
|
+
mean the caller was not limited at all, so it answers 0.0.
|
|
166
|
+
"""
|
|
167
|
+
if not self._hits:
|
|
168
|
+
return 0.0
|
|
169
|
+
return max(0.0, self._hits[0] + WINDOW_S - now)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class RateLimiter:
|
|
173
|
+
"""Per-client request rates (4.1), thread-safe and bounded.
|
|
174
|
+
|
|
175
|
+
The REST service serves several threads at once, and 4.1's limits are per
|
|
176
|
+
client rather than per connection, so the counting has to be shared and
|
|
177
|
+
therefore locked. Every operation is O(expired events) and never waits.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
clock: Clock = monotonic,
|
|
184
|
+
max_clients: int = MAX_TRACKED_CLIENTS,
|
|
185
|
+
) -> None:
|
|
186
|
+
# A monotonic clock, never the wall clock: a clock adjustment must not
|
|
187
|
+
# extend or erase a limit window (see ``echoact.util.ids``).
|
|
188
|
+
self._clock = clock
|
|
189
|
+
self._max_clients = max_clients
|
|
190
|
+
self._lock = threading.Lock()
|
|
191
|
+
self._windows: dict[str, dict[RequestClass, _Window]] = {}
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def tracked_clients(self) -> int:
|
|
195
|
+
with self._lock:
|
|
196
|
+
return len(self._windows)
|
|
197
|
+
|
|
198
|
+
def check(self, client_id: str, request_class: RequestClass) -> Decision:
|
|
199
|
+
"""Count one request and say whether it is allowed.
|
|
200
|
+
|
|
201
|
+
Consumes the allowance when it allows. Call it once per request, at
|
|
202
|
+
the point the request is admitted, so a rejected request costs nothing
|
|
203
|
+
against the caller's own limit.
|
|
204
|
+
"""
|
|
205
|
+
return self._evaluate(client_id, request_class, consume=True)
|
|
206
|
+
|
|
207
|
+
def peek(self, client_id: str, request_class: RequestClass) -> Decision:
|
|
208
|
+
"""The same answer without consuming, for F-69's status screen."""
|
|
209
|
+
return self._evaluate(client_id, request_class, consume=False)
|
|
210
|
+
|
|
211
|
+
def raise_if_limited(self, client_id: str, request_class: RequestClass) -> Decision:
|
|
212
|
+
"""``check`` that raises RATE_LIMITED with F-57's hint."""
|
|
213
|
+
decision = self.check(client_id, request_class)
|
|
214
|
+
decision.raise_if_limited()
|
|
215
|
+
return decision
|
|
216
|
+
|
|
217
|
+
def forget(self, client_id: str) -> None:
|
|
218
|
+
"""Drop a client's windows, e.g. once its credential is revoked."""
|
|
219
|
+
with self._lock:
|
|
220
|
+
self._windows.pop(client_id, None)
|
|
221
|
+
|
|
222
|
+
def sweep(self) -> int:
|
|
223
|
+
"""Drop clients with nothing counted any more; returns how many.
|
|
224
|
+
|
|
225
|
+
The cap already bounds the table, so this is housekeeping rather than
|
|
226
|
+
safety: it keeps ``tracked_clients`` truthful for F-69's screen and
|
|
227
|
+
keeps N-26's eight-hour run free of a table that only ever grows.
|
|
228
|
+
"""
|
|
229
|
+
now = self._clock()
|
|
230
|
+
with self._lock:
|
|
231
|
+
stale = [k for k, b in self._windows.items() if _all_expired(b, now)]
|
|
232
|
+
for client_id in stale:
|
|
233
|
+
del self._windows[client_id]
|
|
234
|
+
return len(stale)
|
|
235
|
+
|
|
236
|
+
def _evaluate(self, client_id: str, request_class: RequestClass, *, consume: bool) -> Decision:
|
|
237
|
+
limit = _LIMITS[request_class]
|
|
238
|
+
now = self._clock()
|
|
239
|
+
with self._lock:
|
|
240
|
+
buckets = self._windows.get(client_id)
|
|
241
|
+
if buckets is None:
|
|
242
|
+
if self._prune_locked(now):
|
|
243
|
+
# At the cap, an unknown client is refused rather than
|
|
244
|
+
# admitted untracked, which would make the cap a bypass.
|
|
245
|
+
return Decision(
|
|
246
|
+
allowed=False,
|
|
247
|
+
request_class=request_class,
|
|
248
|
+
limit=limit,
|
|
249
|
+
remaining=0,
|
|
250
|
+
retry_after_s=self._soonest_free_locked(now),
|
|
251
|
+
)
|
|
252
|
+
if not consume:
|
|
253
|
+
# A peek must not create the bucket it is asking about, or
|
|
254
|
+
# F-69's screen would fill the table by drawing itself.
|
|
255
|
+
return Decision(
|
|
256
|
+
allowed=True,
|
|
257
|
+
request_class=request_class,
|
|
258
|
+
limit=limit,
|
|
259
|
+
remaining=limit,
|
|
260
|
+
retry_after_s=0.0,
|
|
261
|
+
)
|
|
262
|
+
buckets = {c: _Window() for c in RequestClass}
|
|
263
|
+
self._windows[client_id] = buckets
|
|
264
|
+
|
|
265
|
+
window = buckets[request_class]
|
|
266
|
+
window.trim(now)
|
|
267
|
+
if len(window) >= limit:
|
|
268
|
+
return Decision(
|
|
269
|
+
allowed=False,
|
|
270
|
+
request_class=request_class,
|
|
271
|
+
limit=limit,
|
|
272
|
+
remaining=0,
|
|
273
|
+
retry_after_s=window.retry_after(now),
|
|
274
|
+
)
|
|
275
|
+
if consume:
|
|
276
|
+
window.add(now)
|
|
277
|
+
return Decision(
|
|
278
|
+
allowed=True,
|
|
279
|
+
request_class=request_class,
|
|
280
|
+
limit=limit,
|
|
281
|
+
remaining=limit - len(window),
|
|
282
|
+
retry_after_s=0.0,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _prune_locked(self, now: float) -> bool:
|
|
286
|
+
"""Drop clients with nothing left in either window. True if still full."""
|
|
287
|
+
if len(self._windows) < self._max_clients:
|
|
288
|
+
return False
|
|
289
|
+
for client_id in [k for k, b in self._windows.items() if _all_expired(b, now)]:
|
|
290
|
+
del self._windows[client_id]
|
|
291
|
+
return len(self._windows) >= self._max_clients
|
|
292
|
+
|
|
293
|
+
def _soonest_free_locked(self, now: float) -> float:
|
|
294
|
+
"""How long until some tracked client's window empties a slot.
|
|
295
|
+
|
|
296
|
+
The table cannot stay full longer than one window, so this is a real
|
|
297
|
+
figure rather than a guess, and it is capped at the window for the
|
|
298
|
+
degenerate case where every bucket was just touched.
|
|
299
|
+
"""
|
|
300
|
+
soonest = WINDOW_S
|
|
301
|
+
for buckets in self._windows.values():
|
|
302
|
+
for window in buckets.values():
|
|
303
|
+
after = window.retry_after(now)
|
|
304
|
+
if 0.0 < after < soonest:
|
|
305
|
+
soonest = after
|
|
306
|
+
return soonest
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class AuthFailureLimiter:
|
|
310
|
+
"""4.1's separate limit on authentication failures, per connection origin.
|
|
311
|
+
|
|
312
|
+
Ten failures inside a minute block authentication retries from that origin
|
|
313
|
+
for sixty seconds. The block is exactly that: the service keeps serving
|
|
314
|
+
every other origin, already-authenticated requests are untouched, and the
|
|
315
|
+
GUI never notices -- 4.1 says the local service as a whole and the GUI are
|
|
316
|
+
not terminated, so a lockout may not be escalated into a shutdown.
|
|
317
|
+
|
|
318
|
+
"Origin" is whatever the service can attribute a connection to, normally
|
|
319
|
+
the peer address. On a loopback-only listener (N-17) that is one value in
|
|
320
|
+
practice, which is the honest reading: every caller is on this machine, so
|
|
321
|
+
the lockout is a global brake on password guessing rather than a way to
|
|
322
|
+
isolate one attacker from another.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def __init__(
|
|
326
|
+
self,
|
|
327
|
+
*,
|
|
328
|
+
clock: Clock = monotonic,
|
|
329
|
+
max_origins: int = MAX_TRACKED_ORIGINS,
|
|
330
|
+
) -> None:
|
|
331
|
+
self._clock = clock
|
|
332
|
+
self._max_origins = max_origins
|
|
333
|
+
self._lock = threading.Lock()
|
|
334
|
+
self._failures: dict[str, _Window] = {}
|
|
335
|
+
self._locked_until: dict[str, float] = {}
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def tracked_origins(self) -> int:
|
|
339
|
+
with self._lock:
|
|
340
|
+
return self._tracked_locked()
|
|
341
|
+
|
|
342
|
+
def sweep(self) -> int:
|
|
343
|
+
"""Drop origins with no live failures and no live lockout.
|
|
344
|
+
|
|
345
|
+
The count is what ``tracked_origins`` drops by, which is only true
|
|
346
|
+
because the two tables are disjoint: a locked origin is counted once,
|
|
347
|
+
under its lockout, and reclaiming it means the lockout has lapsed.
|
|
348
|
+
"""
|
|
349
|
+
now = self._clock()
|
|
350
|
+
with self._lock:
|
|
351
|
+
stale = [o for o, w in self._failures.items() if _window_expired(w, now)]
|
|
352
|
+
for origin in stale:
|
|
353
|
+
del self._failures[origin]
|
|
354
|
+
lapsed = [o for o, until in self._locked_until.items() if until <= now]
|
|
355
|
+
for origin in lapsed:
|
|
356
|
+
del self._locked_until[origin]
|
|
357
|
+
return len(stale) + len(lapsed)
|
|
358
|
+
|
|
359
|
+
def check(self, origin: str) -> Lockout:
|
|
360
|
+
"""Ask whether this origin may attempt authentication at all.
|
|
361
|
+
|
|
362
|
+
Call it before verifying a credential. It counts nothing: a locked-out
|
|
363
|
+
attempt must not extend its own lockout, or an eager client would never
|
|
364
|
+
be let back in.
|
|
365
|
+
"""
|
|
366
|
+
now = self._clock()
|
|
367
|
+
with self._lock:
|
|
368
|
+
return self._state_locked(origin, now)
|
|
369
|
+
|
|
370
|
+
def raise_if_locked(self, origin: str) -> None:
|
|
371
|
+
"""``check`` that raises AUTH_LOCKED_OUT with the remaining time."""
|
|
372
|
+
self.check(origin).raise_if_locked()
|
|
373
|
+
|
|
374
|
+
def record_failure(self, origin: str) -> Lockout:
|
|
375
|
+
"""Count one authentication failure and report the resulting state.
|
|
376
|
+
|
|
377
|
+
The lockout starts at the failure that reaches the limit and runs for
|
|
378
|
+
4.1's sixty seconds from that instant, not from the first failure in
|
|
379
|
+
the window.
|
|
380
|
+
"""
|
|
381
|
+
now = self._clock()
|
|
382
|
+
with self._lock:
|
|
383
|
+
state = self._state_locked(origin, now)
|
|
384
|
+
if state.locked:
|
|
385
|
+
return state
|
|
386
|
+
window = self._failures.get(origin)
|
|
387
|
+
if window is None:
|
|
388
|
+
if self._prune_locked(now):
|
|
389
|
+
# Table full: treat the origin as locked out rather than
|
|
390
|
+
# untracked. Refusing an authentication attempt is the
|
|
391
|
+
# safe direction, and the caller is told when to retry.
|
|
392
|
+
return Lockout(
|
|
393
|
+
locked=True,
|
|
394
|
+
failures=0,
|
|
395
|
+
limit=AUTH_FAILURES_PER_MIN,
|
|
396
|
+
retry_after_s=self._soonest_free_locked(now),
|
|
397
|
+
)
|
|
398
|
+
window = _Window()
|
|
399
|
+
self._failures[origin] = window
|
|
400
|
+
window.add(now)
|
|
401
|
+
if len(window) >= AUTH_FAILURES_PER_MIN:
|
|
402
|
+
# The lockout replaces the window rather than sitting beside
|
|
403
|
+
# an emptied one: the lockout entry is now what tracks this
|
|
404
|
+
# origin, and leaving a spent window behind made the origin
|
|
405
|
+
# count twice in ``sweep`` and zero times against the cap.
|
|
406
|
+
self._locked_until[origin] = now + AUTH_LOCKOUT_S
|
|
407
|
+
del self._failures[origin]
|
|
408
|
+
return Lockout(
|
|
409
|
+
locked=True,
|
|
410
|
+
failures=AUTH_FAILURES_PER_MIN,
|
|
411
|
+
limit=AUTH_FAILURES_PER_MIN,
|
|
412
|
+
retry_after_s=AUTH_LOCKOUT_S,
|
|
413
|
+
)
|
|
414
|
+
return Lockout(
|
|
415
|
+
locked=False,
|
|
416
|
+
failures=len(window),
|
|
417
|
+
limit=AUTH_FAILURES_PER_MIN,
|
|
418
|
+
retry_after_s=0.0,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def record_success(self, origin: str) -> None:
|
|
422
|
+
"""Clear the window after an authentication that worked.
|
|
423
|
+
|
|
424
|
+
A client that mistypes a credential twice and then presents a valid one
|
|
425
|
+
has demonstrated it is not guessing; carrying those failures forward
|
|
426
|
+
for the rest of the minute would eventually lock out the legitimate
|
|
427
|
+
integration. It concedes nothing: reaching this point already required
|
|
428
|
+
a valid credential.
|
|
429
|
+
"""
|
|
430
|
+
with self._lock:
|
|
431
|
+
self._failures.pop(origin, None)
|
|
432
|
+
self._locked_until.pop(origin, None)
|
|
433
|
+
|
|
434
|
+
def forget(self, origin: str) -> None:
|
|
435
|
+
with self._lock:
|
|
436
|
+
self._failures.pop(origin, None)
|
|
437
|
+
self._locked_until.pop(origin, None)
|
|
438
|
+
|
|
439
|
+
def _state_locked(self, origin: str, now: float) -> Lockout:
|
|
440
|
+
until = self._locked_until.get(origin)
|
|
441
|
+
if until is not None:
|
|
442
|
+
if now < until:
|
|
443
|
+
return Lockout(
|
|
444
|
+
locked=True,
|
|
445
|
+
failures=AUTH_FAILURES_PER_MIN,
|
|
446
|
+
limit=AUTH_FAILURES_PER_MIN,
|
|
447
|
+
retry_after_s=until - now,
|
|
448
|
+
)
|
|
449
|
+
# The lockout has run out. Forget the failures that caused it, or
|
|
450
|
+
# the next single mistake would re-lock the origin immediately.
|
|
451
|
+
del self._locked_until[origin]
|
|
452
|
+
self._failures.pop(origin, None)
|
|
453
|
+
window = self._failures.get(origin)
|
|
454
|
+
if window is None:
|
|
455
|
+
return Lockout(
|
|
456
|
+
locked=False, failures=0, limit=AUTH_FAILURES_PER_MIN, retry_after_s=0.0
|
|
457
|
+
)
|
|
458
|
+
window.trim(now)
|
|
459
|
+
if window.empty:
|
|
460
|
+
del self._failures[origin]
|
|
461
|
+
return Lockout(
|
|
462
|
+
locked=False, failures=0, limit=AUTH_FAILURES_PER_MIN, retry_after_s=0.0
|
|
463
|
+
)
|
|
464
|
+
return Lockout(
|
|
465
|
+
locked=False, failures=len(window), limit=AUTH_FAILURES_PER_MIN, retry_after_s=0.0
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def _tracked_locked(self) -> int:
|
|
469
|
+
"""How many origins the two tables hold between them.
|
|
470
|
+
|
|
471
|
+
They are disjoint by construction -- an origin is either accumulating
|
|
472
|
+
failures or locked out, and locking moves it from one to the other --
|
|
473
|
+
so this is a sum, not a union. Were that ever to stop being true the
|
|
474
|
+
sum would over-count, which refuses an untracked attempt sooner: the
|
|
475
|
+
safe direction for a cap whose job is to stay finite.
|
|
476
|
+
"""
|
|
477
|
+
return len(self._failures) + len(self._locked_until)
|
|
478
|
+
|
|
479
|
+
def _prune_locked(self, now: float) -> bool:
|
|
480
|
+
"""Drop lapsed origins. True if the tables are still full.
|
|
481
|
+
|
|
482
|
+
MAX_TRACKED_ORIGINS covers lockouts too. A cap on ``_failures``
|
|
483
|
+
alone bounds nothing: ten failures move an origin into
|
|
484
|
+
``_locked_until`` and out of the table being measured, so an inventor
|
|
485
|
+
of origins buys one permanent entry per ten attempts -- exactly the
|
|
486
|
+
traffic the limit exists to answer.
|
|
487
|
+
"""
|
|
488
|
+
if self._tracked_locked() < self._max_origins:
|
|
489
|
+
return False
|
|
490
|
+
for origin in [o for o, w in self._failures.items() if _window_expired(w, now)]:
|
|
491
|
+
del self._failures[origin]
|
|
492
|
+
for origin in [o for o, until in self._locked_until.items() if until <= now]:
|
|
493
|
+
del self._locked_until[origin]
|
|
494
|
+
return self._tracked_locked() >= self._max_origins
|
|
495
|
+
|
|
496
|
+
def _soonest_free_locked(self, now: float) -> float:
|
|
497
|
+
"""When a slot next frees up, for the refusal's retry hint.
|
|
498
|
+
|
|
499
|
+
Lockouts are counted here for the same reason they are counted
|
|
500
|
+
against the cap: once an origin locks it has no failure window left,
|
|
501
|
+
so a table full of lockouts would answer with the bare WINDOW_S
|
|
502
|
+
default -- a made-up constant, which is what F-57's hint must not be.
|
|
503
|
+
"""
|
|
504
|
+
soonest = max(WINDOW_S, AUTH_LOCKOUT_S)
|
|
505
|
+
for window in self._failures.values():
|
|
506
|
+
after = window.retry_after(now)
|
|
507
|
+
if 0.0 < after < soonest:
|
|
508
|
+
soonest = after
|
|
509
|
+
for until in self._locked_until.values():
|
|
510
|
+
after = until - now
|
|
511
|
+
if 0.0 < after < soonest:
|
|
512
|
+
soonest = after
|
|
513
|
+
return soonest
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _window_expired(window: _Window, now: float) -> bool:
|
|
517
|
+
window.trim(now)
|
|
518
|
+
return window.empty
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _all_expired(buckets: dict[RequestClass, _Window], now: float) -> bool:
|
|
522
|
+
return all(_window_expired(w, now) for w in buckets.values())
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
__all__ = [
|
|
526
|
+
"MAX_TRACKED_CLIENTS",
|
|
527
|
+
"MAX_TRACKED_ORIGINS",
|
|
528
|
+
"WINDOW_S",
|
|
529
|
+
"AuthFailureLimiter",
|
|
530
|
+
"Decision",
|
|
531
|
+
"Lockout",
|
|
532
|
+
"RateLimiter",
|
|
533
|
+
"RequestClass",
|
|
534
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The local REST service: Section 2.10's twelve operations, and nothing else.
|
|
2
|
+
|
|
3
|
+
Import order matters here only in that ``server`` pulls in uvicorn, which the
|
|
4
|
+
GUI does not need until the owner actually starts the service; everything the
|
|
5
|
+
routes need is reachable without it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .app import CONTRACT_OPERATIONS, CONTRACT_PATHS, create_app
|
|
11
|
+
from .deps import ServiceContext
|
|
12
|
+
from .server import ServiceRunner
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CONTRACT_OPERATIONS",
|
|
16
|
+
"CONTRACT_PATHS",
|
|
17
|
+
"ServiceContext",
|
|
18
|
+
"ServiceRunner",
|
|
19
|
+
"create_app",
|
|
20
|
+
]
|