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/runtime.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""数据面运行时:把配置 + 后端 + 规则引擎 + 会话表 + sink 装配成一个可运行对象。
|
|
2
|
+
|
|
3
|
+
职责:
|
|
4
|
+
* 数据面只跑在服务进程里;CLI 只读状态;
|
|
5
|
+
* 拦截/决策路径禁止阻塞(决策函数只做纯计算 + 记账 + 改道);
|
|
6
|
+
* 热重载原子替换 Profile,不中断已有会话;
|
|
7
|
+
* `backend=null` 时不监听 sink,只提供 decide 与状态输出;
|
|
8
|
+
其余后端接入 `tunnel/sink.py` 与 `tunnel/relay.py`。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import dataclasses
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import signal
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from .backend import Backend
|
|
24
|
+
from .backend.base import BackendUnavailable
|
|
25
|
+
from .config import Config, ConfigError, ConfigManager
|
|
26
|
+
from .dns.modes import MODE_BYPASS, MODE_LOCAL, build_dns_policy
|
|
27
|
+
from .dns.udp import UdpForwarder, UdpPolicyResolver, UdpSessionTable
|
|
28
|
+
from .log import EventLogger, get_logger
|
|
29
|
+
from .proc import current_pid, get_proc_info, is_alive
|
|
30
|
+
from .rules import GuardSet, LoopBreaker, fallback_decision, stats_snapshot
|
|
31
|
+
from .tunnel.sessions import SessionTable
|
|
32
|
+
from .types import Action, ConnRequest, Decision, Endpoint
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from .tunnel.sink import Sink
|
|
36
|
+
|
|
37
|
+
LOG = get_logger("proxycore.runtime")
|
|
38
|
+
|
|
39
|
+
# RFC 1928:`UDP ASSOCIATE` 的 DST.ADDR/DST.PORT 用 0.0.0.0:0 表示"还不知道目标"
|
|
40
|
+
UDP_ASSOCIATE_ANY = Endpoint(addr="0.0.0.0", port=0)
|
|
41
|
+
|
|
42
|
+
# 已知限制:建立 UDP 关联要跟上游做一次 TCP 握手,是阻塞调用,而
|
|
43
|
+
# `decide_udp()` 跑在后端的事件线程上(那条线程同时负责 TCP 决策,
|
|
44
|
+
# classify 只有 200ms 预算)。所以这里用一个独立上限把它掐住,避免默认 15s 的
|
|
45
|
+
# `upstream_timeout_s` 把整条决策线程拖死。
|
|
46
|
+
# 正确做法是改成本地"先丢首个数据报、后台线程建关联"的两段式。
|
|
47
|
+
UDP_ASSOCIATE_TIMEOUT_S = 3.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Runtime:
|
|
51
|
+
"""进程级数据面。生命周期:`start()` → `serve_forever()` → `stop()`。"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, manager: ConfigManager, backend: Backend) -> None:
|
|
54
|
+
self.manager = manager
|
|
55
|
+
self.backend = backend
|
|
56
|
+
self.log = EventLogger(LOG)
|
|
57
|
+
self.sessions = SessionTable(
|
|
58
|
+
max_sessions=manager.config.service.max_sessions,
|
|
59
|
+
log=self.log,
|
|
60
|
+
)
|
|
61
|
+
self.breaker = LoopBreaker(
|
|
62
|
+
window_s=manager.config.service.loop_breaker.window_s,
|
|
63
|
+
threshold=manager.config.service.loop_breaker.threshold,
|
|
64
|
+
)
|
|
65
|
+
self.guards = GuardSet(
|
|
66
|
+
self_pids=set(),
|
|
67
|
+
breaker=self.breaker,
|
|
68
|
+
on_unknown_pid=manager.config.service.on_unknown_pid,
|
|
69
|
+
)
|
|
70
|
+
self.sink: Sink | None = None
|
|
71
|
+
self._stop_event = threading.Event()
|
|
72
|
+
self._maintenance: threading.Thread | None = None
|
|
73
|
+
self._reload_watch: threading.Thread | None = None
|
|
74
|
+
self._started_at = 0.0
|
|
75
|
+
self._backend_error: str = ""
|
|
76
|
+
|
|
77
|
+
# DNS 三模式由 Runtime 持有,三处消费:
|
|
78
|
+
# ① 守卫 G3 白名单(local 模式的 DNS 服务器);
|
|
79
|
+
# ② `guards.name_resolver` → `dst_name` 规则;③ sink dial 时的解析计划。
|
|
80
|
+
self.dns_policy = build_dns_policy(manager.config.profile)
|
|
81
|
+
self._dns_revision = -1
|
|
82
|
+
|
|
83
|
+
# UDP 数据面。只有 `supports_udp` 的后端才会注册,
|
|
84
|
+
# 否则 `on_udp()` 会抛 `NotImplementedError`(windivert 就是这种)。
|
|
85
|
+
self.udp_sessions = UdpSessionTable(idle_timeout_s=manager.config.service.udp_idle_timeout_s)
|
|
86
|
+
self.udp_policy = UdpPolicyResolver(manager.config.udp_policy)
|
|
87
|
+
self.udp = UdpForwarder(
|
|
88
|
+
policy=self.udp_policy,
|
|
89
|
+
sessions=self.udp_sessions,
|
|
90
|
+
dialer_factory=self._make_udp_association,
|
|
91
|
+
)
|
|
92
|
+
self._udp_registered = False
|
|
93
|
+
self._refresh_guards()
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------ 生命周期 --
|
|
96
|
+
@property
|
|
97
|
+
def config(self) -> Config:
|
|
98
|
+
return self.manager.config
|
|
99
|
+
|
|
100
|
+
def start(self, *, sink_test_target: str = "", upstream_alias: str = "") -> None:
|
|
101
|
+
self._started_at = time.time()
|
|
102
|
+
self._refresh_guards()
|
|
103
|
+
self.backend.set_decider(self.decide)
|
|
104
|
+
# `Runtime.decide()` 自己会记 captured/direct/blocked(见 `_count`),
|
|
105
|
+
# 后端必须停手,否则同一个包两边各记一次 = 双倍(docs:验收里这两个数
|
|
106
|
+
# 是要给人看的,翻倍等于把"抓了多少"误导成两倍)。
|
|
107
|
+
self.backend.counts_decisions = False
|
|
108
|
+
self._register_udp()
|
|
109
|
+
try:
|
|
110
|
+
self.backend.start()
|
|
111
|
+
except BackendUnavailable as exc:
|
|
112
|
+
self._backend_error = str(exc)
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
manual_mode = bool(sink_test_target or upstream_alias)
|
|
116
|
+
if sink_test_target:
|
|
117
|
+
self.sessions.set_test_target(sink_test_target)
|
|
118
|
+
if upstream_alias:
|
|
119
|
+
self.sessions.force_upstream = upstream_alias
|
|
120
|
+
|
|
121
|
+
if self.backend.name != "null" or manual_mode:
|
|
122
|
+
self._start_sink()
|
|
123
|
+
if self.backend.name == "null":
|
|
124
|
+
self.log.warn(
|
|
125
|
+
"backend_null",
|
|
126
|
+
"backend=null:不抓包、不改道,仅联调",
|
|
127
|
+
sink_test_target=sink_test_target or "-",
|
|
128
|
+
forced_upstream=upstream_alias or "-",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
self._maintenance = threading.Thread(target=self._maintenance_loop, name="ppx-maintenance", daemon=True)
|
|
132
|
+
self._maintenance.start()
|
|
133
|
+
self.log.info(
|
|
134
|
+
"runtime_started",
|
|
135
|
+
backend=self.backend.name,
|
|
136
|
+
sink=f"{self.config.service.sink.host}:{self.config.service.sink.port}",
|
|
137
|
+
rules=len(self.config.profile.rules),
|
|
138
|
+
dns_mode=self.dns_policy.mode,
|
|
139
|
+
)
|
|
140
|
+
if self.dns_policy.mode in {MODE_LOCAL, MODE_BYPASS}:
|
|
141
|
+
# local 模式会在本地查 DNS,查询本身可能直连出去。
|
|
142
|
+
# 这是用户选的模式(不是缺陷),但必须显式告知,不能让人以为没泄漏。
|
|
143
|
+
self.log.warn(
|
|
144
|
+
"dns_local_mode_leaks",
|
|
145
|
+
mode=self.dns_policy.mode,
|
|
146
|
+
local_transport=self.dns_policy.local_transport,
|
|
147
|
+
dns_server=self.dns_policy.local_server or "(system)",
|
|
148
|
+
hint="local/bypass 会在本地解析域名:本地 DNS 查询可能直连出去",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def stop(self, *, reason: str = "shutdown") -> None:
|
|
152
|
+
self._stop_event.set()
|
|
153
|
+
try:
|
|
154
|
+
self.backend.stop() # 契约:stop() 后 2s 内不再回调 decide
|
|
155
|
+
self._await_backend_quiet()
|
|
156
|
+
if self.sink is not None:
|
|
157
|
+
self.sink.stop()
|
|
158
|
+
self.sink = None
|
|
159
|
+
finally:
|
|
160
|
+
# 停掉 UDP 后台建关联的 worker。放在 backend.stop() 之后:这之后不会
|
|
161
|
+
# 再有新的 `decide_udp()` 进来,worker 手里的活做完就退出,不会边关边建。
|
|
162
|
+
with contextlib.suppress(Exception):
|
|
163
|
+
self.udp.close()
|
|
164
|
+
# 已建好的会话(控制连接 + 中继通道)也要释放,否则 socket 一直开着
|
|
165
|
+
self.udp_sessions.close_all()
|
|
166
|
+
self._write_state(reason=reason)
|
|
167
|
+
self.log.info("runtime_stopped", reason=reason, sessions=self.sessions.count())
|
|
168
|
+
|
|
169
|
+
def serve_forever(self, *, reload_watch: bool = True, poll_s: float = 1.0) -> None:
|
|
170
|
+
if reload_watch:
|
|
171
|
+
self._reload_watch = threading.Thread(target=self._watch_config, name="ppx-reload", daemon=True)
|
|
172
|
+
self._reload_watch.start()
|
|
173
|
+
self._install_signal_handlers()
|
|
174
|
+
try:
|
|
175
|
+
while not self._stop_event.wait(poll_s):
|
|
176
|
+
pass
|
|
177
|
+
finally:
|
|
178
|
+
if self.sink is not None:
|
|
179
|
+
self.sink.stop()
|
|
180
|
+
|
|
181
|
+
def _install_signal_handlers(self) -> None:
|
|
182
|
+
def handler(signum: int, _frame: Any) -> None: # pragma: no cover - 信号路径
|
|
183
|
+
self.log.warn("signal_received", signum=signum)
|
|
184
|
+
self._stop_event.set()
|
|
185
|
+
|
|
186
|
+
for name in ("SIGINT", "SIGTERM"):
|
|
187
|
+
sig = getattr(signal, name, None)
|
|
188
|
+
if sig is None:
|
|
189
|
+
continue
|
|
190
|
+
with contextlib.suppress(ValueError, OSError): # 非主线程/平台限制
|
|
191
|
+
signal.signal(sig, handler)
|
|
192
|
+
|
|
193
|
+
def _await_backend_quiet(self, timeout: float | None = None) -> None:
|
|
194
|
+
"""契约:`stop()` 后 2 秒内不再回调 `decide`。"""
|
|
195
|
+
from .backend.base import STOP_QUIESCE_SECONDS
|
|
196
|
+
|
|
197
|
+
deadline = time.monotonic() + (timeout or STOP_QUIESCE_SECONDS)
|
|
198
|
+
while time.monotonic() < deadline:
|
|
199
|
+
if not getattr(self.backend, "running", False):
|
|
200
|
+
return
|
|
201
|
+
time.sleep(0.05)
|
|
202
|
+
self.log.warn("backend_quiet_timeout", backend=self.backend.name)
|
|
203
|
+
|
|
204
|
+
# -------------------------------------------------------------- 决策 ----
|
|
205
|
+
def _count(self, key: str, delta: int = 1) -> None:
|
|
206
|
+
"""后端计数(`count()` 是 BaseBackend 的便利方法,Protocol 未强制)。"""
|
|
207
|
+
counter = getattr(self.backend, "count", None)
|
|
208
|
+
if callable(counter):
|
|
209
|
+
counter(key, delta)
|
|
210
|
+
|
|
211
|
+
def decide(self, conn: ConnRequest) -> Decision:
|
|
212
|
+
"""后端回调入口。只做纯计算 + 记账,不得阻塞。"""
|
|
213
|
+
from .rules import decide as rules_decide
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# "抓到一条连接"是既成事实,与后面判成什么无关,放在最前面,
|
|
217
|
+
# 免得决策引擎抛异常时连 captured 都丢了。
|
|
218
|
+
self._count("captured")
|
|
219
|
+
if conn.proc.pid <= 0 and self.backend.supports_pid_native:
|
|
220
|
+
self.log.warn("unknown_pid_from_native_backend", backend=self.backend.name)
|
|
221
|
+
elif conn.proc.pid > 0 and not conn.proc.name:
|
|
222
|
+
info = get_proc_info(conn.proc.pid)
|
|
223
|
+
conn.proc.name = info.name
|
|
224
|
+
conn.proc.path = conn.proc.path or info.path
|
|
225
|
+
conn.proc.session_id = conn.proc.session_id or info.session_id
|
|
226
|
+
|
|
227
|
+
decision = rules_decide(conn, self.config.profile, self.sessions, self.guards)
|
|
228
|
+
self.breaker.note(conn.proc.pid, conn.dst)
|
|
229
|
+
|
|
230
|
+
if decision.action is Action.DIRECT:
|
|
231
|
+
self._count("direct")
|
|
232
|
+
elif decision.action is Action.BLOCK:
|
|
233
|
+
self._count("blocked")
|
|
234
|
+
if decision.reason == "loop-breaker":
|
|
235
|
+
self.log.warn(
|
|
236
|
+
"loop_breaker_tripped",
|
|
237
|
+
pid=conn.proc.pid,
|
|
238
|
+
proc=conn.proc.name,
|
|
239
|
+
dst=str(conn.dst),
|
|
240
|
+
window_s=self.breaker.window_s,
|
|
241
|
+
threshold=self.breaker.threshold,
|
|
242
|
+
)
|
|
243
|
+
self.sessions.record_decision(conn, decision)
|
|
244
|
+
return decision
|
|
245
|
+
except Exception as exc: # 决策路径不允许把异常抛回抓包线程(BLE001 已按文件豁免)
|
|
246
|
+
self.log.error("decide_failed", pid=conn.proc.pid, dst=str(conn.dst), error=str(exc))
|
|
247
|
+
# 异常 = "不知道自己该干什么"。默认 block 而不是 direct:
|
|
248
|
+
# 改道失败禁止静默直连,决策失败同理,放行等于漏真实 IP。
|
|
249
|
+
try:
|
|
250
|
+
policy = self.config.service.on_decide_error
|
|
251
|
+
fallback = fallback_decision(policy, self.config.profile, reason="decide-error")
|
|
252
|
+
except Exception as nested: # 兜底自己再失败也绝不外抛:这条分支的意义就是"不逃"
|
|
253
|
+
self.log.error("decide_fallback_failed", error=str(nested))
|
|
254
|
+
return Decision(Action.BLOCK, reason="decide-error:block")
|
|
255
|
+
self._count("blocked" if fallback.action is Action.BLOCK else "direct")
|
|
256
|
+
return fallback
|
|
257
|
+
|
|
258
|
+
# --------------------------------------------------------------- UDP ----
|
|
259
|
+
def _register_udp(self) -> None:
|
|
260
|
+
"""把 UDP 处理器挂到后端。
|
|
261
|
+
|
|
262
|
+
`supports_udp=False` 的后端(路线 A 的 windivert)完全没有 UDP 路径:
|
|
263
|
+
它的两个句柄过滤器都以 `tcp` 开头,UDP 报文压根不进用户态。所以
|
|
264
|
+
`udp_policy`(含 `by_port: {443: block}`)在该后端上是纯配置、零效果:
|
|
265
|
+
既不做 proxy,也不做 block,QUIC 走的是系统默认路由。
|
|
266
|
+
这里不打补丁、不吞异常,只记一条信息就跳过;试图注册会抛
|
|
267
|
+
`NotImplementedError`,而被它挡住的是"整条启动流程",不值得。
|
|
268
|
+
"""
|
|
269
|
+
if not getattr(self.backend, "supports_udp", False):
|
|
270
|
+
self.log.info(
|
|
271
|
+
"udp_unsupported_backend",
|
|
272
|
+
backend=getattr(self.backend, "name", "?"),
|
|
273
|
+
hint="该后端不捕获 UDP,udp_policy 不会生效(QUIC 直连)",
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
self.backend.on_udp(self.decide_udp)
|
|
277
|
+
self._udp_registered = True
|
|
278
|
+
policy = self.udp_policy.describe()
|
|
279
|
+
self.log.info(
|
|
280
|
+
"udp_registered",
|
|
281
|
+
backend=getattr(self.backend, "name", "?"),
|
|
282
|
+
default=policy["default"],
|
|
283
|
+
by_port=policy["by_port"],
|
|
284
|
+
)
|
|
285
|
+
if self.udp_policy.leaks_ip():
|
|
286
|
+
# 显式告知:`direct` 会让支持 QUIC 的应用绕过代理
|
|
287
|
+
self.log.warn(
|
|
288
|
+
"udp_policy_leaks_ip",
|
|
289
|
+
default=policy["default"],
|
|
290
|
+
hint="udp_policy 含 direct → 支持 QUIC 的应用会绕过代理并泄漏真实 IP",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def _make_udp_association(self, alias: str) -> Any:
|
|
294
|
+
"""`UdpForwarder.dialer_factory`:按别名建立一条 SOCKS5 UDP 关联。
|
|
295
|
+
|
|
296
|
+
返回的是 `UdpAssociation`(不是 dialer),这是 forwarder 的契约:
|
|
297
|
+
`assoc = factory(alias)`。按 RFC 1928,`UDP ASSOCIATE` 请求里的
|
|
298
|
+
DST.ADDR/DST.PORT 填 `0.0.0.0:0`,表示"客户端此刻还不知道要发去哪儿"。
|
|
299
|
+
|
|
300
|
+
返回 `None` 的语义是"没有可用的上游" → forwarder 丢弃数据报并记
|
|
301
|
+
`E_UPSTREAM_DOWN`。
|
|
302
|
+
"""
|
|
303
|
+
if not alias:
|
|
304
|
+
return None
|
|
305
|
+
upstream = self.config.profile.upstreams.get(alias)
|
|
306
|
+
if upstream is None:
|
|
307
|
+
return None
|
|
308
|
+
from .upstream import make_dialer
|
|
309
|
+
|
|
310
|
+
dialer = make_dialer(
|
|
311
|
+
upstream,
|
|
312
|
+
timeout=min(self.config.service.upstream_timeout_s, UDP_ASSOCIATE_TIMEOUT_S),
|
|
313
|
+
resolver=self.config.profile.upstreams.get,
|
|
314
|
+
)
|
|
315
|
+
if not dialer.supports_udp():
|
|
316
|
+
# SOCKS4 / HTTP CONNECT 永远不支持 UDP;SOCKS5 需要 upstream.udp=true
|
|
317
|
+
return None
|
|
318
|
+
sync_udp = getattr(dialer, "dial_udp_sync", None)
|
|
319
|
+
if sync_udp is None:
|
|
320
|
+
# `UpstreamDialer` 只声明了异步 `dial_udp`;
|
|
321
|
+
# 同步入口是各 dialer 自己提供的(目前只有 Socks5Dialer 有)。
|
|
322
|
+
self.log.error(
|
|
323
|
+
"udp_dialer_has_no_sync_path",
|
|
324
|
+
upstream=alias,
|
|
325
|
+
kind=getattr(dialer, "kind", "?"),
|
|
326
|
+
code="E_UPSTREAM_DOWN",
|
|
327
|
+
)
|
|
328
|
+
return None
|
|
329
|
+
return sync_udp(UDP_ASSOCIATE_ANY)
|
|
330
|
+
|
|
331
|
+
def decide_udp(self, conn: ConnRequest, payload: bytes = b"") -> Action:
|
|
332
|
+
"""`backend.on_udp` 回调:一条 UDP 数据报该怎么办。
|
|
333
|
+
|
|
334
|
+
与 TCP 路径的分工:后端已经带着同一个 `conn` 走过 `decide()`(规则 +
|
|
335
|
+
三重守卫)。但那条判定会被后端用本函数的返回值覆盖,所以守卫必须在这里
|
|
336
|
+
再挡一次,否则 `udp_policy.default=proxy` 就能把环路流量塞进代理。
|
|
337
|
+
|
|
338
|
+
顺序(任何一步命中即返回,不再往下走):
|
|
339
|
+
|
|
340
|
+
1. 环路防护(不可配置):自身 PID / 上游与 DNS 端点 /
|
|
341
|
+
私网回环 / 熔断 → `DIRECT`(放行原包,不接管);
|
|
342
|
+
2. PID 未知 → 按 `on_unknown_pid`,默认 `DIRECT`(路线 A 竞态兜底);
|
|
343
|
+
3. 规则:进程本身没被判为 PROXY 的(DIRECT/BLOCK)原样返回;
|
|
344
|
+
`udp_policy` 只管"已经要走代理的进程"的 UDP,不改变直连进程的行为;
|
|
345
|
+
4. `udp_policy`:`direct` | `block` | `proxy`。
|
|
346
|
+
"""
|
|
347
|
+
try:
|
|
348
|
+
return self._decide_udp_inner(conn, payload)
|
|
349
|
+
except Exception as exc: # 回调不允许异常逃回抓包线程(与 decide 同策略)
|
|
350
|
+
self.log.error("decide_udp_failed", pid=conn.proc.pid, dst=str(conn.dst), error=str(exc))
|
|
351
|
+
return Action.BLOCK # 失败即失败:既不直连,也不假装成功
|
|
352
|
+
|
|
353
|
+
def _decide_udp_inner(self, conn: ConnRequest, payload: bytes) -> Action:
|
|
354
|
+
from .rules import decide as rules_decide
|
|
355
|
+
|
|
356
|
+
g = self.guards
|
|
357
|
+
if g.is_self_pid(conn.proc.pid):
|
|
358
|
+
return Action.DIRECT
|
|
359
|
+
if g.is_upstream_endpoint(conn.dst):
|
|
360
|
+
return Action.DIRECT
|
|
361
|
+
if g.is_private(conn.dst.addr):
|
|
362
|
+
# 私网/回环默认直连(默认放行回环/私网);UDP 没有"改道",
|
|
363
|
+
# 接管后只能注入,且没有规则可依据,所以一律不接管。
|
|
364
|
+
return Action.DIRECT
|
|
365
|
+
if self.breaker.check(conn.proc.pid, conn.dst):
|
|
366
|
+
# `check()` 是只读版本(不记账), UDP 是包级的,按数据报计一次数
|
|
367
|
+
# 会让 5s/50 次的阈值被任何视频流瞬间打爆,所以只读检查。
|
|
368
|
+
self.log.warn(
|
|
369
|
+
"loop_breaker_tripped",
|
|
370
|
+
pid=conn.proc.pid,
|
|
371
|
+
proc=conn.proc.name,
|
|
372
|
+
dst=str(conn.dst),
|
|
373
|
+
proto="udp",
|
|
374
|
+
)
|
|
375
|
+
return Action.DIRECT
|
|
376
|
+
|
|
377
|
+
# 规则探测(含 PID 未知时按 `on_unknown_pid` 的兜底,与 TCP 路径同一套逻辑):
|
|
378
|
+
# 用不带熔断器的守卫副本,避免 `breaker_tripped()` 重复记账
|
|
379
|
+
# (熔断已在上面用只读 `check()` 处理过)。`rules.decide` 除了这条计数之外
|
|
380
|
+
# 是纯函数,这里只取它的 action/upstream,不重复记账、不重复建会话。
|
|
381
|
+
probe_guards = dataclasses.replace(g, breaker=None, track_unknown_pid=False)
|
|
382
|
+
rule_decision = rules_decide(conn, self.config.profile, self.sessions, probe_guards)
|
|
383
|
+
if rule_decision.action is not Action.PROXY:
|
|
384
|
+
return rule_decision.action
|
|
385
|
+
|
|
386
|
+
action = self.udp_policy.action_for(conn.dst.port)
|
|
387
|
+
if action is Action.DIRECT:
|
|
388
|
+
self.log.debug("udp_direct", pid=conn.proc.pid, dst=str(conn.dst), leak=True)
|
|
389
|
+
return Action.DIRECT
|
|
390
|
+
if action is Action.BLOCK:
|
|
391
|
+
self.log.debug("udp_blocked", pid=conn.proc.pid, dst=str(conn.dst))
|
|
392
|
+
return Action.BLOCK
|
|
393
|
+
|
|
394
|
+
if not payload:
|
|
395
|
+
# 临时闸门:后端目前只报事件、不上报载荷(事件里传的是 `b""`)。
|
|
396
|
+
# 0 字节 UDP 报文在协议上合法,所以这个判断编码的是"载荷没送到用户态"
|
|
397
|
+
# 这一事实,不是"报文为空"。后端补上载荷后必须换成显式标志。
|
|
398
|
+
self.udp_sessions.note_dropped("no-payload")
|
|
399
|
+
self.log.error(
|
|
400
|
+
"udp_payload_unavailable",
|
|
401
|
+
pid=conn.proc.pid,
|
|
402
|
+
dst=str(conn.dst),
|
|
403
|
+
code="E_UPSTREAM_DOWN",
|
|
404
|
+
hint="UDP 载荷尚未从驱动上报到用户态,无法转发 → 丢弃(不直连)",
|
|
405
|
+
)
|
|
406
|
+
return Action.BLOCK
|
|
407
|
+
return self.udp.handle(
|
|
408
|
+
conn.proc.pid,
|
|
409
|
+
conn.src.port,
|
|
410
|
+
conn.dst,
|
|
411
|
+
payload,
|
|
412
|
+
upstream_alias=rule_decision.upstream,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# ------------------------------------------------------------ sink ------
|
|
416
|
+
def _start_sink(self) -> None:
|
|
417
|
+
from .tunnel.sink import Sink
|
|
418
|
+
|
|
419
|
+
self.sink = Sink(
|
|
420
|
+
backend=self.backend,
|
|
421
|
+
sessions=self.sessions,
|
|
422
|
+
config=self.config,
|
|
423
|
+
log=self.log,
|
|
424
|
+
dns=self.dns_policy,
|
|
425
|
+
)
|
|
426
|
+
self.sink.start()
|
|
427
|
+
|
|
428
|
+
# ---------------------------------------------------------- 维护线程 ---
|
|
429
|
+
def _maintenance_loop(self) -> None:
|
|
430
|
+
while not self._stop_event.wait(1.0):
|
|
431
|
+
try:
|
|
432
|
+
self._tick()
|
|
433
|
+
except Exception as exc: # 维护线程必须活下来(BLE001 已按文件豁免)
|
|
434
|
+
self.log.error("maintenance_failed", error=str(exc))
|
|
435
|
+
|
|
436
|
+
def _tick(self) -> None:
|
|
437
|
+
now = time.time()
|
|
438
|
+
self._refresh_dns_names()
|
|
439
|
+
removed = self.sessions.reap(now=now)
|
|
440
|
+
if removed:
|
|
441
|
+
self.log.debug("sessions_reaped", count=removed, active=self.sessions.count())
|
|
442
|
+
# UDP:60s 空闲回收。进程退出立即回收在下面和 PID 一起做。
|
|
443
|
+
udp_removed = self.udp_sessions.reap(now=now)
|
|
444
|
+
if udp_removed:
|
|
445
|
+
self.log.debug(
|
|
446
|
+
"udp_sessions_reaped", count=udp_removed, active=self.udp_sessions.count()
|
|
447
|
+
)
|
|
448
|
+
# 进程退出后清理粘性
|
|
449
|
+
dead = [pid for pid in self.sessions.sticky_pids() if not is_alive(pid)]
|
|
450
|
+
for pid in dead:
|
|
451
|
+
self.sessions.forget_pid(pid)
|
|
452
|
+
# UDP 关联没有"等它自己超时"的理由:进程一死,它的 (pid, src_port) 永远不会再来。
|
|
453
|
+
# 只能传"确实还活着的 PID",`prune_pids` 会把不在集合里的全部删掉,
|
|
454
|
+
# 传一个残缺的白名单等于误杀活会话。
|
|
455
|
+
udp_alive = {pid for pid in self.udp_sessions.pids() if is_alive(pid)}
|
|
456
|
+
udp_pruned = self.udp_sessions.prune_pids(udp_alive)
|
|
457
|
+
if udp_pruned:
|
|
458
|
+
self.log.debug("udp_sessions_pruned", count=udp_pruned)
|
|
459
|
+
if int(time.time()) % 5 == 0:
|
|
460
|
+
self._write_state(reason="periodic")
|
|
461
|
+
|
|
462
|
+
def _write_state(self, *, reason: str) -> None:
|
|
463
|
+
path = self._state_path()
|
|
464
|
+
backend_stats = self.backend.stats() if self.backend else {}
|
|
465
|
+
payload = {
|
|
466
|
+
"pid": current_pid(),
|
|
467
|
+
"backend": getattr(self.backend, "name", "?"),
|
|
468
|
+
"started_at": self._started_at,
|
|
469
|
+
"updated_at": time.time(),
|
|
470
|
+
"reason": reason,
|
|
471
|
+
"sink": f"{self.config.service.sink.host}:{self.config.service.sink.port}",
|
|
472
|
+
"config": self.config.path,
|
|
473
|
+
"config_generation": self.manager.generation,
|
|
474
|
+
"active_sessions": self.sessions.count(),
|
|
475
|
+
"loop_breaker_trips": self.breaker.trips_total,
|
|
476
|
+
"unknown_pid": self.guards.unknown_pid,
|
|
477
|
+
"dns_mode": self.dns_policy.mode,
|
|
478
|
+
"dns_cache_entries": self.dns_policy.cache.stats()["entries"],
|
|
479
|
+
**{k: v for k, v in backend_stats.items() if isinstance(v, (int, str))},
|
|
480
|
+
}
|
|
481
|
+
try:
|
|
482
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
tmp = path.with_suffix(".json.tmp")
|
|
484
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
485
|
+
os.replace(tmp, path)
|
|
486
|
+
except OSError as exc: # pragma: no cover - 权限/磁盘问题
|
|
487
|
+
self.log.warn("state_write_failed", path=str(path), error=str(exc))
|
|
488
|
+
|
|
489
|
+
def _state_path(self) -> Path:
|
|
490
|
+
override = os.environ.get("PROCPROXY_RUNTIME_DIR")
|
|
491
|
+
if override:
|
|
492
|
+
return Path(override) / "state.json"
|
|
493
|
+
if os.name == "nt":
|
|
494
|
+
base = os.environ.get("PROGRAMDATA", r"C:\ProgramData")
|
|
495
|
+
return Path(base) / "ProcProxy" / "state.json"
|
|
496
|
+
return Path.home() / ".procproxy" / "state.json"
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------ 热重载 ---
|
|
499
|
+
def apply_profile(self) -> None:
|
|
500
|
+
"""热重载后的唯一收口:把所有由 Profile 派生的运行时状态一次性换掉。
|
|
501
|
+
|
|
502
|
+
两个入口共用它:文件监视器(`_watch_config`)与 `api.ProxyHandle.reload()`。
|
|
503
|
+
同一个动作只保留一套实现,否则两条路会分叉(API 那条曾经漏掉 UDP/DNS 刷新)。
|
|
504
|
+
新增"由 Profile 派生"的状态时只改这里。
|
|
505
|
+
|
|
506
|
+
DNS 策略是原地 `configure()`:`Sink` 持有同一个对象、worker 线程随时在读,
|
|
507
|
+
换对象就得同步改 `sink.dns`,漏掉一处就是"热重载后 DNS 静默不生效"。
|
|
508
|
+
缓存刻意保留,改配置不该把 DNS 记录扔光。
|
|
509
|
+
"""
|
|
510
|
+
self.dns_policy.configure(self.config.profile)
|
|
511
|
+
self._refresh_guards()
|
|
512
|
+
self.udp_policy = UdpPolicyResolver(self.config.udp_policy)
|
|
513
|
+
self.udp.policy = self.udp_policy # forwarder 持有的是同一个对象,必须同换
|
|
514
|
+
self.udp_sessions.idle_timeout_s = float(self.config.service.udp_idle_timeout_s)
|
|
515
|
+
self.sessions.on_profile_reload(self.config.profile)
|
|
516
|
+
|
|
517
|
+
def _watch_config(self) -> None:
|
|
518
|
+
path = self.config.path
|
|
519
|
+
if not path:
|
|
520
|
+
return
|
|
521
|
+
last_mtime = _mtime(path)
|
|
522
|
+
control_path = self._state_path().with_name("control.json")
|
|
523
|
+
while not self._stop_event.wait(1.0):
|
|
524
|
+
action = _consume_control(control_path)
|
|
525
|
+
if action is not None:
|
|
526
|
+
# 控制文件是"一次性指令":先删再停,否则残留的 panic 会让
|
|
527
|
+
# 下一次启动在第一秒又把自己停掉。
|
|
528
|
+
event = "panic_stop_requested" if action == "panic" else "stop_requested"
|
|
529
|
+
self.log.warn(event, path=str(control_path))
|
|
530
|
+
self._stop_event.set()
|
|
531
|
+
return
|
|
532
|
+
current = _mtime(path)
|
|
533
|
+
if current and current != last_mtime:
|
|
534
|
+
last_mtime = current
|
|
535
|
+
try:
|
|
536
|
+
new = self.manager.reload(path) # 校验失败 → 抛异常并保留旧配置
|
|
537
|
+
self.apply_profile()
|
|
538
|
+
self.log.info(
|
|
539
|
+
"config_reloaded",
|
|
540
|
+
rules=len(new.profile.rules),
|
|
541
|
+
udp_default=self.udp_policy.default,
|
|
542
|
+
dns_mode=self.dns_policy.mode,
|
|
543
|
+
)
|
|
544
|
+
except ConfigError as exc:
|
|
545
|
+
self.log.error("config_reload_rejected", code=exc.code, message=exc.message)
|
|
546
|
+
|
|
547
|
+
def _refresh_guards(self) -> None:
|
|
548
|
+
"""重建守卫集合。"""
|
|
549
|
+
cfg = self.config
|
|
550
|
+
self.guards.self_pids = set(self.sessions.self_pids()) | {current_pid()}
|
|
551
|
+
self.guards.upstream_endpoints = cfg.profile.upstream_endpoints()
|
|
552
|
+
self.guards.on_unknown_pid = cfg.service.on_unknown_pid
|
|
553
|
+
self.guards.breaker = self.breaker
|
|
554
|
+
self._refresh_dns_guards()
|
|
555
|
+
|
|
556
|
+
def _refresh_dns_guards(self) -> None:
|
|
557
|
+
"""DNS 相关的守卫与规则输入(三处,全部来自 `dns_policy`)。
|
|
558
|
+
|
|
559
|
+
① `name_resolver`,`dst_name` 规则的唯一数据来源。**不接上,
|
|
560
|
+
`dst_name` 规则就永远是死规则**(`GuardSet.name_for_ip` 直接返回 None);
|
|
561
|
+
② `inferred_names`,命中推断映射时日志要打 `name-inferred`
|
|
562
|
+
,同时 BLOCK 规则禁用推断映射(`rules.decide`);
|
|
563
|
+
③ G3 白名单,`local` 模式下本地 DNS 服务器必须直连,否则 DNS 查询
|
|
564
|
+
自己会被代理并形成环路。
|
|
565
|
+
"""
|
|
566
|
+
policy = self.dns_policy
|
|
567
|
+
self.guards.name_resolver = policy.name_for
|
|
568
|
+
self.guards.inferred_names = policy.inferred_names()
|
|
569
|
+
self._dns_revision = policy.revision()
|
|
570
|
+
self.guards.local_dns_servers = set()
|
|
571
|
+
if policy.mode in {MODE_LOCAL, MODE_BYPASS} and policy.local_server:
|
|
572
|
+
self.guards.local_dns_servers.add(policy.local_server)
|
|
573
|
+
self.guards.upstream_endpoints.update(policy.guard_endpoints())
|
|
574
|
+
|
|
575
|
+
def _refresh_dns_names(self) -> None:
|
|
576
|
+
"""缓存变了才重算 `inferred_names`(它最长 20000 条,不能每次 tick 都算)。"""
|
|
577
|
+
policy = self.dns_policy
|
|
578
|
+
revision = policy.revision()
|
|
579
|
+
if revision == self._dns_revision:
|
|
580
|
+
return
|
|
581
|
+
self._dns_revision = revision
|
|
582
|
+
self.guards.inferred_names = policy.inferred_names()
|
|
583
|
+
|
|
584
|
+
# ------------------------------------------------------------- 快照 ----
|
|
585
|
+
# --------------------------------------------------------------- DNS ----
|
|
586
|
+
def note_dns_answer(
|
|
587
|
+
self, addr: str, name: str, *, ttl_s: float | None = None, inferred: bool = True
|
|
588
|
+
) -> None:
|
|
589
|
+
"""记录一条 IP→域名映射,这是 `dst_name` 规则唯一的名字来源。
|
|
590
|
+
|
|
591
|
+
`inferred=True`(默认)表示来自 DNS 嗅探,只用于近似匹配:日志打
|
|
592
|
+
`name-inferred`,不参与 dial、不参与 BLOCK。
|
|
593
|
+
`inferred=False` 表示权威来源(应用真的连的就是这个域名),可以交给上游远端解析。
|
|
594
|
+
|
|
595
|
+
目前没有后端会调用它:路线 A 的过滤器以 `tcp` 开头,DNS 报文不进用户态;
|
|
596
|
+
而且抓包式后端本来就拿不到主机名(内核只给地址),所以"权威域名"
|
|
597
|
+
在这条路线上不存在。接口保留但不做假实现:没有嗅探就是没有名字,
|
|
598
|
+
不假装能匹配 `dst_name`。
|
|
599
|
+
|
|
600
|
+
这带来一条结构性限制:在透明捕获类后端上,`dns_mode=remote` 防不住 DNS 泄漏。
|
|
601
|
+
等我们看到那条连接时,应用早已 `getaddrinfo` 完成,泄漏已经发生。
|
|
602
|
+
"""
|
|
603
|
+
self.dns_policy.note_answer(addr, name, ttl_s=ttl_s, inferred=inferred)
|
|
604
|
+
self._refresh_dns_names()
|
|
605
|
+
|
|
606
|
+
def on_network_change(self, *, flush_system: bool = False) -> dict[str, Any]:
|
|
607
|
+
"""网络接口变化 → 清推断映射。
|
|
608
|
+
|
|
609
|
+
方法体在 `dns.cache.on_network_change`,这里只负责接线 + 记账:
|
|
610
|
+
清了多少条、刷没刷系统缓存,都要留下一条可查的日志。
|
|
611
|
+
"""
|
|
612
|
+
from .dns.cache import on_network_change as _flush
|
|
613
|
+
|
|
614
|
+
result = _flush(self.dns_policy.cache, flush_system=flush_system)
|
|
615
|
+
self._refresh_dns_names()
|
|
616
|
+
self.log.info("dns_cache_flushed", **result)
|
|
617
|
+
return result
|
|
618
|
+
|
|
619
|
+
def snapshot(self) -> dict[str, Any]:
|
|
620
|
+
return {
|
|
621
|
+
"backend": self.backend.stats(),
|
|
622
|
+
"rules": stats_snapshot(self.config.profile, self.guards),
|
|
623
|
+
"sessions": self.sessions.stats(),
|
|
624
|
+
"breaker": self.breaker.stats(),
|
|
625
|
+
"udp": self.udp_sessions.stats(),
|
|
626
|
+
"dns": self.dns_policy.describe(),
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _mtime(path: str) -> float:
|
|
631
|
+
try:
|
|
632
|
+
return os.path.getmtime(path)
|
|
633
|
+
except OSError:
|
|
634
|
+
return 0.0
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _consume_control(path: Path) -> str | None:
|
|
638
|
+
"""读取并删除一次性的控制指令(`stop` / `panic`)。
|
|
639
|
+
|
|
640
|
+
删除必须发生在返回之前:控制文件表示"执行一次",留下了就会影响下一次启动。
|
|
641
|
+
非法内容/未知指令返回 None 且不删除(可能是别人正在写的文件)。
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
645
|
+
except (OSError, json.JSONDecodeError):
|
|
646
|
+
return None
|
|
647
|
+
if not isinstance(payload, dict):
|
|
648
|
+
return None
|
|
649
|
+
action = str(payload.get("action", "")).strip().lower()
|
|
650
|
+
if action not in {"stop", "panic"}:
|
|
651
|
+
return None
|
|
652
|
+
with contextlib.suppress(OSError):
|
|
653
|
+
path.unlink()
|
|
654
|
+
return action
|