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.
Files changed (45) hide show
  1. procproxy-0.1.0.dist-info/METADATA +489 -0
  2. procproxy-0.1.0.dist-info/RECORD +45 -0
  3. procproxy-0.1.0.dist-info/WHEEL +5 -0
  4. procproxy-0.1.0.dist-info/entry_points.txt +2 -0
  5. procproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. procproxy-0.1.0.dist-info/top_level.txt +1 -0
  7. proxycore/__init__.py +7 -0
  8. proxycore/__main__.py +65 -0
  9. proxycore/api.py +226 -0
  10. proxycore/backend/__init__.py +49 -0
  11. proxycore/backend/base.py +159 -0
  12. proxycore/backend/null.py +51 -0
  13. proxycore/backend/windivert/__init__.py +657 -0
  14. proxycore/backend/windivert/filter.py +123 -0
  15. proxycore/backend/windivert/pidmap.py +289 -0
  16. proxycore/backend/windivert/pkt.py +424 -0
  17. proxycore/canproxy.py +229 -0
  18. proxycore/checkroute.py +247 -0
  19. proxycore/cli.py +605 -0
  20. proxycore/config.py +1010 -0
  21. proxycore/dns/__init__.py +46 -0
  22. proxycore/dns/cache.py +209 -0
  23. proxycore/dns/modes.py +451 -0
  24. proxycore/dns/udp.py +606 -0
  25. proxycore/forward.py +572 -0
  26. proxycore/ipc.py +637 -0
  27. proxycore/log.py +298 -0
  28. proxycore/pathsetup.py +112 -0
  29. proxycore/probe.py +168 -0
  30. proxycore/proc.py +342 -0
  31. proxycore/rules.py +730 -0
  32. proxycore/runtime.py +654 -0
  33. proxycore/service/__init__.py +536 -0
  34. proxycore/service/__main__.py +124 -0
  35. proxycore/tunnel/__init__.py +8 -0
  36. proxycore/tunnel/relay.py +393 -0
  37. proxycore/tunnel/sessions.py +315 -0
  38. proxycore/tunnel/sink.py +325 -0
  39. proxycore/types.py +165 -0
  40. proxycore/upstream/__init__.py +52 -0
  41. proxycore/upstream/base.py +150 -0
  42. proxycore/upstream/chain.py +92 -0
  43. proxycore/upstream/http_connect.py +148 -0
  44. proxycore/upstream/socks4.py +106 -0
  45. proxycore/upstream/socks5.py +266 -0
