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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WSFrame:
|
|
12
|
+
direction: str # client | server
|
|
13
|
+
opcode: int
|
|
14
|
+
payload: bytes
|
|
15
|
+
entry_id: int
|
|
16
|
+
|
|
17
|
+
def text(self) -> str:
|
|
18
|
+
if self.opcode == 1:
|
|
19
|
+
return self.payload.decode("utf-8", errors="replace")
|
|
20
|
+
return f"<binary {len(self.payload)} bytes>"
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
import base64
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
"direction": self.direction,
|
|
27
|
+
"opcode": self.opcode,
|
|
28
|
+
"payload": base64.b64encode(self.payload).decode() if self.payload else "",
|
|
29
|
+
"text": self.text(),
|
|
30
|
+
"entry_id": self.entry_id,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WSInterceptManager:
|
|
35
|
+
"""
|
|
36
|
+
Intercepts WebSocket frames for manual review, similar to HTTP intercept.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._enabled = False
|
|
41
|
+
self._pending: dict[int, tuple[WSFrame, asyncio.Event, list[bytes]]] = {}
|
|
42
|
+
self._counter = 0
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
self._subscribers: list[asyncio.Queue] = []
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def enabled(self) -> bool:
|
|
48
|
+
return self._enabled
|
|
49
|
+
|
|
50
|
+
def set_enabled(self, value: bool) -> None:
|
|
51
|
+
self._enabled = value
|
|
52
|
+
if not value:
|
|
53
|
+
# release all pending frames
|
|
54
|
+
for frame_id in list(self._pending):
|
|
55
|
+
_, event, _ = self._pending[frame_id]
|
|
56
|
+
event.set()
|
|
57
|
+
|
|
58
|
+
async def intercept(self, frame: WSFrame) -> bytes:
|
|
59
|
+
"""
|
|
60
|
+
Pause the frame for manual review.
|
|
61
|
+
Returns the (possibly edited) payload.
|
|
62
|
+
"""
|
|
63
|
+
if not self._enabled:
|
|
64
|
+
return frame.payload
|
|
65
|
+
|
|
66
|
+
async with self._lock:
|
|
67
|
+
self._counter += 1
|
|
68
|
+
frame_id = self._counter
|
|
69
|
+
event = asyncio.Event()
|
|
70
|
+
result: list[bytes] = [frame.payload]
|
|
71
|
+
self._pending[frame_id] = (frame, event, result)
|
|
72
|
+
|
|
73
|
+
self._notify(frame_id, frame)
|
|
74
|
+
|
|
75
|
+
await event.wait()
|
|
76
|
+
|
|
77
|
+
async with self._lock:
|
|
78
|
+
_, _, result = self._pending.pop(frame_id, (None, None, [frame.payload]))
|
|
79
|
+
|
|
80
|
+
return result[0]
|
|
81
|
+
|
|
82
|
+
def forward(self, frame_id: int, payload: bytes | None = None) -> None:
|
|
83
|
+
entry = self._pending.get(frame_id)
|
|
84
|
+
if entry:
|
|
85
|
+
frame, event, result = entry
|
|
86
|
+
if payload is not None:
|
|
87
|
+
result[0] = payload
|
|
88
|
+
event.set()
|
|
89
|
+
|
|
90
|
+
def drop(self, frame_id: int) -> None:
|
|
91
|
+
entry = self._pending.get(frame_id)
|
|
92
|
+
if entry:
|
|
93
|
+
frame, event, result = entry
|
|
94
|
+
result[0] = b"" # empty = drop
|
|
95
|
+
event.set()
|
|
96
|
+
|
|
97
|
+
def list_pending(self) -> list[dict]:
|
|
98
|
+
return [{"id": fid, **frame.to_dict()} for fid, (frame, _, _) in self._pending.items()]
|
|
99
|
+
|
|
100
|
+
def subscribe(self) -> asyncio.Queue:
|
|
101
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=128)
|
|
102
|
+
self._subscribers.append(q)
|
|
103
|
+
return q
|
|
104
|
+
|
|
105
|
+
def unsubscribe(self, q: asyncio.Queue) -> None:
|
|
106
|
+
import contextlib
|
|
107
|
+
|
|
108
|
+
with contextlib.suppress(ValueError):
|
|
109
|
+
self._subscribers.remove(q)
|
|
110
|
+
|
|
111
|
+
def _notify(self, frame_id: int, frame: WSFrame) -> None:
|
|
112
|
+
import contextlib
|
|
113
|
+
|
|
114
|
+
data = {"id": frame_id, **frame.to_dict()}
|
|
115
|
+
for q in self._subscribers:
|
|
116
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
117
|
+
q.put_nowait(data)
|
|
File without changes
|
pypproxy/proxy/proxy.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from pypproxy.cert.ca import CA
|
|
10
|
+
from pypproxy.intercept.manager import InterceptManager
|
|
11
|
+
from pypproxy.interceptor.interceptor import Interceptor
|
|
12
|
+
from pypproxy.proto import grpc as grpc_proto
|
|
13
|
+
from pypproxy.proto import ws as ws_proto
|
|
14
|
+
from pypproxy.script.engine import ScriptEngine
|
|
15
|
+
from pypproxy.store.store import Store
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
HTTP_200_CONNECT = b"HTTP/1.1 200 Connection Established\r\n\r\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Proxy:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
ca: CA,
|
|
26
|
+
interceptor: Interceptor,
|
|
27
|
+
store: Store,
|
|
28
|
+
script: ScriptEngine | None = None,
|
|
29
|
+
ignore: set[str] | None = None,
|
|
30
|
+
intercept_manager: InterceptManager | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._ca = ca
|
|
33
|
+
self._interceptor = interceptor
|
|
34
|
+
self._store = store
|
|
35
|
+
self._script = script
|
|
36
|
+
self._ignore = ignore or set()
|
|
37
|
+
self._intercept = intercept_manager
|
|
38
|
+
|
|
39
|
+
async def handle(
|
|
40
|
+
self,
|
|
41
|
+
reader: asyncio.StreamReader,
|
|
42
|
+
writer: asyncio.StreamWriter,
|
|
43
|
+
) -> None:
|
|
44
|
+
try:
|
|
45
|
+
request_line = await reader.readline()
|
|
46
|
+
if not request_line:
|
|
47
|
+
return
|
|
48
|
+
headers_raw = await _read_headers(reader)
|
|
49
|
+
headers = _parse_headers(headers_raw)
|
|
50
|
+
|
|
51
|
+
parts = request_line.decode(errors="replace").split()
|
|
52
|
+
if len(parts) < 3:
|
|
53
|
+
return
|
|
54
|
+
method, target, _ = parts[0], parts[1], parts[2]
|
|
55
|
+
|
|
56
|
+
if method == "CONNECT":
|
|
57
|
+
await self._handle_connect(reader, writer, target, headers)
|
|
58
|
+
else:
|
|
59
|
+
await self._handle_http(reader, writer, method, target, headers, scheme="http")
|
|
60
|
+
except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
|
|
61
|
+
pass
|
|
62
|
+
finally:
|
|
63
|
+
writer.close()
|
|
64
|
+
|
|
65
|
+
async def _handle_connect(
|
|
66
|
+
self,
|
|
67
|
+
reader: asyncio.StreamReader,
|
|
68
|
+
writer: asyncio.StreamWriter,
|
|
69
|
+
target: str,
|
|
70
|
+
headers: dict,
|
|
71
|
+
) -> None:
|
|
72
|
+
host = target.split(":")[0]
|
|
73
|
+
port = int(target.split(":")[1]) if ":" in target else 443
|
|
74
|
+
|
|
75
|
+
if host in self._ignore:
|
|
76
|
+
await self._tunnel(reader, writer, host, port)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
writer.write(HTTP_200_CONNECT)
|
|
80
|
+
await writer.drain()
|
|
81
|
+
|
|
82
|
+
ssl_ctx = self._ca.ssl_context_for(host)
|
|
83
|
+
try:
|
|
84
|
+
tls_reader, tls_writer = await asyncio.wait_for(
|
|
85
|
+
self._upgrade_server_tls(reader, writer, ssl_ctx), timeout=10
|
|
86
|
+
)
|
|
87
|
+
except (TimeoutError, ssl.SSLError, OSError) as e:
|
|
88
|
+
logger.debug("TLS handshake failed for %s: %s", host, e)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
await self._serve_decrypted(tls_reader, tls_writer, host, port)
|
|
93
|
+
finally:
|
|
94
|
+
tls_writer.close()
|
|
95
|
+
|
|
96
|
+
async def _upgrade_server_tls(
|
|
97
|
+
self,
|
|
98
|
+
reader: asyncio.StreamReader,
|
|
99
|
+
writer: asyncio.StreamWriter,
|
|
100
|
+
ssl_ctx: ssl.SSLContext,
|
|
101
|
+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
102
|
+
transport = writer.transport
|
|
103
|
+
loop = asyncio.get_event_loop()
|
|
104
|
+
tls_transport = await loop.start_tls(
|
|
105
|
+
transport, transport.get_protocol(), ssl_ctx, server_side=True
|
|
106
|
+
)
|
|
107
|
+
tls_reader = asyncio.StreamReader()
|
|
108
|
+
tls_protocol = asyncio.StreamReaderProtocol(tls_reader)
|
|
109
|
+
tls_transport.set_protocol(tls_protocol)
|
|
110
|
+
tls_writer = asyncio.StreamWriter(tls_transport, tls_protocol, tls_reader, loop)
|
|
111
|
+
return tls_reader, tls_writer
|
|
112
|
+
|
|
113
|
+
async def _serve_decrypted(
|
|
114
|
+
self,
|
|
115
|
+
reader: asyncio.StreamReader,
|
|
116
|
+
writer: asyncio.StreamWriter,
|
|
117
|
+
host: str,
|
|
118
|
+
port: int,
|
|
119
|
+
) -> None:
|
|
120
|
+
while True:
|
|
121
|
+
request_line = await reader.readline()
|
|
122
|
+
if not request_line:
|
|
123
|
+
return
|
|
124
|
+
headers_raw = await _read_headers(reader)
|
|
125
|
+
headers = _parse_headers(headers_raw)
|
|
126
|
+
|
|
127
|
+
if ws_proto.is_upgrade(headers):
|
|
128
|
+
await self._handle_websocket(reader, writer, host, port, request_line, headers_raw)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
parts = request_line.decode(errors="replace").split()
|
|
132
|
+
if len(parts) < 2:
|
|
133
|
+
return
|
|
134
|
+
method, path = parts[0], parts[1]
|
|
135
|
+
|
|
136
|
+
content_length = int(headers.get("content-length", ["0"])[0] or 0)
|
|
137
|
+
body = await reader.read(content_length) if content_length > 0 else b""
|
|
138
|
+
|
|
139
|
+
await self._handle_https(writer, method, host, port, path, headers, body)
|
|
140
|
+
|
|
141
|
+
if headers.get("connection", [""])[0].lower() == "close":
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
async def _handle_http(
|
|
145
|
+
self,
|
|
146
|
+
reader: asyncio.StreamReader,
|
|
147
|
+
writer: asyncio.StreamWriter,
|
|
148
|
+
method: str,
|
|
149
|
+
target: str,
|
|
150
|
+
headers: dict,
|
|
151
|
+
scheme: str,
|
|
152
|
+
) -> None:
|
|
153
|
+
parsed = urlparse(target if target.startswith("http") else f"http://{target}")
|
|
154
|
+
host = parsed.netloc or parsed.hostname or target
|
|
155
|
+
path = parsed.path or "/"
|
|
156
|
+
if parsed.query:
|
|
157
|
+
path += "?" + parsed.query
|
|
158
|
+
query = parsed.query or ""
|
|
159
|
+
|
|
160
|
+
content_length = int(headers.get("content-length", ["0"])[0] or 0)
|
|
161
|
+
body = await reader.read(content_length) if content_length > 0 else b""
|
|
162
|
+
|
|
163
|
+
response = await self._forward(method, scheme, host, path, query, headers, body)
|
|
164
|
+
writer.write(response)
|
|
165
|
+
await writer.drain()
|
|
166
|
+
|
|
167
|
+
async def _handle_https(
|
|
168
|
+
self,
|
|
169
|
+
writer: asyncio.StreamWriter,
|
|
170
|
+
method: str,
|
|
171
|
+
host: str,
|
|
172
|
+
port: int,
|
|
173
|
+
path: str,
|
|
174
|
+
headers: dict,
|
|
175
|
+
body: bytes,
|
|
176
|
+
) -> None:
|
|
177
|
+
full_host = f"{host}:{port}" if port != 443 else host
|
|
178
|
+
query = ""
|
|
179
|
+
if "?" in path:
|
|
180
|
+
path, query = path.split("?", 1)
|
|
181
|
+
|
|
182
|
+
response = await self._forward(method, "https", full_host, path, query, headers, body)
|
|
183
|
+
writer.write(response)
|
|
184
|
+
await writer.drain()
|
|
185
|
+
|
|
186
|
+
async def _forward(
|
|
187
|
+
self,
|
|
188
|
+
method: str,
|
|
189
|
+
scheme: str,
|
|
190
|
+
host: str,
|
|
191
|
+
path: str,
|
|
192
|
+
query: str,
|
|
193
|
+
headers: dict,
|
|
194
|
+
body: bytes,
|
|
195
|
+
) -> bytes:
|
|
196
|
+
import httpx
|
|
197
|
+
|
|
198
|
+
from pypproxy.codec import decode_body
|
|
199
|
+
|
|
200
|
+
if self._script:
|
|
201
|
+
body = self._script.on_request(method, host, path, body)
|
|
202
|
+
|
|
203
|
+
# Manual intercept — pause until user forwards or drops
|
|
204
|
+
if self._intercept:
|
|
205
|
+
headers, body, drop = await self._intercept.intercept(
|
|
206
|
+
method, scheme, host, path, headers, body
|
|
207
|
+
)
|
|
208
|
+
if drop:
|
|
209
|
+
return b"HTTP/1.1 403 Forbidden\r\nContent-Length: 7\r\n\r\ndropped"
|
|
210
|
+
|
|
211
|
+
entry, blocked = self._interceptor.process_request(
|
|
212
|
+
method, scheme, host, path, query, headers, body
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if blocked:
|
|
216
|
+
return b"HTTP/1.1 403 Forbidden\r\nContent-Length: 7\r\n\r\nblocked"
|
|
217
|
+
|
|
218
|
+
url = f"{scheme}://{host}{path}"
|
|
219
|
+
if query:
|
|
220
|
+
url += "?" + query
|
|
221
|
+
|
|
222
|
+
req_headers = {
|
|
223
|
+
k: ", ".join(v)
|
|
224
|
+
for k, v in headers.items()
|
|
225
|
+
if k.lower() not in ("proxy-connection", "proxy-authorization")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
start = time.monotonic()
|
|
229
|
+
try:
|
|
230
|
+
async with httpx.AsyncClient(
|
|
231
|
+
verify=False,
|
|
232
|
+
timeout=30,
|
|
233
|
+
http2=True,
|
|
234
|
+
) as client:
|
|
235
|
+
resp = await client.request(
|
|
236
|
+
method=method,
|
|
237
|
+
url=url,
|
|
238
|
+
headers=req_headers,
|
|
239
|
+
content=entry.req_body,
|
|
240
|
+
follow_redirects=False,
|
|
241
|
+
)
|
|
242
|
+
raw_body = resp.content
|
|
243
|
+
content_encoding = resp.headers.get("content-encoding", "")
|
|
244
|
+
decoded_body, applied_encoding = decode_body(raw_body, content_encoding)
|
|
245
|
+
|
|
246
|
+
if self._script:
|
|
247
|
+
decoded_body = self._script.on_response(resp.status_code, decoded_body)
|
|
248
|
+
|
|
249
|
+
if grpc_proto.is_grpc({k: [v] for k, v in resp.headers.items()}):
|
|
250
|
+
grpc_proto.log_frames(entry.id, "response", decoded_body)
|
|
251
|
+
|
|
252
|
+
resp_headers_dict = {}
|
|
253
|
+
for k, v in resp.headers.multi_items():
|
|
254
|
+
resp_headers_dict.setdefault(k.lower(), []).append(v)
|
|
255
|
+
|
|
256
|
+
# Store decoded body so UI can display plain text
|
|
257
|
+
self._interceptor.process_response(
|
|
258
|
+
entry, resp.status_code, resp_headers_dict, decoded_body, start
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Forward original (encoded) body to client unless script modified it
|
|
262
|
+
forward_body = decoded_body if self._script else raw_body
|
|
263
|
+
# Strip content-encoding header if we decoded the body to forward as-is
|
|
264
|
+
forward_headers = list(resp.headers.multi_items())
|
|
265
|
+
if applied_encoding and not self._script:
|
|
266
|
+
pass # keep original encoding; body is untouched
|
|
267
|
+
elif applied_encoding and self._script:
|
|
268
|
+
forward_headers = [
|
|
269
|
+
(k, v) for k, v in forward_headers if k.lower() != "content-encoding"
|
|
270
|
+
]
|
|
271
|
+
return _build_http_response(resp.status_code, forward_headers, forward_body)
|
|
272
|
+
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.warning("upstream error %s %s: %s", method, url, e)
|
|
275
|
+
msg = str(e).encode()
|
|
276
|
+
return _build_http_response(502, [], msg)
|
|
277
|
+
|
|
278
|
+
async def _handle_websocket(
|
|
279
|
+
self,
|
|
280
|
+
client_reader: asyncio.StreamReader,
|
|
281
|
+
client_writer: asyncio.StreamWriter,
|
|
282
|
+
host: str,
|
|
283
|
+
port: int,
|
|
284
|
+
request_line: bytes,
|
|
285
|
+
headers_raw: bytes,
|
|
286
|
+
) -> None:
|
|
287
|
+
from pypproxy.store.models import Entry
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
server_reader, server_writer = await asyncio.open_connection(
|
|
291
|
+
host, port, ssl=ssl.create_default_context()
|
|
292
|
+
)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.warning("ws: cannot connect to %s:%d: %s", host, port, e)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
server_writer.write(request_line + headers_raw + b"\r\n")
|
|
298
|
+
await server_writer.drain()
|
|
299
|
+
|
|
300
|
+
resp_line = await server_reader.readline()
|
|
301
|
+
resp_headers_raw = await _read_headers(server_reader)
|
|
302
|
+
client_writer.write(resp_line + resp_headers_raw + b"\r\n")
|
|
303
|
+
await client_writer.drain()
|
|
304
|
+
|
|
305
|
+
entry = self._store.add(
|
|
306
|
+
Entry(
|
|
307
|
+
method="GET",
|
|
308
|
+
scheme="wss",
|
|
309
|
+
host=host,
|
|
310
|
+
path="/",
|
|
311
|
+
protocol="ws",
|
|
312
|
+
tags=["websocket"],
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
await ws_proto.relay_frames(
|
|
317
|
+
client_reader,
|
|
318
|
+
client_writer,
|
|
319
|
+
server_reader,
|
|
320
|
+
server_writer,
|
|
321
|
+
entry,
|
|
322
|
+
self._store,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
async def _tunnel(
|
|
326
|
+
self,
|
|
327
|
+
reader: asyncio.StreamReader,
|
|
328
|
+
writer: asyncio.StreamWriter,
|
|
329
|
+
host: str,
|
|
330
|
+
port: int,
|
|
331
|
+
) -> None:
|
|
332
|
+
writer.write(HTTP_200_CONNECT)
|
|
333
|
+
await writer.drain()
|
|
334
|
+
try:
|
|
335
|
+
server_reader, server_writer = await asyncio.open_connection(host, port)
|
|
336
|
+
except OSError:
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
async def pipe(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
|
|
340
|
+
try:
|
|
341
|
+
while True:
|
|
342
|
+
data = await r.read(65536)
|
|
343
|
+
if not data:
|
|
344
|
+
break
|
|
345
|
+
w.write(data)
|
|
346
|
+
await w.drain()
|
|
347
|
+
except (ConnectionResetError, BrokenPipeError):
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
await asyncio.gather(
|
|
351
|
+
pipe(reader, server_writer),
|
|
352
|
+
pipe(server_reader, writer),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _build_http_response(status: int, headers: list[tuple[str, str]], body: bytes) -> bytes:
|
|
357
|
+
reason = _STATUS.get(status, "Unknown")
|
|
358
|
+
lines = [f"HTTP/1.1 {status} {reason}"]
|
|
359
|
+
skip = {"transfer-encoding"}
|
|
360
|
+
for k, v in headers:
|
|
361
|
+
if k.lower() not in skip:
|
|
362
|
+
lines.append(f"{k}: {v}")
|
|
363
|
+
lines.append(f"Content-Length: {len(body)}")
|
|
364
|
+
lines.append("")
|
|
365
|
+
lines.append("")
|
|
366
|
+
return "\r\n".join(lines).encode() + body
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def _read_headers(reader: asyncio.StreamReader) -> bytes:
|
|
370
|
+
buf = b""
|
|
371
|
+
while True:
|
|
372
|
+
line = await reader.readline()
|
|
373
|
+
buf += line
|
|
374
|
+
if line in (b"\r\n", b"\n", b""):
|
|
375
|
+
break
|
|
376
|
+
return buf
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _parse_headers(raw: bytes) -> dict:
|
|
380
|
+
headers: dict[str, list[str]] = {}
|
|
381
|
+
for line in raw.split(b"\r\n"):
|
|
382
|
+
if b":" not in line:
|
|
383
|
+
continue
|
|
384
|
+
k, _, v = line.partition(b":")
|
|
385
|
+
key = k.strip().decode(errors="replace").lower()
|
|
386
|
+
val = v.strip().decode(errors="replace")
|
|
387
|
+
headers.setdefault(key, []).append(val)
|
|
388
|
+
return headers
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
_STATUS = {
|
|
392
|
+
200: "OK",
|
|
393
|
+
201: "Created",
|
|
394
|
+
204: "No Content",
|
|
395
|
+
301: "Moved Permanently",
|
|
396
|
+
302: "Found",
|
|
397
|
+
304: "Not Modified",
|
|
398
|
+
400: "Bad Request",
|
|
399
|
+
401: "Unauthorized",
|
|
400
|
+
403: "Forbidden",
|
|
401
|
+
404: "Not Found",
|
|
402
|
+
405: "Method Not Allowed",
|
|
403
|
+
429: "Too Many Requests",
|
|
404
|
+
500: "Internal Server Error",
|
|
405
|
+
502: "Bad Gateway",
|
|
406
|
+
503: "Service Unavailable",
|
|
407
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from pypproxy.store.models import Entry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ReplayOptions:
|
|
15
|
+
override_host: str = ""
|
|
16
|
+
extra_headers: dict[str, str] = field(default_factory=dict)
|
|
17
|
+
timeout_seconds: int = 30
|
|
18
|
+
count: int = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ReplayResult:
|
|
23
|
+
entry_id: int
|
|
24
|
+
status_code: int = 0
|
|
25
|
+
body: bytes = b""
|
|
26
|
+
duration_ms: int = 0
|
|
27
|
+
error: str = ""
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"entry_id": self.entry_id,
|
|
32
|
+
"status_code": self.status_code,
|
|
33
|
+
"body": base64.b64encode(self.body).decode() if self.body else "",
|
|
34
|
+
"duration_ms": self.duration_ms,
|
|
35
|
+
"error": self.error,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def replay_one(entry: Entry, opts: ReplayOptions) -> ReplayResult:
|
|
40
|
+
host = opts.override_host or entry.host
|
|
41
|
+
url = f"{entry.scheme}://{host}{entry.path}"
|
|
42
|
+
if entry.query:
|
|
43
|
+
url += f"?{entry.query}"
|
|
44
|
+
|
|
45
|
+
headers = {}
|
|
46
|
+
for k, vs in entry.req_headers.items():
|
|
47
|
+
headers[k] = ", ".join(vs)
|
|
48
|
+
headers.update(opts.extra_headers)
|
|
49
|
+
|
|
50
|
+
start = time.monotonic()
|
|
51
|
+
try:
|
|
52
|
+
async with httpx.AsyncClient(
|
|
53
|
+
timeout=opts.timeout_seconds,
|
|
54
|
+
verify=False,
|
|
55
|
+
) as client:
|
|
56
|
+
resp = await client.request(
|
|
57
|
+
method=entry.method,
|
|
58
|
+
url=url,
|
|
59
|
+
headers=headers,
|
|
60
|
+
content=entry.req_body,
|
|
61
|
+
)
|
|
62
|
+
dur = int((time.monotonic() - start) * 1000)
|
|
63
|
+
return ReplayResult(
|
|
64
|
+
entry_id=entry.id,
|
|
65
|
+
status_code=resp.status_code,
|
|
66
|
+
body=resp.content,
|
|
67
|
+
duration_ms=dur,
|
|
68
|
+
)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
dur = int((time.monotonic() - start) * 1000)
|
|
71
|
+
return ReplayResult(entry_id=entry.id, duration_ms=dur, error=str(e))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def replay_many(entry: Entry, opts: ReplayOptions) -> list[ReplayResult]:
|
|
75
|
+
count = max(1, opts.count)
|
|
76
|
+
tasks = [replay_one(entry, opts) for _ in range(count)]
|
|
77
|
+
return await asyncio.gather(*tasks)
|
|
File without changes
|