procproxy 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.
- procproxy-0.1.0.dist-info/METADATA +489 -0
- procproxy-0.1.0.dist-info/RECORD +45 -0
- procproxy-0.1.0.dist-info/WHEEL +5 -0
- procproxy-0.1.0.dist-info/entry_points.txt +2 -0
- procproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- procproxy-0.1.0.dist-info/top_level.txt +1 -0
- proxycore/__init__.py +7 -0
- proxycore/__main__.py +65 -0
- proxycore/api.py +226 -0
- proxycore/backend/__init__.py +49 -0
- proxycore/backend/base.py +159 -0
- proxycore/backend/null.py +51 -0
- proxycore/backend/windivert/__init__.py +657 -0
- proxycore/backend/windivert/filter.py +123 -0
- proxycore/backend/windivert/pidmap.py +289 -0
- proxycore/backend/windivert/pkt.py +424 -0
- proxycore/canproxy.py +229 -0
- proxycore/checkroute.py +247 -0
- proxycore/cli.py +605 -0
- proxycore/config.py +1010 -0
- proxycore/dns/__init__.py +46 -0
- proxycore/dns/cache.py +209 -0
- proxycore/dns/modes.py +451 -0
- proxycore/dns/udp.py +606 -0
- proxycore/forward.py +572 -0
- proxycore/ipc.py +637 -0
- proxycore/log.py +298 -0
- proxycore/pathsetup.py +112 -0
- proxycore/probe.py +168 -0
- proxycore/proc.py +342 -0
- proxycore/rules.py +730 -0
- proxycore/runtime.py +654 -0
- proxycore/service/__init__.py +536 -0
- proxycore/service/__main__.py +124 -0
- proxycore/tunnel/__init__.py +8 -0
- proxycore/tunnel/relay.py +393 -0
- proxycore/tunnel/sessions.py +315 -0
- proxycore/tunnel/sink.py +325 -0
- proxycore/types.py +165 -0
- proxycore/upstream/__init__.py +52 -0
- proxycore/upstream/base.py +150 -0
- proxycore/upstream/chain.py +92 -0
- proxycore/upstream/http_connect.py +148 -0
- proxycore/upstream/socks4.py +106 -0
- proxycore/upstream/socks5.py +266 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""路线 A · WinDivert 后端。
|
|
2
|
+
|
|
3
|
+
数据流:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
拦截线程
|
|
7
|
+
WinDivert.recv()
|
|
8
|
+
├─ 解析 IP/TCP
|
|
9
|
+
├─ src_port == sink 端口? → 方向一:sink 的应答
|
|
10
|
+
│ ├─ 查表(按客户端端口)取原始目标;查不到 → 原样放行
|
|
11
|
+
│ ├─ 改写 src = 原始目标 # 客户端 socket 才认这条应答
|
|
12
|
+
│ ├─ packet.direction = INBOUND # 反射:内核才会投递给本地 socket
|
|
13
|
+
│ └─ send
|
|
14
|
+
└─ 方向二:出向包
|
|
15
|
+
├─ 已在改道表里 → 直接改写目的(后续所有的包都要改,不只 SYN)
|
|
16
|
+
└─ 新连接的 SYN → 查 PID → decide(先记账、查表竞态)
|
|
17
|
+
├─ DIRECT → 原样放行
|
|
18
|
+
├─ BLOCK → 丢弃(不 send)
|
|
19
|
+
└─ PROXY → 记账 → 改写 dst = sink → direction = INBOUND → send
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
为什么必须双向:只改首个 SYN 的话,握手在第二包就断,客户端的后续包仍发往
|
|
23
|
+
原始目标,而 sink 的应答源地址是 sink、四元组对不上,会被内核直接丢弃,
|
|
24
|
+
`accept()` 永不返回。症状是改道日志正常,但应用卡死。
|
|
25
|
+
|
|
26
|
+
为什么必须翻方向标志:只改地址、不把包标记为"入向",内核不会把它投递给本地
|
|
27
|
+
socket。这是 WinDivert 官方 `streamdump` 示例的 "reflect" 手法,也是本实现
|
|
28
|
+
最容易漏掉的一环。
|
|
29
|
+
|
|
30
|
+
设计说明:
|
|
31
|
+
* 不按 PID 过滤(WinDivert 没有这个能力,只能查表);
|
|
32
|
+
* 上游/sink 端点在用户态再判一次(过滤器排除是静态的,上游地址会变);
|
|
33
|
+
* 抓取范围是"所有出向 TCP",本机每个出向 TCP 包都会过一次用户态:路线 A 透明
|
|
34
|
+
代理的固有开销;
|
|
35
|
+
* 无 pydivert / 非管理员 / 非 Windows → `BackendUnavailable`(绝不静默直连)。
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import contextlib
|
|
41
|
+
import os
|
|
42
|
+
import socket
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from proxycore.backend.base import BackendUnavailable, BaseBackend
|
|
48
|
+
from proxycore.log import EventLogger, get_logger
|
|
49
|
+
from proxycore.types import Action, ConnRequest, Endpoint, ProcInfo, Proto
|
|
50
|
+
|
|
51
|
+
from .filter import build_filter, upstream_ips_from_config
|
|
52
|
+
from .pidmap import PortPidTable, is_supported
|
|
53
|
+
from .pkt import IPPROTO_TCP, PacketError, ParsedPacket, reflect_packet
|
|
54
|
+
|
|
55
|
+
LOG = EventLogger(get_logger("proxycore.backend.windivert"))
|
|
56
|
+
|
|
57
|
+
DEFAULT_PRIORITY = 123 # WinDivert 优先级(文档未规定具体值)
|
|
58
|
+
MAX_RECV_ERRORS = 5
|
|
59
|
+
|
|
60
|
+
# 改道记录存活时长(秒)。反向路径在整条连接期间都要用这张表,
|
|
61
|
+
# 所以不能在 accept 一次后就删。5 分钟是回收策略的取值。
|
|
62
|
+
REDIRECT_TTL_S = 300.0
|
|
63
|
+
# 每隔多少个包做一次 GC(避免每包都遍历字典)
|
|
64
|
+
GC_EVERY_PACKETS = 512
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WinDivertBackend(BaseBackend):
|
|
68
|
+
"""路线 A 后端。"""
|
|
69
|
+
|
|
70
|
+
name = "windivert"
|
|
71
|
+
# 路线 A 完全不碰 UDP:两个句柄(v4/v6)的过滤器都以 `tcp` 开头,
|
|
72
|
+
# UDP 数据报根本进不来,更不可能被改写或阻断。
|
|
73
|
+
# `udp_policy` 在本后端下整体空转,包括 `443: block`(拦 QUIC)。
|
|
74
|
+
# 希望路线 A 至少做到"直连/阻断"两种,阻断那一半还没写。
|
|
75
|
+
supports_udp = False
|
|
76
|
+
supports_pid_native = False # PID 必须查 GetExtendedTcpTable
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
config: Any = None,
|
|
82
|
+
dry_run: bool = False,
|
|
83
|
+
sink: Endpoint | None = None,
|
|
84
|
+
pid_table: PortPidTable | None = None,
|
|
85
|
+
**_ignored: Any,
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__()
|
|
88
|
+
self.config = config
|
|
89
|
+
self.dry_run = dry_run
|
|
90
|
+
self._sink = sink
|
|
91
|
+
self.pid_table = pid_table or PortPidTable()
|
|
92
|
+
self._handle: Any = None
|
|
93
|
+
self._handle6: Any = None
|
|
94
|
+
self._thread: threading.Thread | None = None
|
|
95
|
+
self._thread6: threading.Thread | None = None
|
|
96
|
+
self._stop = threading.Event()
|
|
97
|
+
self._filter = ""
|
|
98
|
+
self._filter6 = ""
|
|
99
|
+
self._exclude_ips: list[str] = []
|
|
100
|
+
# (地址族, 客户端源端口) → (原始目标, 记账时间)。反向路径在整条连接期间都要读它。
|
|
101
|
+
#
|
|
102
|
+
# 键里必须带地址族:v4 与 v6 的临时端口来自同一段,两条连接完全可能撞同一个端口号。
|
|
103
|
+
# 只按端口做键的话,v6 的 sink 连接会被拨到 v4 那条连接的原始目标上。
|
|
104
|
+
# 对代理来说"连错目标"是红线。
|
|
105
|
+
self._redirects: dict[tuple[bool, int], tuple[Endpoint, float]] = {}
|
|
106
|
+
# (地址族, 客户端源端口) → (客户端真实地址, 正向包的接口索引)。
|
|
107
|
+
# 反向路径要把 sink 的应答送回"客户端真实地址",可回环里的应答包只带着
|
|
108
|
+
# <回环>:<客户端端口>,反查不出真实地址;接口索引同理(见 _reflect_sink_reply)。
|
|
109
|
+
# 与 `_redirects` 同生共死:增删一律走 _record_redirect / _forget_redirect。
|
|
110
|
+
self._redirect_peer: dict[tuple[bool, int], tuple[str, int]] = {}
|
|
111
|
+
self._recv_errors = 0
|
|
112
|
+
self._unknown_pid = 0
|
|
113
|
+
self._packet_count = 0
|
|
114
|
+
# pydivert 的方向常量,在 start() 里缓存(避免每包都 import)
|
|
115
|
+
self._dir_inbound: Any = None
|
|
116
|
+
self._dir_outbound: Any = None
|
|
117
|
+
self.last_error = ""
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------ 生命周期 --
|
|
120
|
+
def start(self) -> None:
|
|
121
|
+
if not is_supported():
|
|
122
|
+
raise BackendUnavailable(
|
|
123
|
+
f"WinDivert 后端仅支持 Windows(当前 os.name={os.name});"
|
|
124
|
+
"联调请用 backend=null"
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
import pydivert
|
|
128
|
+
except ImportError as exc:
|
|
129
|
+
raise BackendUnavailable(
|
|
130
|
+
f"缺少 pydivert:{exc}。路线 A 需要 `pip install pydivert psutil`,且必须管理员运行"
|
|
131
|
+
) from exc
|
|
132
|
+
# 反射要用它把改写后的包标记为"入向"(否则内核不投递给本地 socket);
|
|
133
|
+
# 正向改道反过来要用 OUTBOUND(理由见 _redirect_to_sink)。
|
|
134
|
+
self._dir_inbound = pydivert.Direction.INBOUND
|
|
135
|
+
self._dir_outbound = pydivert.Direction.OUTBOUND
|
|
136
|
+
|
|
137
|
+
sink = self._sink or self._sink_from_config()
|
|
138
|
+
self._exclude_ips = upstream_ips_from_config(self.config)
|
|
139
|
+
self._filter = build_filter(
|
|
140
|
+
sink_addr=sink.addr, sink_port=sink.port, exclude_ips=self._exclude_ips
|
|
141
|
+
)
|
|
142
|
+
LOG.info("windivert_filter", filter_expr=self._filter, sink=str(sink), exclude=self._exclude_ips)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
self._handle = pydivert.WinDivert(self._filter, priority=DEFAULT_PRIORITY)
|
|
146
|
+
self._handle.open()
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
raise BackendUnavailable(
|
|
149
|
+
f"打开 WinDivert 失败:{exc}。常见原因:① 非管理员;② EDR/杀软拦截了 WinDivert;"
|
|
150
|
+
"③ 已有其它 WinDivert 实例。诊断:`sc query WinDivert`"
|
|
151
|
+
) from exc
|
|
152
|
+
|
|
153
|
+
self._open_v6_handle(pydivert, sink)
|
|
154
|
+
|
|
155
|
+
super().start()
|
|
156
|
+
self._stop.clear()
|
|
157
|
+
self._thread = threading.Thread(
|
|
158
|
+
target=self._capture_loop, args=(self._handle,), name="ppx-windivert", daemon=True
|
|
159
|
+
)
|
|
160
|
+
self._thread.start()
|
|
161
|
+
if self._handle6 is not None:
|
|
162
|
+
self._thread6 = threading.Thread(
|
|
163
|
+
target=self._capture_loop, args=(self._handle6,), name="ppx-windivert6", daemon=True
|
|
164
|
+
)
|
|
165
|
+
self._thread6.start()
|
|
166
|
+
LOG.info("windivert_started", dry_run=self.dry_run, ipv6=self._handle6 is not None)
|
|
167
|
+
|
|
168
|
+
def _open_v6_handle(self, pydivert: Any, sink: Endpoint) -> None:
|
|
169
|
+
"""开第二个句柄抓 IPv6(一个句柄一条过滤器:WinDivert 的硬约束)。
|
|
170
|
+
|
|
171
|
+
不静默直连(AGENTS.md):v6 打不开时不能装作没事。v4 那条过滤器用的是
|
|
172
|
+
`ip.DstAddr`(IPv4 专有字段),WinDivert 里"字段与报文不相关 ⇒ test 判 false",
|
|
173
|
+
所以 v6 报文一个都不会进 v4 通道,双栈目标由 Windows 优先选 v6,
|
|
174
|
+
漏过就是真实 IP 直接出网。打不开就 WARN 到日志里,让用户知道覆盖面只有 v4。
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
self._filter6 = build_filter(
|
|
178
|
+
sink_addr="::1", sink_port=sink.port, exclude_ips=self._exclude_ips, ipv6=True
|
|
179
|
+
)
|
|
180
|
+
handle6 = pydivert.WinDivert(self._filter6, priority=DEFAULT_PRIORITY)
|
|
181
|
+
handle6.open()
|
|
182
|
+
except Exception as exc: # noqa: BLE001 - v6 打不开不该让整个后端起不来
|
|
183
|
+
self._handle6 = None
|
|
184
|
+
self._filter6 = ""
|
|
185
|
+
LOG.warn(
|
|
186
|
+
"windivert_ipv6_not_intercepted",
|
|
187
|
+
msg="IPv6 句柄打开失败:IPv6 流量不会被拦截,将直连出去(真实 IP 可能泄漏)。"
|
|
188
|
+
"双栈目标由 Windows 优先选 v6",
|
|
189
|
+
error=str(exc),
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
self._handle6 = handle6
|
|
193
|
+
LOG.info("windivert_filter_ipv6", filter_expr=self._filter6, sink=f"[::1]:{sink.port}")
|
|
194
|
+
|
|
195
|
+
def stop(self) -> None:
|
|
196
|
+
self._stop.set()
|
|
197
|
+
handle, self._handle = self._handle, None
|
|
198
|
+
handle6, self._handle6 = self._handle6, None
|
|
199
|
+
for item in (handle, handle6):
|
|
200
|
+
if item is not None:
|
|
201
|
+
with contextlib.suppress(Exception):
|
|
202
|
+
item.close()
|
|
203
|
+
thread, self._thread = self._thread, None
|
|
204
|
+
if thread is not None:
|
|
205
|
+
thread.join(timeout=2.0) # 契约:stop() 后 2s 内不再回调 decide
|
|
206
|
+
thread6, self._thread6 = self._thread6, None
|
|
207
|
+
if thread6 is not None:
|
|
208
|
+
thread6.join(timeout=2.0)
|
|
209
|
+
self._redirects.clear()
|
|
210
|
+
self._redirect_peer.clear()
|
|
211
|
+
super().stop()
|
|
212
|
+
LOG.info("windivert_stopped", captured=self.stats().get("captured", 0))
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------ 改道 -----
|
|
215
|
+
def _record_redirect(
|
|
216
|
+
self, is_v6: bool, client_port: int, original: Endpoint, client_addr: str, ifidx: int
|
|
217
|
+
) -> None:
|
|
218
|
+
"""记账。"""
|
|
219
|
+
key = (bool(is_v6), int(client_port))
|
|
220
|
+
self._redirects[key] = (original, time.time())
|
|
221
|
+
self._redirect_peer[key] = (client_addr, ifidx)
|
|
222
|
+
self.set_active(redirects=len(self._redirects))
|
|
223
|
+
|
|
224
|
+
def _forget_redirect(self, key: tuple[bool, int]) -> None:
|
|
225
|
+
self._redirects.pop(key, None)
|
|
226
|
+
self._redirect_peer.pop(key, None)
|
|
227
|
+
|
|
228
|
+
def _touch_redirect(self, key: tuple[bool, int]) -> None:
|
|
229
|
+
"""刷新活跃连接的时间戳。
|
|
230
|
+
|
|
231
|
+
TTL 必须从"最后一次流量"算,而不是从 SYN 算:超过 TTL 的连接一旦被
|
|
232
|
+
回收,后续包会走 `_send()` 原样发往真实目标,等于绕过代理。
|
|
233
|
+
"""
|
|
234
|
+
entry = self._redirects.get(key)
|
|
235
|
+
if entry is not None:
|
|
236
|
+
self._redirects[key] = (entry[0], time.time())
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _key_of(sock: object, backend_ctx: int) -> tuple[bool, int]:
|
|
240
|
+
"""从 sink 递过来的套接字判断地址族(路线 A 的 `backend_ctx` 就是客户端源端口)。
|
|
241
|
+
|
|
242
|
+
sink 的 v4/v6 是两个监听套接字,`client.family` 天然带族信息,
|
|
243
|
+
所以不需要改后端契约里的 `backend_ctx: int`。
|
|
244
|
+
"""
|
|
245
|
+
is_v6 = getattr(sock, "family", None) == socket.AF_INET6
|
|
246
|
+
return is_v6, int(backend_ctx)
|
|
247
|
+
|
|
248
|
+
def redirect(self, conn: ConnRequest, sink: Endpoint) -> bool:
|
|
249
|
+
"""登记 sink 并记账(真正的报文改写发生在抓包循环里)。
|
|
250
|
+
|
|
251
|
+
先记账再发包:`_redirects[(地址族, 客户端源端口)] = (原始目标, 时间)`,sink 侧用源端口反查。
|
|
252
|
+
|
|
253
|
+
这里只更新目标与时间戳,不动 `_redirect_peer`,客户端真实地址与接口索引
|
|
254
|
+
只有抓包循环手里有(`_decide_and_record` 已经记过),本方法是给外部调用者的
|
|
255
|
+
入口,拿不到那两个值,覆写只会把反向路径要用的信息抹掉。
|
|
256
|
+
"""
|
|
257
|
+
if not self.running:
|
|
258
|
+
self.count("failed")
|
|
259
|
+
return False
|
|
260
|
+
self._sink = sink
|
|
261
|
+
key = (bool(conn.dst.is_v6), int(conn.backend_id))
|
|
262
|
+
self._redirects[key] = (conn.dst, time.time())
|
|
263
|
+
self.set_active(redirects=len(self._redirects))
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
def lookup_original(self, client_socket: object, backend_ctx: int) -> Endpoint | None:
|
|
267
|
+
"""路线 A 的原始目标由会话表维护,本后端提供自己的映射作为兜底。
|
|
268
|
+
|
|
269
|
+
返回 None = 没有重定向记录(sink 必须关闭该连接);
|
|
270
|
+
查询出错 = 抛异常(本实现没有会失败的查询路径)。
|
|
271
|
+
|
|
272
|
+
不弹出记录:反向路径还要用它把 sink 的应答改写成"来自原始目标",
|
|
273
|
+
整条连接生命周期内都必须留着(回收由 `_gc_redirects` 按时间做)。
|
|
274
|
+
在这里 pop 会让反向路径拿不到目标。
|
|
275
|
+
按 (地址族, 端口) 查:v4/v6 同名端口是两条不同的连接(见 `_redirects` 的说明)。
|
|
276
|
+
"""
|
|
277
|
+
entry = self._redirects.get(self._key_of(client_socket, backend_ctx))
|
|
278
|
+
return entry[0] if entry is not None else None
|
|
279
|
+
|
|
280
|
+
def _gc_redirects(self, now: float) -> int:
|
|
281
|
+
"""回收超时的改道记录。"""
|
|
282
|
+
dead = [key for key, (_t, ts) in self._redirects.items() if now - ts > REDIRECT_TTL_S]
|
|
283
|
+
for key in dead:
|
|
284
|
+
self._forget_redirect(key)
|
|
285
|
+
if dead:
|
|
286
|
+
self.set_active(redirects=len(self._redirects))
|
|
287
|
+
return len(dead)
|
|
288
|
+
|
|
289
|
+
def on_udp(self, handler: Any) -> None:
|
|
290
|
+
raise NotImplementedError(
|
|
291
|
+
"本后端不捕获 UDP(两个句柄的过滤器都以 tcp 开头):udp_policy 整体空转,"
|
|
292
|
+
"UDP/QUIC 一律直连出去,既不做 proxy 也不做 block。"
|
|
293
|
+
)
|
|
294
|
+
# ------------------------------------------------------------ 抓包 -----
|
|
295
|
+
def _sink_from_config(self) -> Endpoint:
|
|
296
|
+
sink_cfg = getattr(getattr(self.config, "service", None), "sink", None)
|
|
297
|
+
if sink_cfg is not None:
|
|
298
|
+
host = str(sink_cfg.host)
|
|
299
|
+
return Endpoint(addr=host, port=int(sink_cfg.port), is_v6=":" in host)
|
|
300
|
+
return Endpoint("127.0.0.1", 19080)
|
|
301
|
+
|
|
302
|
+
def _capture_loop(self, handle: Any) -> None:
|
|
303
|
+
"""一个句柄一条线程:v4 一条、v6 一条(WinDivert 一个句柄只能有一条过滤器)。"""
|
|
304
|
+
if handle is None: # pragma: no cover - 启动顺序错误
|
|
305
|
+
return
|
|
306
|
+
sink = self._sink or self._sink_from_config()
|
|
307
|
+
while not self._stop.is_set():
|
|
308
|
+
try:
|
|
309
|
+
packet = handle.recv()
|
|
310
|
+
except Exception as exc: # noqa: BLE001 - 驱动被卸载 / 句柄失效
|
|
311
|
+
if self._stop.is_set():
|
|
312
|
+
return
|
|
313
|
+
self._recv_errors += 1
|
|
314
|
+
self.last_error = str(exc)
|
|
315
|
+
if self._recv_errors > MAX_RECV_ERRORS:
|
|
316
|
+
LOG.error("windivert_recv_failed", error=str(exc), errors=self._recv_errors)
|
|
317
|
+
return
|
|
318
|
+
time.sleep(0.05)
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
self._handle_packet(packet, sink)
|
|
323
|
+
except Exception as exc: # noqa: BLE001 - 单包失败不能让抓包线程死掉
|
|
324
|
+
LOG.error("windivert_packet_failed", error=str(exc))
|
|
325
|
+
|
|
326
|
+
def _handle_packet(self, packet: Any, sink: Endpoint) -> None:
|
|
327
|
+
"""处理一个被拦下的包(两个方向都走这里)。
|
|
328
|
+
|
|
329
|
+
两个方向的注入方式必须不同:正向改成"回环→sink"并按出向注入,
|
|
330
|
+
反向改成"原始目标→客户端"并按入向注入。这是本机实测出来的硬约束,
|
|
331
|
+
细节见 `_redirect_to_sink` 与 `_reflect_sink_reply`。
|
|
332
|
+
"""
|
|
333
|
+
self.count_decision("captured")
|
|
334
|
+
raw = bytes(getattr(packet, "raw", b"") or b"")
|
|
335
|
+
if not raw: # pragma: no cover - pydivert 一定给 raw
|
|
336
|
+
self._send(packet)
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
parsed = ParsedPacket.parse(raw)
|
|
341
|
+
except PacketError as exc:
|
|
342
|
+
LOG.debug("pkt_parse_skipped", error=str(exc))
|
|
343
|
+
self._send(packet)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if parsed.proto != IPPROTO_TCP:
|
|
347
|
+
# 过滤器以 `tcp` 开头,UDP 报文压根进不来;这里主要是 IPv6 扩展头
|
|
348
|
+
# (NextHeader=0/43/44/60)被解析器标成 proto=0 的情况。
|
|
349
|
+
# 放行等于让这部分流量绕过代理直连出去,所以按"无法处理"阻断并计数。
|
|
350
|
+
self.count("unsupported")
|
|
351
|
+
LOG.warn(
|
|
352
|
+
"pkt_unsupported_dropped",
|
|
353
|
+
proto=parsed.proto,
|
|
354
|
+
src=f"{parsed.src_addr}:{parsed.src_port}",
|
|
355
|
+
dst=f"{parsed.dst_addr}:{parsed.dst_port}",
|
|
356
|
+
warnings=parsed.warnings,
|
|
357
|
+
hint="报文无法解析/不是 TCP(可能是 IPv6 扩展头),已丢弃而不是直连",
|
|
358
|
+
)
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
self._packet_count += 1
|
|
362
|
+
if self._packet_count % GC_EVERY_PACKETS == 0:
|
|
363
|
+
self._gc_redirects(time.time())
|
|
364
|
+
|
|
365
|
+
# ---- 方向一:来自 sink 的包 → 反射成"来自原始目标" ----
|
|
366
|
+
# 只有本族的回环 sink 端口才算:v4 的 19080 与 v6 的 19080 是两条不同的流。
|
|
367
|
+
if parsed.src_port == sink.port and parsed.src_addr == self._loopback_for(parsed.is_v6):
|
|
368
|
+
self._reflect_sink_reply(packet, raw, parsed)
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
# ---- 方向二:出向包(客户端 → 目标) ----
|
|
372
|
+
self._handle_outbound(packet, raw, parsed, sink)
|
|
373
|
+
|
|
374
|
+
def _reflect_sink_reply(self, packet: Any, raw: bytes, parsed: ParsedPacket) -> None:
|
|
375
|
+
"""sink 发出的应答:改写成"来自原始目标、发往客户端真实地址",入向注入。
|
|
376
|
+
|
|
377
|
+
客户端 socket 在 SYN_SENT 里等的是 `原始目标` 的应答。sink 回的是
|
|
378
|
+
`sink → 客户端`,四元组不匹配,内核会直接丢弃,所以必须把源改掉。
|
|
379
|
+
查表用的是客户端端口(= 这条包的目的端口)。
|
|
380
|
+
|
|
381
|
+
目的地址也必须改:正向改道为了让报文走通回环,把源地址写成了回环地址
|
|
382
|
+
(v4 `127.0.0.1`、v6 `::1`),于是 sink 的应答目的是 `<回环>:<客户端端口>`,
|
|
383
|
+
而客户端 socket 其实挂在真实网卡地址上,不改回真实地址的话,这条应答
|
|
384
|
+
哪也去不了。真实地址存在 `_redirect_peer` 里(回环报文自身查不出来)。
|
|
385
|
+
|
|
386
|
+
注入接口索引同样要取正向包记下的那个:sink 的应答是从回环路径出来的,
|
|
387
|
+
它自带的索引是回环接口(实测 v4/v6 都是 1),拿它注入到网卡地址上会被丢掉。
|
|
388
|
+
"""
|
|
389
|
+
key = (parsed.is_v6, parsed.dst_port)
|
|
390
|
+
entry = self._redirects.get(key)
|
|
391
|
+
if entry is None:
|
|
392
|
+
# 不是我们改道的连接(或记录已回收)→ 原样放行,不猜
|
|
393
|
+
self._send(packet)
|
|
394
|
+
return
|
|
395
|
+
self._touch_redirect(key)
|
|
396
|
+
|
|
397
|
+
original = entry[0]
|
|
398
|
+
peer = self._redirect_peer.get(key)
|
|
399
|
+
dst_addr = peer[0] if peer is not None else parsed.dst_addr
|
|
400
|
+
ifidx = peer[1] if peer is not None else None
|
|
401
|
+
try:
|
|
402
|
+
rewritten = reflect_packet(
|
|
403
|
+
raw,
|
|
404
|
+
src_addr=original.addr,
|
|
405
|
+
src_port=original.port,
|
|
406
|
+
dst_addr=dst_addr,
|
|
407
|
+
dst_port=parsed.dst_port,
|
|
408
|
+
)
|
|
409
|
+
except PacketError as exc:
|
|
410
|
+
self.count("failed")
|
|
411
|
+
LOG.error("reverse_rewrite_failed", dst=str(original), error=str(exc))
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
self._send_rewritten(packet, rewritten, outbound=False, ifidx=ifidx)
|
|
415
|
+
|
|
416
|
+
def _handle_outbound(
|
|
417
|
+
self, packet: Any, raw: bytes, parsed: ParsedPacket, sink: Endpoint
|
|
418
|
+
) -> None:
|
|
419
|
+
"""出向包:新连接先做决策,已有连接直接改写目的。"""
|
|
420
|
+
# 查表必须带地址族:v4 的 40000 号端口和 v6 的 40000 号端口是两条连接。
|
|
421
|
+
key = (parsed.is_v6, parsed.src_port)
|
|
422
|
+
established = self._redirects.get(key)
|
|
423
|
+
|
|
424
|
+
if established is not None:
|
|
425
|
+
self._touch_redirect(key)
|
|
426
|
+
else:
|
|
427
|
+
# 只有首个 SYN(SYN 置位且 ACK 未置位)才做决策;
|
|
428
|
+
# 其它包不是新连接(可能是没记上的短连接),原样放行。
|
|
429
|
+
if not parsed.is_syn_only:
|
|
430
|
+
self._send(packet)
|
|
431
|
+
return
|
|
432
|
+
if not self._decide_and_record(packet, parsed, sink):
|
|
433
|
+
return # 决策结果是 DIRECT/BLOCK,_decide_and_record 已经处理完
|
|
434
|
+
|
|
435
|
+
self._redirect_to_sink(packet, raw, parsed, sink)
|
|
436
|
+
|
|
437
|
+
def _redirect_to_sink(
|
|
438
|
+
self, packet: Any, raw: bytes, parsed: ParsedPacket, sink: Endpoint
|
|
439
|
+
) -> None:
|
|
440
|
+
"""把出向包改写成"发给 sink",用出向方式注入。
|
|
441
|
+
|
|
442
|
+
两个反直觉但实测必须的点(2026-10-01 本机逐条验证):
|
|
443
|
+
|
|
444
|
+
1. 源地址也要改成回环地址,不能原样保留客户端的网卡地址。
|
|
445
|
+
报文得"整条都在回环里"才投递得进去:
|
|
446
|
+
`127.0.0.1:客户端端口 → 127.0.0.1:sink` 能到 sink;
|
|
447
|
+
而 `192.168.2.160:客户端端口 → 127.0.0.1:sink`(只改目的)
|
|
448
|
+
会被内核直接丢掉,协议栈拒绝"从真实接口进来的、目的是 127/8 的报文"。
|
|
449
|
+
2. 必须按出向注入,不能标成入向。入向注入的目的若是 127.0.0.1,
|
|
450
|
+
内核同样不投递(连同"挂在回环接口上""置 Loopback 位"都试过,均无效);
|
|
451
|
+
出向注入则由协议栈按路由把它当作本机发出的回环流量,正常送到 sink。
|
|
452
|
+
|
|
453
|
+
源地址被改掉之后,sink 侧看到的对端是 `127.0.0.1:<客户端端口>`(v6 是
|
|
454
|
+
`[::1]:<客户端端口>`),所以它只能按端口反查原始目标(sink.py 就是这么做的),
|
|
455
|
+
客户端真实地址另行记录在 `_redirect_peer`,供反向路径用。
|
|
456
|
+
|
|
457
|
+
v6 沿用同一套规则:2026-10-01 用 `pinpoint13_v6.py` 实测,v4 的结论原样
|
|
458
|
+
搬到 v6 成立,`[::1]:客户端端口 → [::1]:sink`、出向注入、置 Loopback 位,
|
|
459
|
+
sink 就能 accept;标志位矩阵里其余四种组合(入向、不置 Loopback 位等)全不投递。
|
|
460
|
+
所以这里的目的地址取本族回环而不是 `sink.addr`:`sink.addr` 是配置里的
|
|
461
|
+
`127.0.0.1`,写成它的话 v6 报文会被内核丢掉。
|
|
462
|
+
"""
|
|
463
|
+
loopback = self._loopback_for(parsed.is_v6)
|
|
464
|
+
try:
|
|
465
|
+
rewritten = reflect_packet(
|
|
466
|
+
raw,
|
|
467
|
+
src_addr=loopback,
|
|
468
|
+
src_port=parsed.src_port,
|
|
469
|
+
dst_addr=loopback,
|
|
470
|
+
dst_port=sink.port,
|
|
471
|
+
)
|
|
472
|
+
except PacketError as exc:
|
|
473
|
+
LOG.error("redirect_rewrite_failed", dst=f"{parsed.dst_addr}:{parsed.dst_port}", error=str(exc))
|
|
474
|
+
self.count("failed")
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
self.count("redirected")
|
|
478
|
+
try:
|
|
479
|
+
self._send_rewritten(packet, rewritten, outbound=True, ifidx=self._ifidx(packet))
|
|
480
|
+
except Exception as exc: # noqa: BLE001 - 单包回注失败不能让抓包线程死掉
|
|
481
|
+
# 绝不静默:改道成功但回注失败 = 应用卡在 SYN_SENT
|
|
482
|
+
self.count("failed")
|
|
483
|
+
LOG.error(
|
|
484
|
+
"redirect_inject_failed",
|
|
485
|
+
src_port=parsed.src_port,
|
|
486
|
+
sink=str(sink),
|
|
487
|
+
error=str(exc),
|
|
488
|
+
hint="改道成功但回注失败:应用会卡在 SYN_SENT,且上游一条 CONNECT 都不会有",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def _decide_and_record(
|
|
492
|
+
self, packet: Any, parsed: ParsedPacket, sink: Endpoint
|
|
493
|
+
) -> bool:
|
|
494
|
+
"""对新连接做决策。返回 True = 应该改道到 sink。"""
|
|
495
|
+
pid = self.pid_table.lookup(
|
|
496
|
+
parsed.src_port, parsed.dst_addr, parsed.dst_port, is_v6=parsed.is_v6
|
|
497
|
+
)
|
|
498
|
+
if pid == 0:
|
|
499
|
+
self._unknown_pid += 1
|
|
500
|
+
LOG.debug(
|
|
501
|
+
"pid_unknown",
|
|
502
|
+
src_port=parsed.src_port,
|
|
503
|
+
dst=f"{parsed.dst_addr}:{parsed.dst_port}",
|
|
504
|
+
total=self._unknown_pid,
|
|
505
|
+
)
|
|
506
|
+
conn = ConnRequest(
|
|
507
|
+
proc=ProcInfo(pid=pid, name="", path=""),
|
|
508
|
+
proto=Proto.TCP,
|
|
509
|
+
src=Endpoint(parsed.src_addr, parsed.src_port, parsed.is_v6),
|
|
510
|
+
dst=Endpoint(parsed.dst_addr, parsed.dst_port, parsed.is_v6),
|
|
511
|
+
backend_id=parsed.src_port,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
decision = self.decide(conn)
|
|
515
|
+
|
|
516
|
+
if decision.action is Action.BLOCK:
|
|
517
|
+
self.count_decision("blocked")
|
|
518
|
+
LOG.debug("blocked", pid=pid, dst=str(conn.dst), rule=decision.rule_id)
|
|
519
|
+
return False # 不回注 = 丢弃
|
|
520
|
+
|
|
521
|
+
if self.dry_run:
|
|
522
|
+
LOG.info("dry_run_redirect", pid=pid, dst=str(conn.dst), sink=str(sink), dry_run=True)
|
|
523
|
+
self._send(packet)
|
|
524
|
+
return False
|
|
525
|
+
|
|
526
|
+
if decision.action is Action.DIRECT:
|
|
527
|
+
self.count_decision("direct")
|
|
528
|
+
self._send(packet)
|
|
529
|
+
return False
|
|
530
|
+
|
|
531
|
+
# ---- 先记账再发包 ----
|
|
532
|
+
self._record_redirect(
|
|
533
|
+
parsed.is_v6, parsed.src_port, conn.dst, parsed.src_addr, self._ifidx(packet)
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
LOG.debug(
|
|
537
|
+
"redirect",
|
|
538
|
+
pid=pid,
|
|
539
|
+
proc=conn.proc.name or "?",
|
|
540
|
+
original=str(conn.dst),
|
|
541
|
+
sink=str(sink),
|
|
542
|
+
rule=decision.rule_id,
|
|
543
|
+
)
|
|
544
|
+
return True
|
|
545
|
+
|
|
546
|
+
def _ifidx(self, packet: Any) -> int:
|
|
547
|
+
"""取抓到的包所属的接口索引(反向注入必须挂回同一个接口)。"""
|
|
548
|
+
network = getattr(getattr(packet, "wd_addr", None), "Network", None)
|
|
549
|
+
try:
|
|
550
|
+
return int(getattr(network, "IfIdx", 0))
|
|
551
|
+
except (TypeError, ValueError): # 测试替身没有 wd_addr / 值不是数字
|
|
552
|
+
return 0
|
|
553
|
+
|
|
554
|
+
@staticmethod
|
|
555
|
+
def _loopback_for(is_v6: bool) -> str:
|
|
556
|
+
"""本族回环地址:正向改写的源/目的、反向路径的识别都用它。
|
|
557
|
+
|
|
558
|
+
只能是回环地址,报文才"整条在回环里",内核才投递(见 `_redirect_to_sink`)。
|
|
559
|
+
按报文自己的地址族取,不看 sink 配置,sink 的配置地址是 `127.0.0.1`,
|
|
560
|
+
但 v6 的连接必须改道到 `::1`。
|
|
561
|
+
"""
|
|
562
|
+
return "::1" if is_v6 else "127.0.0.1"
|
|
563
|
+
|
|
564
|
+
def _handle_for(self, packet: Any) -> Any:
|
|
565
|
+
"""按报文地址族挑句柄。v4/v6 各开了一个句柄,回注要还给对应的那个。
|
|
566
|
+
|
|
567
|
+
`WinDivertSend` 本身不查过滤器,所以拿 v4 句柄送 v6 包"碰巧"也能通;
|
|
568
|
+
但那是实现细节,一旦变了就是静默丢包(应用卡在 SYN_SENT,没有任何报错)。
|
|
569
|
+
地址族从 IP 头首字节的高 4 位取,合成包、改写包、原始包都适用。
|
|
570
|
+
"""
|
|
571
|
+
raw = getattr(packet, "raw", None)
|
|
572
|
+
is_v6 = False
|
|
573
|
+
if raw is not None:
|
|
574
|
+
try:
|
|
575
|
+
is_v6 = (bytes(raw)[0] >> 4) == 6
|
|
576
|
+
except (TypeError, IndexError, ValueError): # 测试替身没有 raw
|
|
577
|
+
is_v6 = False
|
|
578
|
+
if is_v6 and self._handle6 is not None:
|
|
579
|
+
return self._handle6
|
|
580
|
+
return self._handle
|
|
581
|
+
|
|
582
|
+
def _send(self, packet: Any) -> None:
|
|
583
|
+
handle = self._handle_for(packet)
|
|
584
|
+
if handle is None: # pragma: no cover
|
|
585
|
+
return
|
|
586
|
+
handle.send(packet)
|
|
587
|
+
|
|
588
|
+
def _send_rewritten(
|
|
589
|
+
self, packet: Any, raw: bytes, *, outbound: bool, ifidx: int | None = None
|
|
590
|
+
) -> None:
|
|
591
|
+
"""回注改写后的报文(必须构造新 Packet,不能原地改 `raw`)。
|
|
592
|
+
|
|
593
|
+
pydivert 3.x 的 `Packet.raw` 是 `memoryview`,内部 `__to_buffers()` 走
|
|
594
|
+
`self.raw.obj`。把 `bytes` 直接赋给 `packet.raw` 之后,`send()` 会抛
|
|
595
|
+
`AttributeError: 'bytes' object has no attribute 'obj'`,报文永远到不了 sink。
|
|
596
|
+
这里改为按公开构造方式建新包。
|
|
597
|
+
|
|
598
|
+
方向与接口索引不能沿用原包:
|
|
599
|
+
* 正向改道是出向注入(原包也是出向,但源地址已改成回环,必须显式置出向);
|
|
600
|
+
* 反向应答是入向注入,而原包来自回环路径、自带的是回环接口索引(实测为 1),
|
|
601
|
+
拿它注入到网卡地址上会被内核丢掉,所以传入正向包记下的 `ifidx`。
|
|
602
|
+
"""
|
|
603
|
+
handle = self._handle_for(packet)
|
|
604
|
+
if handle is None: # pragma: no cover
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
import pydivert
|
|
608
|
+
|
|
609
|
+
direction = self._dir_outbound if outbound else self._dir_inbound
|
|
610
|
+
if direction is None: # pragma: no cover - start() 里已缓存
|
|
611
|
+
direction = pydivert.Direction.OUTBOUND if outbound else pydivert.Direction.INBOUND
|
|
612
|
+
|
|
613
|
+
out = pydivert.Packet(raw, direction=direction)
|
|
614
|
+
# 用改写后的报文挑句柄(地址族与原始包一致,但这里的数据更权威)
|
|
615
|
+
handle = self._handle_for(out) or handle
|
|
616
|
+
addr = getattr(out, "wd_addr", None)
|
|
617
|
+
if addr is not None:
|
|
618
|
+
addr.Outbound = 1 if outbound else 0
|
|
619
|
+
# 回环流量要置 Loopback 位:内核据此把它当本机回环报文处理
|
|
620
|
+
addr.Loopback = 1 if outbound else 0
|
|
621
|
+
addr.Sniffed = 0
|
|
622
|
+
addr.Impostor = 0
|
|
623
|
+
# 没显式给就沿用原包的接口索引(老行为,够用);生产路径两个方向都会显式传
|
|
624
|
+
idx = self._ifidx(packet) if ifidx is None else ifidx
|
|
625
|
+
if idx:
|
|
626
|
+
addr.Network.IfIdx = int(idx)
|
|
627
|
+
# 校验和交给 pydivert 重算兜底(wd_addr 上的 *Checksum 标志会影响 helper 的行为,
|
|
628
|
+
# 让它自己算最稳);我们 pkt.py 的校验和另有单测保证正确性。
|
|
629
|
+
handle.send(out)
|
|
630
|
+
|
|
631
|
+
# ------------------------------------------------------------ 统计 -----
|
|
632
|
+
def stats(self) -> dict[str, Any]:
|
|
633
|
+
out = super().stats()
|
|
634
|
+
out["recv_errors"] = self._recv_errors
|
|
635
|
+
out["unknown_pid"] = self._unknown_pid
|
|
636
|
+
out["filter"] = self._filter
|
|
637
|
+
out["pidmap"] = self.pid_table.stats()
|
|
638
|
+
out["dry_run"] = self.dry_run
|
|
639
|
+
return out
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# --------------------------------------------------------- 工具函数 --------
|
|
643
|
+
def probe_availability() -> tuple[bool, str]:
|
|
644
|
+
"""探测 WinDivert 是否可用(`cli doctor` 用),不抛异常。"""
|
|
645
|
+
if not is_supported():
|
|
646
|
+
return False, "仅 Windows 支持"
|
|
647
|
+
try:
|
|
648
|
+
import pydivert
|
|
649
|
+
except ImportError:
|
|
650
|
+
return False, "缺 pydivert(pip install pydivert psutil)"
|
|
651
|
+
return True, f"pydivert {getattr(pydivert, '__version__', 'unknown')} 已安装;运行需要管理员权限"
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
__all__ = [
|
|
655
|
+
"WinDivertBackend",
|
|
656
|
+
"probe_availability",
|
|
657
|
+
]
|