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,123 @@
1
+ """路线 A 的 WinDivert 过滤器表达式构造。
2
+
3
+ 一条表达式要同时满足四件事,漏一条就会成环或卡死:
4
+
5
+ * 抓所有出向 TCP,不只 SYN。整条连接两个方向都要改写,只抓 SYN 会在握手第二包断。
6
+ * 排除回环。改道后应用连的是 127.0.0.1:<sink>,不排除会自我循环。
7
+ * 排除 sink 端口,回环之外再加一道。
8
+ * 排除上游代理和本地 DNS 的 IP。拦截器自己连上游会被再次捕获,同样成环。
9
+
10
+ 地址族:ip.DstAddr 只匹配 IPv4,ipv6.DstAddr 才匹配 IPv6(没有 ipv4. 前缀)。
11
+ 官方文档明确"字段与报文不相关时该 test 判为 false",所以含 ip.DstAddr 的表达式
12
+ 永远不匹配 IPv6 报文。要覆盖 v6 必须另开一个句柄、用 ipv6.* 写第二条表达式,
13
+ 本函数不试图在一条表达式里同时管两个族。
14
+
15
+ 上游地址如果是域名,解析结果会变,而改过滤表达式要重开句柄。所以静态场景由
16
+ build_filter() 排除,用户态在每次 redirect() 前再判一次。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ipaddress
22
+ from collections.abc import Iterable
23
+
24
+ LOOPBACK_V4 = "127.0.0.1"
25
+ LOOPBACK_V6 = "::1"
26
+
27
+
28
+ def _normalize_ips(values: Iterable[str], *, version: int | None = None) -> list[str]:
29
+ """过滤出合法的 IP 字面量。
30
+
31
+ `version` 给出时只保留该地址族:WinDivert 的字段命名空间是按族分的
32
+ (`ip.*` 对 IPv4、`ipv6.*` 对 IPv6),把 v4 地址写进 v6 表达式等于没写。
33
+ """
34
+ out: list[str] = []
35
+ for value in values:
36
+ value = str(value).strip()
37
+ if not value:
38
+ continue
39
+ # 只接受 IP 字面量;域名不能出现在 WinDivert 表达式里
40
+ try:
41
+ parsed = ipaddress.ip_address(value)
42
+ except ValueError:
43
+ continue
44
+ if version is not None and parsed.version != version:
45
+ continue
46
+ if value not in out:
47
+ out.append(value)
48
+ return out
49
+
50
+
51
+ def build_filter(
52
+ *,
53
+ sink_addr: str = "",
54
+ sink_port: int = 19080,
55
+ exclude_ips: Iterable[str] = (),
56
+ ipv6: bool = False,
57
+ sniffer: bool = False,
58
+ ) -> str:
59
+ """构造过滤器表达式。
60
+
61
+ `sink_addr` 留空时按 `ipv6` 取默认回环地址(v4 用 `127.0.0.1`、v6 用 `::1`)。
62
+ `sniffer=True` 加 `sniff`:只观察不拦截,`--dry-run` 用。
63
+
64
+ `ipv6=True` 产出的是给第二个句柄用的那条表达式(见模块 docstring 的地址族说明)。
65
+ 两个分支互不排除对方的回环:v4 表达式里写 `ipv6.DstAddr != ::1` 恒真,没有意义,
66
+ 反过来同理。代价是 `ipv6=False` 时 v6 流量整体静默漏过,调用方必须明确这一点。
67
+ """
68
+ family = 6 if ipv6 else 4
69
+ ns = "ipv6" if ipv6 else "ip"
70
+ loopback = LOOPBACK_V6 if ipv6 else LOOPBACK_V4
71
+
72
+ sink_addr = sink_addr or loopback
73
+ clauses: list[str] = ["tcp", f"(outbound or tcp.SrcPort == {int(sink_port)})"]
74
+ clauses.append(f"tcp.DstPort != {int(sink_port)}")
75
+
76
+ # 本族回环地址永不代理,但 sink 的应答必须放行。
77
+ #
78
+ # 改道后整条连接都在回环里流动:
79
+ # 正向 127.0.0.1:客户端端口 → 127.0.0.1:<sink>
80
+ # 反向 127.0.0.1:<sink> → 127.0.0.1:客户端端口
81
+ # 反向包的目的地址也是回环,整段排除会连 sink 的应答一起丢掉。症状是改道日志
82
+ # 正常、sink 也 accept 了,客户端却收不到响应而超时。
83
+ # 所以只放行源端口等于 sink 端口的那一类:那一定是 sink 的应答,
84
+ # 不可能是应用自己发起的回环连接。
85
+ clauses.append(f"({ns}.DstAddr != {loopback} or tcp.SrcPort == {int(sink_port)})")
86
+
87
+ # sink 自己也要排除(sink 不在回环上时上面那条盖不住)
88
+ if sink_addr and sink_addr != loopback:
89
+ clauses.append(f"{ns}.DstAddr != {sink_addr}")
90
+
91
+ for addr in _normalize_ips(exclude_ips, version=family):
92
+ if addr in (sink_addr, loopback):
93
+ continue # 上面已写过,再写一遍会重复
94
+ clauses.append(f"{ns}.DstAddr != {addr}")
95
+
96
+ expr = " and ".join(clauses)
97
+ if sniffer:
98
+ expr += " and sniff"
99
+ return expr
100
+
101
+
102
+ def upstream_ips_from_config(config: object) -> list[str]:
103
+ """从配置里取上游地址(用于过滤器排除与用户态二次校验)。"""
104
+ profile = getattr(config, "profile", None)
105
+ upstreams = getattr(profile, "upstreams", {}) or {}
106
+ out: list[str] = []
107
+ for upstream in upstreams.values():
108
+ host = str(getattr(upstream, "host", "")).strip()
109
+ if not host:
110
+ continue
111
+ try:
112
+ ipaddress.ip_address(host)
113
+ except ValueError:
114
+ continue # 域名:需要先解析,交给用户态判断
115
+ if host not in out:
116
+ out.append(host)
117
+ dns = str(getattr(profile, "dns_local_server", "") or "").strip()
118
+ if dns and dns not in out:
119
+ out.append(dns)
120
+ return out
121
+
122
+
123
+ __all__ = ["build_filter", "upstream_ips_from_config"]
@@ -0,0 +1,289 @@
1
+ """路线 A · `local_port → PID` 查表(等价于内核 ALE 层拿到的 `PROCESS_ID`)。
2
+
3
+ `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` 在 SYN 发出后通常能查到
4
+ 该连接(状态 `SYN_SENT=3`),但存在窗口。工程处理:
5
+
6
+ 1. 先按 `local_port` 精确匹配(并校验 remote 一致);
7
+ 2. 不中 → 最多 3 次、每次 3ms 后刷新重试;
8
+ 3. 仍不中 → 按 `(raddr, rport)` 反查兜底(同机同目标并发少时可用);
9
+ 4. 返回 0 表示未知 → 核心按 `on_unknown_pid` 策略处理并记 WARN。
10
+
11
+ 禁止用 `GetTcpTable`(不带 PID 的版本), 明令禁止项。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import socket
17
+ import struct
18
+ import threading
19
+ import time
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from ...log import EventLogger, get_logger
24
+
25
+ LOG = EventLogger(get_logger("proxycore.backend.windivert.pidmap"))
26
+
27
+ AF_INET = 2
28
+ AF_INET6 = 23
29
+ TCP_TABLE_OWNER_PID_ALL = 5
30
+ UDP_TABLE_OWNER_PID = 1
31
+
32
+ # MIB_TCPROW_OWNER_PID: state, localAddr, localPort, remoteAddr, remotePort, pid (每项 4 字节)
33
+ TCP_ROW_SIZE = 24
34
+ TCP6_ROW_SIZE = 56 # + scope ids 与 16 字节地址
35
+ UDP_ROW_SIZE = 12
36
+ UDP6_ROW_SIZE = 28
37
+
38
+ STATE_SYN_SENT = 3
39
+ STATE_ESTABLISHED = 5
40
+
41
+ DEFAULT_RETRIES = 3
42
+ DEFAULT_RETRY_DELAY_S = 0.003
43
+ DEFAULT_TTL_S = 0.25
44
+
45
+ _UNKNOWN_PID = 0
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class TcpRow:
50
+ state: int
51
+ local_port: int
52
+ local_addr: str
53
+ remote_addr: str
54
+ remote_port: int
55
+ pid: int
56
+ is_v6: bool = False
57
+
58
+
59
+ class PortPidTable:
60
+ """带 TTL 的 `local_port → TcpRow` 快照表。
61
+
62
+ 只用于首次建表与兜底反查;热点路径是"命中即返回"。
63
+ """
64
+
65
+ def __init__(self, *, ttl_s: float = DEFAULT_TTL_S) -> None:
66
+ self.ttl_s = ttl_s
67
+ self._lock = threading.RLock()
68
+ self._by_port: dict[tuple[bool, int], TcpRow] = {}
69
+ self._rows: list[TcpRow] = []
70
+ self._refreshed_at = 0.0
71
+ self.refresh_count = 0
72
+ self.last_error = ""
73
+
74
+ # ------------------------------------------------------------ 刷新 -----
75
+ def refresh(self, *, force: bool = False) -> None:
76
+ with self._lock:
77
+ now = time.monotonic()
78
+ if not force and self._rows and now - self._refreshed_at < self.ttl_s:
79
+ return
80
+ rows: list[TcpRow] = []
81
+ try:
82
+ rows.extend(_read_tcp_table(AF_INET))
83
+ rows.extend(_read_tcp_table(AF_INET6))
84
+ except OSError as exc:
85
+ self.last_error = str(exc)
86
+ LOG.warn(
87
+ "pidmap_refresh_failed",
88
+ error=str(exc),
89
+ hint="需要管理员权限;路线 A 必须有管理员才能查全表",
90
+ )
91
+ return
92
+ self._rows = rows
93
+ self._by_port = {(row.is_v6, row.local_port): row for row in rows}
94
+ self._refreshed_at = now
95
+ self.refresh_count += 1
96
+
97
+ # ------------------------------------------------------------ 查询 -----
98
+ def lookup(
99
+ self,
100
+ local_port: int,
101
+ remote_addr: str | None = None,
102
+ remote_port: int | None = None,
103
+ *,
104
+ is_v6: bool = False,
105
+ retries: int = DEFAULT_RETRIES,
106
+ retry_delay_s: float = DEFAULT_RETRY_DELAY_S,
107
+ ) -> int:
108
+ """返回 PID;查不到返回 0(未知),绝不猜。"""
109
+ for attempt in range(max(1, retries)):
110
+ self.refresh(force=attempt > 0)
111
+ row = self._match_local(local_port, remote_addr, remote_port, is_v6)
112
+ if row is not None:
113
+ return row.pid
114
+ if attempt < retries - 1:
115
+ time.sleep(retry_delay_s)
116
+ # 兜底:按 (raddr, rport) 反查
117
+ row = self._match_remote(remote_addr, remote_port)
118
+ if row is not None:
119
+ return row.pid
120
+ return _UNKNOWN_PID
121
+
122
+ def all_rows(self) -> list[TcpRow]:
123
+ self.refresh()
124
+ with self._lock:
125
+ return list(self._rows)
126
+
127
+ def stats(self) -> dict[str, Any]:
128
+ with self._lock:
129
+ return {
130
+ "rows": len(self._rows),
131
+ "refreshes": self.refresh_count,
132
+ "last_error": self.last_error,
133
+ "ttl_s": self.ttl_s,
134
+ }
135
+
136
+ # ------------------------------------------------------------ 匹配 -----
137
+ def _match_local(
138
+ self, local_port: int, remote_addr: str | None, remote_port: int | None, is_v6: bool
139
+ ) -> TcpRow | None:
140
+ with self._lock:
141
+ row = self._by_port.get((is_v6, local_port)) or self._by_port.get((not is_v6, local_port))
142
+ if row is None:
143
+ return None
144
+ if remote_addr and remote_port:
145
+ if row.remote_addr == remote_addr and row.remote_port == remote_port:
146
+ return row
147
+ # 端口撞车(TIME_WAIT 复用)→ 继续找同端口里 remote 匹配的那条
148
+ with self._lock:
149
+ for candidate in self._rows:
150
+ if (
151
+ candidate.local_port == local_port
152
+ and candidate.remote_addr == remote_addr
153
+ and candidate.remote_port == remote_port
154
+ ):
155
+ return candidate
156
+ return None
157
+ return row
158
+
159
+ def _match_remote(self, remote_addr: str | None, remote_port: int | None) -> TcpRow | None:
160
+ if not remote_addr or not remote_port:
161
+ return None
162
+ with self._lock:
163
+ for row in self._rows:
164
+ if row.remote_addr == remote_addr and row.remote_port == remote_port:
165
+ return row
166
+ return None
167
+
168
+
169
+ # ---------------------------------------------------------- 系统调用 -------
170
+ def _iphlpapi() -> Any:
171
+ import ctypes
172
+ import ctypes.wintypes as wintypes
173
+
174
+ lib = ctypes.WinDLL("iphlpapi", use_last_error=True)
175
+ lib.GetExtendedTcpTable.argtypes = [
176
+ wintypes.LPVOID,
177
+ ctypes.POINTER(wintypes.DWORD),
178
+ wintypes.BOOL,
179
+ wintypes.ULONG,
180
+ wintypes.ULONG,
181
+ wintypes.ULONG,
182
+ ]
183
+ lib.GetExtendedTcpTable.restype = wintypes.DWORD
184
+ return lib
185
+
186
+
187
+ def _read_tcp_table(family: int) -> list[TcpRow]:
188
+ """读 `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)`;失败抛 OSError。"""
189
+ import ctypes
190
+ import ctypes.wintypes as wintypes
191
+
192
+ lib = _iphlpapi()
193
+ size = wintypes.DWORD(0)
194
+ rc = lib.GetExtendedTcpTable(None, ctypes.byref(size), False, family, TCP_TABLE_OWNER_PID_ALL, 0)
195
+ if rc not in (0, 122): # 122 = ERROR_INSUFFICIENT_BUFFER
196
+ raise OSError(rc, f"GetExtendedTcpTable(size) 失败 rc={rc}")
197
+ if size.value == 0:
198
+ return []
199
+
200
+ buf = ctypes.create_string_buffer(size.value)
201
+ rc = lib.GetExtendedTcpTable(buf, ctypes.byref(size), False, family, TCP_TABLE_OWNER_PID_ALL, 0)
202
+ if rc != 0:
203
+ raise OSError(rc, f"GetExtendedTcpTable(data) 失败 rc={rc}")
204
+
205
+ raw = bytes(buf.raw[: size.value])
206
+ count = struct.unpack_from("<I", raw, 0)[0]
207
+ return _decode_rows(raw, count, family)
208
+
209
+
210
+ def _decode_rows(raw: bytes, count: int, family: int) -> list[TcpRow]:
211
+ """解析 `MIB_TCPTABLE_OWNER_PID` / `MIB_TCP6TABLE_OWNER_PID` 的裸字节。
212
+
213
+ Windows 结构一律是小端(不要用 `!`)。两个结构的字段顺序不一样,
214
+ 这是最容易写错的地方:
215
+
216
+ v4 `MIB_TCPROW_OWNER_PID`(每项 24 字节), `dwState` 在最前:
217
+ 0 state / 4 localAddr(4) / 8 localPort / 12 remoteAddr(4) / 16 remotePort / 20 pid
218
+
219
+ v6 `MIB_TCP6ROW_OWNER_PID`(每项 56 字节), `dwState` 在第 7 位,地址在最前:
220
+ 0 localAddr(16) / 16 localScopeId / 20 localPort /
221
+ 24 remoteAddr(16) / 40 remoteScopeId / 44 remotePort / 48 state / 52 pid
222
+
223
+ > 2026-10-01 真机实测:v6 分支早期照搬了 v4 的"state 在前",于是整行错位
224
+ > 解出来的 `local_port` 恒为 0、`remote_port` 其实读的是 `dwState`(监听的套接字
225
+ > 解出 `state=2`)。后果是所有 v6 连接都查不到 PID,按 `on_unknown_pid=direct`
226
+ > 直接放行 = 真实 IP 泄漏。单测当时和实现犯了同一个错,所以两边都"通过";
227
+ > 是 `pidmap_v6_probe.py` 对着真表才暴露出来。
228
+
229
+ `dwLocalPort`/`dwRemotePort` 的低 16 位是网络序端口(用 `socket.ntohs` 还原)。
230
+ """
231
+ rows: list[TcpRow] = []
232
+ is_v6 = family == AF_INET6
233
+ row_size = TCP6_ROW_SIZE if is_v6 else TCP_ROW_SIZE
234
+ offset = 4 # 跳过 dwNumEntries
235
+ for _ in range(count):
236
+ if offset + row_size > len(raw):
237
+ break
238
+ if is_v6:
239
+ local_addr = socket.inet_ntop(socket.AF_INET6, raw[offset : offset + 16])
240
+ local_port = _ntoh_port(struct.unpack_from("<I", raw, offset + 20)[0])
241
+ remote_addr = socket.inet_ntop(socket.AF_INET6, raw[offset + 24 : offset + 40])
242
+ remote_port = _ntoh_port(struct.unpack_from("<I", raw, offset + 44)[0])
243
+ state = struct.unpack_from("<I", raw, offset + 48)[0]
244
+ pid = struct.unpack_from("<I", raw, offset + 52)[0]
245
+ else:
246
+ state = struct.unpack_from("<I", raw, offset)[0]
247
+ local_addr = socket.inet_ntoa(raw[offset + 4 : offset + 8])
248
+ local_port = _ntoh_port(struct.unpack_from("<I", raw, offset + 8)[0])
249
+ remote_addr = socket.inet_ntoa(raw[offset + 12 : offset + 16])
250
+ remote_port = _ntoh_port(struct.unpack_from("<I", raw, offset + 16)[0])
251
+ pid = struct.unpack_from("<I", raw, offset + 20)[0]
252
+ rows.append(
253
+ TcpRow(
254
+ state=state,
255
+ local_port=local_port,
256
+ local_addr=local_addr,
257
+ remote_addr=remote_addr,
258
+ remote_port=remote_port,
259
+ pid=pid,
260
+ is_v6=is_v6,
261
+ )
262
+ )
263
+ offset += row_size
264
+ return rows
265
+
266
+
267
+ def _ntoh_port(dw_port: int) -> int:
268
+ """`dwLocalPort` 的低 16 位是网络序端口。"""
269
+ return socket.ntohs(dw_port & 0xFFFF)
270
+
271
+
272
+ def is_supported() -> bool:
273
+ import os
274
+
275
+ return os.name == "nt"
276
+
277
+
278
+ __all__ = [
279
+ "AF_INET",
280
+ "AF_INET6",
281
+ "DEFAULT_RETRIES",
282
+ "STATE_ESTABLISHED",
283
+ "STATE_SYN_SENT",
284
+ "TCP_TABLE_OWNER_PID_ALL",
285
+ "UDP_TABLE_OWNER_PID",
286
+ "PortPidTable",
287
+ "TcpRow",
288
+ "is_supported",
289
+ ]