pypproxy 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.
- pypproxy/__init__.py +0 -0
- pypproxy/api/__init__.py +0 -0
- pypproxy/api/server.py +427 -0
- pypproxy/bulk/__init__.py +0 -0
- pypproxy/bulk/sender.py +97 -0
- pypproxy/cert/__init__.py +0 -0
- pypproxy/cert/ca.py +144 -0
- pypproxy/cert/client_cert.py +65 -0
- pypproxy/codec.py +176 -0
- pypproxy/config/__init__.py +0 -0
- pypproxy/config/config.py +106 -0
- pypproxy/dns/__init__.py +0 -0
- pypproxy/dns/server.py +149 -0
- pypproxy/exporter/__init__.py +0 -0
- pypproxy/exporter/exporter.py +122 -0
- pypproxy/exporter/importer.py +169 -0
- pypproxy/graphql/__init__.py +0 -0
- pypproxy/graphql/detector.py +76 -0
- pypproxy/graphql/introspection.py +217 -0
- pypproxy/graphql/modifier.py +98 -0
- pypproxy/graphql/schema_store.py +33 -0
- pypproxy/intercept/__init__.py +0 -0
- pypproxy/intercept/manager.py +142 -0
- pypproxy/interceptor/__init__.py +0 -0
- pypproxy/interceptor/interceptor.py +172 -0
- pypproxy/proto/__init__.py +0 -0
- pypproxy/proto/grpc.py +48 -0
- pypproxy/proto/mqtt.py +119 -0
- pypproxy/proto/ws.py +120 -0
- pypproxy/proto/ws_intercept.py +117 -0
- pypproxy/proxy/__init__.py +0 -0
- pypproxy/proxy/proxy.py +407 -0
- pypproxy/replay/__init__.py +0 -0
- pypproxy/replay/replay.py +77 -0
- pypproxy/rule/__init__.py +0 -0
- pypproxy/rule/rule.py +198 -0
- pypproxy/scan/__init__.py +0 -0
- pypproxy/scan/scanner.py +296 -0
- pypproxy/script/__init__.py +0 -0
- pypproxy/script/engine.py +49 -0
- pypproxy/security/__init__.py +0 -0
- pypproxy/security/header_checker.py +308 -0
- pypproxy/security/int_overflow.py +193 -0
- pypproxy/security/jwt_checker.py +273 -0
- pypproxy/security/plugin.py +152 -0
- pypproxy/security/randomness.py +165 -0
- pypproxy/store/__init__.py +0 -0
- pypproxy/store/db.py +189 -0
- pypproxy/store/filter_parser.py +181 -0
- pypproxy/store/fts.py +105 -0
- pypproxy/store/models.py +81 -0
- pypproxy/store/scope.py +63 -0
- pypproxy/store/store.py +120 -0
- pypproxy/ui/__init__.py +0 -0
- pypproxy/ui/app.py +386 -0
- pypproxy/ui/bulk_sender_ui.py +125 -0
- pypproxy/ui/cui.py +162 -0
- pypproxy/ui/detail.py +179 -0
- pypproxy/ui/diff_view.py +118 -0
- pypproxy/ui/graphql_tab.py +265 -0
- pypproxy/ui/import_tab.py +136 -0
- pypproxy/ui/intercept_dialog.py +74 -0
- pypproxy/ui/resender.py +140 -0
- pypproxy/ui/scan_tab.py +98 -0
- pypproxy/ui/security_tab.py +356 -0
- pypproxy/ui/settings.py +413 -0
- pypproxy/ui/theme.py +59 -0
- pypproxy-0.1.0.dist-info/METADATA +19 -0
- pypproxy-0.1.0.dist-info/RECORD +72 -0
- pypproxy-0.1.0.dist-info/WHEEL +4 -0
- pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
- pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
pypproxy/__init__.py
ADDED
|
File without changes
|
pypproxy/api/__init__.py
ADDED
|
File without changes
|
pypproxy/api/server.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from fastapi.responses import JSONResponse, PlainTextResponse, Response
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from pypproxy.bulk.sender import BulkPayload, bulk_send, race_send
|
|
14
|
+
from pypproxy.exporter.exporter import export_all, export_har, import_rules
|
|
15
|
+
from pypproxy.exporter.importer import import_har, import_json
|
|
16
|
+
from pypproxy.replay.replay import ReplayOptions, replay_many
|
|
17
|
+
from pypproxy.rule.rule import Rule, RuleManager
|
|
18
|
+
from pypproxy.store.models import Filter
|
|
19
|
+
from pypproxy.store.scope import ScopeManager, ScopeRule
|
|
20
|
+
from pypproxy.store.store import Store
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
app = FastAPI(title="paxy API")
|
|
25
|
+
app.add_middleware(
|
|
26
|
+
CORSMiddleware,
|
|
27
|
+
allow_origins=["*"],
|
|
28
|
+
allow_methods=["*"],
|
|
29
|
+
allow_headers=["*"],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_store: Store | None = None
|
|
33
|
+
_rules: RuleManager | None = None
|
|
34
|
+
_scope: ScopeManager | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def init(store: Store, rules: RuleManager, scope: ScopeManager | None = None) -> None:
|
|
38
|
+
global _store, _rules, _scope
|
|
39
|
+
_store = store
|
|
40
|
+
_rules = rules
|
|
41
|
+
_scope = scope
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register_routes(target_app: FastAPI) -> None:
|
|
45
|
+
"""Include all API routes into another FastAPI/NiceGUI app (for GUI mode)."""
|
|
46
|
+
target_app.include_router(app.router)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- traffic ---
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.get("/api/traffic")
|
|
53
|
+
async def list_traffic(
|
|
54
|
+
offset: int = 0,
|
|
55
|
+
limit: int = 100,
|
|
56
|
+
method: str = "",
|
|
57
|
+
host: str = "",
|
|
58
|
+
search: str = "",
|
|
59
|
+
protocol: str = "",
|
|
60
|
+
) -> JSONResponse:
|
|
61
|
+
assert _store is not None
|
|
62
|
+
f = Filter(method=method, host=host, search=search, protocol=protocol)
|
|
63
|
+
entries, total = _store.list(f, offset, limit)
|
|
64
|
+
return JSONResponse(
|
|
65
|
+
{
|
|
66
|
+
"entries": [e.to_dict() for e in entries],
|
|
67
|
+
"total": total,
|
|
68
|
+
"offset": offset,
|
|
69
|
+
"limit": limit,
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.get("/api/traffic/{entry_id}")
|
|
75
|
+
async def get_traffic(entry_id: int) -> JSONResponse:
|
|
76
|
+
assert _store is not None
|
|
77
|
+
entry = _store.get(entry_id)
|
|
78
|
+
if entry is None:
|
|
79
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
80
|
+
return JSONResponse(entry.to_dict())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- rules ---
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.get("/api/rules")
|
|
87
|
+
async def list_rules() -> JSONResponse:
|
|
88
|
+
assert _rules is not None
|
|
89
|
+
return JSONResponse([r.to_dict() for r in _rules.list()])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.post("/api/rules")
|
|
93
|
+
async def create_rule(data: dict) -> JSONResponse:
|
|
94
|
+
assert _rules is not None
|
|
95
|
+
rule = Rule.from_dict(data)
|
|
96
|
+
_rules.add(rule)
|
|
97
|
+
return JSONResponse(rule.to_dict())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.put("/api/rules/{rule_id}")
|
|
101
|
+
async def update_rule(rule_id: int, data: dict) -> JSONResponse:
|
|
102
|
+
assert _rules is not None
|
|
103
|
+
data["id"] = rule_id
|
|
104
|
+
rule = Rule.from_dict(data)
|
|
105
|
+
_rules.update(rule)
|
|
106
|
+
return JSONResponse(rule.to_dict())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.delete("/api/rules/{rule_id}")
|
|
110
|
+
async def delete_rule(rule_id: int) -> Response:
|
|
111
|
+
assert _rules is not None
|
|
112
|
+
_rules.delete(rule_id)
|
|
113
|
+
return Response(status_code=204)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- replay ---
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ReplayRequest(BaseModel):
|
|
120
|
+
entry_id: int
|
|
121
|
+
options: dict = {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.post("/api/replay")
|
|
125
|
+
async def replay(req: ReplayRequest) -> JSONResponse:
|
|
126
|
+
assert _store is not None
|
|
127
|
+
entry = _store.get(req.entry_id)
|
|
128
|
+
if entry is None:
|
|
129
|
+
raise HTTPException(status_code=404, detail="entry not found")
|
|
130
|
+
opts = ReplayOptions(
|
|
131
|
+
override_host=req.options.get("override_host", ""),
|
|
132
|
+
extra_headers=req.options.get("extra_headers", {}),
|
|
133
|
+
timeout_seconds=req.options.get("timeout_seconds", 30),
|
|
134
|
+
count=req.options.get("count", 1),
|
|
135
|
+
)
|
|
136
|
+
results = await replay_many(entry, opts)
|
|
137
|
+
return JSONResponse([r.to_dict() for r in results])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- bulk sender ---
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class BulkRequest(BaseModel):
|
|
144
|
+
entry_id: int
|
|
145
|
+
payloads: list[dict] = []
|
|
146
|
+
count: int = 10
|
|
147
|
+
concurrency: int = 10
|
|
148
|
+
mode: str = "payloads" # "payloads" or "race"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.post("/api/bulk")
|
|
152
|
+
async def bulk(req: BulkRequest) -> JSONResponse:
|
|
153
|
+
assert _store is not None
|
|
154
|
+
entry = _store.get(req.entry_id)
|
|
155
|
+
if entry is None:
|
|
156
|
+
raise HTTPException(status_code=404, detail="entry not found")
|
|
157
|
+
if req.mode == "race":
|
|
158
|
+
results = await race_send(entry, count=req.count)
|
|
159
|
+
else:
|
|
160
|
+
payloads = [
|
|
161
|
+
BulkPayload(
|
|
162
|
+
label=p.get("label", f"payload-{i}"),
|
|
163
|
+
override_body=p.get("body", "").encode() if p.get("body") else b"",
|
|
164
|
+
override_headers=p.get("headers", {}),
|
|
165
|
+
override_path=p.get("path", ""),
|
|
166
|
+
)
|
|
167
|
+
for i, p in enumerate(req.payloads)
|
|
168
|
+
]
|
|
169
|
+
results = await bulk_send(entry, payloads, concurrency=req.concurrency)
|
|
170
|
+
return JSONResponse([r.to_dict() for r in results])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- export / import ---
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.get("/api/export/json")
|
|
177
|
+
async def export_json() -> PlainTextResponse:
|
|
178
|
+
assert _store is not None and _rules is not None
|
|
179
|
+
entries, _ = _store.list(Filter(), 0, 0)
|
|
180
|
+
return PlainTextResponse(export_all(entries, _rules), media_type="application/json")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.get("/api/export/har")
|
|
184
|
+
async def export_har_endpoint() -> PlainTextResponse:
|
|
185
|
+
assert _store is not None
|
|
186
|
+
entries, _ = _store.list(Filter(), 0, 0)
|
|
187
|
+
return PlainTextResponse(export_har(entries), media_type="application/json")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@app.post("/api/import/rules")
|
|
191
|
+
async def import_rules_endpoint(data: dict) -> JSONResponse:
|
|
192
|
+
assert _rules is not None
|
|
193
|
+
import json as _json
|
|
194
|
+
|
|
195
|
+
count = import_rules(_json.dumps(data.get("rules", data)), _rules)
|
|
196
|
+
return JSONResponse({"imported": count})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.post("/api/import/har")
|
|
200
|
+
async def import_har_endpoint(data: dict) -> JSONResponse:
|
|
201
|
+
assert _store is not None
|
|
202
|
+
import json as _json
|
|
203
|
+
|
|
204
|
+
count = import_har(_json.dumps(data), _store)
|
|
205
|
+
return JSONResponse({"imported": count})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.post("/api/import/json")
|
|
209
|
+
async def import_json_endpoint(data: dict) -> JSONResponse:
|
|
210
|
+
assert _store is not None
|
|
211
|
+
import json as _json
|
|
212
|
+
|
|
213
|
+
count = import_json(_json.dumps(data.get("entries", data)), _store)
|
|
214
|
+
return JSONResponse({"imported": count})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --- full-text search ---
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.get("/api/search")
|
|
221
|
+
async def fts_search(q: str = "", limit: int = 50) -> JSONResponse:
|
|
222
|
+
assert _store is not None
|
|
223
|
+
if not q:
|
|
224
|
+
return JSONResponse([])
|
|
225
|
+
db = getattr(_store, "_db", None)
|
|
226
|
+
if db is None:
|
|
227
|
+
# fallback: in-memory filter
|
|
228
|
+
f = Filter(search=q)
|
|
229
|
+
entries, _ = _store.list(f, 0, limit)
|
|
230
|
+
return JSONResponse(
|
|
231
|
+
[{"entry_id": e.id, "rank": 0.0, "snippet": e.host + e.path} for e in entries]
|
|
232
|
+
)
|
|
233
|
+
results = await db.search(q, limit)
|
|
234
|
+
return JSONResponse([r.to_dict() for r in results])
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --- scope ---
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.get("/api/scope")
|
|
241
|
+
async def list_scope() -> JSONResponse:
|
|
242
|
+
if _scope is None:
|
|
243
|
+
return JSONResponse({"enabled": False, "rules": []})
|
|
244
|
+
return JSONResponse(
|
|
245
|
+
{
|
|
246
|
+
"enabled": _scope.enabled,
|
|
247
|
+
"rules": [r.to_dict() for r in _scope.list()],
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.post("/api/scope")
|
|
253
|
+
async def update_scope(data: dict) -> JSONResponse:
|
|
254
|
+
if _scope is None:
|
|
255
|
+
return JSONResponse({"error": "scope not initialized"}, status_code=503)
|
|
256
|
+
if "enabled" in data:
|
|
257
|
+
_scope.set_enabled(bool(data["enabled"]))
|
|
258
|
+
if "add" in data:
|
|
259
|
+
_scope.add(
|
|
260
|
+
ScopeRule(
|
|
261
|
+
pattern=data["add"].get("pattern", ""),
|
|
262
|
+
mode=data["add"].get("mode", "glob"),
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
if "remove" in data:
|
|
266
|
+
_scope.remove(data["remove"])
|
|
267
|
+
return JSONResponse({"enabled": _scope.enabled, "rules": [r.to_dict() for r in _scope.list()]})
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --- active scan ---
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class ScanRequest(BaseModel):
|
|
274
|
+
entry_id: int
|
|
275
|
+
categories: list[str] = []
|
|
276
|
+
concurrency: int = 5
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.post("/api/scan")
|
|
280
|
+
async def active_scan(req: ScanRequest) -> JSONResponse:
|
|
281
|
+
assert _store is not None
|
|
282
|
+
from pypproxy.scan.scanner import run_scan
|
|
283
|
+
|
|
284
|
+
entry = _store.get(req.entry_id)
|
|
285
|
+
if entry is None:
|
|
286
|
+
raise HTTPException(status_code=404, detail="entry not found")
|
|
287
|
+
cats = req.categories or None
|
|
288
|
+
results = await run_scan(entry, categories=cats, concurrency=req.concurrency)
|
|
289
|
+
return JSONResponse([r.to_dict() for r in results])
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --- GraphQL ---
|
|
293
|
+
|
|
294
|
+
_gql_schema_store = None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def init_graphql() -> None:
|
|
298
|
+
global _gql_schema_store
|
|
299
|
+
from pypproxy.graphql.schema_store import SchemaStore
|
|
300
|
+
|
|
301
|
+
_gql_schema_store = SchemaStore()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class IntrospectRequest(BaseModel):
|
|
305
|
+
url: str
|
|
306
|
+
headers: dict[str, str] = {}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.post("/api/graphql/introspect")
|
|
310
|
+
async def graphql_introspect(req: IntrospectRequest) -> JSONResponse:
|
|
311
|
+
from pypproxy.graphql.introspection import fetch_schema
|
|
312
|
+
|
|
313
|
+
schema = await fetch_schema(req.url, req.headers)
|
|
314
|
+
if schema is None:
|
|
315
|
+
raise HTTPException(status_code=502, detail="Introspection failed or not supported")
|
|
316
|
+
if _gql_schema_store is not None:
|
|
317
|
+
from urllib.parse import urlparse
|
|
318
|
+
|
|
319
|
+
host = urlparse(req.url).netloc
|
|
320
|
+
_gql_schema_store.set(host, schema)
|
|
321
|
+
return JSONResponse(schema.to_dict())
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.get("/api/graphql/schemas")
|
|
325
|
+
async def graphql_list_schemas() -> JSONResponse:
|
|
326
|
+
if _gql_schema_store is None:
|
|
327
|
+
return JSONResponse([])
|
|
328
|
+
return JSONResponse(
|
|
329
|
+
[
|
|
330
|
+
{"host": host, "query_type": s.query_type, "mutation_type": s.mutation_type}
|
|
331
|
+
for host, s in _gql_schema_store.all().items()
|
|
332
|
+
]
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@app.get("/api/graphql/schema/{host}")
|
|
337
|
+
async def graphql_get_schema(host: str) -> JSONResponse:
|
|
338
|
+
if _gql_schema_store is None:
|
|
339
|
+
raise HTTPException(status_code=404, detail="no schema store")
|
|
340
|
+
schema = _gql_schema_store.get(host)
|
|
341
|
+
if schema is None:
|
|
342
|
+
raise HTTPException(status_code=404, detail=f"no schema for {host}")
|
|
343
|
+
return JSONResponse(schema.to_dict())
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.delete("/api/graphql/schema/{host}")
|
|
347
|
+
async def graphql_delete_schema(host: str) -> Response:
|
|
348
|
+
if _gql_schema_store is not None:
|
|
349
|
+
_gql_schema_store.delete(host)
|
|
350
|
+
return Response(status_code=204)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class GQLReplayRequest(BaseModel):
|
|
354
|
+
entry_id: int
|
|
355
|
+
query: str = ""
|
|
356
|
+
variables: dict = {}
|
|
357
|
+
operation_name: str = ""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@app.post("/api/graphql/replay")
|
|
361
|
+
async def graphql_replay(req: GQLReplayRequest) -> JSONResponse:
|
|
362
|
+
assert _store is not None
|
|
363
|
+
import json as _json
|
|
364
|
+
import time
|
|
365
|
+
|
|
366
|
+
entry = _store.get(req.entry_id)
|
|
367
|
+
if entry is None:
|
|
368
|
+
raise HTTPException(status_code=404, detail="entry not found")
|
|
369
|
+
|
|
370
|
+
url = f"{entry.scheme}://{entry.host}{entry.path}"
|
|
371
|
+
body_dict: dict = {}
|
|
372
|
+
if entry.req_body:
|
|
373
|
+
with contextlib.suppress(Exception):
|
|
374
|
+
body_dict = _json.loads(entry.req_body)
|
|
375
|
+
|
|
376
|
+
if req.query:
|
|
377
|
+
body_dict["query"] = req.query
|
|
378
|
+
if req.variables:
|
|
379
|
+
body_dict["variables"] = req.variables
|
|
380
|
+
if req.operation_name:
|
|
381
|
+
body_dict["operationName"] = req.operation_name
|
|
382
|
+
|
|
383
|
+
req_headers = {k: ", ".join(v) for k, v in entry.req_headers.items()}
|
|
384
|
+
start = time.monotonic()
|
|
385
|
+
try:
|
|
386
|
+
async with httpx.AsyncClient(verify=False, timeout=30, http2=True) as client:
|
|
387
|
+
resp = await client.post(url, json=body_dict, headers=req_headers)
|
|
388
|
+
dur = int((time.monotonic() - start) * 1000)
|
|
389
|
+
return JSONResponse(
|
|
390
|
+
{
|
|
391
|
+
"status_code": resp.status_code,
|
|
392
|
+
"duration_ms": dur,
|
|
393
|
+
"body": resp.json()
|
|
394
|
+
if "json" in resp.headers.get("content-type", "")
|
|
395
|
+
else resp.text,
|
|
396
|
+
}
|
|
397
|
+
)
|
|
398
|
+
except Exception as e:
|
|
399
|
+
return JSONResponse({"error": str(e)}, status_code=502)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# --- clear ---
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@app.post("/api/clear")
|
|
406
|
+
async def clear() -> Response:
|
|
407
|
+
assert _store is not None
|
|
408
|
+
_store.clear()
|
|
409
|
+
return Response(status_code=204)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# --- websocket ---
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@app.websocket("/ws")
|
|
416
|
+
async def ws_endpoint(websocket: WebSocket) -> None:
|
|
417
|
+
assert _store is not None
|
|
418
|
+
await websocket.accept()
|
|
419
|
+
q = _store.subscribe()
|
|
420
|
+
try:
|
|
421
|
+
while True:
|
|
422
|
+
entry = await q.get()
|
|
423
|
+
await websocket.send_text(json.dumps(entry.to_dict()))
|
|
424
|
+
except WebSocketDisconnect:
|
|
425
|
+
pass
|
|
426
|
+
finally:
|
|
427
|
+
_store.unsubscribe(q)
|
|
File without changes
|
pypproxy/bulk/sender.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from pypproxy.store.models import Entry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class BulkPayload:
|
|
15
|
+
label: str = ""
|
|
16
|
+
override_body: bytes = b""
|
|
17
|
+
override_headers: dict[str, str] = field(default_factory=dict)
|
|
18
|
+
override_path: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class BulkResult:
|
|
23
|
+
label: str
|
|
24
|
+
status_code: int = 0
|
|
25
|
+
body: bytes = b""
|
|
26
|
+
duration_ms: int = 0
|
|
27
|
+
error: str = ""
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, Any]:
|
|
30
|
+
import base64
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"label": self.label,
|
|
34
|
+
"status_code": self.status_code,
|
|
35
|
+
"body": base64.b64encode(self.body).decode() if self.body else "",
|
|
36
|
+
"duration_ms": self.duration_ms,
|
|
37
|
+
"error": self.error,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def bulk_send(
|
|
42
|
+
entry: Entry,
|
|
43
|
+
payloads: list[BulkPayload],
|
|
44
|
+
timeout: int = 30,
|
|
45
|
+
concurrency: int = 10,
|
|
46
|
+
) -> list[BulkResult]:
|
|
47
|
+
"""Send multiple variants of the same request concurrently."""
|
|
48
|
+
sem = asyncio.Semaphore(concurrency)
|
|
49
|
+
|
|
50
|
+
async def _one(payload: BulkPayload) -> BulkResult:
|
|
51
|
+
async with sem:
|
|
52
|
+
return await _send(entry, payload, timeout)
|
|
53
|
+
|
|
54
|
+
return await asyncio.gather(*[_one(p) for p in payloads])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _send(entry: Entry, payload: BulkPayload, timeout: int) -> BulkResult:
|
|
58
|
+
path = payload.override_path or entry.path
|
|
59
|
+
url = f"{entry.scheme}://{entry.host}{path}"
|
|
60
|
+
if entry.query:
|
|
61
|
+
url += f"?{entry.query}"
|
|
62
|
+
|
|
63
|
+
headers = {k: ", ".join(v) for k, v in entry.req_headers.items()}
|
|
64
|
+
headers.update(payload.override_headers)
|
|
65
|
+
body = payload.override_body if payload.override_body else entry.req_body
|
|
66
|
+
|
|
67
|
+
start = time.monotonic()
|
|
68
|
+
try:
|
|
69
|
+
async with httpx.AsyncClient(verify=False, timeout=timeout, http2=True) as client:
|
|
70
|
+
resp = await client.request(
|
|
71
|
+
method=entry.method,
|
|
72
|
+
url=url,
|
|
73
|
+
headers=headers,
|
|
74
|
+
content=body,
|
|
75
|
+
)
|
|
76
|
+
return BulkResult(
|
|
77
|
+
label=payload.label,
|
|
78
|
+
status_code=resp.status_code,
|
|
79
|
+
body=resp.content,
|
|
80
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
81
|
+
)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
return BulkResult(
|
|
84
|
+
label=payload.label,
|
|
85
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
86
|
+
error=str(e),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def race_send(
|
|
91
|
+
entry: Entry,
|
|
92
|
+
count: int = 10,
|
|
93
|
+
timeout: int = 30,
|
|
94
|
+
) -> list[BulkResult]:
|
|
95
|
+
"""Send the same request `count` times simultaneously (race condition test)."""
|
|
96
|
+
payloads = [BulkPayload(label=f"race-{i}") for i in range(count)]
|
|
97
|
+
return await bulk_send(entry, payloads, timeout=timeout, concurrency=count)
|
|
File without changes
|
pypproxy/cert/ca.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import ssl
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cryptography import x509
|
|
11
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
13
|
+
from cryptography.x509.oid import NameOID
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CA:
|
|
17
|
+
def __init__(self, cert: x509.Certificate, key: rsa.RSAPrivateKey) -> None:
|
|
18
|
+
self._cert = cert
|
|
19
|
+
self._key = key
|
|
20
|
+
self._cache: dict[str, ssl.SSLContext] = {}
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def load_or_create(cls, cert_path: str, key_path: str) -> CA:
|
|
25
|
+
cp, kp = Path(cert_path).resolve(), Path(key_path).resolve()
|
|
26
|
+
if cp.exists() and kp.exists():
|
|
27
|
+
return cls._load(cp, kp)
|
|
28
|
+
return cls._generate(cp, kp)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def _load(cls, cert_path: Path, key_path: Path) -> CA:
|
|
32
|
+
cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
|
|
33
|
+
key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
|
34
|
+
return cls(cert, key) # type: ignore[arg-type]
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def _generate(cls, cert_path: Path, key_path: Path) -> CA:
|
|
38
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
39
|
+
now = datetime.now(UTC)
|
|
40
|
+
cert = (
|
|
41
|
+
x509.CertificateBuilder()
|
|
42
|
+
.subject_name(
|
|
43
|
+
x509.Name(
|
|
44
|
+
[
|
|
45
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "paxy CA"),
|
|
46
|
+
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "paxy"),
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
.issuer_name(
|
|
51
|
+
x509.Name(
|
|
52
|
+
[
|
|
53
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "paxy CA"),
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
.public_key(key.public_key())
|
|
58
|
+
.serial_number(x509.random_serial_number())
|
|
59
|
+
.not_valid_before(now - timedelta(hours=1))
|
|
60
|
+
.not_valid_after(now + timedelta(days=3650))
|
|
61
|
+
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
|
62
|
+
.add_extension(
|
|
63
|
+
x509.KeyUsage(
|
|
64
|
+
digital_signature=False,
|
|
65
|
+
content_commitment=False,
|
|
66
|
+
key_encipherment=False,
|
|
67
|
+
data_encipherment=False,
|
|
68
|
+
key_agreement=False,
|
|
69
|
+
key_cert_sign=True,
|
|
70
|
+
crl_sign=True,
|
|
71
|
+
encipher_only=False,
|
|
72
|
+
decipher_only=False,
|
|
73
|
+
),
|
|
74
|
+
critical=True,
|
|
75
|
+
)
|
|
76
|
+
.sign(key, hashes.SHA256())
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
cert_path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
81
|
+
key_path.write_bytes(
|
|
82
|
+
key.private_bytes(
|
|
83
|
+
serialization.Encoding.PEM,
|
|
84
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
85
|
+
serialization.NoEncryption(),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return cls(cert, key)
|
|
89
|
+
|
|
90
|
+
def cert_pem(self) -> bytes:
|
|
91
|
+
return self._cert.public_bytes(serialization.Encoding.PEM)
|
|
92
|
+
|
|
93
|
+
def ssl_context_for(self, hostname: str) -> ssl.SSLContext:
|
|
94
|
+
with self._lock:
|
|
95
|
+
if hostname in self._cache:
|
|
96
|
+
return self._cache[hostname]
|
|
97
|
+
ctx = self._make_context(hostname)
|
|
98
|
+
self._cache[hostname] = ctx
|
|
99
|
+
return ctx
|
|
100
|
+
|
|
101
|
+
def _make_context(self, hostname: str) -> ssl.SSLContext:
|
|
102
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
103
|
+
now = datetime.now(UTC)
|
|
104
|
+
|
|
105
|
+
san: list[x509.GeneralName]
|
|
106
|
+
try:
|
|
107
|
+
san = [x509.IPAddress(ipaddress.ip_address(hostname))]
|
|
108
|
+
except ValueError:
|
|
109
|
+
san = [x509.DNSName(hostname)]
|
|
110
|
+
|
|
111
|
+
cert = (
|
|
112
|
+
x509.CertificateBuilder()
|
|
113
|
+
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
|
|
114
|
+
.issuer_name(self._cert.subject)
|
|
115
|
+
.public_key(key.public_key())
|
|
116
|
+
.serial_number(x509.random_serial_number())
|
|
117
|
+
.not_valid_before(now - timedelta(hours=1))
|
|
118
|
+
.not_valid_after(now + timedelta(days=1))
|
|
119
|
+
.add_extension(x509.SubjectAlternativeName(san), critical=False)
|
|
120
|
+
.add_extension(
|
|
121
|
+
x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),
|
|
122
|
+
critical=False,
|
|
123
|
+
)
|
|
124
|
+
.sign(self._key, hashes.SHA256())
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
|
128
|
+
key_pem = key.private_bytes(
|
|
129
|
+
serialization.Encoding.PEM,
|
|
130
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
131
|
+
serialization.NoEncryption(),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Write to temp files because ssl.SSLContext requires file paths.
|
|
135
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf:
|
|
136
|
+
cf.write(cert_pem)
|
|
137
|
+
cert_file = cf.name
|
|
138
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as kf:
|
|
139
|
+
kf.write(key_pem)
|
|
140
|
+
key_file = kf.name
|
|
141
|
+
|
|
142
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
143
|
+
ctx.load_cert_chain(cert_file, key_file)
|
|
144
|
+
return ctx
|