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,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ssl
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ClientCert:
|
|
10
|
+
name: str
|
|
11
|
+
cert_path: str
|
|
12
|
+
key_path: str
|
|
13
|
+
host_pattern: str = "*" # glob pattern, "*" means all hosts
|
|
14
|
+
|
|
15
|
+
def matches(self, host: str) -> bool:
|
|
16
|
+
import fnmatch
|
|
17
|
+
|
|
18
|
+
return fnmatch.fnmatch(host, self.host_pattern)
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
"name": self.name,
|
|
23
|
+
"cert_path": self.cert_path,
|
|
24
|
+
"key_path": self.key_path,
|
|
25
|
+
"host_pattern": self.host_pattern,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClientCertManager:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._certs: list[ClientCert] = []
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def add(self, cert: ClientCert) -> None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._certs.append(cert)
|
|
37
|
+
|
|
38
|
+
def remove(self, name: str) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._certs = [c for c in self._certs if c.name != name]
|
|
41
|
+
|
|
42
|
+
def list(self) -> list[ClientCert]:
|
|
43
|
+
with self._lock:
|
|
44
|
+
return list(self._certs)
|
|
45
|
+
|
|
46
|
+
def get_for_host(self, host: str) -> ClientCert | None:
|
|
47
|
+
with self._lock:
|
|
48
|
+
for cert in self._certs:
|
|
49
|
+
if cert.matches(host):
|
|
50
|
+
return cert
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def ssl_context_for(self, host: str) -> ssl.SSLContext | None:
|
|
54
|
+
cert = self.get_for_host(host)
|
|
55
|
+
if cert is None:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
ctx = ssl.create_default_context()
|
|
59
|
+
ctx.load_cert_chain(cert.cert_path, cert.key_path)
|
|
60
|
+
return ctx
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def to_list(self) -> list[dict]:
|
|
65
|
+
return [c.to_dict() for c in self.list()]
|
pypproxy/codec.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import struct
|
|
7
|
+
import zlib
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---- content-encoding decode/encode ----
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def decode_body(body: bytes, content_encoding: str) -> tuple[bytes, str]:
|
|
16
|
+
"""Decompress body according to Content-Encoding.
|
|
17
|
+
Returns (decoded_bytes, applied_encoding). Falls back to original on error.
|
|
18
|
+
"""
|
|
19
|
+
if not body or not content_encoding:
|
|
20
|
+
return body, ""
|
|
21
|
+
encoding = content_encoding.lower().strip()
|
|
22
|
+
try:
|
|
23
|
+
if encoding == "gzip":
|
|
24
|
+
return gzip.decompress(body), "gzip"
|
|
25
|
+
if encoding == "br":
|
|
26
|
+
import brotli # type: ignore[import-untyped]
|
|
27
|
+
|
|
28
|
+
return brotli.decompress(body), "br"
|
|
29
|
+
if encoding in ("deflate", "zlib"):
|
|
30
|
+
try:
|
|
31
|
+
return zlib.decompress(body), encoding
|
|
32
|
+
except zlib.error:
|
|
33
|
+
return zlib.decompress(body, -zlib.MAX_WBITS), encoding
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.debug("decode_body failed (encoding=%s): %s", encoding, e)
|
|
36
|
+
return body, ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def encode_body(body: bytes, encoding: str) -> bytes:
|
|
40
|
+
if not encoding:
|
|
41
|
+
return body
|
|
42
|
+
try:
|
|
43
|
+
if encoding == "gzip":
|
|
44
|
+
return gzip.compress(body)
|
|
45
|
+
if encoding == "br":
|
|
46
|
+
import brotli # type: ignore[import-untyped]
|
|
47
|
+
|
|
48
|
+
return brotli.compress(body)
|
|
49
|
+
if encoding in ("deflate", "zlib"):
|
|
50
|
+
return zlib.compress(body)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.debug("encode_body failed (encoding=%s): %s", encoding, e)
|
|
53
|
+
return body
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---- binary protocol decode ----
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def decode_msgpack(data: bytes) -> str:
|
|
60
|
+
"""Decode MessagePack bytes to a pretty-printed JSON string."""
|
|
61
|
+
try:
|
|
62
|
+
import msgpack
|
|
63
|
+
|
|
64
|
+
obj = msgpack.unpackb(data, raw=False, strict_map_key=False)
|
|
65
|
+
return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return f"<msgpack decode error: {e}>"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def decode_cbor(data: bytes) -> str:
|
|
71
|
+
"""Decode CBOR bytes to a pretty-printed JSON string."""
|
|
72
|
+
try:
|
|
73
|
+
import cbor2
|
|
74
|
+
|
|
75
|
+
obj = cbor2.loads(data)
|
|
76
|
+
return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
return f"<cbor decode error: {e}>"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def decode_protobuf_raw(data: bytes) -> str:
|
|
82
|
+
"""Decode raw protobuf bytes using wire-type heuristics (no schema needed)."""
|
|
83
|
+
try:
|
|
84
|
+
return _decode_proto_fields(data, indent=0)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
return f"<protobuf decode error: {e}>"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _decode_proto_fields(data: bytes, indent: int) -> str:
|
|
90
|
+
lines: list[str] = []
|
|
91
|
+
pos = 0
|
|
92
|
+
pad = " " * indent
|
|
93
|
+
while pos < len(data):
|
|
94
|
+
if pos >= len(data):
|
|
95
|
+
break
|
|
96
|
+
# read varint for tag+wire_type
|
|
97
|
+
tag_wire, pos = _read_varint(data, pos)
|
|
98
|
+
if tag_wire is None:
|
|
99
|
+
break
|
|
100
|
+
field_num = tag_wire >> 3
|
|
101
|
+
wire_type = tag_wire & 0x7
|
|
102
|
+
if wire_type == 0: # varint
|
|
103
|
+
val, pos = _read_varint(data, pos)
|
|
104
|
+
lines.append(f"{pad}field {field_num} (varint): {val}")
|
|
105
|
+
elif wire_type == 1: # 64-bit
|
|
106
|
+
if pos + 8 > len(data):
|
|
107
|
+
break
|
|
108
|
+
val = struct.unpack_from("<Q", data, pos)[0]
|
|
109
|
+
pos += 8
|
|
110
|
+
lines.append(f"{pad}field {field_num} (64-bit): {val}")
|
|
111
|
+
elif wire_type == 2: # length-delimited
|
|
112
|
+
length, pos = _read_varint(data, pos)
|
|
113
|
+
if length is None or pos + length > len(data):
|
|
114
|
+
break
|
|
115
|
+
payload = data[pos : pos + length]
|
|
116
|
+
pos += length
|
|
117
|
+
# try nested message
|
|
118
|
+
try:
|
|
119
|
+
nested = _decode_proto_fields(payload, indent + 1)
|
|
120
|
+
lines.append(f"{pad}field {field_num} (embedded):")
|
|
121
|
+
lines.append(nested)
|
|
122
|
+
except Exception:
|
|
123
|
+
try:
|
|
124
|
+
lines.append(f"{pad}field {field_num} (string): {payload.decode()!r}")
|
|
125
|
+
except Exception:
|
|
126
|
+
lines.append(f"{pad}field {field_num} (bytes): {payload.hex()}")
|
|
127
|
+
elif wire_type == 5: # 32-bit
|
|
128
|
+
if pos + 4 > len(data):
|
|
129
|
+
break
|
|
130
|
+
val = struct.unpack_from("<I", data, pos)[0]
|
|
131
|
+
pos += 4
|
|
132
|
+
lines.append(f"{pad}field {field_num} (32-bit): {val}")
|
|
133
|
+
else:
|
|
134
|
+
lines.append(f"{pad}field {field_num} (unknown wire type {wire_type})")
|
|
135
|
+
break
|
|
136
|
+
return "\n".join(lines)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_varint(data: bytes, pos: int) -> tuple[int | None, int]:
|
|
140
|
+
result, shift = 0, 0
|
|
141
|
+
while pos < len(data):
|
|
142
|
+
b = data[pos]
|
|
143
|
+
pos += 1
|
|
144
|
+
result |= (b & 0x7F) << shift
|
|
145
|
+
shift += 7
|
|
146
|
+
if not (b & 0x80):
|
|
147
|
+
return result, pos
|
|
148
|
+
return None, pos
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def sniff_content_type(body: bytes, content_type: str) -> str:
|
|
152
|
+
"""Return a hint for how to display the body: json, xml, proto, msgpack, cbor, text, binary."""
|
|
153
|
+
ct = content_type.lower()
|
|
154
|
+
if "json" in ct:
|
|
155
|
+
return "json"
|
|
156
|
+
if "xml" in ct or "html" in ct:
|
|
157
|
+
return "xml"
|
|
158
|
+
if "grpc" in ct or "protobuf" in ct:
|
|
159
|
+
return "proto"
|
|
160
|
+
if "msgpack" in ct:
|
|
161
|
+
return "msgpack"
|
|
162
|
+
if "cbor" in ct:
|
|
163
|
+
return "cbor"
|
|
164
|
+
if not body:
|
|
165
|
+
return "text"
|
|
166
|
+
# heuristic: try JSON
|
|
167
|
+
try:
|
|
168
|
+
json.loads(body.decode("utf-8", errors="strict"))
|
|
169
|
+
return "json"
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
# check binary
|
|
173
|
+
printable = sum(1 for b in body[:256] if 0x20 <= b < 0x7F or b in (9, 10, 13))
|
|
174
|
+
if len(body) > 0 and printable / min(len(body), 256) < 0.7:
|
|
175
|
+
return "binary"
|
|
176
|
+
return "text"
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ProxyConfig:
|
|
11
|
+
addr: str = "0.0.0.0"
|
|
12
|
+
port: int = 8080
|
|
13
|
+
ignore: list[str] = field(default_factory=list)
|
|
14
|
+
max_body: int = 1024 * 1024 # 1MB
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CAConfig:
|
|
19
|
+
cert_path: str = ""
|
|
20
|
+
key_path: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class UIConfig:
|
|
25
|
+
addr: str = "0.0.0.0"
|
|
26
|
+
port: int = 8081
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ScriptConfig:
|
|
31
|
+
path: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Config:
|
|
36
|
+
proxy: ProxyConfig = field(default_factory=ProxyConfig)
|
|
37
|
+
ca: CAConfig = field(default_factory=CAConfig)
|
|
38
|
+
ui: UIConfig = field(default_factory=UIConfig)
|
|
39
|
+
script: ScriptConfig = field(default_factory=ScriptConfig)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def default(cls) -> Config:
|
|
43
|
+
cfg = cls()
|
|
44
|
+
home = Path.home()
|
|
45
|
+
cfg.ca.cert_path = str(home / ".paxy" / "ca-cert.pem")
|
|
46
|
+
cfg.ca.key_path = str(home / ".paxy" / "ca-key.pem")
|
|
47
|
+
return cfg
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load(cls, path: str) -> Config:
|
|
51
|
+
cfg = cls.default()
|
|
52
|
+
p = Path(path)
|
|
53
|
+
if not p.exists():
|
|
54
|
+
return cfg
|
|
55
|
+
with p.open() as f:
|
|
56
|
+
data = yaml.safe_load(f) or {}
|
|
57
|
+
|
|
58
|
+
if proxy := data.get("proxy"):
|
|
59
|
+
if "addr" in proxy:
|
|
60
|
+
cfg.proxy.addr = proxy["addr"]
|
|
61
|
+
if "port" in proxy:
|
|
62
|
+
cfg.proxy.port = int(proxy["port"])
|
|
63
|
+
if "ignore" in proxy:
|
|
64
|
+
cfg.proxy.ignore = proxy["ignore"]
|
|
65
|
+
if "max_body" in proxy:
|
|
66
|
+
cfg.proxy.max_body = int(proxy["max_body"])
|
|
67
|
+
|
|
68
|
+
if ca := data.get("ca"):
|
|
69
|
+
if "cert_path" in ca:
|
|
70
|
+
cfg.ca.cert_path = str(Path(ca["cert_path"]).resolve())
|
|
71
|
+
if "key_path" in ca:
|
|
72
|
+
cfg.ca.key_path = str(Path(ca["key_path"]).resolve())
|
|
73
|
+
|
|
74
|
+
if ui := data.get("ui"):
|
|
75
|
+
if "addr" in ui:
|
|
76
|
+
cfg.ui.addr = ui["addr"]
|
|
77
|
+
if "port" in ui:
|
|
78
|
+
cfg.ui.port = int(ui["port"])
|
|
79
|
+
|
|
80
|
+
if (script := data.get("script")) and "path" in script:
|
|
81
|
+
cfg.script.path = str(Path(script["path"]).resolve())
|
|
82
|
+
|
|
83
|
+
return cfg
|
|
84
|
+
|
|
85
|
+
def save(self, path: str) -> None:
|
|
86
|
+
data = {
|
|
87
|
+
"proxy": {
|
|
88
|
+
"addr": self.proxy.addr,
|
|
89
|
+
"port": self.proxy.port,
|
|
90
|
+
"ignore": self.proxy.ignore,
|
|
91
|
+
"max_body": self.proxy.max_body,
|
|
92
|
+
},
|
|
93
|
+
"ca": {
|
|
94
|
+
"cert_path": self.ca.cert_path,
|
|
95
|
+
"key_path": self.ca.key_path,
|
|
96
|
+
},
|
|
97
|
+
"ui": {
|
|
98
|
+
"addr": self.ui.addr,
|
|
99
|
+
"port": self.ui.port,
|
|
100
|
+
},
|
|
101
|
+
"script": {
|
|
102
|
+
"path": self.script.path,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
with open(path, "w") as f:
|
|
106
|
+
yaml.dump(data, f, default_flow_style=False)
|
pypproxy/dns/__init__.py
ADDED
|
File without changes
|
pypproxy/dns/server.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import struct
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
DNS_PORT = 53153 # unprivileged default; use 53 with sudo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DNSServer:
|
|
14
|
+
"""
|
|
15
|
+
Minimal DNS server that spoofs configured domains to a target IP.
|
|
16
|
+
All other queries are forwarded to an upstream resolver.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
overrides: dict[str, str],
|
|
22
|
+
upstream: str = "8.8.8.8",
|
|
23
|
+
port: int = DNS_PORT,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._overrides = {k.lower().rstrip("."): v for k, v in overrides.items()}
|
|
26
|
+
self._upstream = upstream
|
|
27
|
+
self._port = port
|
|
28
|
+
self._transport: asyncio.BaseTransport | None = None
|
|
29
|
+
|
|
30
|
+
async def start(self) -> None:
|
|
31
|
+
loop = asyncio.get_event_loop()
|
|
32
|
+
self._transport, _ = await loop.create_datagram_endpoint(
|
|
33
|
+
lambda: _DNSProtocol(self._overrides, self._upstream),
|
|
34
|
+
local_addr=("0.0.0.0", self._port),
|
|
35
|
+
)
|
|
36
|
+
logger.info("DNS server listening on UDP :%d (upstream: %s)", self._port, self._upstream)
|
|
37
|
+
|
|
38
|
+
def stop(self) -> None:
|
|
39
|
+
if self._transport:
|
|
40
|
+
self._transport.close()
|
|
41
|
+
|
|
42
|
+
def set_overrides(self, overrides: dict[str, str]) -> None:
|
|
43
|
+
self._overrides = {k.lower().rstrip("."): v for k, v in overrides.items()}
|
|
44
|
+
if self._transport:
|
|
45
|
+
proto = self._transport.get_protocol()
|
|
46
|
+
if hasattr(proto, "overrides"):
|
|
47
|
+
proto.overrides = self._overrides # type: ignore[attr-defined]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _DNSProtocol(asyncio.DatagramProtocol):
|
|
51
|
+
def __init__(self, overrides: dict[str, str], upstream: str) -> None:
|
|
52
|
+
self.overrides = overrides
|
|
53
|
+
self._upstream = upstream
|
|
54
|
+
self._transport: asyncio.DatagramTransport | None = None
|
|
55
|
+
|
|
56
|
+
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
57
|
+
self._transport = transport # type: ignore[assignment]
|
|
58
|
+
|
|
59
|
+
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
|
60
|
+
asyncio.ensure_future(self._handle(data, addr))
|
|
61
|
+
|
|
62
|
+
async def _handle(self, data: bytes, addr: tuple) -> None:
|
|
63
|
+
try:
|
|
64
|
+
name, qtype = _parse_query(data)
|
|
65
|
+
except Exception:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
name_lower = name.lower().rstrip(".")
|
|
69
|
+
logger.debug("DNS query: %s (type=%d) from %s", name, qtype, addr)
|
|
70
|
+
|
|
71
|
+
if qtype == 1 and name_lower in self.overrides:
|
|
72
|
+
ip = self.overrides[name_lower]
|
|
73
|
+
logger.info("DNS spoof: %s -> %s", name, ip)
|
|
74
|
+
reply = _build_reply(data, name, ip)
|
|
75
|
+
if self._transport:
|
|
76
|
+
self._transport.sendto(reply, addr)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Forward to upstream
|
|
80
|
+
try:
|
|
81
|
+
reply = await _forward(data, self._upstream)
|
|
82
|
+
if self._transport and reply:
|
|
83
|
+
self._transport.sendto(reply, addr)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.debug("DNS forward error: %s", e)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_query(data: bytes) -> tuple[str, int]:
|
|
89
|
+
offset = 12 # skip header
|
|
90
|
+
labels: list[str] = []
|
|
91
|
+
while offset < len(data):
|
|
92
|
+
length = data[offset]
|
|
93
|
+
offset += 1
|
|
94
|
+
if length == 0:
|
|
95
|
+
break
|
|
96
|
+
labels.append(data[offset : offset + length].decode())
|
|
97
|
+
offset += length
|
|
98
|
+
qtype = struct.unpack_from(">H", data, offset)[0]
|
|
99
|
+
return ".".join(labels), qtype
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_reply(query: bytes, name: str, ip: str) -> bytes:
|
|
103
|
+
# Header: copy transaction ID, set QR=1, AA=1, QDCOUNT=1, ANCOUNT=1
|
|
104
|
+
tid = query[:2]
|
|
105
|
+
flags = b"\x81\x80"
|
|
106
|
+
counts = b"\x00\x01\x00\x01\x00\x00\x00\x00"
|
|
107
|
+
header = tid + flags + counts
|
|
108
|
+
|
|
109
|
+
# Question section (copy from query, up to and including QTYPE+QCLASS)
|
|
110
|
+
question_end = 12
|
|
111
|
+
while question_end < len(query):
|
|
112
|
+
length = query[question_end]
|
|
113
|
+
question_end += 1
|
|
114
|
+
if length == 0:
|
|
115
|
+
question_end += 4 # QTYPE + QCLASS
|
|
116
|
+
break
|
|
117
|
+
question_end += length
|
|
118
|
+
question = query[12:question_end]
|
|
119
|
+
|
|
120
|
+
# Answer: name pointer, A record, TTL=60, RDATA=ip
|
|
121
|
+
answer = b"\xc0\x0c" # pointer to question name
|
|
122
|
+
answer += b"\x00\x01" # TYPE A
|
|
123
|
+
answer += b"\x00\x01" # CLASS IN
|
|
124
|
+
answer += b"\x00\x00\x00\x3c" # TTL 60
|
|
125
|
+
answer += b"\x00\x04" # RDLENGTH 4
|
|
126
|
+
answer += socket.inet_aton(ip)
|
|
127
|
+
|
|
128
|
+
return header + question + answer
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _forward(data: bytes, upstream: str) -> bytes | None:
|
|
132
|
+
loop = asyncio.get_event_loop()
|
|
133
|
+
fut: asyncio.Future[bytes] = loop.create_future()
|
|
134
|
+
|
|
135
|
+
class _Forwarder(asyncio.DatagramProtocol):
|
|
136
|
+
def datagram_received(self, d: bytes, _: tuple) -> None:
|
|
137
|
+
if not fut.done():
|
|
138
|
+
fut.set_result(d)
|
|
139
|
+
|
|
140
|
+
def error_received(self, exc: Exception) -> None:
|
|
141
|
+
if not fut.done():
|
|
142
|
+
fut.set_exception(exc)
|
|
143
|
+
|
|
144
|
+
transport, _ = await loop.create_datagram_endpoint(_Forwarder, remote_addr=(upstream, 53))
|
|
145
|
+
transport.sendto(data)
|
|
146
|
+
try:
|
|
147
|
+
return await asyncio.wait_for(fut, timeout=3)
|
|
148
|
+
finally:
|
|
149
|
+
transport.close()
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pypproxy.rule.rule import RuleManager
|
|
8
|
+
from pypproxy.store.models import Entry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def export_entries(entries: list[Entry]) -> str:
|
|
12
|
+
"""Export entries to JSON string."""
|
|
13
|
+
return json.dumps(
|
|
14
|
+
[e.to_dict() for e in entries],
|
|
15
|
+
indent=2,
|
|
16
|
+
ensure_ascii=False,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def export_rules(rules: RuleManager) -> str:
|
|
21
|
+
"""Export rules to JSON string."""
|
|
22
|
+
return json.dumps(
|
|
23
|
+
[r.to_dict() for r in rules.list()],
|
|
24
|
+
indent=2,
|
|
25
|
+
ensure_ascii=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def export_all(entries: list[Entry], rules: RuleManager) -> str:
|
|
30
|
+
"""Export everything to a single JSON string."""
|
|
31
|
+
return json.dumps(
|
|
32
|
+
{
|
|
33
|
+
"version": 1,
|
|
34
|
+
"entries": [e.to_dict() for e in entries],
|
|
35
|
+
"rules": [r.to_dict() for r in rules.list()],
|
|
36
|
+
},
|
|
37
|
+
indent=2,
|
|
38
|
+
ensure_ascii=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def import_rules(data: str, rules: RuleManager) -> int:
|
|
43
|
+
"""Import rules from JSON string. Returns count of imported rules."""
|
|
44
|
+
from pypproxy.rule.rule import Rule
|
|
45
|
+
|
|
46
|
+
parsed: list[dict[str, Any]] = json.loads(data)
|
|
47
|
+
if isinstance(parsed, dict):
|
|
48
|
+
parsed = parsed.get("rules", [])
|
|
49
|
+
count = 0
|
|
50
|
+
for item in parsed:
|
|
51
|
+
rule = Rule.from_dict(item)
|
|
52
|
+
rules.add(rule)
|
|
53
|
+
count += 1
|
|
54
|
+
return count
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def export_har(entries: list[Entry]) -> str:
|
|
58
|
+
"""Export entries in HAR (HTTP Archive) format."""
|
|
59
|
+
har_entries = []
|
|
60
|
+
for e in entries:
|
|
61
|
+
req_headers = [{"name": k, "value": ", ".join(v)} for k, v in e.req_headers.items()]
|
|
62
|
+
resp_headers = [{"name": k, "value": ", ".join(v)} for k, v in e.resp_headers.items()]
|
|
63
|
+
body_text = ""
|
|
64
|
+
if e.resp_body:
|
|
65
|
+
try:
|
|
66
|
+
body_text = e.resp_body.decode("utf-8", errors="replace")
|
|
67
|
+
except Exception:
|
|
68
|
+
body_text = base64.b64encode(e.resp_body).decode()
|
|
69
|
+
|
|
70
|
+
url = f"{e.scheme}://{e.host}{e.path}"
|
|
71
|
+
if e.query:
|
|
72
|
+
url += f"?{e.query}"
|
|
73
|
+
|
|
74
|
+
har_entries.append(
|
|
75
|
+
{
|
|
76
|
+
"startedDateTime": e.created_at.isoformat(),
|
|
77
|
+
"time": e.duration_ms,
|
|
78
|
+
"request": {
|
|
79
|
+
"method": e.method,
|
|
80
|
+
"url": url,
|
|
81
|
+
"httpVersion": "HTTP/1.1",
|
|
82
|
+
"headers": req_headers,
|
|
83
|
+
"queryString": [],
|
|
84
|
+
"cookies": [],
|
|
85
|
+
"headersSize": -1,
|
|
86
|
+
"bodySize": len(e.req_body),
|
|
87
|
+
"postData": {
|
|
88
|
+
"mimeType": e.req_headers.get("content-type", [""])[0],
|
|
89
|
+
"text": e.req_body.decode("utf-8", errors="replace") if e.req_body else "",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
"response": {
|
|
93
|
+
"status": e.status_code,
|
|
94
|
+
"statusText": "",
|
|
95
|
+
"httpVersion": "HTTP/1.1",
|
|
96
|
+
"headers": resp_headers,
|
|
97
|
+
"cookies": [],
|
|
98
|
+
"content": {
|
|
99
|
+
"size": len(e.resp_body),
|
|
100
|
+
"mimeType": e.resp_headers.get("content-type", [""])[0],
|
|
101
|
+
"text": body_text,
|
|
102
|
+
},
|
|
103
|
+
"redirectURL": "",
|
|
104
|
+
"headersSize": -1,
|
|
105
|
+
"bodySize": len(e.resp_body),
|
|
106
|
+
},
|
|
107
|
+
"cache": {},
|
|
108
|
+
"timings": {"send": 0, "wait": e.duration_ms, "receive": 0},
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return json.dumps(
|
|
113
|
+
{
|
|
114
|
+
"log": {
|
|
115
|
+
"version": "1.2",
|
|
116
|
+
"creator": {"name": "paxy", "version": "0.1.0"},
|
|
117
|
+
"entries": har_entries,
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
indent=2,
|
|
121
|
+
ensure_ascii=False,
|
|
122
|
+
)
|