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,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PendingRequest:
|
|
12
|
+
id: int
|
|
13
|
+
method: str
|
|
14
|
+
scheme: str
|
|
15
|
+
host: str
|
|
16
|
+
path: str
|
|
17
|
+
headers: dict[str, list[str]]
|
|
18
|
+
body: bytes
|
|
19
|
+
# modified versions (user edits)
|
|
20
|
+
edited_headers: dict[str, list[str]] = field(default_factory=dict)
|
|
21
|
+
edited_body: bytes = b""
|
|
22
|
+
_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
|
23
|
+
_decision: str = "forward" # forward | drop
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
import base64
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
"id": self.id,
|
|
30
|
+
"method": self.method,
|
|
31
|
+
"scheme": self.scheme,
|
|
32
|
+
"host": self.host,
|
|
33
|
+
"path": self.path,
|
|
34
|
+
"headers": self.headers,
|
|
35
|
+
"body": base64.b64encode(self.body).decode() if self.body else "",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InterceptManager:
|
|
40
|
+
"""Controls whether requests are paused for manual review."""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._enabled = False
|
|
44
|
+
self._pending: dict[int, PendingRequest] = {}
|
|
45
|
+
self._counter = 0
|
|
46
|
+
self._lock = asyncio.Lock()
|
|
47
|
+
self._subscribers: list[asyncio.Queue] = []
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def enabled(self) -> bool:
|
|
51
|
+
return self._enabled
|
|
52
|
+
|
|
53
|
+
def set_enabled(self, value: bool) -> None:
|
|
54
|
+
self._enabled = value
|
|
55
|
+
if not value:
|
|
56
|
+
# release all pending requests
|
|
57
|
+
for req in list(self._pending.values()):
|
|
58
|
+
req._decision = "forward"
|
|
59
|
+
req._event.set()
|
|
60
|
+
|
|
61
|
+
async def intercept(
|
|
62
|
+
self,
|
|
63
|
+
method: str,
|
|
64
|
+
scheme: str,
|
|
65
|
+
host: str,
|
|
66
|
+
path: str,
|
|
67
|
+
headers: dict[str, list[str]],
|
|
68
|
+
body: bytes,
|
|
69
|
+
) -> tuple[dict[str, list[str]], bytes, bool]:
|
|
70
|
+
"""Pause the request for manual review.
|
|
71
|
+
|
|
72
|
+
Returns (headers, body, drop) where drop=True means the request should be blocked.
|
|
73
|
+
If interception is disabled, returns immediately with original values.
|
|
74
|
+
"""
|
|
75
|
+
if not self._enabled:
|
|
76
|
+
return headers, body, False
|
|
77
|
+
|
|
78
|
+
async with self._lock:
|
|
79
|
+
self._counter += 1
|
|
80
|
+
req_id = self._counter
|
|
81
|
+
|
|
82
|
+
req = PendingRequest(
|
|
83
|
+
id=req_id,
|
|
84
|
+
method=method,
|
|
85
|
+
scheme=scheme,
|
|
86
|
+
host=host,
|
|
87
|
+
path=path,
|
|
88
|
+
headers=dict(headers),
|
|
89
|
+
body=body,
|
|
90
|
+
edited_headers=dict(headers),
|
|
91
|
+
edited_body=body,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async with self._lock:
|
|
95
|
+
self._pending[req_id] = req
|
|
96
|
+
self._notify(req)
|
|
97
|
+
|
|
98
|
+
logger.debug("intercept: waiting for decision on %s %s%s", method, host, path)
|
|
99
|
+
await req._event.wait()
|
|
100
|
+
|
|
101
|
+
async with self._lock:
|
|
102
|
+
self._pending.pop(req_id, None)
|
|
103
|
+
|
|
104
|
+
drop = req._decision == "drop"
|
|
105
|
+
return req.edited_headers, req.edited_body, drop
|
|
106
|
+
|
|
107
|
+
def forward(self, req_id: int, headers: dict | None = None, body: bytes | None = None) -> None:
|
|
108
|
+
req = self._pending.get(req_id)
|
|
109
|
+
if req:
|
|
110
|
+
if headers is not None:
|
|
111
|
+
req.edited_headers = headers
|
|
112
|
+
if body is not None:
|
|
113
|
+
req.edited_body = body
|
|
114
|
+
req._decision = "forward"
|
|
115
|
+
req._event.set()
|
|
116
|
+
|
|
117
|
+
def drop(self, req_id: int) -> None:
|
|
118
|
+
req = self._pending.get(req_id)
|
|
119
|
+
if req:
|
|
120
|
+
req._decision = "drop"
|
|
121
|
+
req._event.set()
|
|
122
|
+
|
|
123
|
+
def list_pending(self) -> list[PendingRequest]:
|
|
124
|
+
return list(self._pending.values())
|
|
125
|
+
|
|
126
|
+
def subscribe(self) -> asyncio.Queue:
|
|
127
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=64)
|
|
128
|
+
self._subscribers.append(q)
|
|
129
|
+
return q
|
|
130
|
+
|
|
131
|
+
def unsubscribe(self, q: asyncio.Queue) -> None:
|
|
132
|
+
import contextlib
|
|
133
|
+
|
|
134
|
+
with contextlib.suppress(ValueError):
|
|
135
|
+
self._subscribers.remove(q)
|
|
136
|
+
|
|
137
|
+
def _notify(self, req: PendingRequest) -> None:
|
|
138
|
+
import contextlib
|
|
139
|
+
|
|
140
|
+
for q in self._subscribers:
|
|
141
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
142
|
+
q.put_nowait(req)
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from pypproxy.rule.rule import Action, MatchContext, Modification, RuleManager
|
|
6
|
+
from pypproxy.store.models import Entry
|
|
7
|
+
from pypproxy.store.scope import ScopeManager
|
|
8
|
+
from pypproxy.store.store import Store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Interceptor:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
rules: RuleManager,
|
|
15
|
+
store: Store,
|
|
16
|
+
scope: ScopeManager | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._rules = rules
|
|
19
|
+
self._store = store
|
|
20
|
+
self._scope = scope
|
|
21
|
+
|
|
22
|
+
def process_request(
|
|
23
|
+
self,
|
|
24
|
+
method: str,
|
|
25
|
+
scheme: str,
|
|
26
|
+
host: str,
|
|
27
|
+
path: str,
|
|
28
|
+
query: str,
|
|
29
|
+
headers: dict[str, list[str]],
|
|
30
|
+
body: bytes,
|
|
31
|
+
) -> tuple[Entry, bool]:
|
|
32
|
+
# Scope check — skip recording if host is out of scope
|
|
33
|
+
if self._scope and not self._scope.is_in_scope(host):
|
|
34
|
+
entry = Entry(
|
|
35
|
+
method=method,
|
|
36
|
+
scheme=scheme,
|
|
37
|
+
host=host,
|
|
38
|
+
path=path,
|
|
39
|
+
query=query,
|
|
40
|
+
req_headers=dict(headers),
|
|
41
|
+
req_body=body,
|
|
42
|
+
protocol=scheme,
|
|
43
|
+
)
|
|
44
|
+
return entry, False # pass through without recording
|
|
45
|
+
|
|
46
|
+
entry = Entry(
|
|
47
|
+
method=method,
|
|
48
|
+
scheme=scheme,
|
|
49
|
+
host=host,
|
|
50
|
+
path=path,
|
|
51
|
+
query=query,
|
|
52
|
+
req_headers=dict(headers),
|
|
53
|
+
req_body=body,
|
|
54
|
+
protocol=scheme,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# GraphQL detection
|
|
58
|
+
from pypproxy.graphql.detector import (
|
|
59
|
+
extract_operation_name,
|
|
60
|
+
extract_operation_type,
|
|
61
|
+
is_graphql,
|
|
62
|
+
parse_operation,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if is_graphql(entry):
|
|
66
|
+
op = parse_operation(body)
|
|
67
|
+
entry.graphql_op_type = extract_operation_type(op["query"])
|
|
68
|
+
entry.graphql_operation = extract_operation_name(op["query"]) or op.get(
|
|
69
|
+
"operationName", ""
|
|
70
|
+
)
|
|
71
|
+
entry.tags.append("graphql")
|
|
72
|
+
entry.protocol = "graphql"
|
|
73
|
+
|
|
74
|
+
ctx = MatchContext(
|
|
75
|
+
method=method,
|
|
76
|
+
host=host,
|
|
77
|
+
path=path,
|
|
78
|
+
headers=headers,
|
|
79
|
+
body=body,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
rule = self._rules.match(ctx)
|
|
83
|
+
blocked = False
|
|
84
|
+
|
|
85
|
+
if rule:
|
|
86
|
+
if rule.action == Action.BLOCK:
|
|
87
|
+
entry.tags.append("blocked")
|
|
88
|
+
blocked = True
|
|
89
|
+
elif rule.action == Action.MODIFY:
|
|
90
|
+
headers, body = _apply_request_mods(headers, body, rule.modifications)
|
|
91
|
+
entry.req_headers = dict(headers)
|
|
92
|
+
entry.req_body = body
|
|
93
|
+
entry.modified = True
|
|
94
|
+
elif rule.action == Action.REDIRECT:
|
|
95
|
+
entry.tags.append("redirected")
|
|
96
|
+
|
|
97
|
+
self._store.add(entry)
|
|
98
|
+
return entry, blocked
|
|
99
|
+
|
|
100
|
+
def process_response(
|
|
101
|
+
self,
|
|
102
|
+
entry: Entry,
|
|
103
|
+
status_code: int,
|
|
104
|
+
headers: dict[str, list[str]],
|
|
105
|
+
body: bytes,
|
|
106
|
+
start_time: float,
|
|
107
|
+
) -> tuple[dict[str, list[str]], bytes]:
|
|
108
|
+
entry.status_code = status_code
|
|
109
|
+
entry.resp_headers = dict(headers)
|
|
110
|
+
entry.resp_body = body
|
|
111
|
+
entry.duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
112
|
+
|
|
113
|
+
ctx = MatchContext(
|
|
114
|
+
method=entry.method,
|
|
115
|
+
host=entry.host,
|
|
116
|
+
path=entry.path,
|
|
117
|
+
headers=headers,
|
|
118
|
+
body=body,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
rule = self._rules.match(ctx)
|
|
122
|
+
if rule and rule.action == Action.MODIFY:
|
|
123
|
+
headers, body = _apply_response_mods(headers, body, rule.modifications)
|
|
124
|
+
entry.resp_headers = dict(headers)
|
|
125
|
+
entry.resp_body = body
|
|
126
|
+
entry.modified = True
|
|
127
|
+
|
|
128
|
+
self._store.update(entry)
|
|
129
|
+
return headers, body
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _apply_request_mods(
|
|
133
|
+
headers: dict[str, list[str]],
|
|
134
|
+
body: bytes,
|
|
135
|
+
mods: list[Modification],
|
|
136
|
+
) -> tuple[dict[str, list[str]], bytes]:
|
|
137
|
+
headers = dict(headers)
|
|
138
|
+
for m in mods:
|
|
139
|
+
if m.target == "req_header":
|
|
140
|
+
headers = _apply_header_mod(headers, m)
|
|
141
|
+
elif m.target == "req_body" and m.operation == "replace":
|
|
142
|
+
body = m.value.encode()
|
|
143
|
+
return headers, body
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _apply_response_mods(
|
|
147
|
+
headers: dict[str, list[str]],
|
|
148
|
+
body: bytes,
|
|
149
|
+
mods: list[Modification],
|
|
150
|
+
) -> tuple[dict[str, list[str]], bytes]:
|
|
151
|
+
headers = dict(headers)
|
|
152
|
+
for m in mods:
|
|
153
|
+
if m.target == "resp_header":
|
|
154
|
+
headers = _apply_header_mod(headers, m)
|
|
155
|
+
elif m.target == "resp_body":
|
|
156
|
+
if m.operation == "replace":
|
|
157
|
+
body = m.value.encode()
|
|
158
|
+
elif m.operation == "find_replace":
|
|
159
|
+
body = body.replace(m.find.encode(), m.replace.encode())
|
|
160
|
+
return headers, body
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _apply_header_mod(headers: dict[str, list[str]], m: Modification) -> dict[str, list[str]]:
|
|
164
|
+
h = {k.lower(): v for k, v in headers.items()}
|
|
165
|
+
key = m.key.lower()
|
|
166
|
+
if m.operation == "set":
|
|
167
|
+
h[key] = [m.value]
|
|
168
|
+
elif m.operation == "delete":
|
|
169
|
+
h.pop(key, None)
|
|
170
|
+
elif m.operation == "append":
|
|
171
|
+
h.setdefault(key, []).append(m.value)
|
|
172
|
+
return h
|
|
File without changes
|
pypproxy/proto/grpc.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import struct
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GrpcFrame(NamedTuple):
|
|
11
|
+
compressed: bool
|
|
12
|
+
data: bytes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_grpc(headers: dict) -> bool:
|
|
16
|
+
ct = headers.get("content-type", [""])[0]
|
|
17
|
+
return ct.startswith("application/grpc")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode_frames(data: bytes) -> list[GrpcFrame]:
|
|
21
|
+
frames: list[GrpcFrame] = []
|
|
22
|
+
offset = 0
|
|
23
|
+
while offset + 5 <= len(data):
|
|
24
|
+
compressed = bool(data[offset])
|
|
25
|
+
length = struct.unpack_from(">I", data, offset + 1)[0]
|
|
26
|
+
offset += 5
|
|
27
|
+
if offset + length > len(data):
|
|
28
|
+
break
|
|
29
|
+
frames.append(GrpcFrame(compressed=compressed, data=data[offset : offset + length]))
|
|
30
|
+
offset += length
|
|
31
|
+
return frames
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def encode_frame(frame: GrpcFrame) -> bytes:
|
|
35
|
+
header = struct.pack(">BI", int(frame.compressed), len(frame.data))
|
|
36
|
+
return header + frame.data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def log_frames(entry_id: int, direction: str, data: bytes) -> None:
|
|
40
|
+
for i, frame in enumerate(decode_frames(data)):
|
|
41
|
+
logger.debug(
|
|
42
|
+
"grpc frame entry=%d dir=%s index=%d compressed=%s len=%d",
|
|
43
|
+
entry_id,
|
|
44
|
+
direction,
|
|
45
|
+
i,
|
|
46
|
+
frame.compressed,
|
|
47
|
+
len(frame.data),
|
|
48
|
+
)
|
pypproxy/proto/mqtt.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import struct
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
PACKET_TYPES = {
|
|
10
|
+
1: "CONNECT",
|
|
11
|
+
2: "CONNACK",
|
|
12
|
+
3: "PUBLISH",
|
|
13
|
+
4: "PUBACK",
|
|
14
|
+
5: "PUBREC",
|
|
15
|
+
6: "PUBREL",
|
|
16
|
+
7: "PUBCOMP",
|
|
17
|
+
8: "SUBSCRIBE",
|
|
18
|
+
9: "SUBACK",
|
|
19
|
+
10: "UNSUBSCRIBE",
|
|
20
|
+
11: "UNSUBACK",
|
|
21
|
+
12: "PINGREQ",
|
|
22
|
+
13: "PINGRESP",
|
|
23
|
+
14: "DISCONNECT",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MQTTFrame(NamedTuple):
|
|
28
|
+
packet_type: int
|
|
29
|
+
packet_name: str
|
|
30
|
+
flags: int
|
|
31
|
+
payload: bytes
|
|
32
|
+
topic: str
|
|
33
|
+
qos: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_mqtt(data: bytes) -> bool:
|
|
37
|
+
"""Heuristic: check if data looks like an MQTT CONNECT packet."""
|
|
38
|
+
if len(data) < 10:
|
|
39
|
+
return False
|
|
40
|
+
packet_type = (data[0] >> 4) & 0x0F
|
|
41
|
+
if packet_type != 1:
|
|
42
|
+
return False
|
|
43
|
+
try:
|
|
44
|
+
proto_len = struct.unpack_from(">H", data, 2)[0]
|
|
45
|
+
proto_name = data[4 : 4 + proto_len]
|
|
46
|
+
return proto_name in (b"MQTT", b"MQIsdp")
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def decode_frames(data: bytes) -> list[MQTTFrame]:
|
|
52
|
+
frames: list[MQTTFrame] = []
|
|
53
|
+
offset = 0
|
|
54
|
+
while offset < len(data):
|
|
55
|
+
result = _read_frame(data, offset)
|
|
56
|
+
if result is None:
|
|
57
|
+
break
|
|
58
|
+
f, consumed = result
|
|
59
|
+
frames.append(f)
|
|
60
|
+
offset += consumed
|
|
61
|
+
return frames
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_frame(data: bytes, offset: int) -> tuple[MQTTFrame, int] | None:
|
|
65
|
+
if offset >= len(data):
|
|
66
|
+
return None
|
|
67
|
+
byte0 = data[offset]
|
|
68
|
+
packet_type = (byte0 >> 4) & 0x0F
|
|
69
|
+
flags = byte0 & 0x0F
|
|
70
|
+
offset += 1
|
|
71
|
+
|
|
72
|
+
remaining_length = 0
|
|
73
|
+
multiplier = 1
|
|
74
|
+
len_bytes = 0
|
|
75
|
+
for _ in range(4):
|
|
76
|
+
if offset >= len(data):
|
|
77
|
+
return None
|
|
78
|
+
b = data[offset]
|
|
79
|
+
offset += 1
|
|
80
|
+
len_bytes += 1
|
|
81
|
+
remaining_length += (b & 0x7F) * multiplier
|
|
82
|
+
multiplier *= 128
|
|
83
|
+
if not (b & 0x80):
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
if offset + remaining_length > len(data):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
payload = data[offset : offset + remaining_length]
|
|
90
|
+
consumed = 1 + len_bytes + remaining_length
|
|
91
|
+
|
|
92
|
+
topic = ""
|
|
93
|
+
qos = (flags >> 1) & 0x03
|
|
94
|
+
if packet_type == 3 and len(payload) >= 2:
|
|
95
|
+
topic_len = struct.unpack_from(">H", payload, 0)[0]
|
|
96
|
+
if 2 + topic_len <= len(payload):
|
|
97
|
+
topic = payload[2 : 2 + topic_len].decode(errors="replace")
|
|
98
|
+
|
|
99
|
+
return MQTTFrame(
|
|
100
|
+
packet_type=packet_type,
|
|
101
|
+
packet_name=PACKET_TYPES.get(packet_type, f"UNKNOWN({packet_type})"),
|
|
102
|
+
flags=flags,
|
|
103
|
+
payload=payload,
|
|
104
|
+
topic=topic,
|
|
105
|
+
qos=qos,
|
|
106
|
+
), consumed
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def log_frames(entry_id: int, direction: str, data: bytes) -> None:
|
|
110
|
+
for frame in decode_frames(data):
|
|
111
|
+
logger.debug(
|
|
112
|
+
"mqtt frame entry=%d dir=%s type=%s topic=%r qos=%d len=%d",
|
|
113
|
+
entry_id,
|
|
114
|
+
direction,
|
|
115
|
+
frame.packet_name,
|
|
116
|
+
frame.topic,
|
|
117
|
+
frame.qos,
|
|
118
|
+
len(frame.payload),
|
|
119
|
+
)
|
pypproxy/proto/ws.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import struct
|
|
6
|
+
|
|
7
|
+
from pypproxy.store.models import Entry
|
|
8
|
+
from pypproxy.store.store import Store
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
OPCODE_TEXT = 0x1
|
|
13
|
+
OPCODE_BINARY = 0x2
|
|
14
|
+
OPCODE_CLOSE = 0x8
|
|
15
|
+
OPCODE_PING = 0x9
|
|
16
|
+
OPCODE_PONG = 0xA
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_upgrade(headers: dict) -> bool:
|
|
20
|
+
upgrade = headers.get("upgrade", [""])[0].lower()
|
|
21
|
+
return upgrade == "websocket"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def relay_frames(
|
|
25
|
+
client_reader: asyncio.StreamReader,
|
|
26
|
+
client_writer: asyncio.StreamWriter,
|
|
27
|
+
server_reader: asyncio.StreamReader,
|
|
28
|
+
server_writer: asyncio.StreamWriter,
|
|
29
|
+
entry: Entry,
|
|
30
|
+
store: Store,
|
|
31
|
+
) -> None:
|
|
32
|
+
async def _relay(
|
|
33
|
+
src_r: asyncio.StreamReader,
|
|
34
|
+
dst_w: asyncio.StreamWriter,
|
|
35
|
+
direction: str,
|
|
36
|
+
) -> None:
|
|
37
|
+
try:
|
|
38
|
+
while True:
|
|
39
|
+
frame = await read_frame(src_r)
|
|
40
|
+
if frame is None:
|
|
41
|
+
break
|
|
42
|
+
fin, opcode, payload = frame
|
|
43
|
+
log_frame(entry.id, direction, opcode, payload)
|
|
44
|
+
await write_frame(dst_w, fin, opcode, payload, mask=False)
|
|
45
|
+
if opcode == OPCODE_CLOSE:
|
|
46
|
+
break
|
|
47
|
+
except (asyncio.IncompleteReadError, ConnectionResetError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
await asyncio.gather(
|
|
51
|
+
_relay(client_reader, server_writer, "client"),
|
|
52
|
+
_relay(server_reader, client_writer, "server"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def read_frame(
|
|
57
|
+
reader: asyncio.StreamReader,
|
|
58
|
+
) -> tuple[bool, int, bytes] | None:
|
|
59
|
+
try:
|
|
60
|
+
header = await reader.readexactly(2)
|
|
61
|
+
except asyncio.IncompleteReadError:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
fin = bool(header[0] & 0x80)
|
|
65
|
+
opcode = header[0] & 0x0F
|
|
66
|
+
masked = bool(header[1] & 0x80)
|
|
67
|
+
payload_len = header[1] & 0x7F
|
|
68
|
+
|
|
69
|
+
if payload_len == 126:
|
|
70
|
+
ext = await reader.readexactly(2)
|
|
71
|
+
payload_len = struct.unpack(">H", ext)[0]
|
|
72
|
+
elif payload_len == 127:
|
|
73
|
+
ext = await reader.readexactly(8)
|
|
74
|
+
payload_len = struct.unpack(">Q", ext)[0]
|
|
75
|
+
|
|
76
|
+
mask_key = b""
|
|
77
|
+
if masked:
|
|
78
|
+
mask_key = await reader.readexactly(4)
|
|
79
|
+
|
|
80
|
+
payload = await reader.readexactly(payload_len)
|
|
81
|
+
if masked:
|
|
82
|
+
payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
|
|
83
|
+
|
|
84
|
+
return fin, opcode, payload
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def write_frame(
|
|
88
|
+
writer: asyncio.StreamWriter,
|
|
89
|
+
fin: bool,
|
|
90
|
+
opcode: int,
|
|
91
|
+
payload: bytes,
|
|
92
|
+
mask: bool = False,
|
|
93
|
+
) -> None:
|
|
94
|
+
b0 = (0x80 if fin else 0x00) | opcode
|
|
95
|
+
length = len(payload)
|
|
96
|
+
|
|
97
|
+
if length < 126:
|
|
98
|
+
b1 = length
|
|
99
|
+
header = bytes([b0, b1])
|
|
100
|
+
elif length < 65536:
|
|
101
|
+
b1 = 126
|
|
102
|
+
header = bytes([b0, b1]) + struct.pack(">H", length)
|
|
103
|
+
else:
|
|
104
|
+
b1 = 127
|
|
105
|
+
header = bytes([b0, b1]) + struct.pack(">Q", length)
|
|
106
|
+
|
|
107
|
+
writer.write(header + payload)
|
|
108
|
+
await writer.drain()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def log_frame(entry_id: int, direction: str, opcode: int, payload: bytes) -> None:
|
|
112
|
+
if opcode == OPCODE_TEXT:
|
|
113
|
+
logger.debug(
|
|
114
|
+
"ws frame entry=%d dir=%s text=%s",
|
|
115
|
+
entry_id,
|
|
116
|
+
direction,
|
|
117
|
+
payload.decode(errors="replace")[:200],
|
|
118
|
+
)
|
|
119
|
+
elif opcode == OPCODE_BINARY:
|
|
120
|
+
logger.debug("ws frame entry=%d dir=%s binary len=%d", entry_id, direction, len(payload))
|