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
proxycore/dns/udp.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""UDP 策略与会话。
|
|
2
|
+
|
|
3
|
+
原则:默认保守、可配置、失败即失败(不静默直连)。
|
|
4
|
+
|
|
5
|
+
* `udp_policy.default` + `udp_policy.by_port` 决定每条 UDP 数据报的去向;
|
|
6
|
+
* `block` 是推荐默认(QUIC 被丢包后浏览器会回落 HTTP/2 over TCP → 走代理);
|
|
7
|
+
* `direct` 会泄漏真实 IP,文档必须明确告知;
|
|
8
|
+
* 每个 `(pid, src_port)` 一条 SOCKS5 UDP 关联(共用会串包);60s 无包回收;
|
|
9
|
+
* 分片 UDP:直接放行并记 WARN(注入分片极易出错)。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import queue
|
|
16
|
+
import socket
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Protocol
|
|
22
|
+
|
|
23
|
+
from ..log import EventLogger, get_logger
|
|
24
|
+
from ..types import Action, Endpoint, UdpAssociation
|
|
25
|
+
from ..upstream.base import UpstreamDown
|
|
26
|
+
from ..upstream.socks5 import build_udp_datagram, parse_udp_datagram
|
|
27
|
+
|
|
28
|
+
LOG = EventLogger(get_logger("proxycore.dns.udp"))
|
|
29
|
+
|
|
30
|
+
DEFAULT_IDLE_TIMEOUT_S = 60.0
|
|
31
|
+
MAX_UDP_DATAGRAM = 65535
|
|
32
|
+
|
|
33
|
+
# 后台建关联的 worker 数。一次 `UDP ASSOCIATE` 是一次完整 TCP 握手 + 认证,
|
|
34
|
+
# 单 worker 会让所有待建关联串行排在一次慢握手后面;worker 太多则是白占栈。
|
|
35
|
+
ASSOCIATE_WORKERS = 4
|
|
36
|
+
# 待建关联队列上限。满了就丢弃数据报(记 WARN),绝不阻塞决策线程。
|
|
37
|
+
ASSOCIATE_QUEUE_MAX = 1024
|
|
38
|
+
|
|
39
|
+
# Windows 特有:尚未绑定的连接less socket 上调用 `recvfrom`,Winsock 返回
|
|
40
|
+
# WSAEINVAL(10022) 而不是 WSAEWOULDBLOCK(10035),语义却同样是"现在没有数据"。
|
|
41
|
+
# 第一次 `send()` 会隐式绑定,此后就恢复成正常的 10035。
|
|
42
|
+
# 必须单独识别:漏掉它会让"还没发过包就先收包"的调用路径直接抛异常。
|
|
43
|
+
_WSAEINVAL = 10022
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _errno_of(exc: OSError) -> int | None:
|
|
47
|
+
"""Windows 上 `OSError.errno` 就是 winsock 错误码,优先用 `winerror`。"""
|
|
48
|
+
winerror = getattr(exc, "winerror", None)
|
|
49
|
+
if isinstance(winerror, int):
|
|
50
|
+
return winerror
|
|
51
|
+
return exc.errno
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UdpRelayTransport:
|
|
55
|
+
"""到 SOCKS5 中继的一条 UDP 通道(每 `(pid, src_port)` 一条)。
|
|
56
|
+
|
|
57
|
+
为什么每条会话各用一个 socket:中继按源地址:源端口回包,
|
|
58
|
+
多条会话共用一个 socket 时回包无法区分归属(串包)。
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, family: int = socket.AF_INET) -> None:
|
|
62
|
+
self._sock = socket.socket(family, socket.SOCK_DGRAM)
|
|
63
|
+
self._sock.settimeout(0.0)
|
|
64
|
+
|
|
65
|
+
def send(self, payload: bytes, addr: tuple[str, int]) -> int:
|
|
66
|
+
return self._sock.sendto(payload, addr)
|
|
67
|
+
|
|
68
|
+
def recv(self, *, timeout: float = 0.0, size: int = MAX_UDP_DATAGRAM) -> bytes | None:
|
|
69
|
+
"""取一个回包;没有就返回 None("没数据"不是错误,不抛异常)。
|
|
70
|
+
|
|
71
|
+
`timeout=0` ⇒ 非阻塞轮询(调用方自己掌握节奏);`timeout>0` ⇒ 最长等这么久。
|
|
72
|
+
"""
|
|
73
|
+
self._sock.settimeout(timeout if timeout > 0 else 0.0)
|
|
74
|
+
try:
|
|
75
|
+
data, _peer = self._sock.recvfrom(size)
|
|
76
|
+
except (TimeoutError, BlockingIOError):
|
|
77
|
+
return None
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
if _errno_of(exc) == _WSAEINVAL:
|
|
80
|
+
return None # 见文件头的 _WSAEINVAL 说明:还没绑定过而已
|
|
81
|
+
raise # 真错误(WSAENOTSOCK、WSAENETDOWN…)不能被吞掉
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
with contextlib.suppress(OSError):
|
|
86
|
+
self._sock.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class UdpTransport(Protocol):
|
|
90
|
+
"""到 SOCKS5 中继的 UDP 通道(真实实现或测试替身)。"""
|
|
91
|
+
|
|
92
|
+
def send(self, payload: bytes, addr: tuple[str, int]) -> int: ...
|
|
93
|
+
|
|
94
|
+
def recv(self, *, timeout: float = 0.0, size: int = MAX_UDP_DATAGRAM) -> bytes | None: ...
|
|
95
|
+
|
|
96
|
+
def close(self) -> None: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(slots=True)
|
|
100
|
+
class UdpSession:
|
|
101
|
+
"""一条 NAT 式 UDP 会话(键是 `(pid, src_port)`)。"""
|
|
102
|
+
|
|
103
|
+
key: tuple[int, int]
|
|
104
|
+
assoc: UdpAssociation | None = None
|
|
105
|
+
transport: UdpTransport | None = None # 到中继的 UDP 通道(UdpRelayTransport 或测试替身)
|
|
106
|
+
upstream: str = ""
|
|
107
|
+
dst: Endpoint | None = None
|
|
108
|
+
last_seen: float = 0.0
|
|
109
|
+
packets_up: int = 0
|
|
110
|
+
packets_down: int = 0
|
|
111
|
+
bytes_up: int = 0
|
|
112
|
+
bytes_down: int = 0
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict[str, Any]:
|
|
115
|
+
return {
|
|
116
|
+
"pid": self.key[0],
|
|
117
|
+
"src_port": self.key[1],
|
|
118
|
+
"upstream": self.upstream,
|
|
119
|
+
"dst": str(self.dst) if self.dst else "",
|
|
120
|
+
"packets_up": self.packets_up,
|
|
121
|
+
"packets_down": self.packets_down,
|
|
122
|
+
"bytes_up": self.bytes_up,
|
|
123
|
+
"bytes_down": self.bytes_down,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class UdpPolicyResolver:
|
|
128
|
+
"""`udp_policy` → `Action`。"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, policy: Any = None) -> None:
|
|
131
|
+
self.default = str(getattr(policy, "default", "direct") or "direct")
|
|
132
|
+
self.by_port: dict[int, str] = dict(getattr(policy, "by_port", {}) or {})
|
|
133
|
+
|
|
134
|
+
def action_for(self, port: int) -> Action:
|
|
135
|
+
raw = self.by_port.get(int(port), self.default)
|
|
136
|
+
return {
|
|
137
|
+
"direct": Action.DIRECT,
|
|
138
|
+
"proxy": Action.PROXY,
|
|
139
|
+
"block": Action.BLOCK,
|
|
140
|
+
}.get(str(raw), Action.BLOCK) # 未知策略按失败处理,绝不默认直连
|
|
141
|
+
|
|
142
|
+
def describe(self) -> dict[str, Any]:
|
|
143
|
+
return {"default": self.default, "by_port": {str(k): v for k, v in self.by_port.items()}}
|
|
144
|
+
|
|
145
|
+
def leaks_ip(self) -> bool:
|
|
146
|
+
"""`direct` 会让支持 QUIC 的应用绕过代理(文档必须写明)。"""
|
|
147
|
+
return self.default == "direct" or any(v == "direct" for v in self.by_port.values())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class UdpSessionTable:
|
|
151
|
+
"""`(pid, src_port) → UdpSession` 的 NAT 表。"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, *, idle_timeout_s: float = DEFAULT_IDLE_TIMEOUT_S, clock: Any = time.time) -> None:
|
|
154
|
+
self.idle_timeout_s = float(idle_timeout_s)
|
|
155
|
+
self._clock = clock
|
|
156
|
+
self._lock = threading.RLock()
|
|
157
|
+
self._sessions: dict[tuple[int, int], UdpSession] = {}
|
|
158
|
+
self._counters = {
|
|
159
|
+
"opened": 0,
|
|
160
|
+
"reaped": 0,
|
|
161
|
+
"dropped": 0,
|
|
162
|
+
"fragmented": 0,
|
|
163
|
+
"no_upstream": 0,
|
|
164
|
+
"no_payload": 0,
|
|
165
|
+
"send_failed": 0,
|
|
166
|
+
# 两段式建关联期间丢弃的数据报。它不等于故障:这是决策线程
|
|
167
|
+
# 绝不阻塞的代价(QUIC 会重传、DNS 会重试)。
|
|
168
|
+
"associating": 0,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------ 会话 -----
|
|
172
|
+
def open(
|
|
173
|
+
self,
|
|
174
|
+
pid: int,
|
|
175
|
+
src_port: int,
|
|
176
|
+
*,
|
|
177
|
+
upstream: str = "",
|
|
178
|
+
dst: Endpoint | None = None,
|
|
179
|
+
assoc: UdpAssociation | None = None,
|
|
180
|
+
transport: UdpTransport | None = None,
|
|
181
|
+
) -> UdpSession:
|
|
182
|
+
key = (int(pid), int(src_port))
|
|
183
|
+
with self._lock:
|
|
184
|
+
session = UdpSession(
|
|
185
|
+
key=key,
|
|
186
|
+
assoc=assoc,
|
|
187
|
+
transport=transport,
|
|
188
|
+
upstream=upstream,
|
|
189
|
+
dst=dst,
|
|
190
|
+
last_seen=self._clock(),
|
|
191
|
+
)
|
|
192
|
+
self._sessions[key] = session
|
|
193
|
+
self._counters["opened"] += 1
|
|
194
|
+
return session
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _release(session: UdpSession) -> None:
|
|
198
|
+
"""释放一条会话占用的全部资源:控制连接 + 中继 UDP 通道。
|
|
199
|
+
|
|
200
|
+
漏掉 transport 的后果不是"泄漏一个 socket"这么轻,关联列表已经把它
|
|
201
|
+
移除,客户端就再也拿不到回包,而 socket 一直开着、内核缓冲区继续堆积,
|
|
202
|
+
直到进程退出。
|
|
203
|
+
"""
|
|
204
|
+
if session.assoc is not None:
|
|
205
|
+
session.assoc.close()
|
|
206
|
+
if session.transport is not None:
|
|
207
|
+
with contextlib.suppress(Exception): # 关闭失败无补救
|
|
208
|
+
session.transport.close()
|
|
209
|
+
session.transport = None
|
|
210
|
+
|
|
211
|
+
def get(self, pid: int, src_port: int) -> UdpSession | None:
|
|
212
|
+
with self._lock:
|
|
213
|
+
return self._sessions.get((int(pid), int(src_port)))
|
|
214
|
+
|
|
215
|
+
def touch(self, pid: int, src_port: int) -> UdpSession | None:
|
|
216
|
+
with self._lock:
|
|
217
|
+
session = self._sessions.get((int(pid), int(src_port)))
|
|
218
|
+
if session is not None:
|
|
219
|
+
session.last_seen = self._clock()
|
|
220
|
+
return session
|
|
221
|
+
|
|
222
|
+
def close(self, pid: int, src_port: int) -> None:
|
|
223
|
+
with self._lock:
|
|
224
|
+
session = self._sessions.pop((int(pid), int(src_port)), None)
|
|
225
|
+
if session is not None:
|
|
226
|
+
self._release(session)
|
|
227
|
+
|
|
228
|
+
def close_all(self) -> int:
|
|
229
|
+
"""释放全部会话(`Runtime.stop()` 用),返回释放条数。
|
|
230
|
+
|
|
231
|
+
与 `reap()`/`prune_pids()` 的区别只是"无条件":退出时不能让任何 socket
|
|
232
|
+
或控制连接留到进程结束,否则 `stop()` 之后就再没人能关它们了。
|
|
233
|
+
"""
|
|
234
|
+
with self._lock:
|
|
235
|
+
victims = list(self._sessions.values())
|
|
236
|
+
self._sessions.clear()
|
|
237
|
+
for session in victims:
|
|
238
|
+
self._release(session)
|
|
239
|
+
return len(victims)
|
|
240
|
+
|
|
241
|
+
def reap(self, *, now: float | None = None) -> int:
|
|
242
|
+
stamp = now if now is not None else self._clock()
|
|
243
|
+
removed = 0
|
|
244
|
+
with self._lock:
|
|
245
|
+
victims = [
|
|
246
|
+
key
|
|
247
|
+
for key, session in self._sessions.items()
|
|
248
|
+
if stamp - session.last_seen > self.idle_timeout_s
|
|
249
|
+
]
|
|
250
|
+
for key in victims:
|
|
251
|
+
session = self._sessions.pop(key, None)
|
|
252
|
+
if session is not None:
|
|
253
|
+
self._release(session)
|
|
254
|
+
removed += 1
|
|
255
|
+
self._counters["reaped"] += removed
|
|
256
|
+
return removed
|
|
257
|
+
|
|
258
|
+
def prune_pids(self, alive: set[int]) -> int:
|
|
259
|
+
"""进程退出立即回收。"""
|
|
260
|
+
removed = 0
|
|
261
|
+
with self._lock:
|
|
262
|
+
for key in [k for k in self._sessions if k[0] not in alive]:
|
|
263
|
+
session = self._sessions.pop(key, None)
|
|
264
|
+
if session is not None:
|
|
265
|
+
self._release(session)
|
|
266
|
+
removed += 1
|
|
267
|
+
return removed
|
|
268
|
+
|
|
269
|
+
def count(self) -> int:
|
|
270
|
+
with self._lock:
|
|
271
|
+
return len(self._sessions)
|
|
272
|
+
|
|
273
|
+
def pids(self) -> set[int]:
|
|
274
|
+
"""当前表里出现过的 PID(维护线程据此筛出已退出的进程)。"""
|
|
275
|
+
with self._lock:
|
|
276
|
+
return {key[0] for key in self._sessions}
|
|
277
|
+
|
|
278
|
+
def snapshot(self) -> list[dict[str, Any]]:
|
|
279
|
+
with self._lock:
|
|
280
|
+
return [s.to_dict() for s in self._sessions.values()]
|
|
281
|
+
|
|
282
|
+
def stats(self) -> dict[str, int]:
|
|
283
|
+
with self._lock:
|
|
284
|
+
out = dict(self._counters)
|
|
285
|
+
out["active"] = len(self._sessions)
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
def note_dropped(self, reason: str) -> None:
|
|
289
|
+
"""记录一次丢弃(关联失败 → 记 `E_UPSTREAM_DOWN`,不直连)。
|
|
290
|
+
|
|
291
|
+
`dropped` 是总数,`no_upstream`/`fragmented`/`no_payload`/`send_failed`/
|
|
292
|
+
`associating` 是分类计数,排查时必须能一眼分辨"上游不支持"和"根本没拿到载荷"。
|
|
293
|
+
"""
|
|
294
|
+
key = {
|
|
295
|
+
"no-upstream": "no_upstream",
|
|
296
|
+
"fragmented": "fragmented",
|
|
297
|
+
"no-payload": "no_payload",
|
|
298
|
+
"send-failed": "send_failed",
|
|
299
|
+
"associating": "associating",
|
|
300
|
+
}.get(reason)
|
|
301
|
+
with self._lock:
|
|
302
|
+
if key is not None:
|
|
303
|
+
self._counters[key] += 1
|
|
304
|
+
self._counters["dropped"] += 1
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class UdpForwarder:
|
|
308
|
+
"""把 UDP 数据报按策略交给上游(SOCKS5 UDP ASSOCIATE)。
|
|
309
|
+
|
|
310
|
+
关联失败 → 丢弃该数据报并记 `E_UPSTREAM_DOWN`(不直连)。
|
|
311
|
+
|
|
312
|
+
## 两段式建关联(为什么 `handle()` 绝不阻塞)
|
|
313
|
+
|
|
314
|
+
建一条 SOCKS5 UDP 关联要跟上游做一次完整 TCP 握手 + 认证,是阻塞调用。
|
|
315
|
+
而 `handle()` 跑在后端的事件线程上(那条线程还要处理其它决策),
|
|
316
|
+
超时后驱动按默认放行(PERMIT),真实 IP 就泄漏了。所以决策线程上
|
|
317
|
+
绝不允许出现网络等待:
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
首个数据报 → 立刻返回 BLOCK(丢弃)+ 把关联丢给后台 worker
|
|
321
|
+
关联就绪 → PROXY
|
|
322
|
+
就绪前的每个数据报 → 继续丢弃(计数 associating)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
代价是每个 `(pid, src_port)` 丢开头若干个包。这是可接受的:QUIC 会重传、
|
|
326
|
+
DNS 会重试,而"决策线程被一次慢握手拖住"会导致全机 TCP 决策一起变慢。
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
def __init__(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
policy: UdpPolicyResolver,
|
|
333
|
+
sessions: UdpSessionTable,
|
|
334
|
+
dialer_factory: Callable[[str], UdpAssociation | None] | None = None,
|
|
335
|
+
transport_factory: Callable[[int], UdpTransport] | None = None,
|
|
336
|
+
associate_workers: int = ASSOCIATE_WORKERS,
|
|
337
|
+
) -> None:
|
|
338
|
+
self.policy = policy
|
|
339
|
+
self.sessions = sessions
|
|
340
|
+
self._dialer_factory = dialer_factory
|
|
341
|
+
# `None` ⇒ 用真实的 `UdpRelayTransport`;测试注入替身以免真的发包。
|
|
342
|
+
self._transport_factory: Callable[[int], UdpTransport] = transport_factory or UdpRelayTransport
|
|
343
|
+
|
|
344
|
+
# 后台建关联:固定数量 worker + 去重队列。
|
|
345
|
+
# 不去重的话,一个视频流的每个丢包都会排一次队;不用线程池而用固定
|
|
346
|
+
# worker 是因为关联数受"不同 (pid,src_port) 数"限制,且一次握手最长
|
|
347
|
+
# `UDP_ASSOCIATE_TIMEOUT_S`,worker 太多只是白占栈。
|
|
348
|
+
self._associate_workers = max(1, int(associate_workers))
|
|
349
|
+
self._queue: queue.Queue[tuple[int, int, str] | None] | None = None
|
|
350
|
+
self._workers: list[threading.Thread] = []
|
|
351
|
+
self._inflight: set[tuple[int, int]] = set()
|
|
352
|
+
self._assoc_lock = threading.Lock()
|
|
353
|
+
self._closed = False
|
|
354
|
+
|
|
355
|
+
# -------------------------------------------------------- 后台建关联 -----
|
|
356
|
+
def _start_workers_locked(self) -> None:
|
|
357
|
+
"""惰性启动 worker(没用 UDP 的进程不该平白多出几条线程)。调用方须持锁。"""
|
|
358
|
+
if self._queue is not None:
|
|
359
|
+
return
|
|
360
|
+
q: queue.Queue[tuple[int, int, str] | None] = queue.Queue(maxsize=ASSOCIATE_QUEUE_MAX)
|
|
361
|
+
self._queue = q
|
|
362
|
+
for i in range(self._associate_workers):
|
|
363
|
+
thread = threading.Thread(
|
|
364
|
+
target=self._associate_worker, args=(q,), name=f"ppx-udp-assoc-{i}", daemon=True
|
|
365
|
+
)
|
|
366
|
+
thread.start()
|
|
367
|
+
self._workers.append(thread)
|
|
368
|
+
|
|
369
|
+
def _associate_worker(self, q: queue.Queue[tuple[int, int, str] | None]) -> None:
|
|
370
|
+
"""worker 主循环。
|
|
371
|
+
|
|
372
|
+
`q` 必须由参数传入、不能在循环里读 `self._queue`,`close()` 会把
|
|
373
|
+
`self._queue` 置 `None`,而此刻 worker 可能正拿着一个包在收尾,读属性
|
|
374
|
+
就会 `AttributeError`(线程里抛异常只打日志,但 `task_done()` 漏掉会让
|
|
375
|
+
`close()` 的 `join()` 逻辑失去意义)。
|
|
376
|
+
"""
|
|
377
|
+
while True:
|
|
378
|
+
item = q.get()
|
|
379
|
+
if item is None: # 毒丸:close() 放的
|
|
380
|
+
q.task_done()
|
|
381
|
+
return
|
|
382
|
+
pid, src_port, alias = item
|
|
383
|
+
try:
|
|
384
|
+
self.ensure_association(pid, src_port, alias)
|
|
385
|
+
except Exception as exc: # noqa: BLE001 - worker 线程不能死
|
|
386
|
+
# `ensure_association` 自己已经把失败路径都记了账,这里只兜底
|
|
387
|
+
LOG.error("udp_associate_worker_failed", pid=pid, src_port=src_port, error=str(exc))
|
|
388
|
+
finally:
|
|
389
|
+
with self._assoc_lock:
|
|
390
|
+
self._inflight.discard((pid, src_port))
|
|
391
|
+
q.task_done()
|
|
392
|
+
|
|
393
|
+
def request_association(self, pid: int, src_port: int, upstream_alias: str) -> bool:
|
|
394
|
+
"""非阻塞地排队建关联。
|
|
395
|
+
|
|
396
|
+
返回 `True` = 这次真的排上了;`False` = 已经就绪、已经在建、或已关闭
|
|
397
|
+
(三种情况调用方都不需要做任何事)。
|
|
398
|
+
"""
|
|
399
|
+
key = (int(pid), int(src_port))
|
|
400
|
+
session = self.sessions.get(pid, src_port)
|
|
401
|
+
if session is not None and session.assoc is not None and session.transport is not None:
|
|
402
|
+
return False # 已就绪
|
|
403
|
+
with self._assoc_lock:
|
|
404
|
+
if self._closed or key in self._inflight:
|
|
405
|
+
return False
|
|
406
|
+
self._start_workers_locked()
|
|
407
|
+
assert self._queue is not None
|
|
408
|
+
self._inflight.add(key)
|
|
409
|
+
try:
|
|
410
|
+
self._queue.put_nowait((key[0], key[1], upstream_alias))
|
|
411
|
+
except queue.Full:
|
|
412
|
+
# 队列满意味着"待建关联数"已远超正常值:宁可丢弃也不阻塞决策线程。
|
|
413
|
+
self._inflight.discard(key)
|
|
414
|
+
LOG.warn("udp_associate_queue_full", pid=pid, src_port=src_port)
|
|
415
|
+
return False
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
def close(self, *, timeout: float = 1.0) -> None:
|
|
419
|
+
"""停掉后台 worker(幂等)。已建好的会话由 `UdpSessionTable` 负责释放。"""
|
|
420
|
+
with self._assoc_lock:
|
|
421
|
+
if self._closed:
|
|
422
|
+
return
|
|
423
|
+
self._closed = True
|
|
424
|
+
q, self._queue = self._queue, None
|
|
425
|
+
if q is not None:
|
|
426
|
+
for _ in self._workers:
|
|
427
|
+
with contextlib.suppress(queue.Full):
|
|
428
|
+
q.put_nowait(None) # 毒丸
|
|
429
|
+
for thread in self._workers:
|
|
430
|
+
thread.join(timeout=timeout)
|
|
431
|
+
self._workers.clear()
|
|
432
|
+
|
|
433
|
+
def decide_action(self, dst_port: int) -> Action:
|
|
434
|
+
return self.policy.action_for(dst_port)
|
|
435
|
+
|
|
436
|
+
@staticmethod
|
|
437
|
+
def _relay_family(assoc: UdpAssociation) -> int:
|
|
438
|
+
relay = getattr(assoc, "relay", None)
|
|
439
|
+
return socket.AF_INET6 if getattr(relay, "is_v6", False) else socket.AF_INET
|
|
440
|
+
|
|
441
|
+
def ensure_association(self, pid: int, src_port: int, upstream_alias: str) -> UdpSession | None:
|
|
442
|
+
"""建立(或复用)`(pid, src_port)` 的 UDP 关联及其数据通道。
|
|
443
|
+
|
|
444
|
+
只有"控制连接 + 数据通道"两者俱全才算可用会话:缺了 transport 的会话
|
|
445
|
+
发不出包也收不到包,必须当作不存在,否则 `handle()` 会以为成功而静默丢包。
|
|
446
|
+
"""
|
|
447
|
+
session = self.sessions.get(pid, src_port)
|
|
448
|
+
if session is not None and session.assoc is not None and session.transport is not None:
|
|
449
|
+
return session
|
|
450
|
+
|
|
451
|
+
if self._dialer_factory is None:
|
|
452
|
+
self.sessions.note_dropped("no-upstream")
|
|
453
|
+
LOG.error(
|
|
454
|
+
"udp_associate_unavailable",
|
|
455
|
+
pid=pid,
|
|
456
|
+
src_port=src_port,
|
|
457
|
+
code="E_UPSTREAM_DOWN",
|
|
458
|
+
hint="没有可用的 UDP 关联(上游需支持 SOCKS5 UDP ASSOCIATE,且配置 udp=true)",
|
|
459
|
+
)
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
assoc: UdpAssociation | None
|
|
463
|
+
try:
|
|
464
|
+
if session is not None and session.assoc is not None:
|
|
465
|
+
assoc = session.assoc # 控制连接还在(比如只有通道建失败过),补通道即可
|
|
466
|
+
else:
|
|
467
|
+
assoc = self._dialer_factory(upstream_alias)
|
|
468
|
+
except Exception as exc: # noqa: BLE001 - 关联失败只丢弃该数据报
|
|
469
|
+
self.sessions.note_dropped("no-upstream")
|
|
470
|
+
LOG.error("udp_associate_failed", upstream=upstream_alias, code="E_UPSTREAM_DOWN", error=str(exc))
|
|
471
|
+
return None
|
|
472
|
+
if assoc is None:
|
|
473
|
+
# 工厂用 None 表示"上游不可用"(别名不存在 / 不支持 UDP)。
|
|
474
|
+
# 必须在这里挡掉:放它过去会建出一个 `assoc=None` 的空壳会话,
|
|
475
|
+
# 之后每次 `handle()` 都失败,而计数里一次 `no_upstream` 都没有。
|
|
476
|
+
self.sessions.note_dropped("no-upstream")
|
|
477
|
+
LOG.error(
|
|
478
|
+
"udp_associate_unavailable",
|
|
479
|
+
upstream=upstream_alias,
|
|
480
|
+
code="E_UPSTREAM_DOWN",
|
|
481
|
+
hint="上游不存在或未启用 UDP(upstream.udp=true)",
|
|
482
|
+
)
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
transport = self._transport_factory(self._relay_family(assoc))
|
|
487
|
+
except Exception as exc: # noqa: BLE001 - 建不出通道等价于没有关联
|
|
488
|
+
# 关联本身是"死物":没有通道它永远用不上,不关掉就是泄漏一条控制连接。
|
|
489
|
+
with contextlib.suppress(Exception):
|
|
490
|
+
assoc.close()
|
|
491
|
+
self.sessions.note_dropped("no-upstream")
|
|
492
|
+
LOG.error(
|
|
493
|
+
"udp_transport_failed",
|
|
494
|
+
upstream=upstream_alias,
|
|
495
|
+
code="E_UPSTREAM_DOWN",
|
|
496
|
+
error=str(exc),
|
|
497
|
+
)
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
if session is not None:
|
|
501
|
+
session.assoc = assoc
|
|
502
|
+
session.transport = transport
|
|
503
|
+
if not session.upstream:
|
|
504
|
+
session.upstream = upstream_alias
|
|
505
|
+
return session
|
|
506
|
+
return self.sessions.open(pid, src_port, upstream=upstream_alias, assoc=assoc, transport=transport)
|
|
507
|
+
|
|
508
|
+
def handle(
|
|
509
|
+
self, pid: int, src_port: int, dst: Endpoint, payload: bytes, *, upstream_alias: str = ""
|
|
510
|
+
) -> Action:
|
|
511
|
+
"""把数据报封装成 SOCKS5 UDP 报文发给中继,返回实际采取的动作。
|
|
512
|
+
|
|
513
|
+
调用方只应依据返回值决定是否放行原始报文:返回 `PROXY` 时原始数据报
|
|
514
|
+
必须被丢弃(否则同一个包会直连出去一次,等于白白泄漏真实 IP),
|
|
515
|
+
返回 `BLOCK` 时同样丢弃(失败即失败,绝不静默直连)。
|
|
516
|
+
|
|
517
|
+
本函数绝不阻塞:关联没就绪时不建,只排队并丢弃当前数据报
|
|
518
|
+
(两段式,理由见类 docstring)。
|
|
519
|
+
"""
|
|
520
|
+
action = self.decide_action(dst.port)
|
|
521
|
+
if action is not Action.PROXY:
|
|
522
|
+
if action is Action.DIRECT:
|
|
523
|
+
LOG.debug("udp_direct", pid=pid, dst=str(dst), port=dst.port, leak=True)
|
|
524
|
+
else:
|
|
525
|
+
LOG.debug("udp_blocked", pid=pid, dst=str(dst), port=dst.port)
|
|
526
|
+
return action
|
|
527
|
+
|
|
528
|
+
existing = self.sessions.get(pid, src_port)
|
|
529
|
+
alias = upstream_alias or (existing.upstream if existing is not None else "")
|
|
530
|
+
session = existing
|
|
531
|
+
if session is None or session.assoc is None or session.transport is None:
|
|
532
|
+
# 关联还没建好(或正在建)→ 丢掉这一个包,把建关联交给后台 worker。
|
|
533
|
+
# 注意这里不是失败:`associating` 与 `no_upstream` 必须能分开看,
|
|
534
|
+
# 否则"首个包被丢"会被误读成"上游不可用"。
|
|
535
|
+
self.sessions.note_dropped("associating")
|
|
536
|
+
if self.request_association(pid, src_port, alias):
|
|
537
|
+
LOG.info("udp_associating", pid=pid, src_port=src_port, dst=str(dst), upstream=alias)
|
|
538
|
+
return Action.BLOCK
|
|
539
|
+
|
|
540
|
+
relay = session.assoc.relay
|
|
541
|
+
try:
|
|
542
|
+
session.transport.send(build_udp_datagram(dst, payload), (relay.addr, relay.port))
|
|
543
|
+
except OSError as exc:
|
|
544
|
+
# UDP 的 sendto 错误是逐包的(路由不可达等),socket 本身通常还好,
|
|
545
|
+
# 所以只丢这一个包、保留关联,重建关联的代价远高于丢一个包。
|
|
546
|
+
self.sessions.note_dropped("send-failed")
|
|
547
|
+
LOG.error("udp_send_failed", pid=pid, dst=str(dst), relay=str(relay), code="E_CONNECT", error=str(exc))
|
|
548
|
+
return Action.BLOCK
|
|
549
|
+
|
|
550
|
+
session.dst = dst
|
|
551
|
+
session.packets_up += 1
|
|
552
|
+
# 记裸载荷字节数,与 bytes_down 口径一致;不含 SOCKS5 UDP 头的开销,
|
|
553
|
+
# 否则统计值取决于目标地址是 v4/v6/域名,和用户看到的流量对不上。
|
|
554
|
+
session.bytes_up += len(payload)
|
|
555
|
+
self.sessions.touch(pid, src_port)
|
|
556
|
+
return Action.PROXY
|
|
557
|
+
|
|
558
|
+
def poll_response(
|
|
559
|
+
self, pid: int, src_port: int, *, timeout: float = 0.0
|
|
560
|
+
) -> tuple[Endpoint, bytes] | None:
|
|
561
|
+
"""取一条来自中继的回包;没有就返回 `None`(超时不算错误)。
|
|
562
|
+
|
|
563
|
+
返回的 `Endpoint` 是回包的原始来源,可能与本会话记录的上行 `dst` 不同
|
|
564
|
+
(同一会话可能向多个目标发包),调用方应以它为准决定把裸载荷回注给谁。
|
|
565
|
+
"""
|
|
566
|
+
session = self.sessions.get(pid, src_port)
|
|
567
|
+
if session is None or session.transport is None:
|
|
568
|
+
return None
|
|
569
|
+
try:
|
|
570
|
+
packet = session.transport.recv(timeout=timeout)
|
|
571
|
+
except OSError as exc:
|
|
572
|
+
LOG.warn("udp_recv_failed", pid=pid, src_port=src_port, error=str(exc))
|
|
573
|
+
return None
|
|
574
|
+
if not packet:
|
|
575
|
+
return None
|
|
576
|
+
try:
|
|
577
|
+
src, payload = parse_udp_datagram(packet)
|
|
578
|
+
except UpstreamDown as exc:
|
|
579
|
+
# 解析失败→ 丢弃并计数
|
|
580
|
+
self.sessions.note_dropped("fragmented")
|
|
581
|
+
LOG.warn("udp_response_dropped", pid=pid, src_port=src_port, reason=str(exc))
|
|
582
|
+
return None
|
|
583
|
+
self.note_response(pid, src_port, payload)
|
|
584
|
+
return src, payload
|
|
585
|
+
|
|
586
|
+
def note_response(self, pid: int, src_port: int, payload: bytes) -> None:
|
|
587
|
+
session = self.sessions.touch(pid, src_port)
|
|
588
|
+
if session is not None:
|
|
589
|
+
session.packets_down += 1
|
|
590
|
+
session.bytes_down += len(payload)
|
|
591
|
+
|
|
592
|
+
def note_fragmented(self, pid: int, dst: Endpoint) -> None:
|
|
593
|
+
"""分片 UDP:直接放行并记 WARN。"""
|
|
594
|
+
self.sessions.note_dropped("fragmented")
|
|
595
|
+
LOG.warn("udp_fragmented_passthrough", pid=pid, dst=str(dst))
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
__all__ = [
|
|
599
|
+
"DEFAULT_IDLE_TIMEOUT_S",
|
|
600
|
+
"MAX_UDP_DATAGRAM",
|
|
601
|
+
"UdpForwarder",
|
|
602
|
+
"UdpPolicyResolver",
|
|
603
|
+
"UdpRelayTransport",
|
|
604
|
+
"UdpSession",
|
|
605
|
+
"UdpSessionTable",
|
|
606
|
+
]
|