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
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
+ ]