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/config.py ADDED
@@ -0,0 +1,1010 @@
1
+ """Profile/Rule/Service 配置加载、校验(V1–V10)、原子热重载。
2
+
3
+ 负责配置 Schema 与 10 条校验规则,以及热重载的原子性
4
+ (校验失败时保留旧配置)。
5
+
6
+ 设计约束:
7
+ * 解析与校验分离(`Profile.from_dict` 只管结构,`validate()` 只管语义),便于单测;
8
+ * 未知字段不报错,只收进 `Config.warnings`(V10 前向兼容);
9
+ * 热重载走 `写临时文件 → 校验 → os.replace`,任何失败都不动现网配置。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import dataclasses
16
+ import ipaddress
17
+ import os
18
+ import socket
19
+ import threading
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, ClassVar
23
+
24
+ import yaml
25
+
26
+ from .log import get_logger
27
+ from .types import Action
28
+
29
+ LOG = get_logger("proxycore.config")
30
+
31
+ # ------------------------------------------------------------------ 错误码 ---
32
+ E_CONFIG_VERSION_UNSUPPORTED = "config_version_unsupported"
33
+ E_RULE_UPSTREAM_MISSING = "rule_upstream_missing"
34
+ E_UPSTREAM_CHAIN_CYCLE = "upstream_chain_cycle"
35
+ E_RULE_MATCH_INVALID = "rule_match_invalid"
36
+ E_RULE_TARGETS_INVALID = "rule_targets_invalid"
37
+ E_SINK_PORT_UNAVAILABLE = "sink_port_unavailable"
38
+ E_LOOP_BREAKER_INVALID = "loop_breaker_invalid"
39
+ E_DNS_MODE_INVALID = "dns_mode_invalid"
40
+ E_RULE_ID_DUPLICATE = "rule_id_duplicate"
41
+ E_CONFIG_UNKNOWN_FIELD = "config_unknown_field"
42
+ E_PROFILE_OPTION_UNSUPPORTED = "profile_option_unsupported"
43
+ # 运行时错误码
44
+ E_BACKEND_UNAVAILABLE = "E_BACKEND_UNAVAILABLE"
45
+
46
+ VALID_ACTIONS = {a.value for a in Action}
47
+ VALID_DNS_MODES = {"local", "remote", "bypass"}
48
+ VALID_UNKNOWN_PID_POLICIES = {"direct", "proxy", "block"}
49
+ # 决策路径抛异常时的兜底(改道失败禁止静默直连,异常同理)。
50
+ VALID_DECIDE_ERROR_POLICIES = {"direct", "proxy", "block"}
51
+ VALID_UDP_POLICIES = {"direct", "proxy", "block",
52
+ }
53
+ VALID_UPSTREAM_KINDS = {"socks5", "socks4", "http", "https"}
54
+ VALID_AUTHS = {"none", "basic", "ntlm", "unsupported"}
55
+ MAX_TARGETS_LEN = 1024 # V5
56
+ WILDCARD_LEN_LIMIT = 260 # 路径长度上限
57
+
58
+
59
+ class ConfigError(Exception):
60
+ """配置校验失败。`code` 是稳定的错误码。"""
61
+
62
+ def __init__(self, code: str, message: str, *, field: str = "", value: Any = None) -> None:
63
+ super().__init__(f"[{code}] {message}")
64
+ self.code = code
65
+ self.message = message
66
+ self.field = field
67
+ self.value = value
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {"code": self.code, "message": self.message, "field": self.field}
71
+
72
+
73
+ # ------------------------------------------------------------------ 工具 ----
74
+ def _as_bool(value: Any, default: bool = False) -> bool:
75
+ if value is None:
76
+ return default
77
+ if isinstance(value, bool):
78
+ return value
79
+ if isinstance(value, str):
80
+ return value.strip().lower() in {"1", "true", "yes", "on"}
81
+ return bool(value)
82
+
83
+
84
+ def _as_int(value: Any, default: int) -> int:
85
+ if value is None:
86
+ return default
87
+ try:
88
+ return int(value)
89
+ except (TypeError, ValueError) as exc:
90
+ raise ConfigError(E_RULE_MATCH_INVALID, f"期望整数,得到 {value!r}", value=value) from exc
91
+
92
+
93
+ def _as_mapping(value: Any, *, field: str) -> dict[str, Any]:
94
+ """配置节必须是映射;否则抛 `ConfigError`(而不是裸 AttributeError/ValueError)。
95
+
96
+ `Config.load()` 的契约是"任何失败抛 ConfigError、调用方保留旧配置",
97
+ 热重载只捕获 ConfigError,所以这里的边界检查是契约的一部分。
98
+ """
99
+ if value is None:
100
+ return {}
101
+ if not isinstance(value, dict):
102
+ raise ConfigError(
103
+ E_RULE_MATCH_INVALID, f"{field} 必须是映射,得到 {type(value).__name__}", field=field, value=value
104
+ )
105
+ return value
106
+
107
+
108
+ def _known_fields(cls: type) -> set[str]:
109
+ return {f.name for f in dataclasses.fields(cls)}
110
+
111
+
112
+ def _collect_unknown(cls: type, doc: dict[str, Any]) -> list[str]:
113
+ known = _known_fields(cls)
114
+ return sorted(k for k in doc if k not in known)
115
+
116
+
117
+ def _split_multi(value: str, seps: str = ",;") -> list[str]:
118
+ out: list[str] = []
119
+ buf = ""
120
+ for ch in value:
121
+ if ch in seps:
122
+ if buf:
123
+ out.append(buf)
124
+ buf = ""
125
+ else:
126
+ buf += ch
127
+ if buf:
128
+ out.append(buf)
129
+ return out
130
+
131
+
132
+ # ------------------------------------------------------------------ 上游 ----
133
+ @dataclass(slots=True)
134
+ class Upstream:
135
+ """上游配置。口令只以 `password_ref` 形式落盘。"""
136
+
137
+ alias: str
138
+ kind: str = "socks5"
139
+ host: str = ""
140
+ port: int = 1080
141
+ user: str = ""
142
+ password: str = "" # 运行时解析后的明文,不序列化
143
+ chain: list[str] = field(default_factory=list)
144
+ auth: str = "none"
145
+ password_ref: str = ""
146
+ udp: bool = False
147
+ insecure: bool = False # https 上游是否跳过证书校验(默认不跳过)
148
+
149
+ @classmethod
150
+ def from_dict(cls, doc: dict[str, Any]) -> Upstream:
151
+ alias = str(doc.get("alias", "")).strip()
152
+ if not alias:
153
+ raise ConfigError(E_RULE_UPSTREAM_MISSING, "upstream 缺少 alias", field="upstreams.alias")
154
+ chain = doc.get("chain") or []
155
+ if isinstance(chain, str):
156
+ chain = _split_multi(chain)
157
+ elif not isinstance(chain, (list, tuple)):
158
+ raise ConfigError(
159
+ E_RULE_UPSTREAM_MISSING,
160
+ f"upstream {alias} 的 chain 必须是列表或字符串,得到 {type(chain).__name__}",
161
+ field="upstreams.chain",
162
+ value=chain,
163
+ )
164
+ auth = str(doc.get("auth", "none") or "none").lower()
165
+ if auth not in VALID_AUTHS:
166
+ raise ConfigError(E_RULE_MATCH_INVALID, f"未知 auth 类型 {auth!r}", field="upstreams.auth")
167
+ kind = str(doc.get("kind", "socks5")).lower()
168
+ if kind not in VALID_UPSTREAM_KINDS:
169
+ raise ConfigError(E_RULE_MATCH_INVALID, f"未知 upstream kind {kind!r}", field="upstreams.kind")
170
+ if auth == "ntlm":
171
+ # HTTP CONNECT 的 NTLM 需要 407 挑战 + 多轮 SSPI,尚未接线;
172
+ # 接受它只会让每条连接在运行期失败,必须在加载期明确拒绝。
173
+ raise ConfigError(
174
+ E_RULE_MATCH_INVALID,
175
+ "auth=ntlm 尚未实现;请改用 auth=basic,或标注 auth=unsupported",
176
+ field="upstreams.auth",
177
+ )
178
+ if kind == "socks4" and auth not in {"none", "unsupported"}:
179
+ auth = "unsupported"
180
+ return cls(
181
+ alias=alias,
182
+ kind=kind,
183
+ host=str(doc.get("host", "")),
184
+ port=_as_int(doc.get("port"), 1080),
185
+ user=str(doc.get("user", "") or ""),
186
+ chain=[str(c) for c in chain],
187
+ auth=auth,
188
+ password_ref=str(doc.get("password_ref", "") or ""),
189
+ udp=_as_bool(doc.get("udp"), default=(kind == "socks5")),
190
+ insecure=_as_bool(doc.get("insecure"), default=False),
191
+ )
192
+
193
+ def to_dict(self) -> dict[str, Any]:
194
+ out: dict[str, Any] = {
195
+ "alias": self.alias,
196
+ "kind": self.kind,
197
+ "host": self.host,
198
+ "port": self.port,
199
+ "chain": list(self.chain),
200
+ "auth": self.auth,
201
+ # 始终输出:socks5 的默认值是 true,省略 `udp: false` 会在往返中反转它。
202
+ "udp": self.udp,
203
+ "insecure": self.insecure,
204
+ }
205
+ if self.user:
206
+ out["user"] = self.user
207
+ if self.password_ref:
208
+ out["password_ref"] = self.password_ref # 引用,不是明文
209
+ return out
210
+
211
+ def resolve_password(self) -> str:
212
+ """按 `password_ref` 取口令:`env:` / `file:` / `dpapi:` / `keyring:`。
213
+
214
+ 解析失败返回空串并记 ERROR(绝不把 hint 当口令用)。
215
+ """
216
+ if self.password:
217
+ return self.password
218
+ ref = self.password_ref
219
+ if not ref:
220
+ return ""
221
+ scheme, _, arg = ref.partition(":")
222
+ scheme = scheme.lower()
223
+ try:
224
+ if scheme == "env":
225
+ return os.environ.get(arg, "")
226
+ if scheme == "file":
227
+ return Path(arg).read_text(encoding="utf-8").strip()
228
+ if scheme == "dpapi":
229
+ return _dpapi_unprotect(arg)
230
+ if scheme == "keyring":
231
+ import keyring
232
+
233
+ return str(keyring.get_password("procproxy", arg) or "")
234
+ except Exception as exc: # 凭据失败必须降级而不是崩溃(BLE001 已按文件豁免)
235
+ LOG.error(
236
+ "password_ref_resolve_failed",
237
+ extra={"event": "password_ref_resolve_failed", "fields": {"hint": scheme, "error": str(exc)}},
238
+ )
239
+ return ""
240
+ LOG.error(
241
+ "password_ref_unknown_scheme",
242
+ extra={"event": "password_ref_unknown_scheme", "fields": {"hint": scheme}},
243
+ )
244
+ return ""
245
+
246
+
247
+ def _dpapi_unprotect(alias: str) -> str:
248
+ """Windows 用 DPAPI 解出凭据(`%ProgramData%\\ProcProxy\\creds\\<alias>.bin`)。"""
249
+ if os.name != "nt":
250
+ return ""
251
+ base = os.environ.get("PROGRAMDATA", r"C:\ProgramData")
252
+ path = Path(base) / "ProcProxy" / "creds" / f"{alias}.bin"
253
+ if not path.exists():
254
+ return ""
255
+ import ctypes
256
+ import ctypes.wintypes as wintypes
257
+
258
+ class DataBlob(ctypes.Structure):
259
+ _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
260
+
261
+ raw = path.read_bytes()
262
+ blob_in = DataBlob(len(raw), ctypes.cast(ctypes.create_string_buffer(raw), ctypes.POINTER(ctypes.c_char)))
263
+ blob_out = DataBlob()
264
+ crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
265
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
266
+ if not crypt32.CryptUnprotectData(
267
+ ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
268
+ ):
269
+ return ""
270
+ try:
271
+ return bytes(ctypes.string_at(blob_out.pbData, blob_out.cbData)).decode("utf-8", "replace")
272
+ finally:
273
+ kernel32.LocalFree(blob_out.pbData)
274
+
275
+
276
+ # ------------------------------------------------------------------ 规则 ----
277
+ @dataclass(slots=True)
278
+ class Rule:
279
+ id: str
280
+ action: Action
281
+ upstream: str = ""
282
+ targets: str = "*"
283
+ dst_ip: str = "*"
284
+ dst_port: str = "*"
285
+ dst_name: str = "*"
286
+ proto: str = "*"
287
+ enabled: bool = True
288
+ keep_private_proxied: bool = False
289
+
290
+ @classmethod
291
+ def from_dict(cls, doc: dict[str, Any]) -> Rule:
292
+ rid = str(doc.get("id", "")).strip()
293
+ raw_action = str(doc.get("action", "direct")).strip().lower()
294
+ if raw_action not in VALID_ACTIONS:
295
+ raise ConfigError(E_RULE_MATCH_INVALID, f"未知 action {raw_action!r}", field=f"rules[{rid}].action")
296
+ proto = str(doc.get("proto", "*") or "*").strip().lower()
297
+ return cls(
298
+ id=rid,
299
+ action=Action(raw_action),
300
+ upstream=str(doc.get("upstream", "") or ""),
301
+ targets=str(doc.get("targets", "*") if doc.get("targets") is not None else "*"),
302
+ dst_ip=str(doc.get("dst_ip", "*") if doc.get("dst_ip") is not None else "*"),
303
+ dst_port=str(doc.get("dst_port", "*") if doc.get("dst_port") is not None else "*"),
304
+ dst_name=str(doc.get("dst_name", "*") if doc.get("dst_name") is not None else "*"),
305
+ proto=proto,
306
+ enabled=_as_bool(doc.get("enabled"), default=True),
307
+ keep_private_proxied=_as_bool(doc.get("keep_private_proxied"), default=False),
308
+ )
309
+
310
+ def to_dict(self) -> dict[str, Any]:
311
+ return {
312
+ "id": self.id,
313
+ "action": self.action.value,
314
+ "upstream": self.upstream,
315
+ "targets": self.targets,
316
+ "dst_ip": self.dst_ip,
317
+ "dst_port": self.dst_port,
318
+ "dst_name": self.dst_name,
319
+ "proto": self.proto,
320
+ "enabled": self.enabled,
321
+ "keep_private_proxied": self.keep_private_proxied,
322
+ }
323
+
324
+
325
+ # ---------------------------------------------------------------- Profile ---
326
+ # 注意:不使用 slots=True,规则引擎的预编译缓存用 WeakKeyDictionary 按 Profile 缓存,
327
+ # 热重载时旧 Profile 被回收即自动失效。
328
+ @dataclass
329
+ class Profile:
330
+ name: str
331
+ rules: list[Rule]
332
+ default_action: Action = Action.DIRECT
333
+ sticky_per_pid: bool = True # 同 PID 固定上游
334
+ handle_services: bool = False # ↔ ProcessServices
335
+ handle_other_users: bool = False # ↔ ProcessOtherUsers
336
+ dns_mode: str = "remote" # local | remote | bypass
337
+ dns_bypass: list[str] = field(default_factory=list)
338
+ upstreams: dict[str, Upstream] = field(default_factory=dict)
339
+ # 扩展字段:本地 DNS 走直连还是走代理,以及用哪个 DNS 服务器
340
+ dns_local_transport: str = "direct" # direct;proxy 尚未实现,校验期拒绝
341
+ dns_local_server: str = "" # 空 = 用系统解析器
342
+
343
+ @classmethod
344
+ def from_dict(
345
+ cls, doc: dict[str, Any], upstreams: dict[str, Upstream] | None = None
346
+ ) -> Profile:
347
+ doc = _as_mapping(doc, field="profile")
348
+ default_action = str(doc.get("default_action", "direct")).strip().lower()
349
+ if default_action not in VALID_ACTIONS:
350
+ raise ConfigError(
351
+ E_RULE_MATCH_INVALID, f"未知 default_action {default_action!r}", field="profile.default_action"
352
+ )
353
+ raw_rules = doc.get("rules") or []
354
+ if not isinstance(raw_rules, list):
355
+ raise ConfigError(E_RULE_MATCH_INVALID, "rules 必须是列表", field="profile.rules")
356
+ bypass = doc.get("dns_bypass") or []
357
+ if isinstance(bypass, str):
358
+ bypass = _split_multi(bypass)
359
+ return cls(
360
+ name=str(doc.get("name", "default")),
361
+ rules=[Rule.from_dict(r) for r in raw_rules],
362
+ default_action=Action(default_action),
363
+ sticky_per_pid=_as_bool(doc.get("sticky_per_pid"), default=True),
364
+ handle_services=_as_bool(doc.get("handle_services"), default=False),
365
+ handle_other_users=_as_bool(doc.get("handle_other_users"), default=False),
366
+ dns_mode=str(doc.get("dns_mode", "remote")).strip().lower(),
367
+ dns_bypass=[str(b) for b in bypass],
368
+ upstreams=dict(upstreams or {}),
369
+ dns_local_transport=str(doc.get("dns_local_transport", "direct")).strip().lower(),
370
+ dns_local_server=str(doc.get("dns_local_server", "") or ""),
371
+ )
372
+
373
+ def to_dict(self) -> dict[str, Any]:
374
+ return {
375
+ "name": self.name,
376
+ "default_action": self.default_action.value,
377
+ "sticky_per_pid": self.sticky_per_pid,
378
+ "handle_services": self.handle_services,
379
+ "handle_other_users": self.handle_other_users,
380
+ "dns_mode": self.dns_mode,
381
+ "dns_bypass": list(self.dns_bypass),
382
+ "dns_local_transport": self.dns_local_transport,
383
+ "dns_local_server": self.dns_local_server,
384
+ "rules": [r.to_dict() for r in self.rules],
385
+ }
386
+
387
+ def upstream_endpoints(self) -> set[tuple[str, int]]:
388
+ """守卫 G3 用:上游代理端点集合。"""
389
+ return {(u.host, u.port) for u in self.upstreams.values() if u.host}
390
+
391
+ # dataclass(eq=True) 会把 __hash__ 置空;规则引擎的预编译缓存需要按身份哈希
392
+ # (语义上 Profile 是只读的:热重载时整体替换对象,绝不原地改字段)。
393
+ __hash__ = object.__hash__
394
+
395
+
396
+ # ------------------------------------------------------------------ 服务 ----
397
+ @dataclass(slots=True)
398
+ class SinkConfig:
399
+ host: str = "127.0.0.1"
400
+ port: int = 19080
401
+
402
+ @classmethod
403
+ def from_dict(cls, doc: dict[str, Any] | None) -> SinkConfig:
404
+ doc = _as_mapping(doc, field="service.sink")
405
+ return cls(host=str(doc.get("host", "127.0.0.1")), port=_as_int(doc.get("port"), 19080))
406
+
407
+
408
+ @dataclass(slots=True)
409
+ class LoopBreakerConfig:
410
+ window_s: float = 5.0
411
+ threshold: int = 50
412
+
413
+ @classmethod
414
+ def from_dict(cls, doc: dict[str, Any] | None) -> LoopBreakerConfig:
415
+ doc = _as_mapping(doc, field="service.loop_breaker")
416
+ return cls(
417
+ window_s=float(doc.get("window_s", 5)),
418
+ threshold=_as_int(doc.get("threshold"), 50),
419
+ )
420
+
421
+
422
+ @dataclass(slots=True)
423
+ class UdpPolicy:
424
+ """:QUIC/HTTP-3 策略必须显式配置,不能默认蒙。"""
425
+
426
+ default: str = "direct" # direct | proxy | block
427
+ by_port: dict[int, str] = field(default_factory=dict)
428
+
429
+ @classmethod
430
+ def from_dict(cls, doc: dict[str, Any] | None) -> UdpPolicy:
431
+ doc = _as_mapping(doc, field="udp_policy")
432
+ default = str(doc.get("default", "direct")).strip().lower()
433
+ if default not in VALID_UDP_POLICIES:
434
+ raise ConfigError(E_DNS_MODE_INVALID, f"未知 udp_policy.default {default!r}", field="udp_policy.default")
435
+ by_port: dict[int, str] = {}
436
+ raw = doc.get("by_port") or {}
437
+ if not isinstance(raw, dict):
438
+ raise ConfigError(E_DNS_MODE_INVALID, "udp_policy.by_port 必须是映射", field="udp_policy.by_port")
439
+ for key, value in raw.items():
440
+ policy = str(value).strip().lower()
441
+ if policy not in VALID_UDP_POLICIES:
442
+ raise ConfigError(
443
+ E_DNS_MODE_INVALID, f"未知 udp_policy.by_port[{key}]={policy!r}", field="udp_policy.by_port"
444
+ )
445
+ by_port[_as_int(key, 0)] = policy
446
+ return cls(default=default, by_port=by_port)
447
+
448
+ def for_port(self, port: int) -> str:
449
+ return self.by_port.get(port, self.default)
450
+
451
+ def to_dict(self) -> dict[str, Any]:
452
+ return {"default": self.default, "by_port": {str(k): v for k, v in self.by_port.items()}}
453
+
454
+
455
+ @dataclass(slots=True)
456
+ class ServiceConfig:
457
+ sink: SinkConfig = field(default_factory=SinkConfig)
458
+ backend: str = "windivert" # windivert | null
459
+ max_sessions: int = 20000
460
+ loop_breaker: LoopBreakerConfig = field(default_factory=LoopBreakerConfig)
461
+ on_unknown_pid: str = "direct" # direct | proxy | block
462
+ # 决策路径抛异常时怎么办。默认 block:异常意味着"不知道自己该干什么",
463
+ # 放行等于把真实 IP 漏出去("全代理"档下就是一次泄漏),而 block 只是这条连接失败。
464
+ on_decide_error: str = "block" # direct | proxy | block
465
+ upstream_timeout_s: float = 15.0 # 上游握手(TCP+DNS+SOCKS5)
466
+ idle_timeout_s: float = 300.0 # 空闲无数据
467
+ half_close_timeout_s: float = 30.0 # TCP 半关闭后残留
468
+ udp_idle_timeout_s: float = 60.0 # UDP 会话
469
+ log_level: str = "INFO"
470
+ log_jsonl: str = ""
471
+ enable_trace_payload: bool = False
472
+ #: 允许连接 IPC 命名管道的本地组名(空 = 只有管理员 + SYSTEM,即默认行为)。
473
+ #: 被放行的组能读状态(status/sessions/rules);改规则(reload)与停服务
474
+ #: (stop/panic)仍需管理员令牌:权限分离在 `ipc.ADMIN_ONLY_COMMANDS`,
475
+ #: 不靠 ACL(管道 ACL 是整体授权:能发命令就必须能写)。
476
+ ipc_allow_group: str = ""
477
+
478
+ @classmethod
479
+ def from_dict(cls, doc: dict[str, Any] | None) -> ServiceConfig:
480
+ doc = dict(_as_mapping(doc, field="service"))
481
+ on_unknown = str(doc.get("on_unknown_pid", "direct")).strip().lower()
482
+ if on_unknown not in VALID_UNKNOWN_PID_POLICIES:
483
+ raise ConfigError(
484
+ E_RULE_MATCH_INVALID, f"未知 on_unknown_pid {on_unknown!r}", field="service.on_unknown_pid"
485
+ )
486
+ on_decide_error = str(doc.get("on_decide_error", "block")).strip().lower()
487
+ if on_decide_error not in VALID_DECIDE_ERROR_POLICIES:
488
+ raise ConfigError(
489
+ E_RULE_MATCH_INVALID,
490
+ f"未知 on_decide_error {on_decide_error!r}",
491
+ field="service.on_decide_error",
492
+ )
493
+ # `backend: null`(YAML 空值)在 PyYAML 里是 None,必须显式映射到 "null" 后端;
494
+ # 只有字段缺失时才回落到默认的 "windivert"。
495
+ raw_backend = doc.get("backend", "windivert")
496
+ backend = str(raw_backend).strip().lower() if raw_backend is not None else "null"
497
+ if backend in {"none", ""}:
498
+ backend = "null"
499
+ return cls(
500
+ sink=SinkConfig.from_dict(doc.get("sink")),
501
+ backend=backend,
502
+ max_sessions=_as_int(doc.get("max_sessions"), 20000),
503
+ loop_breaker=LoopBreakerConfig.from_dict(doc.get("loop_breaker")),
504
+ on_unknown_pid=on_unknown,
505
+ on_decide_error=on_decide_error,
506
+ upstream_timeout_s=float(doc.get("upstream_timeout_s", 15)),
507
+ idle_timeout_s=float(doc.get("idle_timeout_s", 300)),
508
+ half_close_timeout_s=float(doc.get("half_close_timeout_s", 30)),
509
+ udp_idle_timeout_s=float(doc.get("udp_idle_timeout_s", 60)),
510
+ log_level=str(doc.get("log_level", "INFO")),
511
+ log_jsonl=str(doc.get("log_jsonl", "") or ""),
512
+ enable_trace_payload=_as_bool(doc.get("enable_trace_payload"), False),
513
+ ipc_allow_group=str(doc.get("ipc_allow_group", "") or "").strip(),
514
+ )
515
+
516
+ def to_dict(self) -> dict[str, Any]:
517
+ return {
518
+ "sink": {"host": self.sink.host, "port": self.sink.port},
519
+ "backend": self.backend,
520
+ "max_sessions": self.max_sessions,
521
+ "loop_breaker": {"window_s": self.loop_breaker.window_s, "threshold": self.loop_breaker.threshold},
522
+ "on_unknown_pid": self.on_unknown_pid,
523
+ "on_decide_error": self.on_decide_error,
524
+ "upstream_timeout_s": self.upstream_timeout_s,
525
+ "idle_timeout_s": self.idle_timeout_s,
526
+ "half_close_timeout_s": self.half_close_timeout_s,
527
+ "udp_idle_timeout_s": self.udp_idle_timeout_s,
528
+ "log_level": self.log_level,
529
+ "log_jsonl": self.log_jsonl,
530
+ "enable_trace_payload": self.enable_trace_payload,
531
+ "ipc_allow_group": self.ipc_allow_group,
532
+ }
533
+
534
+
535
+ # --------------------------------------------------------------- Config -----
536
+ @dataclass(slots=True)
537
+ class Config:
538
+ version: int
539
+ service: ServiceConfig
540
+ profile: Profile
541
+ udp_policy: UdpPolicy = field(default_factory=UdpPolicy)
542
+ path: str = ""
543
+ raw: dict[str, Any] = field(default_factory=dict)
544
+ warnings: list[str] = field(default_factory=list)
545
+
546
+ _CONFIG_FIELDS: ClassVar[set[str]] = {"version", "service", "profile", "upstreams", "udp_policy"}
547
+
548
+ # ------------------------------------------------------------ 解析 ------
549
+ @classmethod
550
+ def from_dict(cls, doc: dict[str, Any], *, path: str = "") -> Config:
551
+ doc = _as_mapping(doc, field="config")
552
+ version = _as_int(doc.get("version"), 0)
553
+ upstreams = _parse_upstreams(doc.get("upstreams"))
554
+ profile_doc = normalized_profile_doc(doc)
555
+ # 兼容两种排布:schema 里 rules 在 profile.* 下,samples/profiles/*.yaml 里在顶层。
556
+ profile_doc.setdefault("upstreams", upstreams)
557
+ profile = Profile.from_dict(profile_doc, upstreams)
558
+ config = cls(
559
+ version=version,
560
+ service=ServiceConfig.from_dict(doc.get("service")),
561
+ profile=profile,
562
+ udp_policy=UdpPolicy.from_dict(doc.get("udp_policy")),
563
+ path=path,
564
+ raw=copy.deepcopy(doc),
565
+ )
566
+ config.warnings.extend(config._unknown_field_warnings(doc))
567
+ return config
568
+
569
+ @classmethod
570
+ def load(cls, path: str | os.PathLike[str], *, check_sink: bool = False) -> Config:
571
+ """读取 + 解析 + 校验。任何失败抛 `ConfigError`,调用方保留旧配置。"""
572
+ p = Path(path)
573
+ try:
574
+ text = p.read_text(encoding="utf-8")
575
+ except OSError as exc:
576
+ raise ConfigError(E_CONFIG_VERSION_UNSUPPORTED, f"无法读取配置 {p}: {exc}") from exc
577
+ try:
578
+ doc = yaml.safe_load(text)
579
+ except yaml.YAMLError as exc:
580
+ raise ConfigError(E_CONFIG_UNKNOWN_FIELD, f"YAML 解析失败: {exc}") from exc
581
+ if not isinstance(doc, dict):
582
+ raise ConfigError(E_CONFIG_VERSION_UNSUPPORTED, "配置根节点必须是映射")
583
+ config = cls.from_dict(doc, path=str(p))
584
+ config.validate(check_sink=check_sink)
585
+ # 解析凭据引用(失败只记日志,不阻断加载,认证失败在拨号时归类为 E_UPSTREAM_AUTH)
586
+ for up in config.profile.upstreams.values():
587
+ up.password = up.resolve_password()
588
+ return config
589
+
590
+ # ------------------------------------------------------------ 校验 ------
591
+ def validate(self, *, check_sink: bool = False) -> None:
592
+ """实现 V1–V10。任一失败抛 `ConfigError`(携带错误码)。"""
593
+ self._validate_version() # V1
594
+ self._validate_upstreams() # V3
595
+ self._validate_rules() # V2/V4/V5/V9
596
+ self._validate_profile_options()
597
+ if check_sink:
598
+ self._validate_sink() # V6
599
+ self._validate_loop_breaker() # V7
600
+ self._validate_dns() # V8
601
+
602
+ def _validate_version(self) -> None:
603
+ if self.version != 1:
604
+ raise ConfigError(
605
+ E_CONFIG_VERSION_UNSUPPORTED,
606
+ f"只支持 version=1,当前 {self.version!r}",
607
+ field="version",
608
+ value=self.version,
609
+ )
610
+
611
+ def _validate_upstreams(self) -> None:
612
+ ups = self.profile.upstreams
613
+ for alias, up in ups.items():
614
+ if up.alias != alias:
615
+ raise ConfigError(
616
+ E_RULE_UPSTREAM_MISSING, f"upstream 别名不一致: {alias!r} vs {up.alias!r}", field="upstreams"
617
+ )
618
+ if not up.host:
619
+ raise ConfigError(E_RULE_UPSTREAM_MISSING, f"upstream {alias} 缺少 host", field="upstreams.host")
620
+ if not (0 < up.port < 65536):
621
+ raise ConfigError(
622
+ E_RULE_MATCH_INVALID, f"upstream {alias} 端口非法 {up.port}", field="upstreams.port"
623
+ )
624
+ for hop in up.chain:
625
+ if hop not in ups:
626
+ raise ConfigError(
627
+ E_RULE_UPSTREAM_MISSING,
628
+ f"upstream {alias} 的链式跳 {hop!r} 未定义",
629
+ field="upstreams.chain",
630
+ )
631
+ self._check_chain_cycles()
632
+
633
+ def _check_chain_cycles(self) -> None:
634
+ ups = self.profile.upstreams
635
+
636
+ def walk(start: str) -> None:
637
+ seen: list[str] = []
638
+ node = start
639
+ while node:
640
+ if node in seen:
641
+ cycle = " -> ".join([*seen, node])
642
+ raise ConfigError(
643
+ E_UPSTREAM_CHAIN_CYCLE, f"上游链成环: {cycle}", field="upstreams.chain", value=cycle
644
+ )
645
+ seen.append(node)
646
+ if len(seen) > 8:
647
+ raise ConfigError(
648
+ E_UPSTREAM_CHAIN_CYCLE,
649
+ f"上游链跳数超过 8({start})",
650
+ field="upstreams.chain",
651
+ )
652
+ up = ups.get(node)
653
+ node = up.chain[0] if up and up.chain else ""
654
+
655
+ for alias in ups:
656
+ walk(alias)
657
+
658
+ def _validate_rules(self) -> None:
659
+ seen: set[str] = set()
660
+ for rule in self.profile.rules:
661
+ if not rule.id:
662
+ raise ConfigError(E_RULE_ID_DUPLICATE, "规则缺少 id", field="rules.id")
663
+ if rule.id in seen: # V9
664
+ raise ConfigError(E_RULE_ID_DUPLICATE, f"规则 id 重复: {rule.id}", field="rules.id", value=rule.id)
665
+ seen.add(rule.id)
666
+
667
+ if rule.action is Action.PROXY: # V2
668
+ if not rule.upstream:
669
+ raise ConfigError(
670
+ E_RULE_UPSTREAM_MISSING,
671
+ f"规则 {rule.id} action=proxy 但未给 upstream",
672
+ field=f"rules[{rule.id}].upstream",
673
+ )
674
+ if rule.upstream not in self.profile.upstreams:
675
+ raise ConfigError(
676
+ E_RULE_UPSTREAM_MISSING,
677
+ f"规则 {rule.id} 引用了未定义的上游 {rule.upstream!r}",
678
+ field=f"rules[{rule.id}].upstream",
679
+ value=rule.upstream,
680
+ )
681
+
682
+ if not rule.targets.strip(): # V5
683
+ raise ConfigError(
684
+ E_RULE_TARGETS_INVALID, f"规则 {rule.id} 的 targets 为空", field=f"rules[{rule.id}].targets"
685
+ )
686
+ if len(rule.targets) > MAX_TARGETS_LEN:
687
+ raise ConfigError(
688
+ E_RULE_TARGETS_INVALID,
689
+ f"规则 {rule.id} 的 targets 超过 {MAX_TARGETS_LEN} 字符",
690
+ field=f"rules[{rule.id}].targets",
691
+ )
692
+ for pattern in _split_multi(rule.targets, ";"):
693
+ if len(pattern) > WILDCARD_LEN_LIMIT:
694
+ raise ConfigError(
695
+ E_RULE_TARGETS_INVALID,
696
+ f"规则 {rule.id} 的通配符超过 {WILDCARD_LEN_LIMIT} 字符",
697
+ field=f"rules[{rule.id}].targets",
698
+ )
699
+
700
+ if rule.proto not in {"*", "tcp", "udp"}:
701
+ raise ConfigError(
702
+ E_RULE_MATCH_INVALID, f"规则 {rule.id} 的 proto 非法 {rule.proto!r}", field=f"rules[{rule.id}].proto"
703
+ )
704
+
705
+ _validate_ip_expr(rule) # V4 (IP)
706
+ _validate_port_expr(rule) # V4 (端口段 lo<=hi)
707
+
708
+ def _validate_profile_options(self) -> None:
709
+ """拒绝"已解析但尚未接入数据面"的选项。
710
+
711
+ `handle_services` / `handle_other_users` 的判定函数 `should_handle()`
712
+ 目前只有测试在调用;接受 true 会让用户以为服务/其他用户的流量已被
713
+ 按预期过滤,实际行为完全没变。宁可加载失败,也不做假承诺。
714
+ """
715
+ for name in ("handle_services", "handle_other_users"):
716
+ if getattr(self.profile, name):
717
+ raise ConfigError(
718
+ E_PROFILE_OPTION_UNSUPPORTED,
719
+ f"profile.{name}=true 尚未接入数据面(保持 false)",
720
+ field=f"profile.{name}",
721
+ )
722
+
723
+ def _validate_sink(self) -> None:
724
+ sink = self.service.sink
725
+ if not (1024 <= sink.port <= 65535):
726
+ raise ConfigError(
727
+ E_SINK_PORT_UNAVAILABLE,
728
+ f"sink.port 必须在 1024-65535,当前 {sink.port}",
729
+ field="service.sink.port",
730
+ value=sink.port,
731
+ )
732
+ if port_in_use(sink.port, sink.host):
733
+ raise ConfigError(
734
+ E_SINK_PORT_UNAVAILABLE,
735
+ f"sink 端口 {sink.host}:{sink.port} 已被占用/不可用",
736
+ field="service.sink.port",
737
+ value=sink.port,
738
+ )
739
+
740
+ def _validate_loop_breaker(self) -> None:
741
+ lb = self.service.loop_breaker
742
+ if lb.threshold <= 0 or lb.window_s <= 0:
743
+ raise ConfigError(
744
+ E_LOOP_BREAKER_INVALID,
745
+ f"loop_breaker 必须为正数(window_s={lb.window_s}, threshold={lb.threshold})",
746
+ field="service.loop_breaker",
747
+ )
748
+ if self.service.max_sessions <= 0:
749
+ raise ConfigError(E_LOOP_BREAKER_INVALID, "max_sessions 必须为正数", field="service.max_sessions")
750
+
751
+ def _validate_dns(self) -> None:
752
+ if self.profile.dns_mode not in VALID_DNS_MODES:
753
+ raise ConfigError(
754
+ E_DNS_MODE_INVALID,
755
+ f"未知 dns_mode {self.profile.dns_mode!r}",
756
+ field="profile.dns_mode",
757
+ value=self.profile.dns_mode,
758
+ )
759
+ if self.profile.dns_mode == "bypass" and not self.profile.dns_bypass:
760
+ raise ConfigError(
761
+ E_DNS_MODE_INVALID, "dns_mode=bypass 时 dns_bypass 不能为空", field="profile.dns_bypass"
762
+ )
763
+ if self.profile.dns_local_transport not in {"direct", "proxy"}:
764
+ raise ConfigError(
765
+ E_DNS_MODE_INVALID,
766
+ f"未知 dns_local_transport {self.profile.dns_local_transport!r}",
767
+ field="profile.dns_local_transport",
768
+ )
769
+ if self.profile.dns_local_transport == "proxy":
770
+ # 解析器没有接代理通道,接受它等于承诺一件做不到的事(DNS 仍会直连)。
771
+ raise ConfigError(
772
+ E_DNS_MODE_INVALID,
773
+ "dns_local_transport=proxy 尚未实现(本地 DNS 只能直连);"
774
+ "请改用 direct 或把 dns_mode 设为 remote",
775
+ field="profile.dns_local_transport",
776
+ )
777
+
778
+ # ------------------------------------------------------- 未知字段 ------
779
+ def _unknown_field_warnings(self, doc: dict[str, Any]) -> list[str]:
780
+ """V10:未知字段只警告。"""
781
+ out: list[str] = []
782
+ for key in sorted(set(doc) - self._CONFIG_FIELDS):
783
+ if key in {"rules", "upstreams"}:
784
+ continue # 顶层 rules/upstreams 是 samples 的等价写法,已在 from_dict 归一化
785
+ out.append(f"{E_CONFIG_UNKNOWN_FIELD}: 顶层未知字段 {key!r}(已忽略,前向兼容)")
786
+ for key in _collect_unknown(ServiceConfig, doc.get("service") or {}):
787
+ out.append(f"{E_CONFIG_UNKNOWN_FIELD}: service.{key} 未知(已忽略)")
788
+ profile_doc = normalized_profile_doc(doc)
789
+ for key in _collect_unknown(Profile, profile_doc):
790
+ out.append(f"{E_CONFIG_UNKNOWN_FIELD}: profile.{key} 未知(已忽略)")
791
+ for rule_doc in profile_doc.get("rules") or []:
792
+ if not isinstance(rule_doc, dict):
793
+ continue
794
+ for key in _collect_unknown(Rule, rule_doc):
795
+ out.append(
796
+ f"{E_CONFIG_UNKNOWN_FIELD}: rules[{rule_doc.get('id', '?')}].{key} 未知(已忽略)"
797
+ )
798
+ return out
799
+
800
+ # ----------------------------------------------------------- 输出 ------
801
+ def to_dict(self) -> dict[str, Any]:
802
+ return {
803
+ "version": self.version,
804
+ "service": self.service.to_dict(),
805
+ "profile": self.profile.to_dict(),
806
+ "upstreams": [u.to_dict() for u in self.profile.upstreams.values()],
807
+ "udp_policy": self.udp_policy.to_dict(),
808
+ }
809
+
810
+ def dump_yaml(self) -> str:
811
+ return yaml.safe_dump(self.to_dict(), allow_unicode=True, sort_keys=False)
812
+
813
+
814
+ # ------------------------------------------------------------------ 校验辅助 -
815
+ def _validate_ip_expr(rule: Rule) -> None:
816
+ if rule.dst_ip.strip() in {"", "*"}:
817
+ return
818
+ for item in _split_multi(rule.dst_ip, ","):
819
+ item = item.strip()
820
+ if not item or item == "*":
821
+ continue
822
+ try:
823
+ if "/" in item:
824
+ ipaddress.ip_network(item, strict=False)
825
+ else:
826
+ ipaddress.ip_address(item)
827
+ except ValueError as exc:
828
+ raise ConfigError(
829
+ E_RULE_MATCH_INVALID,
830
+ f"规则 {rule.id} 的 dst_ip 非法: {item!r}",
831
+ field=f"rules[{rule.id}].dst_ip",
832
+ value=item,
833
+ ) from exc
834
+
835
+
836
+ def _validate_port_expr(rule: Rule) -> None:
837
+ expr = rule.dst_port.strip()
838
+ if expr in {"", "*"}:
839
+ return
840
+ for item in _split_multi(expr, ","):
841
+ item = item.strip()
842
+ if not item or item == "*":
843
+ continue
844
+ if "-" in item:
845
+ lo_s, _, hi_s = item.partition("-")
846
+ try:
847
+ lo, hi = int(lo_s), int(hi_s)
848
+ except ValueError as exc:
849
+ raise ConfigError(
850
+ E_RULE_MATCH_INVALID,
851
+ f"规则 {rule.id} 的端口段非法: {item!r}",
852
+ field=f"rules[{rule.id}].dst_port",
853
+ value=item,
854
+ ) from exc
855
+ if not (0 < lo <= hi <= 65535):
856
+ raise ConfigError(
857
+ E_RULE_MATCH_INVALID,
858
+ f"规则 {rule.id} 的端口段非法(要求 0<lo<=hi<=65535): {item!r}",
859
+ field=f"rules[{rule.id}].dst_port",
860
+ value=item,
861
+ )
862
+ else:
863
+ try:
864
+ port = int(item)
865
+ except ValueError as exc:
866
+ raise ConfigError(
867
+ E_RULE_MATCH_INVALID,
868
+ f"规则 {rule.id} 的端口非法: {item!r}",
869
+ field=f"rules[{rule.id}].dst_port",
870
+ value=item,
871
+ ) from exc
872
+ if not (0 < port <= 65535):
873
+ raise ConfigError(
874
+ E_RULE_MATCH_INVALID,
875
+ f"规则 {rule.id} 的端口超出范围: {item!r}",
876
+ field=f"rules[{rule.id}].dst_port",
877
+ value=item,
878
+ )
879
+
880
+
881
+ def normalized_profile_doc(doc: dict[str, Any]) -> dict[str, Any]:
882
+ """把配置归一化成"profile 下含 rules/upstreams"的形态。
883
+
884
+ 的 schema 把 `rules` 放在 `profile.*` 下,而 `samples/profiles/*.yaml`
885
+ 放在顶层;两种写法都必须支持(dev.yaml 是官方样例,不能改)。
886
+ """
887
+ profile_doc = dict(_as_mapping(doc.get("profile"), field="profile"))
888
+ if "rules" not in profile_doc and "rules" in doc:
889
+ profile_doc["rules"] = doc.get("rules")
890
+ return profile_doc
891
+
892
+
893
+ def _parse_upstreams(doc: Any) -> dict[str, Upstream]:
894
+ if doc is None:
895
+ return {}
896
+ items: list[Any]
897
+ if isinstance(doc, dict):
898
+ # 也允许 {alias: {...}} 形态
899
+ items = []
900
+ for alias, body in doc.items():
901
+ body = dict(_as_mapping(body, field=f"upstreams.{alias}"))
902
+ body.setdefault("alias", alias)
903
+ items.append(body)
904
+ elif isinstance(doc, list):
905
+ items = list(doc)
906
+ else:
907
+ raise ConfigError(E_RULE_UPSTREAM_MISSING, "upstreams 必须是列表或映射", field="upstreams")
908
+ out: dict[str, Upstream] = {}
909
+ for item in items:
910
+ if not isinstance(item, dict):
911
+ raise ConfigError(E_RULE_UPSTREAM_MISSING, "upstreams 每一项必须是映射", field="upstreams")
912
+ up = Upstream.from_dict(item)
913
+ if up.alias in out:
914
+ raise ConfigError(E_RULE_UPSTREAM_MISSING, f"上游别名重复: {up.alias}", field="upstreams.alias")
915
+ out[up.alias] = up
916
+ return out
917
+
918
+
919
+ def port_in_use(port: int, host: str = "127.0.0.1") -> bool:
920
+ """检测端口是否已被占用(V6)。
921
+
922
+ Windows 上用 `SO_EXCLUSIVEADDRUSE`(`SO_REUSEADDR` 在 Windows 上允许重复绑定,
923
+ 会漏判);POSIX 上用默认语义(不提 SO_REUSEADDR 即为独占)。
924
+ """
925
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
926
+ try:
927
+ if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): # Windows
928
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
929
+ sock.bind((host, port))
930
+ return False
931
+ except OSError:
932
+ return True
933
+ finally:
934
+ sock.close()
935
+
936
+
937
+ # ------------------------------------------------------------ 热重载 -------
938
+ class ConfigManager:
939
+ """持有当前生效配置,支持原子热重载。
940
+
941
+ 红线:
942
+ * 校验失败 → 保留旧配置并抛出 `ConfigError`(调用方决定是否告警);
943
+ * `sink.port`/`backend` 变化 → 明确拒绝热改(要求重启服务)。
944
+ """
945
+
946
+ def __init__(self, config: Config) -> None:
947
+ self._config = config
948
+ self._lock = threading.Lock()
949
+ self._generation = 1
950
+
951
+ @property
952
+ def config(self) -> Config:
953
+ """读取当前配置(无锁读引用,热路径安全)。"""
954
+ return self._config
955
+
956
+ @property
957
+ def generation(self) -> int:
958
+ return self._generation
959
+
960
+ def reload(self, path: str | os.PathLike[str] | None = None, *, check_sink: bool = False) -> Config:
961
+ target = Path(path or self._config.path)
962
+ new = Config.load(target, check_sink=check_sink)
963
+ old = self._config
964
+ self._guard_restart_only(old, new)
965
+ with self._lock:
966
+ self._config = new
967
+ self._generation += 1
968
+ LOG.info(
969
+ "config_reloaded",
970
+ extra={
971
+ "event": "config_reloaded",
972
+ "fields": {
973
+ "rules": len(new.profile.rules),
974
+ "from_rules": len(old.profile.rules),
975
+ "generation": self._generation,
976
+ "path": str(target),
977
+ },
978
+ },
979
+ )
980
+ return new
981
+
982
+ @staticmethod
983
+ def _guard_restart_only(old: Config, new: Config) -> None:
984
+ if old.service.sink.port != new.service.sink.port:
985
+ raise ConfigError(
986
+ E_SINK_PORT_UNAVAILABLE,
987
+ "sink.port 不支持热改(需要重建监听),请重启服务",
988
+ field="service.sink.port",
989
+ value=new.service.sink.port,
990
+ )
991
+ if old.service.backend != new.service.backend:
992
+ raise ConfigError(
993
+ E_RULE_MATCH_INVALID,
994
+ f"backend 不支持热改({old.service.backend} → {new.service.backend}),请重启服务",
995
+ field="service.backend",
996
+ value=new.service.backend,
997
+ )
998
+
999
+
1000
+ # 原子写配置
1001
+
1002
+ def atomic_write(config: Config, path: str | os.PathLike[str]) -> None:
1003
+ """写 `*.tmp` → 校验 → `os.replace`(防止半截配置被读到)。"""
1004
+ target = Path(path)
1005
+ tmp = target.with_suffix(target.suffix + ".tmp")
1006
+ tmp.write_text(config.dump_yaml(), encoding="utf-8")
1007
+ Config.load(tmp) # 校验:失败则不动现网文件
1008
+ os.replace(tmp, target)
1009
+
1010
+