netward 0.4.1__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.
netward/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ Net Ward — Passive Deception Layer for HTTP/Network Services
3
+
4
+ Standalone product for the general DDoS-defense market. Sold to
5
+ operators who get hit by botnet probing / credential stuffing / scrape
6
+ attacks but can't afford enterprise DDoS protection (Cloudflare
7
+ Enterprise, Akamai). Anti-virus doesn't cover this attack surface;
8
+ Net Ward fills that gap.
9
+
10
+ Customer market: ANY operator running an HTTP/network service with
11
+ inbound traffic. Hospitals, indie SaaS, regional ISPs, federal
12
+ contractors, defense-adjacent sites, public-interest projects, niche
13
+ aviation operators, ANY web property under recurring DDoS or scrape
14
+ pressure. The mesh treats every node identically — there is no
15
+ privileged customer relationship.
16
+
17
+ Philosophy: confuse, don't combat.
18
+ - Don't harm bots — they're conscripts following orders
19
+ - Mirror plausible-looking responses so bots think they succeeded
20
+ - Bots' own deduplication logic deprioritizes the target → they move on
21
+ - No active counter-attack, no legal liability, no escalation
22
+
23
+ Operating principle: ALL NODES ARE CONTINUOUSLY PREPARED.
24
+
25
+ There is no sacrificial node in Net Ward. Every node maintains its
26
+ mirror layer at all times. Whoever gets hit first is just whoever the
27
+ attacker happens to target first — they defend SUCCESSFULLY because
28
+ they've been prepared all along. They are not absorbing harm so
29
+ others can prepare; the entire mesh is already prepared.
30
+
31
+ What the first-hit node DOES contribute is REAL-TIME INTEL:
32
+ - Reports where it was hit (Pattern + Source published to mesh)
33
+ - Continues relaying as the attack progresses (sustained, not one-shot)
34
+ - Tracks mutations as bots iterate variants
35
+ - Refreshes confidence on existing patterns as they keep firing
36
+
37
+ Other nodes receive the relay and:
38
+ - Refresh local Pattern confidence
39
+ - Update Source reputation for the attacker's IPs/ASNs
40
+ - Pre-arm against the specific mutation chain in case the attacker
41
+ pivots to them next
42
+
43
+ Network effect: more nodes = smarter mesh = better deception for all.
44
+ Anti-fragile: the harder attackers hit, the more pattern data the
45
+ mesh collects, the better the deception gets for every node.
46
+ Mutation-aware: when bots iterate variants seeking vulnerabilities,
47
+ the mesh tracks the mutation chain so every node stays one step ahead.
48
+
49
+ Architecture: distributed mesh with deception mirror layer.
50
+
51
+ Product positioning:
52
+
53
+ Net Ward sits BESIDE existing security tools, not replacing them.
54
+ - CDN/WAF (Cloudflare, Akamai) handles broad edge filtering
55
+ - Antivirus / EDR handles infected endpoints
56
+ - Net Ward handles ADAPTIVE ABUSE PRESSURE: bot floods, suspicious
57
+ repeat patterns, credential stuffing, scrape attacks, and
58
+ operator-visible deflection
59
+
60
+ Antivirus DOES NOT cover this attack surface. AV protects hosts from
61
+ malware; it doesn't preserve availability when traffic or abuse floods
62
+ the edge. Net Ward fills the gap as a unique add-on for electronic /
63
+ bot warfare protection that saves host operators meaningful downtime
64
+ and remediation cost.
65
+
66
+ Buyer pain (real, validated): small orgs, hospitals, regional
67
+ infrastructure, indie platforms, niche data services often can't
68
+ afford Cloudflare-Enterprise-style support OR don't want opaque
69
+ black-box mitigation. A clear, local/operator-controlled layer has
70
+ genuine market room.
71
+
72
+ CORE PRODUCT REQUIREMENT: FAIL SAFE AND FAIL CLEAN.
73
+
74
+ Net Ward must protect availability without becoming another source of
75
+ risk to the host system. This is not a "nice to have" — it's a
76
+ gating requirement for v1 release.
77
+
78
+ Design principles (encoded in every layer):
79
+
80
+ - NO kernel hooks, NO packet drivers, NO invasive system changes
81
+ - Run as user-space reverse proxy / sidecar / edge service ONLY
82
+ - Config lives in its own directory; never modifies host config
83
+ - Logs are append-only and bounded (no unbounded disk growth)
84
+ - State/cache can be deleted without harming the host application
85
+ - Crash behavior: operator chooses fail-open vs fail-closed explicitly
86
+ (default: fail-open — don't take the host's service down with us)
87
+ - Uninstall = remove the service folder/config, NOT repair the OS
88
+ - NO retaliatory traffic, NO client harm, NO "hack back"
89
+ - Rate limiting and deflection must be REVERSIBLE and EXPLAINABLE
90
+ (operator can roll back any rule, can read why each action fired)
91
+
92
+ Setup target (cheap, easy, accessible):
93
+
94
+ - Single binary OR pure-Python package install
95
+ - ONE config file (validated, with clear error messages on bad config)
96
+ - Docker option for ops teams that prefer containers
97
+ - Works in front of an existing app with MINIMAL routing changes
98
+ - Clear dashboard / CLI: traffic state, active rules, top sources,
99
+ current action being taken — no operator surprise
100
+
101
+ THE PRODUCT PROMISE:
102
+ Protect availability without becoming another source of risk.
103
+
104
+ Scope: HTTP/network services with INBOUND traffic.
105
+ NOT for broadcast feed protection — public one-way feeds without
106
+ authentication or upstream firewall control are a separate problem
107
+ domain that mirror/honeypot countermeasures cannot address.
108
+
109
+ Naming convention:
110
+ - Display name: "Net Ward" (operator-facing brand)
111
+ - Python package: `netward` (lowercase one-word import path)
112
+ - Architecture term: "mirror layer" / "deception mirror" (internal)
113
+ """
netward/__main__.py ADDED
@@ -0,0 +1,55 @@
1
+ """
2
+ Net Ward -- proxy daemon entrypoint
3
+
4
+ python -m netward --config /etc/netward/config.json
5
+
6
+ Loads operator config, seeds vendor patterns on first run, then
7
+ binds the capture loop and runs until interrupted.
8
+ Management commands (install-patterns, list-patterns, etc.) live in
9
+ netward/cli.py and are not part of this entrypoint.
10
+ """
11
+ import argparse
12
+ import asyncio
13
+ import sys
14
+
15
+ from netward.operator_layer import load_config, validate_storage_permissions
16
+ from netward.capture import start_capture_loop
17
+
18
+
19
+ def main() -> None:
20
+ parser = argparse.ArgumentParser(
21
+ prog="netward",
22
+ description="Net Ward passive deception proxy",
23
+ epilog="For pattern management: python netward/cli.py --help",
24
+ )
25
+ parser.add_argument(
26
+ "--config",
27
+ required=True,
28
+ metavar="PATH",
29
+ help="Operator config JSON (see example_config.json for a template)",
30
+ )
31
+ parser.add_argument(
32
+ "--allow-permissive-db",
33
+ action="store_true",
34
+ help="Allow startup even when the Net Ward DB path is world-writable.",
35
+ )
36
+ args = parser.parse_args()
37
+
38
+ try:
39
+ config = load_config(args.config)
40
+ validate_storage_permissions(
41
+ config,
42
+ allow_permissive_db=args.allow_permissive_db,
43
+ )
44
+ except Exception as exc:
45
+ print(f"Config error: {exc}", file=sys.stderr)
46
+ sys.exit(1)
47
+
48
+ try:
49
+ asyncio.run(start_capture_loop(config))
50
+ except KeyboardInterrupt:
51
+ pass # clean exit on Ctrl-C / SIGINT
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
netward/bootstrap.py ADDED
@@ -0,0 +1,89 @@
1
+ """
2
+ Net Ward -- Bootstrap
3
+
4
+ Seed storage with the built-in vendor pattern set on first run.
5
+ Called automatically by start_capture_loop before the proxy binds.
6
+
7
+ install_vendor_patterns(storage, force=False) -> tuple[int, int]
8
+ Reads netward/data/vendor_patterns.json, upserts MirrorResponses
9
+ then Patterns. Idempotent: second call is a no-op unless force=True.
10
+ Returns (patterns_installed, mirrors_installed).
11
+
12
+ Raises BootstrapError on malformed JSON — never touches storage in
13
+ that case, so storage is always left in a consistent state.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ _VENDOR_DATA_PATH = Path(__file__).parent / "data" / "vendor_patterns.json"
23
+
24
+
25
+ class BootstrapError(Exception):
26
+ pass
27
+
28
+
29
+ def install_vendor_patterns(
30
+ storage,
31
+ force: bool = False,
32
+ _data_path: Optional[Path] = None,
33
+ ) -> tuple[int, int]:
34
+ """
35
+ Seed vendor patterns + mirror responses into storage.
36
+
37
+ force=False -- no-op if any vendor-origin patterns already exist.
38
+ force=True -- re-upserts regardless of existing state.
39
+ _data_path -- override for tests; production always uses the package default.
40
+
41
+ Returns (patterns_installed, mirrors_installed).
42
+ """
43
+ # Idempotency guard
44
+ if not force:
45
+ try:
46
+ existing = storage.patterns_active()
47
+ if any(p.get("origin") == "vendor" for p in existing):
48
+ return (0, 0)
49
+ except Exception:
50
+ pass # storage errors fall through to a fresh install attempt
51
+
52
+ # Validate JSON before touching storage
53
+ data_path = _data_path or _VENDOR_DATA_PATH
54
+ try:
55
+ raw = Path(data_path).read_text(encoding="utf-8")
56
+ data = json.loads(raw)
57
+ except json.JSONDecodeError as exc:
58
+ raise BootstrapError(f"vendor_patterns.json is invalid JSON: {exc}") from exc
59
+ except OSError as exc:
60
+ raise BootstrapError(f"cannot read vendor_patterns.json: {exc}") from exc
61
+
62
+ mirrors = data.get("mirror_responses", [])
63
+ patterns = data.get("patterns", [])
64
+ if not isinstance(mirrors, list) or not isinstance(patterns, list):
65
+ raise BootstrapError(
66
+ "vendor_patterns.json must have 'mirror_responses' and 'patterns' arrays"
67
+ )
68
+
69
+ now = time.time()
70
+
71
+ # Upsert mirrors first — patterns reference them by id
72
+ mirrors_installed = 0
73
+ for mr in mirrors:
74
+ mr = dict(mr)
75
+ if not mr.get("created_at"):
76
+ mr["created_at"] = now
77
+ storage.mirror_response_upsert(mr)
78
+ mirrors_installed += 1
79
+
80
+ # Upsert patterns
81
+ patterns_installed = 0
82
+ for pat in patterns:
83
+ pat = dict(pat)
84
+ if not pat.get("created_at"):
85
+ pat["created_at"] = now
86
+ storage.patterns_upsert(pat)
87
+ patterns_installed += 1
88
+
89
+ return (patterns_installed, mirrors_installed)
netward/capture.py ADDED
@@ -0,0 +1,385 @@
1
+ """
2
+ Net Ward -- Capture Layer
3
+
4
+ aiohttp reverse-proxy entry point. All inbound HTTP traffic passes through
5
+ here: build a Probe, classify it, then route -- mirror response (probe/flood)
6
+ or pass-through to upstream (unknown/legit).
7
+
8
+ Design rule (from __init__.py): FAIL SAFE, FAIL OPEN.
9
+ If classification or mirroring raises, the request is forwarded to upstream
10
+ rather than dropped. Net Ward must never take the host application down.
11
+
12
+ Public interface:
13
+ start_capture_loop(config, storage=None) -> None
14
+ Bind to config["listen_address"], proxy to config["upstream_target"].
15
+ Runs until cancelled (asyncio.CancelledError).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import time
21
+ import uuid
22
+ from collections import deque
23
+ from typing import Optional
24
+
25
+ import aiohttp
26
+ from aiohttp import web
27
+
28
+ from netward.schema import OperatorConfig, Probe, RequestMetadata, Source
29
+ from netward import classify as _classify_mod
30
+ from netward import mirror as _mirror_mod
31
+
32
+ # Body capture cap per schema (RequestMetadata.body_snippet)
33
+ _BODY_SNIPPET_MAX = 4096
34
+
35
+ # Must stay in sync with classify.FLOOD_WINDOW_SECS (both are 10.0 s, v0.4.1)
36
+ _RATE_WINDOW_SECS: float = 10.0
37
+
38
+ # Per-source rate windows (source_id -> bounded deque of request timestamps).
39
+ # maxlen=2000: headroom above the 1000-hit flood threshold to avoid evicting
40
+ # in-window timestamps before they age out naturally.
41
+ # Dict is capped at _RATE_WINDOW_MAX_SOURCES; oldest quarter evicted when full.
42
+ _RATE_WINDOW_MAX_SOURCES = 10_000
43
+ _rate_windows: dict[str, deque] = {}
44
+
45
+
46
+ def _get_rate_window(source_id: str) -> deque:
47
+ if source_id not in _rate_windows:
48
+ if len(_rate_windows) >= _RATE_WINDOW_MAX_SOURCES:
49
+ evict_n = _RATE_WINDOW_MAX_SOURCES // 4
50
+ for k in list(_rate_windows.keys())[:evict_n]:
51
+ del _rate_windows[k]
52
+ _rate_windows[source_id] = deque(maxlen=2000)
53
+ return _rate_windows[source_id]
54
+
55
+ # Hop-by-hop headers stripped before forwarding upstream
56
+ _HOP_BY_HOP = frozenset({
57
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
58
+ "te", "trailers", "transfer-encoding", "upgrade",
59
+ })
60
+
61
+ _PATTERN_CACHE_TTL = 30.0
62
+
63
+ # Default mirror variant set (v0.4.1 fingerprint hardening).
64
+ # A single fixed JSON shape was detectable under load (stress-state signature).
65
+ # Variant selection is deterministic per (source_id, path) so a given source
66
+ # sees a consistent shape per path — prevents second-probe shape divergence.
67
+ _DEFAULT_MIRROR: dict = {
68
+ "id": "_netward_default",
69
+ "matches_pattern_id": "",
70
+ "intensity": "minimal",
71
+ "http_status": 200,
72
+ "headers": {"Content-Type": "application/json"},
73
+ "body_template": '{"status":"ok","id":"{{uuid}}","timestamp":"{{timestamp}}"}',
74
+ "body_template_vars": {"uuid": "uuid", "timestamp": "timestamp"},
75
+ "description": "Default minimal mirror (fallback; prefer _select_default_mirror)",
76
+ "created_at": 0.0,
77
+ }
78
+
79
+ _DEFAULT_MIRROR_VARIANTS: list[dict] = [
80
+ {
81
+ "id": "_netward_default_0",
82
+ "matches_pattern_id": "",
83
+ "intensity": "minimal",
84
+ "http_status": 200,
85
+ "headers": {"Content-Type": "application/json"},
86
+ "body_template": '{"status":"ok","id":"{{uuid}}","timestamp":"{{timestamp}}"}',
87
+ "body_template_vars": {"uuid": "uuid", "timestamp": "timestamp"},
88
+ "description": "Default variant 0 — JSON ok",
89
+ "created_at": 0.0,
90
+ },
91
+ {
92
+ "id": "_netward_default_1",
93
+ "matches_pattern_id": "",
94
+ "intensity": "minimal",
95
+ "http_status": 429,
96
+ "headers": {"Content-Type": "application/json", "Retry-After": "30"},
97
+ "body_template": '{"error":"rate limited","retry_after":30}',
98
+ "body_template_vars": {},
99
+ "description": "Default variant 1 — rate limited JSON",
100
+ "created_at": 0.0,
101
+ },
102
+ {
103
+ "id": "_netward_default_2",
104
+ "matches_pattern_id": "",
105
+ "intensity": "minimal",
106
+ "http_status": 503,
107
+ "headers": {"Content-Type": "text/html; charset=utf-8"},
108
+ "body_template": "<html><body><h1>Service Unavailable</h1><p>Try again later.</p></body></html>",
109
+ "body_template_vars": {},
110
+ "description": "Default variant 2 — HTML 503",
111
+ "created_at": 0.0,
112
+ },
113
+ {
114
+ "id": "_netward_default_3",
115
+ "matches_pattern_id": "",
116
+ "intensity": "minimal",
117
+ "http_status": 200,
118
+ "headers": {"Content-Type": "application/json"},
119
+ "body_template": '{"data":null,"next":null}',
120
+ "body_template_vars": {},
121
+ "description": "Default variant 3 — empty result JSON",
122
+ "created_at": 0.0,
123
+ },
124
+ {
125
+ "id": "_netward_default_4",
126
+ "matches_pattern_id": "",
127
+ "intensity": "minimal",
128
+ "http_status": 200,
129
+ "headers": {"Content-Type": "text/plain; charset=utf-8"},
130
+ "body_template": "OK",
131
+ "body_template_vars": {},
132
+ "description": "Default variant 4 — plain OK",
133
+ "created_at": 0.0,
134
+ },
135
+ ]
136
+
137
+
138
+ def _select_default_mirror(source_id: str, path: str) -> dict:
139
+ """Deterministic variant per (source_id, path) to prevent shape divergence."""
140
+ idx = abs(hash(source_id + path)) % len(_DEFAULT_MIRROR_VARIANTS)
141
+ return _DEFAULT_MIRROR_VARIANTS[idx]
142
+
143
+
144
+ async def _read_body_snippet(request: web.Request) -> Optional[str]:
145
+ try:
146
+ raw = await request.read()
147
+ if not raw:
148
+ return None
149
+ return raw[:_BODY_SNIPPET_MAX].decode("utf-8", errors="replace")
150
+ except Exception:
151
+ return None
152
+
153
+
154
+ async def _build_probe(request: web.Request, source_id: str) -> Probe:
155
+ body = await _read_body_snippet(request)
156
+ req_meta: RequestMetadata = {
157
+ "method": request.method,
158
+ "path": request.path,
159
+ "headers": dict(request.headers),
160
+ "query_string": request.query_string or None,
161
+ "body_snippet": body,
162
+ "body_size": len(body.encode("utf-8", errors="replace")) if body else 0,
163
+ "user_agent": request.headers.get("User-Agent"),
164
+ }
165
+ return {
166
+ "id": str(uuid.uuid4()),
167
+ "timestamp": time.time(),
168
+ "source_id": source_id,
169
+ "pattern_id": None,
170
+ "classification": "unknown",
171
+ "request": req_meta,
172
+ "response_id": None,
173
+ "mirror_fired": False,
174
+ "upstream_passed": False,
175
+ }
176
+
177
+
178
+ async def _forward_upstream(
179
+ request: web.Request,
180
+ upstream: str,
181
+ session: aiohttp.ClientSession,
182
+ ) -> Optional[web.Response]:
183
+ """Forward to upstream. Return None when upstream is unavailable."""
184
+ url = upstream.rstrip("/") + request.path
185
+ if request.query_string:
186
+ url += "?" + request.query_string
187
+ body = await request.read()
188
+ fwd_headers = {
189
+ k: v for k, v in request.headers.items()
190
+ if k.lower() not in _HOP_BY_HOP and k.lower() != "host"
191
+ }
192
+ try:
193
+ async with session.request(
194
+ method=request.method,
195
+ url=url,
196
+ headers=fwd_headers,
197
+ data=body,
198
+ allow_redirects=False,
199
+ ) as resp:
200
+ content = await resp.read()
201
+ resp_headers = {
202
+ k: v for k, v in resp.headers.items()
203
+ if k.lower() not in _HOP_BY_HOP | {"content-encoding"}
204
+ }
205
+ return web.Response(status=resp.status, headers=resp_headers, body=content)
206
+ except Exception:
207
+ return None
208
+
209
+
210
+ def _mirror_response_from_probe(probe: Probe, mirror: dict) -> web.Response:
211
+ http_resp = _mirror_mod.fire_mirror(probe, mirror)
212
+ return web.Response(
213
+ status=http_resp["status"],
214
+ headers=http_resp["headers"],
215
+ text=http_resp["body"],
216
+ )
217
+
218
+
219
+ def _fire_and_forget(storage, probe: Probe) -> None:
220
+ """Schedule probe logging without blocking the response path."""
221
+ try:
222
+ loop = asyncio.get_running_loop()
223
+ loop.create_task(asyncio.to_thread(storage.probes_log, probe))
224
+ except RuntimeError:
225
+ pass # no running loop (e.g., during sync tests)
226
+ except Exception:
227
+ pass
228
+
229
+
230
+ def _make_handler(config: OperatorConfig, storage, session: aiohttp.ClientSession):
231
+ upstream = config["upstream_target"]
232
+
233
+ # Per-handler pattern cache (closure) so each handler instance starts fresh
234
+ # and tests don't bleed stale cached patterns into each other.
235
+ _pcache: list = []
236
+ _pcache_at: float = 0.0
237
+
238
+ def _get_patterns() -> list:
239
+ nonlocal _pcache, _pcache_at
240
+ if time.time() - _pcache_at > _PATTERN_CACHE_TTL:
241
+ try:
242
+ _pcache = storage.patterns_active()
243
+ _pcache_at = time.time()
244
+ except Exception:
245
+ pass # fail-open: return stale cache rather than crashing request path
246
+ return _pcache
247
+
248
+ async def handle(request: web.Request) -> web.Response:
249
+ ip = request.remote or "0.0.0.0"
250
+ now = time.time()
251
+
252
+ # Source lookup or create
253
+ source: Source = storage.sources_lookup(ip) or {
254
+ "id": str(uuid.uuid4()),
255
+ "ip_address": ip,
256
+ "reputation": "neutral",
257
+ "first_seen": now,
258
+ "last_seen": now,
259
+ "probe_count": 0,
260
+ "legit_count": 0,
261
+ "notes": [],
262
+ }
263
+ source_id: str = source["id"]
264
+
265
+ # Rate window update (in-memory, bounded + evicting)
266
+ window = _get_rate_window(source_id)
267
+ window.append(now)
268
+ rate_window = list(window)
269
+
270
+ probe = await _build_probe(request, source_id)
271
+
272
+ # Classify -- fail-open on any unhandled exception
273
+ patterns = _get_patterns()
274
+ try:
275
+ result = _classify_mod.classify(
276
+ probe, {"patterns": patterns, "source": source, "rate_window": rate_window}
277
+ )
278
+ except Exception:
279
+ probe["classification"] = "unknown"
280
+ upstream_response = await _forward_upstream(request, upstream, session)
281
+ if upstream_response is None:
282
+ probe["mirror_fired"] = True
283
+ probe["upstream_passed"] = False
284
+ response = _mirror_response_from_probe(
285
+ probe, _select_default_mirror(source_id, request.path)
286
+ )
287
+ else:
288
+ probe["upstream_passed"] = True
289
+ response = upstream_response
290
+ _fire_and_forget(storage, probe)
291
+ return response
292
+
293
+ updated = result["probe"]
294
+ classification = updated.get("classification", "unknown")
295
+
296
+ # Source counters + reputation.
297
+ # Key on fire_mirror, not classification: a "flood"-classified request
298
+ # that didn't match a pattern still passes to upstream — that's legit
299
+ # traffic, not a probe. Only mirror-firing events are probe_count.
300
+ if result["fire_mirror"]:
301
+ source["probe_count"] = source.get("probe_count", 0) + 1
302
+ else:
303
+ source["legit_count"] = source.get("legit_count", 0) + 1
304
+ source["last_seen"] = now
305
+ source = _classify_mod.update_source_reputation(source)
306
+ try:
307
+ storage.sources_upsert(source)
308
+ except Exception:
309
+ pass # fail-open: stale source state is acceptable
310
+
311
+ # Route
312
+ _default_mr = _select_default_mirror(source_id, request.path)
313
+ if result["fire_mirror"]:
314
+ mr_id = result.get("mirror_response_id")
315
+ mr = (storage.mirror_response_lookup(mr_id) if mr_id else None) or _default_mr
316
+ try:
317
+ response = _mirror_response_from_probe(updated, mr)
318
+ except Exception:
319
+ # Mirror failed -- fail-open, pass upstream
320
+ upstream_response = await _forward_upstream(request, upstream, session)
321
+ if upstream_response is None:
322
+ updated["mirror_fired"] = True
323
+ updated["upstream_passed"] = False
324
+ response = _mirror_response_from_probe(updated, _default_mr)
325
+ else:
326
+ updated["mirror_fired"] = False
327
+ updated["upstream_passed"] = True
328
+ response = upstream_response
329
+ else:
330
+ upstream_response = await _forward_upstream(request, upstream, session)
331
+ if upstream_response is None:
332
+ updated["mirror_fired"] = True
333
+ updated["upstream_passed"] = False
334
+ response = _mirror_response_from_probe(updated, _default_mr)
335
+ else:
336
+ updated["upstream_passed"] = True
337
+ response = upstream_response
338
+
339
+ _fire_and_forget(storage, updated)
340
+ return response
341
+
342
+ return handle
343
+
344
+
345
+ async def start_capture_loop(config: OperatorConfig, storage=None) -> None:
346
+ """
347
+ Bind to config["listen_address"] and begin proxying to
348
+ config["upstream_target"]. Runs until cancelled.
349
+ Fail-open: crashing this process does not harm the upstream service.
350
+ """
351
+ if storage is None:
352
+ from netward.storage import Storage
353
+ storage = Storage(config.get("storage_path", "netward.db"))
354
+
355
+ try:
356
+ from netward import bootstrap as _bootstrap
357
+ _bootstrap.install_vendor_patterns(storage)
358
+ except Exception:
359
+ pass # fail-open: never block startup on seed failure
360
+
361
+ connector = aiohttp.TCPConnector()
362
+ session = aiohttp.ClientSession(connector=connector)
363
+ _proxy_handler = _make_handler(config, storage, session)
364
+
365
+ @web.middleware
366
+ async def catch_all(request: web.Request, handler) -> web.Response:
367
+ return await _proxy_handler(request)
368
+
369
+ app = web.Application(middlewares=[catch_all])
370
+
371
+ runner = web.AppRunner(app)
372
+ await runner.setup()
373
+
374
+ listen = config.get("listen_address", "0.0.0.0:8080")
375
+ host, _, port_str = listen.rpartition(":")
376
+ if not host:
377
+ host = "0.0.0.0"
378
+ site = web.TCPSite(runner, host, int(port_str))
379
+ await site.start()
380
+
381
+ try:
382
+ await asyncio.Event().wait()
383
+ finally:
384
+ await session.close()
385
+ await runner.cleanup()