abstractgateway 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.
- abstractgateway/__init__.py +11 -0
- abstractgateway/app.py +55 -0
- abstractgateway/cli.py +30 -0
- abstractgateway/config.py +94 -0
- abstractgateway/hosts/__init__.py +6 -0
- abstractgateway/hosts/bundle_host.py +626 -0
- abstractgateway/hosts/visualflow_host.py +213 -0
- abstractgateway/routes/__init__.py +5 -0
- abstractgateway/routes/gateway.py +393 -0
- abstractgateway/runner.py +429 -0
- abstractgateway/security/__init__.py +5 -0
- abstractgateway/security/gateway_security.py +504 -0
- abstractgateway/service.py +134 -0
- abstractgateway/stores.py +34 -0
- abstractgateway-0.1.0.dist-info/METADATA +101 -0
- abstractgateway-0.1.0.dist-info/RECORD +18 -0
- abstractgateway-0.1.0.dist-info/WHEEL +4 -0
- abstractgateway-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Gateway security middleware (backlog 309).
|
|
2
|
+
|
|
3
|
+
Security goals (v0):
|
|
4
|
+
- Bearer token auth for gateway endpoints (mutating + read endpoints by default).
|
|
5
|
+
- Origin allowlist checks when Origin is present (DNS rebinding / browser-origin defense).
|
|
6
|
+
- Abuse resistance: request body limits, concurrency limits, auth failure lockouts.
|
|
7
|
+
|
|
8
|
+
Design constraints:
|
|
9
|
+
- Must not break durability semantics (command idempotency, replay-first ledger).
|
|
10
|
+
- Must stay dependency-light (stdlib + Starlette/FastAPI already in the server).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import hmac
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Dict, Iterable, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _as_bool(raw: Any, default: bool = False) -> bool:
|
|
30
|
+
if raw is None:
|
|
31
|
+
return default
|
|
32
|
+
if isinstance(raw, bool):
|
|
33
|
+
return raw
|
|
34
|
+
s = str(raw).strip().lower()
|
|
35
|
+
if not s:
|
|
36
|
+
return default
|
|
37
|
+
if s in {"1", "true", "yes", "on"}:
|
|
38
|
+
return True
|
|
39
|
+
if s in {"0", "false", "no", "off"}:
|
|
40
|
+
return False
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _split_csv(raw: Optional[str]) -> list[str]:
|
|
45
|
+
if raw is None:
|
|
46
|
+
return []
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
for part in str(raw).split(","):
|
|
49
|
+
p = part.strip()
|
|
50
|
+
if p:
|
|
51
|
+
out.append(p)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_loopback_ip(host: str) -> bool:
|
|
56
|
+
h = str(host or "").strip().lower()
|
|
57
|
+
return h in {"127.0.0.1", "::1", "localhost", "testclient"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _env(name: str, fallback: Optional[str] = None) -> Optional[str]:
|
|
61
|
+
v = os.getenv(name)
|
|
62
|
+
if v is not None and str(v).strip():
|
|
63
|
+
return v
|
|
64
|
+
if fallback:
|
|
65
|
+
v2 = os.getenv(fallback)
|
|
66
|
+
if v2 is not None and str(v2).strip():
|
|
67
|
+
return v2
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class GatewayAuthPolicy:
|
|
73
|
+
"""Configuration for the Run Gateway security layer."""
|
|
74
|
+
|
|
75
|
+
# Enable/disable middleware entirely (escape hatch).
|
|
76
|
+
enabled: bool = True
|
|
77
|
+
|
|
78
|
+
# Auth tokens (shared secret list; any token is accepted).
|
|
79
|
+
tokens: Tuple[str, ...] = ()
|
|
80
|
+
|
|
81
|
+
# Default: protect both reads and writes.
|
|
82
|
+
protect_read_endpoints: bool = True
|
|
83
|
+
protect_write_endpoints: bool = True
|
|
84
|
+
|
|
85
|
+
# Dev-only convenience: allow unauthenticated reads *from loopback only*.
|
|
86
|
+
dev_allow_unauthenticated_reads_on_loopback: bool = False
|
|
87
|
+
|
|
88
|
+
# Origin allowlist. Only applied when Origin header is present.
|
|
89
|
+
# Supports '*' suffix wildcard for prefix matches (e.g., 'http://localhost:*').
|
|
90
|
+
allowed_origins: Tuple[str, ...] = ("http://localhost:*", "http://127.0.0.1:*")
|
|
91
|
+
|
|
92
|
+
# Abuse resistance
|
|
93
|
+
max_body_bytes: int = 256_000
|
|
94
|
+
max_concurrency: int = 64
|
|
95
|
+
max_sse_connections: int = 32
|
|
96
|
+
|
|
97
|
+
# Auth failure lockout
|
|
98
|
+
lockout_after_failures: int = 5
|
|
99
|
+
lockout_base_s: float = 1.0
|
|
100
|
+
lockout_max_s: float = 60.0
|
|
101
|
+
|
|
102
|
+
# Proxy trust (X-Forwarded-For)
|
|
103
|
+
trust_proxy: bool = False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_gateway_auth_policy_from_env() -> GatewayAuthPolicy:
|
|
107
|
+
"""Load GatewayAuthPolicy from environment variables.
|
|
108
|
+
|
|
109
|
+
Canonical env vars:
|
|
110
|
+
- ABSTRACTGATEWAY_SECURITY=1|0
|
|
111
|
+
- ABSTRACTGATEWAY_AUTH_TOKEN / ABSTRACTGATEWAY_AUTH_TOKENS (comma-separated)
|
|
112
|
+
- ABSTRACTGATEWAY_PROTECT_READ=1|0
|
|
113
|
+
- ABSTRACTGATEWAY_PROTECT_WRITE=1|0
|
|
114
|
+
- ABSTRACTGATEWAY_DEV_READ_NO_AUTH=1|0 (loopback only)
|
|
115
|
+
- ABSTRACTGATEWAY_ALLOWED_ORIGINS (comma-separated; supports '*' suffix wildcard)
|
|
116
|
+
- ABSTRACTGATEWAY_MAX_BODY_BYTES
|
|
117
|
+
- ABSTRACTGATEWAY_MAX_CONCURRENCY
|
|
118
|
+
- ABSTRACTGATEWAY_MAX_SSE
|
|
119
|
+
- ABSTRACTGATEWAY_LOCKOUT_AFTER
|
|
120
|
+
- ABSTRACTGATEWAY_LOCKOUT_BASE_S
|
|
121
|
+
- ABSTRACTGATEWAY_LOCKOUT_MAX_S
|
|
122
|
+
- ABSTRACTGATEWAY_TRUST_PROXY=1|0
|
|
123
|
+
|
|
124
|
+
Compatibility fallbacks (legacy):
|
|
125
|
+
- ABSTRACTFLOW_GATEWAY_*
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
enabled = _as_bool(_env("ABSTRACTGATEWAY_SECURITY", "ABSTRACTFLOW_GATEWAY_SECURITY") or "1", True)
|
|
129
|
+
|
|
130
|
+
tokens = []
|
|
131
|
+
tokens.extend(_split_csv(_env("ABSTRACTGATEWAY_AUTH_TOKEN", "ABSTRACTFLOW_GATEWAY_AUTH_TOKEN")))
|
|
132
|
+
tokens.extend(_split_csv(_env("ABSTRACTGATEWAY_AUTH_TOKENS", "ABSTRACTFLOW_GATEWAY_AUTH_TOKENS")))
|
|
133
|
+
# Deduplicate while preserving order
|
|
134
|
+
seen: set[str] = set()
|
|
135
|
+
tokens2: list[str] = []
|
|
136
|
+
for t in tokens:
|
|
137
|
+
if t not in seen:
|
|
138
|
+
seen.add(t)
|
|
139
|
+
tokens2.append(t)
|
|
140
|
+
|
|
141
|
+
protect_read = _as_bool(_env("ABSTRACTGATEWAY_PROTECT_READ", "ABSTRACTFLOW_GATEWAY_PROTECT_READ") or "1", True)
|
|
142
|
+
protect_write = _as_bool(_env("ABSTRACTGATEWAY_PROTECT_WRITE", "ABSTRACTFLOW_GATEWAY_PROTECT_WRITE") or "1", True)
|
|
143
|
+
dev_read_no_auth = _as_bool(_env("ABSTRACTGATEWAY_DEV_READ_NO_AUTH", "ABSTRACTFLOW_GATEWAY_DEV_READ_NO_AUTH") or "0", False)
|
|
144
|
+
|
|
145
|
+
allowed_origins_raw = _env("ABSTRACTGATEWAY_ALLOWED_ORIGINS", "ABSTRACTFLOW_GATEWAY_ALLOWED_ORIGINS")
|
|
146
|
+
allowed_origins = (
|
|
147
|
+
tuple(_split_csv(allowed_origins_raw))
|
|
148
|
+
if allowed_origins_raw
|
|
149
|
+
else ("http://localhost:*", "http://127.0.0.1:*")
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _as_int(name: str, fallback: str, default: int) -> int:
|
|
153
|
+
raw = _env(name, fallback)
|
|
154
|
+
if raw is None or not str(raw).strip():
|
|
155
|
+
return default
|
|
156
|
+
try:
|
|
157
|
+
return int(str(raw).strip())
|
|
158
|
+
except Exception:
|
|
159
|
+
return default
|
|
160
|
+
|
|
161
|
+
def _as_float(name: str, fallback: str, default: float) -> float:
|
|
162
|
+
raw = _env(name, fallback)
|
|
163
|
+
if raw is None or not str(raw).strip():
|
|
164
|
+
return default
|
|
165
|
+
try:
|
|
166
|
+
return float(str(raw).strip())
|
|
167
|
+
except Exception:
|
|
168
|
+
return default
|
|
169
|
+
|
|
170
|
+
max_body = _as_int("ABSTRACTGATEWAY_MAX_BODY_BYTES", "ABSTRACTFLOW_GATEWAY_MAX_BODY_BYTES", 256_000)
|
|
171
|
+
max_conc = _as_int("ABSTRACTGATEWAY_MAX_CONCURRENCY", "ABSTRACTFLOW_GATEWAY_MAX_CONCURRENCY", 64)
|
|
172
|
+
max_sse = _as_int("ABSTRACTGATEWAY_MAX_SSE", "ABSTRACTFLOW_GATEWAY_MAX_SSE", 32)
|
|
173
|
+
lockout_after = _as_int("ABSTRACTGATEWAY_LOCKOUT_AFTER", "ABSTRACTFLOW_GATEWAY_LOCKOUT_AFTER", 5)
|
|
174
|
+
lockout_base = _as_float("ABSTRACTGATEWAY_LOCKOUT_BASE_S", "ABSTRACTFLOW_GATEWAY_LOCKOUT_BASE_S", 1.0)
|
|
175
|
+
lockout_max = _as_float("ABSTRACTGATEWAY_LOCKOUT_MAX_S", "ABSTRACTFLOW_GATEWAY_LOCKOUT_MAX_S", 60.0)
|
|
176
|
+
trust_proxy = _as_bool(_env("ABSTRACTGATEWAY_TRUST_PROXY", "ABSTRACTFLOW_GATEWAY_TRUST_PROXY") or "0", False)
|
|
177
|
+
|
|
178
|
+
return GatewayAuthPolicy(
|
|
179
|
+
enabled=enabled,
|
|
180
|
+
tokens=tuple(tokens2),
|
|
181
|
+
protect_read_endpoints=bool(protect_read),
|
|
182
|
+
protect_write_endpoints=bool(protect_write),
|
|
183
|
+
dev_allow_unauthenticated_reads_on_loopback=bool(dev_read_no_auth),
|
|
184
|
+
allowed_origins=tuple(allowed_origins),
|
|
185
|
+
max_body_bytes=max(0, int(max_body)),
|
|
186
|
+
max_concurrency=max(1, int(max_conc)),
|
|
187
|
+
max_sse_connections=max(1, int(max_sse)),
|
|
188
|
+
lockout_after_failures=max(1, int(lockout_after)),
|
|
189
|
+
lockout_base_s=max(0.0, float(lockout_base)),
|
|
190
|
+
lockout_max_s=max(0.0, float(lockout_max)),
|
|
191
|
+
trust_proxy=bool(trust_proxy),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _AuthLockoutTracker:
|
|
196
|
+
"""In-memory auth failure tracker (v0).
|
|
197
|
+
|
|
198
|
+
This is intentionally process-local. In production, prefer infra-level rate limiting
|
|
199
|
+
at the reverse proxy + WAF, and treat this as a safety net.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
*,
|
|
205
|
+
after_failures: int,
|
|
206
|
+
base_s: float,
|
|
207
|
+
max_s: float,
|
|
208
|
+
max_entries: int = 10_000,
|
|
209
|
+
) -> None:
|
|
210
|
+
self._after = max(1, int(after_failures))
|
|
211
|
+
self._base = max(0.0, float(base_s))
|
|
212
|
+
self._max = max(0.0, float(max_s))
|
|
213
|
+
self._max_entries = max(100, int(max_entries))
|
|
214
|
+
self._lock = threading.Lock()
|
|
215
|
+
# ip -> (fail_count, locked_until_epoch_s)
|
|
216
|
+
self._state: Dict[str, Tuple[int, float]] = {}
|
|
217
|
+
|
|
218
|
+
def check_locked(self, ip: str) -> Optional[int]:
|
|
219
|
+
now = time.time()
|
|
220
|
+
with self._lock:
|
|
221
|
+
fc, until = self._state.get(ip, (0, 0.0))
|
|
222
|
+
if until > now:
|
|
223
|
+
return int(max(0.0, until - now))
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def record_failure(self, ip: str) -> Optional[int]:
|
|
227
|
+
now = time.time()
|
|
228
|
+
with self._lock:
|
|
229
|
+
if len(self._state) > self._max_entries:
|
|
230
|
+
# best-effort pruning: drop arbitrary entries
|
|
231
|
+
for k in list(self._state.keys())[:1000]:
|
|
232
|
+
self._state.pop(k, None)
|
|
233
|
+
fc, until = self._state.get(ip, (0, 0.0))
|
|
234
|
+
fc += 1
|
|
235
|
+
|
|
236
|
+
if fc < self._after:
|
|
237
|
+
self._state[ip] = (fc, 0.0)
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
# Exponential backoff from the threshold.
|
|
241
|
+
exp = max(0, fc - self._after)
|
|
242
|
+
lock_s = self._base * (2**exp) if self._base > 0 else 0.0
|
|
243
|
+
if self._max > 0:
|
|
244
|
+
lock_s = min(lock_s, self._max)
|
|
245
|
+
until2 = now + lock_s
|
|
246
|
+
self._state[ip] = (fc, until2)
|
|
247
|
+
return int(lock_s)
|
|
248
|
+
|
|
249
|
+
def record_success(self, ip: str) -> None:
|
|
250
|
+
with self._lock:
|
|
251
|
+
if ip in self._state:
|
|
252
|
+
self._state.pop(ip, None)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class GatewaySecurityMiddleware:
|
|
256
|
+
"""ASGI middleware to secure /api/gateway/* endpoints."""
|
|
257
|
+
|
|
258
|
+
def __init__(self, app: Any, *, policy: GatewayAuthPolicy):
|
|
259
|
+
self._app = app
|
|
260
|
+
self._policy = policy
|
|
261
|
+
self._sema = asyncio.Semaphore(int(policy.max_concurrency))
|
|
262
|
+
self._sse_sema = asyncio.Semaphore(int(policy.max_sse_connections))
|
|
263
|
+
self._lockouts = _AuthLockoutTracker(
|
|
264
|
+
after_failures=policy.lockout_after_failures,
|
|
265
|
+
base_s=policy.lockout_base_s,
|
|
266
|
+
max_s=policy.lockout_max_s,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if policy.enabled:
|
|
270
|
+
if policy.protect_write_endpoints and not policy.tokens:
|
|
271
|
+
logger.warning(
|
|
272
|
+
"Gateway security enabled, but no auth token configured "
|
|
273
|
+
"(ABSTRACTGATEWAY_AUTH_TOKEN). Mutating endpoints will be rejected."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# ---------------------------
|
|
277
|
+
# Helpers
|
|
278
|
+
# ---------------------------
|
|
279
|
+
|
|
280
|
+
def _header(self, scope: dict, name: str) -> Optional[str]:
|
|
281
|
+
key = name.lower().encode("utf-8")
|
|
282
|
+
for k, v in scope.get("headers") or []:
|
|
283
|
+
if k == key:
|
|
284
|
+
try:
|
|
285
|
+
return v.decode("utf-8")
|
|
286
|
+
except Exception:
|
|
287
|
+
return None
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def _client_ip(self, scope: dict) -> str:
|
|
291
|
+
if self._policy.trust_proxy:
|
|
292
|
+
xff = self._header(scope, "x-forwarded-for")
|
|
293
|
+
if xff:
|
|
294
|
+
first = xff.split(",")[0].strip()
|
|
295
|
+
if first:
|
|
296
|
+
return first
|
|
297
|
+
client = scope.get("client")
|
|
298
|
+
if isinstance(client, (list, tuple)) and client and isinstance(client[0], str):
|
|
299
|
+
return client[0]
|
|
300
|
+
return "unknown"
|
|
301
|
+
|
|
302
|
+
def _origin_allowed(self, origin: str) -> bool:
|
|
303
|
+
o = str(origin or "").strip()
|
|
304
|
+
if not o:
|
|
305
|
+
return True
|
|
306
|
+
allowed = self._policy.allowed_origins or ()
|
|
307
|
+
if not allowed:
|
|
308
|
+
return False
|
|
309
|
+
for pattern in allowed:
|
|
310
|
+
p = str(pattern or "").strip()
|
|
311
|
+
if not p:
|
|
312
|
+
continue
|
|
313
|
+
if p == "*":
|
|
314
|
+
return True
|
|
315
|
+
if p.endswith("*"):
|
|
316
|
+
if o.startswith(p[:-1]):
|
|
317
|
+
return True
|
|
318
|
+
continue
|
|
319
|
+
if o == p:
|
|
320
|
+
return True
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
def _token_valid(self, token: str) -> bool:
|
|
324
|
+
# Constant-time compare against any configured token.
|
|
325
|
+
for t in self._policy.tokens:
|
|
326
|
+
if hmac.compare_digest(str(token), str(t)):
|
|
327
|
+
return True
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
async def _send_json(
|
|
331
|
+
self,
|
|
332
|
+
send,
|
|
333
|
+
*,
|
|
334
|
+
status: int,
|
|
335
|
+
payload: Dict[str, Any],
|
|
336
|
+
headers: Optional[list[tuple[bytes, bytes]]] = None,
|
|
337
|
+
) -> None:
|
|
338
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
339
|
+
hdrs = [
|
|
340
|
+
(b"content-type", b"application/json; charset=utf-8"),
|
|
341
|
+
(b"content-length", str(len(body)).encode("utf-8")),
|
|
342
|
+
]
|
|
343
|
+
if headers:
|
|
344
|
+
hdrs.extend(headers)
|
|
345
|
+
await send({"type": "http.response.start", "status": int(status), "headers": hdrs})
|
|
346
|
+
await send({"type": "http.response.body", "body": body})
|
|
347
|
+
|
|
348
|
+
async def _reject(
|
|
349
|
+
self, send, *, status: int, detail: str, headers: Optional[list[tuple[bytes, bytes]]] = None
|
|
350
|
+
) -> None:
|
|
351
|
+
await self._send_json(send, status=status, payload={"detail": detail}, headers=headers)
|
|
352
|
+
|
|
353
|
+
async def _try_acquire(self, sem: asyncio.Semaphore, *, timeout_s: float = 0.01) -> bool:
|
|
354
|
+
try:
|
|
355
|
+
await asyncio.wait_for(sem.acquire(), timeout=float(timeout_s))
|
|
356
|
+
return True
|
|
357
|
+
except Exception:
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
# ---------------------------
|
|
361
|
+
# ASGI entrypoint
|
|
362
|
+
# ---------------------------
|
|
363
|
+
|
|
364
|
+
async def __call__(self, scope, receive, send):
|
|
365
|
+
if scope.get("type") != "http":
|
|
366
|
+
return await self._app(scope, receive, send)
|
|
367
|
+
|
|
368
|
+
path = str(scope.get("path") or "")
|
|
369
|
+
if not path.startswith("/api/gateway"):
|
|
370
|
+
return await self._app(scope, receive, send)
|
|
371
|
+
|
|
372
|
+
if not self._policy.enabled:
|
|
373
|
+
return await self._app(scope, receive, send)
|
|
374
|
+
|
|
375
|
+
method = str(scope.get("method") or "GET").upper()
|
|
376
|
+
is_read = method in {"GET", "HEAD"}
|
|
377
|
+
is_write = method in {"POST", "PUT", "PATCH", "DELETE"}
|
|
378
|
+
ip = self._client_ip(scope)
|
|
379
|
+
|
|
380
|
+
# Origin checks (only when Origin is present).
|
|
381
|
+
origin = self._header(scope, "origin")
|
|
382
|
+
if origin is not None and not self._origin_allowed(origin):
|
|
383
|
+
await self._reject(send, status=403, detail="Forbidden (origin not allowed)")
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
# Lockout handling (only meaningful when auth is enabled).
|
|
387
|
+
locked = self._lockouts.check_locked(ip)
|
|
388
|
+
if locked is not None and locked > 0:
|
|
389
|
+
await self._reject(
|
|
390
|
+
send,
|
|
391
|
+
status=429,
|
|
392
|
+
detail="Too Many Requests (auth lockout)",
|
|
393
|
+
headers=[(b"retry-after", str(int(locked)).encode("utf-8"))],
|
|
394
|
+
)
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
# Auth decision.
|
|
398
|
+
auth_required = False
|
|
399
|
+
if is_write and self._policy.protect_write_endpoints:
|
|
400
|
+
auth_required = True
|
|
401
|
+
if is_read and self._policy.protect_read_endpoints:
|
|
402
|
+
# Optional dev escape hatch (loopback-only).
|
|
403
|
+
if self._policy.dev_allow_unauthenticated_reads_on_loopback and _is_loopback_ip(ip):
|
|
404
|
+
auth_required = False
|
|
405
|
+
else:
|
|
406
|
+
auth_required = True
|
|
407
|
+
|
|
408
|
+
# OPTIONS preflight: allow through (but still origin-checked above).
|
|
409
|
+
if method == "OPTIONS":
|
|
410
|
+
return await self._app(scope, receive, send)
|
|
411
|
+
|
|
412
|
+
if auth_required:
|
|
413
|
+
if not self._policy.tokens:
|
|
414
|
+
await self._reject(send, status=503, detail="Gateway auth required but no token configured")
|
|
415
|
+
return
|
|
416
|
+
auth = self._header(scope, "authorization") or ""
|
|
417
|
+
token = ""
|
|
418
|
+
if auth.lower().startswith("bearer "):
|
|
419
|
+
token = auth.split(" ", 1)[1].strip()
|
|
420
|
+
if not token or not self._token_valid(token):
|
|
421
|
+
lock = self._lockouts.record_failure(ip)
|
|
422
|
+
if lock is not None and lock > 0:
|
|
423
|
+
await self._reject(
|
|
424
|
+
send,
|
|
425
|
+
status=429,
|
|
426
|
+
detail="Too Many Requests (auth lockout)",
|
|
427
|
+
headers=[(b"retry-after", str(int(lock)).encode("utf-8"))],
|
|
428
|
+
)
|
|
429
|
+
return
|
|
430
|
+
await self._reject(
|
|
431
|
+
send,
|
|
432
|
+
status=401,
|
|
433
|
+
detail="Unauthorized",
|
|
434
|
+
headers=[(b"www-authenticate", b"Bearer")],
|
|
435
|
+
)
|
|
436
|
+
return
|
|
437
|
+
self._lockouts.record_success(ip)
|
|
438
|
+
|
|
439
|
+
# Body size limits (for mutating endpoints).
|
|
440
|
+
buffered_body: Optional[bytes] = None
|
|
441
|
+
if is_write and self._policy.max_body_bytes > 0:
|
|
442
|
+
cl = self._header(scope, "content-length")
|
|
443
|
+
if cl is not None:
|
|
444
|
+
try:
|
|
445
|
+
if int(cl) > int(self._policy.max_body_bytes):
|
|
446
|
+
await self._reject(send, status=413, detail="Payload Too Large")
|
|
447
|
+
return
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
else:
|
|
451
|
+
# No content-length: buffer up to limit+1, then replay.
|
|
452
|
+
limit = int(self._policy.max_body_bytes)
|
|
453
|
+
chunks: list[bytes] = []
|
|
454
|
+
size = 0
|
|
455
|
+
more = True
|
|
456
|
+
while more:
|
|
457
|
+
message = await receive()
|
|
458
|
+
if message.get("type") != "http.request":
|
|
459
|
+
continue
|
|
460
|
+
body = message.get("body", b"") or b""
|
|
461
|
+
more = bool(message.get("more_body", False))
|
|
462
|
+
if body:
|
|
463
|
+
chunks.append(body)
|
|
464
|
+
size += len(body)
|
|
465
|
+
if size > limit:
|
|
466
|
+
await self._reject(send, status=413, detail="Payload Too Large")
|
|
467
|
+
return
|
|
468
|
+
buffered_body = b"".join(chunks)
|
|
469
|
+
|
|
470
|
+
# Concurrency limits: separate pool for SSE streams.
|
|
471
|
+
is_sse = path.endswith("/ledger/stream")
|
|
472
|
+
sem = self._sse_sema if is_sse else self._sema
|
|
473
|
+
acquired = await self._try_acquire(sem)
|
|
474
|
+
if not acquired:
|
|
475
|
+
await self._reject(
|
|
476
|
+
send,
|
|
477
|
+
status=429,
|
|
478
|
+
detail="Too Many Requests (concurrency limit)",
|
|
479
|
+
headers=[(b"retry-after", b"1")],
|
|
480
|
+
)
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
if buffered_body is None:
|
|
485
|
+
return await self._app(scope, receive, send)
|
|
486
|
+
|
|
487
|
+
# Replay buffered body to downstream app.
|
|
488
|
+
sent = False
|
|
489
|
+
|
|
490
|
+
async def _replay_receive():
|
|
491
|
+
nonlocal sent
|
|
492
|
+
if sent:
|
|
493
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
494
|
+
sent = True
|
|
495
|
+
return {"type": "http.request", "body": buffered_body, "more_body": False}
|
|
496
|
+
|
|
497
|
+
return await self._app(scope, _replay_receive, send)
|
|
498
|
+
finally:
|
|
499
|
+
try:
|
|
500
|
+
sem.release()
|
|
501
|
+
except Exception:
|
|
502
|
+
pass
|
|
503
|
+
|
|
504
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .config import GatewayHostConfig
|
|
8
|
+
from .hosts.visualflow_host import VisualFlowGatewayHost, VisualFlowRegistry
|
|
9
|
+
from .runner import GatewayRunner, GatewayRunnerConfig
|
|
10
|
+
from .security import GatewayAuthPolicy, load_gateway_auth_policy_from_env
|
|
11
|
+
from .stores import GatewayStores, build_file_stores
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class GatewayService:
|
|
16
|
+
"""Composition root: host + runner + security policy."""
|
|
17
|
+
|
|
18
|
+
config: GatewayHostConfig
|
|
19
|
+
stores: GatewayStores
|
|
20
|
+
host: Any
|
|
21
|
+
runner: GatewayRunner
|
|
22
|
+
auth_policy: GatewayAuthPolicy
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_service: Optional[GatewayService] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_gateway_service() -> GatewayService:
|
|
29
|
+
global _service
|
|
30
|
+
if _service is None:
|
|
31
|
+
_service = create_default_gateway_service()
|
|
32
|
+
return _service
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_default_gateway_service() -> GatewayService:
|
|
36
|
+
cfg = GatewayHostConfig.from_env()
|
|
37
|
+
stores = build_file_stores(base_dir=cfg.data_dir)
|
|
38
|
+
|
|
39
|
+
# Workflow source:
|
|
40
|
+
# - bundle (default): `.flow` bundles with VisualFlow JSON (compiled via AbstractRuntime; no AbstractFlow import)
|
|
41
|
+
# - visualflow (optional): load VisualFlow JSON files directly from a directory (host wiring currently uses AbstractFlow extras)
|
|
42
|
+
source = str(os.getenv("ABSTRACTGATEWAY_WORKFLOW_SOURCE", "bundle") or "bundle").strip().lower()
|
|
43
|
+
if source == "bundle":
|
|
44
|
+
from .hosts.bundle_host import WorkflowBundleGatewayHost
|
|
45
|
+
|
|
46
|
+
host = WorkflowBundleGatewayHost.load_from_dir(
|
|
47
|
+
bundles_dir=cfg.flows_dir,
|
|
48
|
+
run_store=stores.run_store,
|
|
49
|
+
ledger_store=stores.ledger_store,
|
|
50
|
+
artifact_store=stores.artifact_store,
|
|
51
|
+
)
|
|
52
|
+
elif source == "visualflow":
|
|
53
|
+
flows = VisualFlowRegistry(flows_dir=cfg.flows_dir).load()
|
|
54
|
+
host = VisualFlowGatewayHost(
|
|
55
|
+
flows_dir=cfg.flows_dir,
|
|
56
|
+
flows=flows,
|
|
57
|
+
run_store=stores.run_store,
|
|
58
|
+
ledger_store=stores.ledger_store,
|
|
59
|
+
artifact_store=stores.artifact_store,
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
raise RuntimeError(f"Unsupported workflow source: {source}. Supported: bundle|visualflow")
|
|
63
|
+
|
|
64
|
+
runner_cfg = GatewayRunnerConfig(
|
|
65
|
+
poll_interval_s=float(cfg.poll_interval_s),
|
|
66
|
+
command_batch_limit=int(cfg.command_batch_limit),
|
|
67
|
+
tick_max_steps=int(cfg.tick_max_steps),
|
|
68
|
+
tick_workers=int(cfg.tick_workers),
|
|
69
|
+
run_scan_limit=int(cfg.run_scan_limit),
|
|
70
|
+
)
|
|
71
|
+
runner = GatewayRunner(base_dir=stores.base_dir, host=host, config=runner_cfg, enable=bool(cfg.runner_enabled))
|
|
72
|
+
|
|
73
|
+
policy = load_gateway_auth_policy_from_env()
|
|
74
|
+
return GatewayService(config=cfg, stores=stores, host=host, runner=runner, auth_policy=policy)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def start_gateway_runner() -> None:
|
|
78
|
+
svc = get_gateway_service()
|
|
79
|
+
svc.runner.start()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def stop_gateway_runner() -> None:
|
|
83
|
+
global _service
|
|
84
|
+
if _service is None:
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
_service.runner.stop()
|
|
88
|
+
finally:
|
|
89
|
+
_service = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_summary(run: Any) -> Dict[str, Any]:
|
|
93
|
+
"""HTTP-safe run summary (do not return full run.vars)."""
|
|
94
|
+
|
|
95
|
+
waiting = getattr(run, "waiting", None)
|
|
96
|
+
status = getattr(getattr(run, "status", None), "value", None) or str(getattr(run, "status", "unknown"))
|
|
97
|
+
out: Dict[str, Any] = {
|
|
98
|
+
"run_id": getattr(run, "run_id", ""),
|
|
99
|
+
"workflow_id": getattr(run, "workflow_id", None),
|
|
100
|
+
"status": status,
|
|
101
|
+
"current_node": getattr(run, "current_node", None),
|
|
102
|
+
"created_at": getattr(run, "created_at", None),
|
|
103
|
+
"updated_at": getattr(run, "updated_at", None),
|
|
104
|
+
"parent_run_id": getattr(run, "parent_run_id", None),
|
|
105
|
+
"error": getattr(run, "error", None),
|
|
106
|
+
# Best-effort pause metadata. We intentionally do not return full run.vars over HTTP.
|
|
107
|
+
"paused": False,
|
|
108
|
+
"pause_reason": None,
|
|
109
|
+
"paused_at": None,
|
|
110
|
+
"resumed_at": None,
|
|
111
|
+
"waiting": None,
|
|
112
|
+
}
|
|
113
|
+
try:
|
|
114
|
+
vars_obj = getattr(run, "vars", None)
|
|
115
|
+
runtime_ns = vars_obj.get("_runtime") if isinstance(vars_obj, dict) else None
|
|
116
|
+
control = runtime_ns.get("control") if isinstance(runtime_ns, dict) else None
|
|
117
|
+
if isinstance(control, dict):
|
|
118
|
+
out["paused"] = bool(control.get("paused") is True)
|
|
119
|
+
out["pause_reason"] = control.get("pause_reason")
|
|
120
|
+
out["paused_at"] = control.get("paused_at")
|
|
121
|
+
out["resumed_at"] = control.get("resumed_at")
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
if waiting is not None:
|
|
125
|
+
out["waiting"] = {
|
|
126
|
+
"reason": getattr(getattr(waiting, "reason", None), "value", None) or str(getattr(waiting, "reason", "")),
|
|
127
|
+
"wait_key": getattr(waiting, "wait_key", None),
|
|
128
|
+
"prompt": getattr(waiting, "prompt", None),
|
|
129
|
+
"choices": getattr(waiting, "choices", None),
|
|
130
|
+
"allow_free_text": getattr(waiting, "allow_free_text", None),
|
|
131
|
+
"details": getattr(waiting, "details", None),
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class GatewayStores:
|
|
10
|
+
"""Concrete durability stores used by a gateway host."""
|
|
11
|
+
|
|
12
|
+
base_dir: Path
|
|
13
|
+
run_store: Any
|
|
14
|
+
ledger_store: Any
|
|
15
|
+
artifact_store: Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_file_stores(*, base_dir: Path) -> GatewayStores:
|
|
19
|
+
"""Create file-backed Run/Ledger/Artifact stores under base_dir.
|
|
20
|
+
|
|
21
|
+
Contract: base_dir is owned by the gateway host process (durable control plane).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from abstractruntime import FileArtifactStore, JsonFileRunStore, JsonlLedgerStore, ObservableLedgerStore
|
|
25
|
+
|
|
26
|
+
base = Path(base_dir).expanduser().resolve()
|
|
27
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
run_store = JsonFileRunStore(base)
|
|
30
|
+
ledger_store = ObservableLedgerStore(JsonlLedgerStore(base))
|
|
31
|
+
artifact_store = FileArtifactStore(base)
|
|
32
|
+
return GatewayStores(base_dir=base, run_store=run_store, ledger_store=ledger_store, artifact_store=artifact_store)
|
|
33
|
+
|
|
34
|
+
|