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/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