@@ -0,0 +1,424 @@
1
+ """路线 A · IPv4/IPv6 报文解析与改写 + 校验和重算。
2
+
3
+ 为什么必须有这个模块:WinDivert 改写目的地址/端口后,IP 头校验和与
4
+ TCP/UDP 伪头校验和都会失效,漏算的表现就是"连接卡死在 SYN_SENT"
5
+ 。pydivert 的 `recalculate_checksums()` 能做这件事,
6
+ 但:
7
+ * 我们要在没有驱动、没有管理员权限的环境里也能验证(单测用字节样本);
8
+ * 需要明确的"改了什么、重算了什么"语义,便于排错。
9
+
10
+ 因此本模块只做纯字节运算,不依赖 pydivert/驱动。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ipaddress
16
+ import socket
17
+ import struct
18
+ from dataclasses import dataclass, field
19
+
20
+ IPPROTO_TCP = 6
21
+ IPPROTO_UDP = 17
22
+
23
+ IPV4_MIN_HEADER = 20
24
+ IPV6_HEADER = 40
25
+ TCP_MIN_HEADER = 20
26
+ UDP_HEADER = 8
27
+
28
+ FIN = 0x01
29
+ SYN = 0x02
30
+ RST = 0x04
31
+ PSH = 0x08
32
+ ACK = 0x10
33
+
34
+
35
+ class PacketError(ValueError):
36
+ """报文无法解析(长度不足/版本不支持/扩展头不支持)。"""
37
+
38
+
39
+ # ============================================================ 校验和 =======
40
+ def ones_complement_sum(data: bytes, initial: int = 0) -> int:
41
+ """RFC 1071 的 16 位反码和。"""
42
+ total = initial
43
+ length = len(data)
44
+ if length & 1:
45
+ total += data[-1] << 8
46
+ length -= 1
47
+ # 用 4 字节步进减少 Python 循环次数
48
+ if length:
49
+ total += sum(struct.unpack(f"!{length // 2}H", data[:length]))
50
+ while total >> 16:
51
+ total = (total & 0xFFFF) + (total >> 16)
52
+ return total
53
+
54
+
55
+ def finish_checksum(total: int) -> int:
56
+ """反码和 → 校验和(取反)。"""
57
+ return (~total) & 0xFFFF
58
+
59
+
60
+ def checksum(data: bytes, initial: int = 0) -> int:
61
+ return finish_checksum(ones_complement_sum(data, initial))
62
+
63
+
64
+ def pseudo_header_v4(src: str, dst: str, proto: int, length: int) -> bytes:
65
+ return (
66
+ socket.inet_aton(src)
67
+ + socket.inet_aton(dst)
68
+ + bytes([0, proto])
69
+ + struct.pack("!H", length)
70
+ )
71
+
72
+
73
+ def pseudo_header_v6(src: str, dst: str, proto: int, length: int) -> bytes:
74
+ return (
75
+ socket.inet_pton(socket.AF_INET6, src)
76
+ + socket.inet_pton(socket.AF_INET6, dst)
77
+ + struct.pack("!I", length) # 32 位长度
78
+ + b"\x00\x00\x00" # 3 字节零
79
+ + bytes([proto])
80
+ )
81
+
82
+
83
+ def verify_v4_header_checksum(packet: bytes) -> bool:
84
+ """校验 IPv4 头校验和(重算后应与之相等)。"""
85
+ if len(packet) < IPV4_MIN_HEADER:
86
+ raise PacketError(f"IPv4 报文过短:{len(packet)}")
87
+ ihl = (packet[0] & 0x0F) * 4
88
+ return ones_complement_sum(packet[:ihl]) == 0xFFFF
89
+
90
+
91
+ def verify_transport_checksum(packet: bytes) -> bool:
92
+ """校验 IPv4 + TCP/UDP 的传输层校验和(含伪头)。"""
93
+ parsed = ParsedPacket.parse(packet)
94
+ return parsed.transport_checksum_valid
95
+
96
+
97
+ # ============================================================ 报文结构 =====
98
+ @dataclass(slots=True)
99
+ class ParsedPacket:
100
+ """一条 IPv4/IPv6 报文的结构化视图(支持原地改写后再序列化)。"""
101
+
102
+ raw: bytearray
103
+ is_v6: bool
104
+ ip_header_len: int
105
+ proto: int
106
+ src_addr: str
107
+ dst_addr: str
108
+ src_port: int
109
+ dst_port: int
110
+ tcp_flags: int = 0
111
+ tcp_seq: int = 0
112
+ tcp_ack: int = 0
113
+ # 供排错/统计
114
+ total_length: int = 0
115
+ payload_len: int = 0
116
+ warnings: list[str] = field(default_factory=list)
117
+
118
+ # ------------------------------------------------------------ 解析 -----
119
+ @classmethod
120
+ def parse(cls, packet: bytes | bytearray) -> ParsedPacket:
121
+ raw = bytearray(packet)
122
+ if not raw:
123
+ raise PacketError("空报文")
124
+ version = raw[0] >> 4
125
+ if version == 4:
126
+ return cls._parse_v4(raw)
127
+ if version == 6:
128
+ return cls._parse_v6(raw)
129
+ raise PacketError(f"不支持的 IP 版本:{version}")
130
+
131
+ @classmethod
132
+ def _parse_v4(cls, raw: bytearray) -> ParsedPacket:
133
+ if len(raw) < IPV4_MIN_HEADER:
134
+ raise PacketError(f"IPv4 报文过短:{len(raw)}")
135
+ ihl = (raw[0] & 0x0F) * 4
136
+ if ihl < IPV4_MIN_HEADER or len(raw) < ihl:
137
+ raise PacketError(f"IPv4 头长度非法:{ihl}")
138
+ total_length = struct.unpack_from("!H", raw, 2)[0] or len(raw)
139
+ proto = raw[9]
140
+ src = socket.inet_ntoa(bytes(raw[12:16]))
141
+ dst = socket.inet_ntoa(bytes(raw[16:20]))
142
+ parsed = cls(
143
+ raw=raw,
144
+ is_v6=False,
145
+ ip_header_len=ihl,
146
+ proto=proto,
147
+ src_addr=src,
148
+ dst_addr=dst,
149
+ src_port=0,
150
+ dst_port=0,
151
+ total_length=total_length,
152
+ )
153
+ parsed._parse_transport()
154
+ return parsed
155
+
156
+ @classmethod
157
+ def _parse_v6(cls, raw: bytearray) -> ParsedPacket:
158
+ if len(raw) < IPV6_HEADER:
159
+ raise PacketError(f"IPv6 报文过短:{len(raw)}")
160
+ payload_len = struct.unpack_from("!H", raw, 4)[0]
161
+ next_header = raw[6]
162
+ src = socket.inet_ntop(socket.AF_INET6, bytes(raw[8:24]))
163
+ dst = socket.inet_ntop(socket.AF_INET6, bytes(raw[24:40]))
164
+ parsed = cls(
165
+ raw=raw,
166
+ is_v6=True,
167
+ ip_header_len=IPV6_HEADER,
168
+ proto=next_header,
169
+ src_addr=src,
170
+ dst_addr=dst,
171
+ src_port=0,
172
+ dst_port=0,
173
+ total_length=IPV6_HEADER + payload_len if payload_len else len(raw),
174
+ )
175
+ if next_header in (0, 43, 44, 60):
176
+ # 扩展头:本实现不解析
177
+ parsed.proto = 0
178
+ parsed.warnings.append(f"IPv6 扩展头未处理(NextHeader={next_header})")
179
+ parsed.payload_len = payload_len
180
+ return parsed
181
+ parsed._parse_transport()
182
+ return parsed
183
+
184
+ def _parse_transport(self) -> None:
185
+ offset = self.ip_header_len
186
+ available = len(self.raw) - offset
187
+ if self.proto == IPPROTO_TCP:
188
+ if available < TCP_MIN_HEADER:
189
+ raise PacketError(f"TCP 头长度不足:{available}")
190
+ self.src_port, self.dst_port = struct.unpack_from("!HH", self.raw, offset)
191
+ self.tcp_seq = struct.unpack_from("!I", self.raw, offset + 4)[0]
192
+ self.tcp_ack = struct.unpack_from("!I", self.raw, offset + 8)[0]
193
+ self.tcp_flags = self.raw[offset + 13]
194
+ data_offset = (self.raw[offset + 12] >> 4) * 4
195
+ if data_offset < TCP_MIN_HEADER or offset + data_offset > len(self.raw):
196
+ raise PacketError(f"TCP 数据偏移非法:{data_offset}")
197
+ self.payload_len = max(0, available - data_offset)
198
+ elif self.proto == IPPROTO_UDP:
199
+ if available < UDP_HEADER:
200
+ raise PacketError(f"UDP 头长度不足:{available}")
201
+ self.src_port, self.dst_port = struct.unpack_from("!HH", self.raw, offset)
202
+ self.payload_len = max(0, struct.unpack_from("!H", self.raw, offset + 4)[0] - UDP_HEADER)
203
+ else:
204
+ # ICMP 等:只填端口 0,交由上层按 proto 处理
205
+ self.payload_len = max(0, available)
206
+
207
+ # ------------------------------------------------------------ 属性 -----
208
+ @property
209
+ def is_syn_only(self) -> bool:
210
+ """新连接的首包:SYN 置位且 ACK 未置位。"""
211
+ return self.proto == IPPROTO_TCP and bool(self.tcp_flags & SYN) and not (self.tcp_flags & ACK)
212
+
213
+ @property
214
+ def is_tcp(self) -> bool:
215
+ return self.proto == IPPROTO_TCP
216
+
217
+ @property
218
+ def is_udp(self) -> bool:
219
+ return self.proto == IPPROTO_UDP
220
+
221
+ def transport_length(self) -> int:
222
+ """传输层段长度(含头,用于伪头长度字段)。"""
223
+ return max(0, self.total_length - self.ip_header_len)
224
+
225
+ # ------------------------------------------------------------ 改写 -----
226
+ def set_dst(self, addr: str, port: int) -> None:
227
+ """改写目的地址与端口(不重算校验和:由 `recalculate_checksums()` 负责)。"""
228
+ ip = ipaddress.ip_address(addr)
229
+ if self.is_v6 != (ip.version == 6):
230
+ raise PacketError(f"地址版本不匹配:报文 is_v6={self.is_v6},地址 {addr}")
231
+ if self.is_v6:
232
+ self.raw[24:40] = ip.packed
233
+ else:
234
+ self.raw[16:20] = ip.packed
235
+ struct.pack_into("!H", self.raw, self.ip_header_len + 2, port)
236
+ self.dst_addr = addr
237
+ self.dst_port = port
238
+
239
+ def set_src(self, addr: str, port: int) -> None:
240
+ """改写源地址与端口(用于构造回包, UDP 注入需要)。"""
241
+ ip = ipaddress.ip_address(addr)
242
+ if self.is_v6 != (ip.version == 6):
243
+ raise PacketError(f"地址版本不匹配:报文 is_v6={self.is_v6},地址 {addr}")
244
+ if self.is_v6:
245
+ self.raw[8:24] = ip.packed
246
+ else:
247
+ self.raw[12:16] = ip.packed
248
+ struct.pack_into("!H", self.raw, self.ip_header_len, port)
249
+ self.src_addr = addr
250
+ self.src_port = port
251
+
252
+ def recalculate_checksums(self) -> None:
253
+ """重算 IP 头校验和 + TCP/UDP 校验和(python-can 风格的原地改写后必调)。"""
254
+ if self.is_v6:
255
+ self._recalc_v6()
256
+ else:
257
+ self._recalc_v4()
258
+
259
+ def _recalc_v4(self) -> None:
260
+ # ① IP 头校验和:先清零再算
261
+ struct.pack_into("!H", self.raw, 10, 0)
262
+ ihl = self.ip_header_len
263
+ struct.pack_into("!H", self.raw, 10, checksum(bytes(self.raw[:ihl])))
264
+ # ② 传输层校验和(含伪头)
265
+ if self.proto not in (IPPROTO_TCP, IPPROTO_UDP):
266
+ return
267
+ seg_len = min(self.transport_length(), len(self.raw) - ihl)
268
+ offset = ihl
269
+ # UDP 校验和为 0 表示"不校验"(IPv4 允许),但既然在改包就统算一遍
270
+ struct.pack_into("!H", self.raw, offset + (16 if self.proto == IPPROTO_TCP else 6), 0)
271
+ segment = bytes(self.raw[offset : offset + seg_len])
272
+ pseudo = pseudo_header_v4(self.src_addr, self.dst_addr, self.proto, seg_len)
273
+ value = checksum(segment, ones_complement_sum(pseudo))
274
+ struct.pack_into("!H", self.raw, offset + (16 if self.proto == IPPROTO_TCP else 6), value)
275
+
276
+ def _recalc_v6(self) -> None:
277
+ if self.proto not in (IPPROTO_TCP, IPPROTO_UDP):
278
+ return
279
+ ihl = self.ip_header_len
280
+ seg_len = min(self.transport_length(), len(self.raw) - ihl)
281
+ offset = ihl
282
+ csum_at = offset + (16 if self.proto == IPPROTO_TCP else 6)
283
+ if self.proto == IPPROTO_UDP:
284
+ # IPv6 下 UDP 校验和是强制的,0 视为非法 → 统一重算
285
+ struct.pack_into("!H", self.raw, csum_at, 0)
286
+ else:
287
+ struct.pack_into("!H", self.raw, csum_at, 0)
288
+ segment = bytes(self.raw[offset : offset + seg_len])
289
+ pseudo = pseudo_header_v6(self.src_addr, self.dst_addr, self.proto, seg_len)
290
+ value = checksum(segment, ones_complement_sum(pseudo))
291
+ if self.proto == IPPROTO_UDP and value == 0:
292
+ value = 0xFFFF # RFC 768:算出来是 0 时用 0xFFFF 表示
293
+ struct.pack_into("!H", self.raw, csum_at, value)
294
+
295
+ # ------------------------------------------------------ 校验和验证 -----
296
+ @property
297
+ def ip_checksum_valid(self) -> bool:
298
+ if self.is_v6:
299
+ return True # IPv6 没有头校验和
300
+ return ones_complement_sum(bytes(self.raw[: self.ip_header_len])) == 0xFFFF
301
+
302
+ @property
303
+ def transport_checksum_valid(self) -> bool:
304
+ """把校验和字段一起算进去,正确时反码和应为 0xFFFF。"""
305
+ if self.proto not in (IPPROTO_TCP, IPPROTO_UDP):
306
+ return True
307
+ ihl = self.ip_header_len
308
+ seg_len = min(self.transport_length(), len(self.raw) - ihl)
309
+ if seg_len <= 0:
310
+ return True
311
+ pseudo = (
312
+ pseudo_header_v6(self.src_addr, self.dst_addr, self.proto, seg_len)
313
+ if self.is_v6
314
+ else pseudo_header_v4(self.src_addr, self.dst_addr, self.proto, seg_len)
315
+ )
316
+ total = ones_complement_sum(bytes(self.raw[ihl : ihl + seg_len]), ones_complement_sum(pseudo))
317
+ return total == 0xFFFF
318
+
319
+ def to_bytes(self) -> bytes:
320
+ return bytes(self.raw)
321
+
322
+
323
+ # ============================================================ 构造辅助 =====
324
+ def build_tcp_syn(src: str, sport: int, dst: str, dport: int, *, seq: int = 1000) -> bytes:
325
+ """构造一个 IPv4 TCP SYN 报文(供单测/工具用,校验和正确)。"""
326
+ tcp = struct.pack("!HHIIBBHHH", sport, dport, seq, 0, 5 << 4, SYN, 64240, 0, 0)
327
+ total_len = IPV4_MIN_HEADER + len(tcp)
328
+ src_raw = socket.inet_aton(src)
329
+ dst_raw = socket.inet_aton(dst)
330
+ ip_header = struct.pack("!BBHHHBBH", 0x45, 0, total_len, 0x1234, 0x4000, 64, IPPROTO_TCP, 0) + src_raw + dst_raw
331
+ packet = bytearray(ip_header + tcp)
332
+ struct.pack_into("!H", packet, 10, checksum(bytes(packet[:IPV4_MIN_HEADER])))
333
+ pseudo = pseudo_header_v4(src, dst, IPPROTO_TCP, len(tcp))
334
+ struct.pack_into(
335
+ "!H", packet, IPV4_MIN_HEADER + 16, checksum(tcp, ones_complement_sum(pseudo))
336
+ )
337
+ return bytes(packet)
338
+
339
+
340
+ def build_udp(src: str, sport: int, dst: str, dport: int, payload: bytes = b"") -> bytes:
341
+ """构造一个 IPv4 UDP 报文(校验和正确)。"""
342
+ udp_len = UDP_HEADER + len(payload)
343
+ udp = struct.pack("!HHHH", sport, dport, udp_len, 0) + payload
344
+ total_len = IPV4_MIN_HEADER + udp_len
345
+ ip_header = (
346
+ struct.pack("!BBHHHBBH", 0x45, 0, total_len, 0x4321, 0x4000, 64, IPPROTO_UDP, 0)
347
+ + socket.inet_aton(src)
348
+ + socket.inet_aton(dst)
349
+ )
350
+ packet = bytearray(ip_header + udp)
351
+ struct.pack_into("!H", packet, 10, checksum(bytes(packet[:IPV4_MIN_HEADER])))
352
+ pseudo = pseudo_header_v4(src, dst, IPPROTO_UDP, udp_len)
353
+ struct.pack_into("!H", packet, IPV4_MIN_HEADER + 6, checksum(udp, ones_complement_sum(pseudo)))
354
+ return bytes(packet)
355
+
356
+
357
+ def build_tcp_syn_v6(src: str, sport: int, dst: str, dport: int, *, seq: int = 2000) -> bytes:
358
+ """构造一个 IPv6 TCP SYN 报文(Next Header = 6,无扩展头)。"""
359
+ tcp = struct.pack("!HHIIBBHHH", sport, dport, seq, 0, 5 << 4, SYN, 64240, 0, 0)
360
+ header = (
361
+ struct.pack("!IHBB", 0x60000000, len(tcp), IPPROTO_TCP, 64)
362
+ + socket.inet_pton(socket.AF_INET6, src)
363
+ + socket.inet_pton(socket.AF_INET6, dst)
364
+ )
365
+ packet = bytearray(header + tcp)
366
+ pseudo = pseudo_header_v6(src, dst, IPPROTO_TCP, len(tcp))
367
+ struct.pack_into("!H", packet, IPV6_HEADER + 16, checksum(tcp, ones_complement_sum(pseudo)))
368
+ return bytes(packet)
369
+
370
+
371
+ def redirect_packet(packet: bytes | bytearray, sink_addr: str, sink_port: int) -> bytes:
372
+ """一步到位:解析 → 改写目的 → 重算校验和。"""
373
+ parsed = ParsedPacket.parse(packet)
374
+ parsed.set_dst(sink_addr, sink_port)
375
+ parsed.recalculate_checksums()
376
+ return parsed.to_bytes()
377
+
378
+
379
+ def reflect_packet(
380
+ packet: bytes | bytearray,
381
+ *,
382
+ src_addr: str,
383
+ src_port: int,
384
+ dst_addr: str,
385
+ dst_port: int,
386
+ ) -> bytes:
387
+ """把报文改写成"从别处发来",供反向路径使用(对应 WinDivert streamdump 的 reflect)。
388
+
389
+ 两个方向都要用:
390
+
391
+ * 客户端 → 目标:改写成 `客户端:端口 → sink`,让 sink 收到这条连接;
392
+ * sink → 客户端:改写成 `原始目标:端口 → 客户端`,让客户端的 socket 认这条应答。
393
+
394
+ 注意:本函数只改地址与端口,不改方向标志。
395
+ 调用方还必须把报文标记为"入向"(`packet.direction = INBOUND`)
396
+ 只改地址不翻方向的话,内核不会把它投递给本地 socket,握手永远完不成。
397
+ """
398
+ parsed = ParsedPacket.parse(packet)
399
+ parsed.set_src(src_addr, src_port)
400
+ parsed.set_dst(dst_addr, dst_port)
401
+ parsed.recalculate_checksums()
402
+ return parsed.to_bytes()
403
+
404
+
405
+ __all__ = [
406
+ "ACK",
407
+ "FIN",
408
+ "IPPROTO_TCP",
409
+ "IPPROTO_UDP",
410
+ "RST",
411
+ "SYN",
412
+ "PacketError",
413
+ "ParsedPacket",
414
+ "build_tcp_syn",
415
+ "build_tcp_syn_v6",
416
+ "build_udp",
417
+ "checksum",
418
+ "finish_checksum",
419
+ "ones_complement_sum",
420
+ "redirect_packet",
421
+ "reflect_packet",
422
+ "verify_transport_checksum",
423
+ "verify_v4_header_checksum",
424
+ ]
proxycore/canproxy.py ADDED
@@ -0,0 +1,229 @@
1
+ """一句话回答"我现在能不能转发进程流量"。
2
+
3
+ 它会检查三件事并给出结论:
4
+ 1. 是否管理员(WinDivert 与服务都需要)
5
+ 2. WinDivert 驱动是否可用(pydivert 自带已签名的 WinDivert64.sys)
6
+ 3. 配置里的 sink 端口是否可用、上游是否可达
7
+
8
+ 用法:
9
+ python -m proxycore can-proxy # 用 dev.yaml 检查
10
+ python -m proxycore can-proxy --config samples/profiles/dev.yaml
11
+ python -m proxycore can-proxy --backend windivert # 指定要检查的后端
12
+ python -m proxycore can-proxy --json # 机器可读输出
13
+
14
+ 退出码:0 = 可以转发进程流量;1 = 还不能(会打印缺什么、怎么补)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import ctypes
21
+ import json
22
+ import os
23
+ import pathlib
24
+ import socket
25
+ import sys
26
+
27
+ from proxycore.config import Config, ConfigError
28
+ from proxycore.proc import IS_WINDOWS
29
+
30
+
31
+ def is_admin() -> bool:
32
+ if not IS_WINDOWS:
33
+ # os.geteuid 只在 POSIX 上存在;Windows 的 os 模块里根本没有这个名字,
34
+ # 所以走 getattr 而不是直接属性访问(否则 Windows 上静态检查与运行期都不好看)。
35
+ geteuid = getattr(os, "geteuid", None)
36
+ return bool(geteuid is not None and geteuid() == 0)
37
+ try:
38
+ return bool(ctypes.windll.shell32.IsUserAnAdmin())
39
+ except Exception: # noqa: BLE001
40
+ return False
41
+
42
+
43
+ def windivert_driver() -> tuple[bool, str]:
44
+ """pydivert 自带 WinDivert64.sys(第三方已签名);能否加载取决于权限与 EDR。"""
45
+ try:
46
+ import pydivert
47
+ except ImportError:
48
+ return False, "未安装 pydivert(pip install pydivert psutil)"
49
+ package_dir = pathlib.Path(pydivert.__file__).parent
50
+ drivers = list(package_dir.rglob("WinDivert*.sys"))
51
+ if not drivers:
52
+ return False, "pydivert 里找不到 WinDivert*.sys"
53
+ driver = drivers[0]
54
+ try:
55
+ handle = pydivert.WinDivert("outbound and tcp and tcp.Syn and tcp.DstPort == 1")
56
+ handle.open()
57
+ handle.close()
58
+ return True, f"可打开({driver.name}, {driver.stat().st_size} 字节)"
59
+ except Exception as exc: # noqa: BLE001 - pydivert 抛的异常类型随版本变
60
+ return False, f"打不开({type(exc).__name__}: {exc})"
61
+
62
+
63
+ def port_state(host: str, port: int) -> tuple[bool, str]:
64
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
65
+ try:
66
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) if hasattr(
67
+ socket, "SO_EXCLUSIVEADDRUSE"
68
+ ) else None
69
+ sock.bind((host, port))
70
+ return True, "空闲(可以监听)"
71
+ except OSError as exc:
72
+ return False, f"被占用或不可用({exc})"
73
+ finally:
74
+ sock.close()
75
+
76
+
77
+ def upstream_state(host: str, port: int) -> tuple[bool, str]:
78
+ try:
79
+ with socket.create_connection((host, port), timeout=1.5):
80
+ return True, "可达"
81
+ except OSError as exc:
82
+ return False, f"不可达({exc})"
83
+
84
+
85
+
86
+ def _repo_root_str() -> str:
87
+ """仓库根(已安装场景下退回当前目录)。路径解析复用 `cli` 里的实现。"""
88
+ from .cli import repo_root
89
+
90
+ root = repo_root()
91
+ return str(root) if root is not None else os.getcwd()
92
+
93
+
94
+ def resolve_config_path(raw: str) -> pathlib.Path:
95
+ from .cli import resolve_config_path as _resolve
96
+
97
+ return _resolve(raw)
98
+
99
+
100
+ def main(argv: list[str] | None = None) -> int:
101
+ ap = argparse.ArgumentParser(prog="procproxy can-proxy", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
102
+ ap.add_argument("--config", default="samples/profiles/dev.yaml")
103
+ ap.add_argument("--backend", default="", choices=["", "null", "windivert"])
104
+ ap.add_argument("--json", action="store_true")
105
+ args = ap.parse_args(argv)
106
+
107
+ admin = is_admin()
108
+ wd_ok, wd_detail = windivert_driver()
109
+
110
+ config: Config | None = None
111
+ config_error = ""
112
+ try:
113
+ config = Config.load(resolve_config_path(args.config))
114
+ except (ConfigError, OSError) as exc:
115
+ config_error = str(exc)
116
+
117
+ backend = args.backend or (config.service.backend if config else "windivert")
118
+ sink_host = config.service.sink.host if config else "127.0.0.1"
119
+ sink_port = config.service.sink.port if config else 19080
120
+ sink_free, sink_detail = port_state(sink_host, sink_port)
121
+
122
+ upstreams = []
123
+ if config is not None:
124
+ for alias, upstream in config.profile.upstreams.items():
125
+ ok, detail = upstream_state(upstream.host, upstream.port)
126
+ upstreams.append({"alias": alias, "endpoint": f"{upstream.host}:{upstream.port}",
127
+ "reachable": ok, "detail": detail})
128
+
129
+ # ---- 结论 ----
130
+ if backend == "null":
131
+ can_proxy = False
132
+ verdict = "backend=null:只做联调,不抓包、不改道 → 不能转发真实进程流量"
133
+ howto = [
134
+ "把 backend 换成 windivert 并以管理员运行",
135
+ "python -m proxycore run --config samples/profiles/dev.yaml --backend windivert",
136
+ ]
137
+ elif backend == "windivert":
138
+ if not IS_WINDOWS:
139
+ can_proxy, verdict, howto = False, "WinDivert 仅 Windows 可用", ["在 Windows 上跑"]
140
+ elif not admin:
141
+ can_proxy = False
142
+ verdict = "WinDivert 已就绪,但当前不是管理员 → 打不开驱动,无法改道"
143
+ howto = [
144
+ "以管理员打开 PowerShell / 终端(右键 → 以管理员身份运行),然后:",
145
+ " cd /d " + _repo_root_str(),
146
+ " python -m proxycore doctor --backend windivert",
147
+ " python -m proxycore run --config samples/profiles/dev.yaml --backend windivert --log-level DEBUG",
148
+ "另一个窗口:curl.exe https://example.invalid/ -o NUL",
149
+ ]
150
+ elif not wd_ok:
151
+ can_proxy = False
152
+ verdict = f"管理员 OK,但 WinDivert 打不开:{wd_detail}"
153
+ howto = ["常见原因:EDR/杀软拦截 WinDivert 驱动;或已有别的 WinDivert 实例",
154
+ "诊断:sc query WinDivert"]
155
+ else:
156
+ can_proxy = True
157
+ verdict = "WinDivert 路线 A 已就绪(驱动与权限)"
158
+ howto = [
159
+ "python -m proxycore run --config samples/profiles/dev.yaml --backend windivert --log-level DEBUG",
160
+ "另一窗口验证:curl.exe https://example.invalid/ -o NUL",
161
+ "python -m proxycore check-route --target-pid curl.exe --expect proxied --live-required",
162
+ ]
163
+
164
+ # 能力检查之外还要确认"现在真的能跑":配置可用、sink 能绑、上游可达。
165
+ # 只报 can_proxy=True 却让 proxy 规则在启动/拨号时全部失败,等于误导。
166
+ if config is None:
167
+ can_proxy = False
168
+ verdict += f";但配置不可用:{config_error or '未知错误'}"
169
+ elif not sink_free:
170
+ can_proxy = False
171
+ verdict += f";但 sink 端口 {sink_host}:{sink_port} {sink_detail}"
172
+ elif not upstreams:
173
+ can_proxy = False
174
+ verdict += ";但配置里没有上游,命中 proxy 规则也没有代理可用"
175
+ elif not any(item["reachable"] for item in upstreams):
176
+ can_proxy = False
177
+ verdict += ";但配置中的所有上游都不可达,命中 proxy 的连接会失败(不会直连)"
178
+
179
+ if can_proxy:
180
+ verdict += ",可以转发进程流量"
181
+
182
+ report = {
183
+ "platform": sys.platform,
184
+ "is_admin": admin,
185
+ "backend": backend,
186
+ "windivert_driver": {"ok": wd_ok, "detail": wd_detail},
187
+ "config": {"ok": config is not None, "error": config_error},
188
+ "sink": {"endpoint": f"{sink_host}:{sink_port}", "free": sink_free, "detail": sink_detail},
189
+ "upstreams": upstreams,
190
+ "can_proxy_process_traffic": can_proxy,
191
+ "verdict": verdict,
192
+ "how_to": howto,
193
+ }
194
+
195
+ if args.json:
196
+ print(json.dumps(report, ensure_ascii=False, indent=2))
197
+ return 0 if can_proxy else 1
198
+
199
+ print()
200
+ print("=" * 70)
201
+ print(" 能不能转发进程流量?")
202
+ print("=" * 70)
203
+ rows = [
204
+ ("平台", sys.platform, IS_WINDOWS),
205
+ ("管理员权限", "是" if admin else "否(关键!)", admin),
206
+ ("WinDivert 驱动", wd_detail, wd_ok),
207
+ (f"sink 端口 {sink_host}:{sink_port}", sink_detail, sink_free),
208
+ ]
209
+ for name, value, ok in rows:
210
+ print(f" [{'OK ' if ok else 'NO '}] {name:<24} {value}")
211
+ for item in upstreams:
212
+ print(f" [{'OK ' if item['reachable'] else 'NO '}] 上游 {item['alias']:<17} "
213
+ f"{item['endpoint']} {item['detail']}")
214
+ if config_error:
215
+ print(f" [NO ] 配置 {args.config}: {config_error}")
216
+
217
+ print()
218
+ print(f"使用后端:{backend}")
219
+ print(f"结论:{'✅ ' if can_proxy else '❌ '}{verdict}")
220
+ print()
221
+ if howto:
222
+ print("怎么才能跑起来:")
223
+ for line in howto:
224
+ print(f" {line}")
225
+ print()
226
+ return 0 if can_proxy else 1
227
+
228
+ if __name__ == "__main__": # pragma: no cover - 也支持以模块方式直接运行
229
+ raise SystemExit(main())