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