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/rules.py
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""规则引擎。
|
|
2
|
+
|
|
3
|
+
`decide()` 的匹配顺序严格不得调整:
|
|
4
|
+
|
|
5
|
+
1. 全局白名单:回环/私网/链路本地 → DIRECT(除非规则显式覆盖 keep_private_proxied)
|
|
6
|
+
2. 自身进程(服务/拦截器/上游代理进程) → DIRECT (reason="self-pid")
|
|
7
|
+
3. 上游代理端点(含 DNS 服务器,若走本地解析) → DIRECT (reason="upstream-endpoint")
|
|
8
|
+
4. 熔断器:同 (pid, dst) 5s 内计数超阈值 → DIRECT + 告警(reason="loop-breaker")
|
|
9
|
+
5. Profile.rules 自上而下,首个命中生效 → 返回其 action/upstream
|
|
10
|
+
6. 未命中 → profile.default_action(默认 DIRECT)
|
|
11
|
+
|
|
12
|
+
性能红线:O(规则数)、单次 < 20µs(1000 条 < 100µs);
|
|
13
|
+
禁止在 `decide()` 里做 DNS 解析、日志格式化、文件读取,因此规则表达式会在
|
|
14
|
+
`CompiledProfile` 里预编译(导入期/热重载期各一次)。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import fnmatch
|
|
20
|
+
import ipaddress
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
import weakref
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any, Protocol
|
|
27
|
+
|
|
28
|
+
from .config import Profile, Rule
|
|
29
|
+
from .types import Action, ConnRequest, Decision, Endpoint, Proto
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StickySessions(Protocol):
|
|
33
|
+
"""`decide()` 只需要一个粘性上游查询接口(`SessionTable` / 测试替身都满足)。"""
|
|
34
|
+
|
|
35
|
+
def sticky_upstream(self, pid: int, rule_id: str, *, default: str = "") -> str: ...
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------- 默认放行的地址集合 ------
|
|
38
|
+
# 默认放行 127/8、10/8、172.16/12、192.168/16、169.254/16、fe80::/10
|
|
39
|
+
DEFAULT_PRIVATE_NETS: tuple[str, ...] = (
|
|
40
|
+
"127.0.0.0/8",
|
|
41
|
+
"10.0.0.0/8",
|
|
42
|
+
"172.16.0.0/12",
|
|
43
|
+
"192.168.0.0/16",
|
|
44
|
+
"169.254.0.0/16",
|
|
45
|
+
"fe80::/10",
|
|
46
|
+
"::1/128",
|
|
47
|
+
"fc00::/7", # ULA
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
REASON_GUARD_PRIVATE = "guard-private"
|
|
51
|
+
REASON_SELF_PID = "self-pid"
|
|
52
|
+
REASON_UPSTREAM_ENDPOINT = "upstream-endpoint"
|
|
53
|
+
REASON_LOOP_BREAKER = "loop-breaker"
|
|
54
|
+
REASON_DEFAULT = "default"
|
|
55
|
+
REASON_UNKNOWN_PID = "unknown-pid"
|
|
56
|
+
REASON_NAME_INFERRED = "name-inferred"
|
|
57
|
+
|
|
58
|
+
_WILDCARD_CHARS = re.compile(r"[*?\[]")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ============================================================ 匹配原语 ======
|
|
62
|
+
def wildcard_match(pattern: str, value: str, *, case_sensitive: bool = False) -> bool:
|
|
63
|
+
"""`*`/`?`/`[]` 通配匹配(大小写不敏感为默认)。
|
|
64
|
+
|
|
65
|
+
无通配符的模式走精确比较(`decide()` 热路径上比 fnmatch 快一个量级)。
|
|
66
|
+
"""
|
|
67
|
+
if pattern == "*":
|
|
68
|
+
return True
|
|
69
|
+
if not case_sensitive:
|
|
70
|
+
pattern, value = pattern.lower(), value.lower()
|
|
71
|
+
if not has_wildcard(pattern):
|
|
72
|
+
return pattern == value
|
|
73
|
+
return fnmatch.fnmatchcase(value, pattern)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def match_process(targets: str, proc_name: str, proc_path: str) -> bool:
|
|
77
|
+
"""`targets` 的进程匹配语义:
|
|
78
|
+
|
|
79
|
+
* 多值用 `;` 分隔;
|
|
80
|
+
* 大小写不敏感;`*`/`?` 通配;
|
|
81
|
+
* 含 `\\` 或 `/` 的模式按完整路径匹配,否则按文件名匹配;
|
|
82
|
+
* 路径为空(受保护进程)时降级为按名称匹配(受保护进程拿不到路径)。
|
|
83
|
+
"""
|
|
84
|
+
name = proc_name or ""
|
|
85
|
+
path = proc_path or ""
|
|
86
|
+
for pattern in _split(targets, ";"):
|
|
87
|
+
if not pattern:
|
|
88
|
+
continue
|
|
89
|
+
if "\\" in pattern or "/" in pattern:
|
|
90
|
+
if not path:
|
|
91
|
+
# 只有模式本身不带目录时,才允许缺路径降级
|
|
92
|
+
base = _basename(pattern)
|
|
93
|
+
if base and wildcard_match(base, name):
|
|
94
|
+
return True
|
|
95
|
+
continue
|
|
96
|
+
if wildcard_match(pattern, path):
|
|
97
|
+
return True
|
|
98
|
+
elif wildcard_match(pattern, name):
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def match_proto(expr: str, proto: Proto) -> bool:
|
|
104
|
+
expr = (expr or "*").strip().lower()
|
|
105
|
+
return expr in {"*", proto.value}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def match_port(expr: str, port: int) -> bool:
|
|
109
|
+
"""`443` | `80,443` | `1000-2000` | `*`(端口段含边界)。"""
|
|
110
|
+
expr = (expr or "*").strip()
|
|
111
|
+
if expr in {"", "*"}:
|
|
112
|
+
return True
|
|
113
|
+
for item in _split(expr, ","):
|
|
114
|
+
item = item.strip()
|
|
115
|
+
if not item or item == "*":
|
|
116
|
+
return True
|
|
117
|
+
if "-" in item:
|
|
118
|
+
lo_s, _, hi_s = item.partition("-")
|
|
119
|
+
try:
|
|
120
|
+
lo, hi = int(lo_s), int(hi_s)
|
|
121
|
+
except ValueError:
|
|
122
|
+
continue
|
|
123
|
+
if lo <= port <= hi:
|
|
124
|
+
return True
|
|
125
|
+
else:
|
|
126
|
+
try:
|
|
127
|
+
if int(item) == port:
|
|
128
|
+
return True
|
|
129
|
+
except ValueError:
|
|
130
|
+
continue
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def match_ip(expr: str, addr: str) -> bool:
|
|
135
|
+
"""CIDR 或具体 IP,多值逗号分隔;v4/v6 都支持。"""
|
|
136
|
+
expr = (expr or "*").strip()
|
|
137
|
+
if expr in {"", "*"}:
|
|
138
|
+
return True
|
|
139
|
+
try:
|
|
140
|
+
target = ipaddress.ip_address(addr)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return False
|
|
143
|
+
for item in _split(expr, ","):
|
|
144
|
+
item = item.strip()
|
|
145
|
+
if not item or item == "*":
|
|
146
|
+
return True
|
|
147
|
+
try:
|
|
148
|
+
net = ipaddress.ip_network(item, strict=False)
|
|
149
|
+
except ValueError:
|
|
150
|
+
continue
|
|
151
|
+
if target.version == net.version and target in net:
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def match_name(expr: str, name: str | None) -> bool:
|
|
157
|
+
"""域名规则:仅在域名已知时匹配。
|
|
158
|
+
|
|
159
|
+
通过 DNS 嗅探推断出来的映射属于近似匹配,日志必须打 `name-inferred`。
|
|
160
|
+
"""
|
|
161
|
+
if name is None:
|
|
162
|
+
return False
|
|
163
|
+
expr = (expr or "*").strip()
|
|
164
|
+
if expr in {"", "*"}:
|
|
165
|
+
return True
|
|
166
|
+
for pattern in _split(expr, ","):
|
|
167
|
+
pattern = pattern.strip()
|
|
168
|
+
if not pattern:
|
|
169
|
+
continue
|
|
170
|
+
if wildcard_match(pattern, name):
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _split(value: str, seps: str) -> list[str]:
|
|
176
|
+
"""按分隔符切分(不用 re.split,热路径更快)。"""
|
|
177
|
+
out: list[str] = []
|
|
178
|
+
buf = ""
|
|
179
|
+
for ch in value:
|
|
180
|
+
if ch in seps:
|
|
181
|
+
out.append(buf)
|
|
182
|
+
buf = ""
|
|
183
|
+
else:
|
|
184
|
+
buf += ch
|
|
185
|
+
out.append(buf)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _basename(pattern: str) -> str:
|
|
190
|
+
return pattern.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_ip(addr: str) -> Any:
|
|
194
|
+
"""解析 IP(失败返回 None,绝不抛异常);热路径只解析一次。"""
|
|
195
|
+
try:
|
|
196
|
+
return ipaddress.ip_address(addr)
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def has_wildcard(pattern: str) -> bool:
|
|
202
|
+
return bool(_WILDCARD_CHARS.search(pattern))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ============================================================ 守卫集合 ======
|
|
206
|
+
@dataclass(slots=True)
|
|
207
|
+
class GuardSet:
|
|
208
|
+
"""三重环路防护 + 全局白名单的输入集合。
|
|
209
|
+
|
|
210
|
+
* `self_pids`:第一重:自身进程白名单;
|
|
211
|
+
* `upstream_endpoints` / `local_dns_servers`,第二重:上游与 DNS 端点;
|
|
212
|
+
* `breaker`:第三重:同 (pid, dst) 的抖动熔断(见 `LoopBreaker`)。
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
self_pids: set[int] = field(default_factory=set)
|
|
216
|
+
upstream_endpoints: set[tuple[str, int]] = field(default_factory=set)
|
|
217
|
+
local_dns_servers: set[str] = field(default_factory=set)
|
|
218
|
+
private_nets: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = field(
|
|
219
|
+
default_factory=lambda: tuple(ipaddress.ip_network(n) for n in DEFAULT_PRIVATE_NETS)
|
|
220
|
+
)
|
|
221
|
+
breaker: LoopBreaker | None = None
|
|
222
|
+
on_unknown_pid: str = "direct"
|
|
223
|
+
name_resolver: Callable[[str], str | None] | None = None
|
|
224
|
+
# 推断出来的域名(DNS 嗅探)→ 命中时日志要带 name-inferred
|
|
225
|
+
inferred_names: set[str] = field(default_factory=set)
|
|
226
|
+
track_unknown_pid: bool = True
|
|
227
|
+
unknown_pid: int = 0
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------ 子判定 --
|
|
230
|
+
def is_private(self, addr: str) -> bool:
|
|
231
|
+
try:
|
|
232
|
+
ip = ipaddress.ip_address(addr)
|
|
233
|
+
except ValueError:
|
|
234
|
+
return False
|
|
235
|
+
return any(ip.version == net.version and ip in net for net in self.private_nets)
|
|
236
|
+
|
|
237
|
+
def is_self_pid(self, pid: int) -> bool:
|
|
238
|
+
return pid in self.self_pids
|
|
239
|
+
|
|
240
|
+
def is_upstream_endpoint(self, dst: Endpoint) -> bool:
|
|
241
|
+
if (dst.addr, dst.port) in self.upstream_endpoints:
|
|
242
|
+
return True
|
|
243
|
+
return dst.addr in self.local_dns_servers and dst.port == 53
|
|
244
|
+
|
|
245
|
+
def name_for_ip(self, addr: str) -> str | None:
|
|
246
|
+
if self.name_resolver is None:
|
|
247
|
+
return None
|
|
248
|
+
return self.name_resolver(addr)
|
|
249
|
+
|
|
250
|
+
def breaker_tripped(self, pid: int, dst: Endpoint) -> bool:
|
|
251
|
+
if self.breaker is None:
|
|
252
|
+
return False
|
|
253
|
+
return self.breaker.tripped(pid, dst)
|
|
254
|
+
|
|
255
|
+
def note(self, pid: int, dst: Endpoint) -> None:
|
|
256
|
+
if self.breaker is not None:
|
|
257
|
+
self.breaker.note(pid, dst)
|
|
258
|
+
|
|
259
|
+
def note_unknown_pid(self) -> None:
|
|
260
|
+
"""路线 A 的 `unknown_pid` 计数。"""
|
|
261
|
+
if self.track_unknown_pid:
|
|
262
|
+
self.unknown_pid += 1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ============================================================ 熔断器 ========
|
|
266
|
+
class LoopBreaker:
|
|
267
|
+
"""环路防护第三重:同 `(pid, dst)` 在 `window_s` 内超过 `threshold` 次即熔断。
|
|
268
|
+
|
|
269
|
+
语义:计数在判定之前检查、判定之后记账,
|
|
270
|
+
因此当阈值 N=50 时,第 51 次连接起返回 DIRECT + 告警。
|
|
271
|
+
|
|
272
|
+
实现:单调时钟环形缓冲,每次查询先淘汰窗口外的样本(O(窗口内次数))。
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(
|
|
276
|
+
self,
|
|
277
|
+
window_s: float = 5.0,
|
|
278
|
+
threshold: int = 50,
|
|
279
|
+
*,
|
|
280
|
+
clock: Callable[[], float] = time.monotonic,
|
|
281
|
+
) -> None:
|
|
282
|
+
self.window_s = float(window_s)
|
|
283
|
+
self.threshold = int(threshold)
|
|
284
|
+
self._clock = clock
|
|
285
|
+
self._hits: dict[tuple[int, str, int], list[float]] = {}
|
|
286
|
+
self._trips: dict[tuple[int, str, int], int] = {}
|
|
287
|
+
self.trips_total = 0
|
|
288
|
+
|
|
289
|
+
def _prune(self, key: tuple[int, str, int], now: float) -> list[float]:
|
|
290
|
+
stamps = self._hits.get(key)
|
|
291
|
+
if stamps is None:
|
|
292
|
+
return []
|
|
293
|
+
cutoff = now - self.window_s
|
|
294
|
+
# 列表是时间递增的,从头切片即可
|
|
295
|
+
idx = 0
|
|
296
|
+
for stamp in stamps:
|
|
297
|
+
if stamp >= cutoff:
|
|
298
|
+
break
|
|
299
|
+
idx += 1
|
|
300
|
+
if idx:
|
|
301
|
+
del stamps[:idx]
|
|
302
|
+
if not stamps:
|
|
303
|
+
del self._hits[key]
|
|
304
|
+
return []
|
|
305
|
+
return stamps
|
|
306
|
+
|
|
307
|
+
def count(self, pid: int, dst: Endpoint) -> int:
|
|
308
|
+
key = (pid, dst.addr, dst.port)
|
|
309
|
+
now = self._clock()
|
|
310
|
+
return len(self._prune(key, now))
|
|
311
|
+
|
|
312
|
+
def check(self, pid: int, dst: Endpoint) -> bool:
|
|
313
|
+
"""只读检查(不记账):是否已达熔断阈值。"""
|
|
314
|
+
key = (pid, dst.addr, dst.port)
|
|
315
|
+
return len(self._prune(key, self._clock())) >= self.threshold
|
|
316
|
+
|
|
317
|
+
def tripped(self, pid: int, dst: Endpoint) -> bool:
|
|
318
|
+
key = (pid, dst.addr, dst.port)
|
|
319
|
+
if len(self._prune(key, self._clock())) >= self.threshold:
|
|
320
|
+
self._trips[key] = self._trips.get(key, 0) + 1
|
|
321
|
+
self.trips_total += 1
|
|
322
|
+
return True
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
def note(self, pid: int, dst: Endpoint) -> None:
|
|
326
|
+
key = (pid, dst.addr, dst.port)
|
|
327
|
+
now = self._clock()
|
|
328
|
+
self._prune(key, now)
|
|
329
|
+
self._hits.setdefault(key, []).append(now)
|
|
330
|
+
|
|
331
|
+
def reset(self, pid: int | None = None) -> None:
|
|
332
|
+
"""熔断复位(维护线程定期调用,或 `cli stop --panic` 后清空)。"""
|
|
333
|
+
if pid is None:
|
|
334
|
+
self._hits.clear()
|
|
335
|
+
self._trips.clear()
|
|
336
|
+
return
|
|
337
|
+
for key in [k for k in self._hits if k[0] == pid]:
|
|
338
|
+
del self._hits[key]
|
|
339
|
+
for key in [k for k in self._trips if k[0] == pid]:
|
|
340
|
+
del self._trips[key]
|
|
341
|
+
|
|
342
|
+
def stats(self) -> dict[str, int]:
|
|
343
|
+
return {"tracked": len(self._hits), "trips_total": self.trips_total}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ============================================================ 预编译 ========
|
|
347
|
+
def _fold(text: str) -> str:
|
|
348
|
+
"""ASCII 快速小写。
|
|
349
|
+
|
|
350
|
+
`str.lower()` 对纯小写字符串会返回同一个对象(CPython 的优化),
|
|
351
|
+
所以这里不需要自己做"是否含大写"的判断,判断本身比调用更贵。
|
|
352
|
+
"""
|
|
353
|
+
if text.islower():
|
|
354
|
+
return text
|
|
355
|
+
return text.lower()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def compile_one_matcher(
|
|
359
|
+
patterns: tuple[str, ...],
|
|
360
|
+
) -> Callable[[str, str], bool]:
|
|
361
|
+
"""把一组进程模式编译成一个 `(folded_name, folded_path) -> bool` 谓词。
|
|
362
|
+
|
|
363
|
+
* 无通配符的模式 → 预建 `frozenset`(名字)/ 集合(路径),O(1) 命中;
|
|
364
|
+
* 有通配符的模式 → fnmatch(只在必要时走正则);
|
|
365
|
+
* 大小写不敏感由调用方每条连接只折叠一次(`prepare()`)来保证,
|
|
366
|
+
因此这里收到的已经是小写键,热路径上零转换开销。
|
|
367
|
+
"""
|
|
368
|
+
names: list[str] = []
|
|
369
|
+
paths: list[str] = []
|
|
370
|
+
name_wild: list[str] = []
|
|
371
|
+
path_wild: list[str] = []
|
|
372
|
+
for pattern in patterns:
|
|
373
|
+
if pattern == "*":
|
|
374
|
+
return _match_any
|
|
375
|
+
wild = has_wildcard(pattern)
|
|
376
|
+
normalized = pattern if wild else pattern.lower()
|
|
377
|
+
if "\\" in pattern or "/" in pattern:
|
|
378
|
+
(path_wild if wild else paths).append(normalized)
|
|
379
|
+
else:
|
|
380
|
+
(name_wild if wild else names).append(normalized)
|
|
381
|
+
|
|
382
|
+
if not names and not name_wild and not paths and not path_wild:
|
|
383
|
+
return _match_none
|
|
384
|
+
|
|
385
|
+
name_exact = frozenset(names)
|
|
386
|
+
path_set = frozenset(paths)
|
|
387
|
+
name_wild_t = tuple(name_wild)
|
|
388
|
+
path_wild_t = tuple(path_wild)
|
|
389
|
+
path_base_pairs = tuple((p, _basename(p)) for p in (*paths, *path_wild_t) if _basename(p))
|
|
390
|
+
any_path_only = not name_exact and not name_wild_t and bool(paths or path_wild_t)
|
|
391
|
+
|
|
392
|
+
def matcher(name_key: str, path_key: str) -> bool:
|
|
393
|
+
if name_exact and name_key in name_exact:
|
|
394
|
+
return True
|
|
395
|
+
if path_set and path_key in path_set:
|
|
396
|
+
return True
|
|
397
|
+
if name_wild_t and any(wildcard_match(p, name_key) for p in name_wild_t):
|
|
398
|
+
return True
|
|
399
|
+
if path_wild_t:
|
|
400
|
+
if path_key:
|
|
401
|
+
if any(wildcard_match(p, path_key) for p in path_wild_t):
|
|
402
|
+
return True
|
|
403
|
+
# 受保护进程拿不到路径 → 降级按文件名匹配(受保护进程拿不到路径)
|
|
404
|
+
elif any(wildcard_match(base, name_key) for _p, base in path_base_pairs):
|
|
405
|
+
return True
|
|
406
|
+
if not any_path_only:
|
|
407
|
+
return False
|
|
408
|
+
if not path_key:
|
|
409
|
+
# 纯路径模式 + 无路径 → 只能按文件名兜底
|
|
410
|
+
return any(wildcard_match(base, name_key) for _p, base in path_base_pairs)
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
return matcher
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _match_any(_name: str, _path: str) -> bool:
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _match_none(_name: str, _path: str) -> bool:
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@dataclass(slots=True)
|
|
425
|
+
class CompiledRule:
|
|
426
|
+
rule: Rule
|
|
427
|
+
matcher: Callable[[str, str], bool]
|
|
428
|
+
proto: str
|
|
429
|
+
ports: tuple[tuple[int, int], ...] | None # None = 任意
|
|
430
|
+
nets: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] | None # None = 任意
|
|
431
|
+
dst_names: tuple[str, ...]
|
|
432
|
+
any_dst_name: bool
|
|
433
|
+
# 从 `rule` 里摊平出来的热路径字段(避免每条规则两次 dataclass 属性访问)
|
|
434
|
+
enabled: bool
|
|
435
|
+
action: Action
|
|
436
|
+
keep_private_proxied: bool
|
|
437
|
+
upstream: str
|
|
438
|
+
rule_id: str
|
|
439
|
+
|
|
440
|
+
def prepare(self, conn: ConnRequest) -> tuple[str, str]:
|
|
441
|
+
"""每条连接只做一次的大小写归一化(热路径预算的关键)。"""
|
|
442
|
+
proc = conn.proc
|
|
443
|
+
return _fold(proc.name or ""), _fold(proc.path or "")
|
|
444
|
+
|
|
445
|
+
def match(self, conn: ConnRequest, name: str | None, ip: Any = None, keys: tuple[str, str] | None = None) -> bool:
|
|
446
|
+
"""热路径:不进 `decide()` 的规则在这里被快速排除。
|
|
447
|
+
|
|
448
|
+
`ip` 是调用方预先解析好的 `ipaddress` 对象(避免每条规则重复解析);
|
|
449
|
+
`keys` 是 `prepare()` 的结果(避免每条规则重复折叠大小写)。
|
|
450
|
+
"""
|
|
451
|
+
if keys is None:
|
|
452
|
+
keys = self.prepare(conn)
|
|
453
|
+
if not self.matcher(keys[0], keys[1]):
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
if self.proto != "*" and self.proto != conn.proto.value:
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
if self.ports is not None and not any(lo <= conn.dst.port <= hi for lo, hi in self.ports):
|
|
460
|
+
return False
|
|
461
|
+
|
|
462
|
+
if self.nets is not None:
|
|
463
|
+
if ip is None:
|
|
464
|
+
return False
|
|
465
|
+
if not any(ip.version == n.version and ip in n for n in self.nets):
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
if not self.any_dst_name:
|
|
469
|
+
if name is None:
|
|
470
|
+
return False # 域名未知 → 该规则不适用(不做近似猜测)
|
|
471
|
+
if not any(wildcard_match(p, name) for p in self.dst_names):
|
|
472
|
+
return False
|
|
473
|
+
return True
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@dataclass(slots=True)
|
|
477
|
+
class CompiledProfile:
|
|
478
|
+
profile: Profile
|
|
479
|
+
rules: list[CompiledRule]
|
|
480
|
+
|
|
481
|
+
@classmethod
|
|
482
|
+
def build(cls, profile: Profile) -> CompiledProfile:
|
|
483
|
+
return cls(profile=profile, rules=[compile_rule(r) for r in profile.rules])
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def compile_rule(rule: Rule) -> CompiledRule:
|
|
487
|
+
targets = tuple(p for p in (t.strip() for t in _split(rule.targets, ";")) if p)
|
|
488
|
+
|
|
489
|
+
ports: list[tuple[int, int]] = []
|
|
490
|
+
ports_any = False
|
|
491
|
+
for item in (p.strip() for p in _split(rule.dst_port or "*", ",")):
|
|
492
|
+
if not item or item == "*":
|
|
493
|
+
ports_any = True
|
|
494
|
+
continue
|
|
495
|
+
if "-" in item:
|
|
496
|
+
lo_s, _, hi_s = item.partition("-")
|
|
497
|
+
try:
|
|
498
|
+
ports.append((int(lo_s), int(hi_s)))
|
|
499
|
+
except ValueError:
|
|
500
|
+
continue
|
|
501
|
+
else:
|
|
502
|
+
try:
|
|
503
|
+
port = int(item)
|
|
504
|
+
except ValueError:
|
|
505
|
+
continue
|
|
506
|
+
ports.append((port, port))
|
|
507
|
+
|
|
508
|
+
nets: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
|
509
|
+
nets_any = False
|
|
510
|
+
for item in (p.strip() for p in _split(rule.dst_ip or "*", ",")):
|
|
511
|
+
if not item or item == "*":
|
|
512
|
+
nets_any = True
|
|
513
|
+
continue
|
|
514
|
+
try:
|
|
515
|
+
nets.append(ipaddress.ip_network(item, strict=False))
|
|
516
|
+
except ValueError:
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
dst_names = tuple(p for p in (t.strip() for t in _split(rule.dst_name or "*", ",")) if p)
|
|
520
|
+
any_dst_name = not dst_names or "*" in dst_names
|
|
521
|
+
|
|
522
|
+
return CompiledRule(
|
|
523
|
+
rule=rule,
|
|
524
|
+
matcher=compile_one_matcher(targets),
|
|
525
|
+
proto=(rule.proto or "*").strip().lower(),
|
|
526
|
+
ports=None if (ports_any or not ports) else tuple(ports),
|
|
527
|
+
nets=None if (nets_any or not nets) else tuple(nets),
|
|
528
|
+
dst_names=tuple(n for n in dst_names if n != "*"),
|
|
529
|
+
any_dst_name=any_dst_name,
|
|
530
|
+
enabled=rule.enabled,
|
|
531
|
+
action=rule.action,
|
|
532
|
+
keep_private_proxied=rule.keep_private_proxied,
|
|
533
|
+
upstream=rule.upstream,
|
|
534
|
+
rule_id=rule.id,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
_COMPILED_CACHE: weakref.WeakKeyDictionary[Profile, CompiledProfile] = weakref.WeakKeyDictionary()
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def compiled(profile: Profile) -> CompiledProfile:
|
|
542
|
+
"""取(并缓存)预编译结果。热重载会换掉整个 `Profile` 对象 → 旧缓存自然失效。"""
|
|
543
|
+
try:
|
|
544
|
+
cached = _COMPILED_CACHE.get(profile)
|
|
545
|
+
if cached is not None:
|
|
546
|
+
return cached
|
|
547
|
+
built = CompiledProfile.build(profile)
|
|
548
|
+
_COMPILED_CACHE[profile] = built
|
|
549
|
+
return built
|
|
550
|
+
except TypeError:
|
|
551
|
+
# Profile 不可弱引用(或临时对象)时退化为每次编译
|
|
552
|
+
return CompiledProfile.build(profile)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ============================================================ 主入口 ========
|
|
556
|
+
def decide(
|
|
557
|
+
conn: ConnRequest,
|
|
558
|
+
profile: Profile,
|
|
559
|
+
sessions: StickySessions | None = None,
|
|
560
|
+
guards: GuardSet | None = None,
|
|
561
|
+
) -> Decision:
|
|
562
|
+
"""按固定顺序做决策(纯函数;不阻塞、不做 IO)。"""
|
|
563
|
+
g = guards if guards is not None else GuardSet()
|
|
564
|
+
sessions = sessions if sessions is not None else NullSessions()
|
|
565
|
+
|
|
566
|
+
# ---- 未知 PID 处置----
|
|
567
|
+
if conn.proc.pid <= 0:
|
|
568
|
+
g.note_unknown_pid()
|
|
569
|
+
return fallback_decision(g.on_unknown_pid or "direct", profile, reason=REASON_UNKNOWN_PID)
|
|
570
|
+
|
|
571
|
+
# ---- 守卫 G1:回环/私网/链路本地 ----
|
|
572
|
+
private = g.is_private(conn.dst.addr)
|
|
573
|
+
dst_ip = _parse_ip(conn.dst.addr)
|
|
574
|
+
|
|
575
|
+
# ---- 守卫 G2:自身进程 ----
|
|
576
|
+
if g.is_self_pid(conn.proc.pid):
|
|
577
|
+
return Decision(Action.DIRECT, reason=REASON_SELF_PID)
|
|
578
|
+
|
|
579
|
+
# ---- 守卫 G3:上游/DNS 端点 ----
|
|
580
|
+
if g.is_upstream_endpoint(conn.dst):
|
|
581
|
+
return Decision(Action.DIRECT, reason=REASON_UPSTREAM_ENDPOINT)
|
|
582
|
+
|
|
583
|
+
# ---- 守卫 G4:熔断 ----
|
|
584
|
+
if g.breaker_tripped(conn.proc.pid, conn.dst):
|
|
585
|
+
return Decision(Action.DIRECT, reason=REASON_LOOP_BREAKER)
|
|
586
|
+
|
|
587
|
+
# ---- 规则匹配(自上而下,首个命中生效)----
|
|
588
|
+
name = g.name_for_ip(conn.dst.addr)
|
|
589
|
+
sticky = sessions.sticky_upstream if profile.sticky_per_pid else None
|
|
590
|
+
pid = conn.proc.pid
|
|
591
|
+
rules = compiled(profile).rules
|
|
592
|
+
keys = rules[0].prepare(conn) if rules else ("", "")
|
|
593
|
+
proto_value = conn.proto.value
|
|
594
|
+
port = conn.dst.port
|
|
595
|
+
for cr in rules:
|
|
596
|
+
if not cr.enabled:
|
|
597
|
+
continue
|
|
598
|
+
action = cr.action
|
|
599
|
+
if private and not cr.keep_private_proxied and action is Action.PROXY:
|
|
600
|
+
# 私网/回环默认直连(默认放行回环/私网):
|
|
601
|
+
# 只有"显式要求代理私网"的规则才能代理私网,其余 proxy 规则不适用。
|
|
602
|
+
continue
|
|
603
|
+
if not cr.matcher(keys[0], keys[1]):
|
|
604
|
+
continue
|
|
605
|
+
proto = cr.proto
|
|
606
|
+
if proto != "*" and proto != proto_value:
|
|
607
|
+
continue
|
|
608
|
+
ports = cr.ports
|
|
609
|
+
if ports is not None and not any(lo <= port <= hi for lo, hi in ports):
|
|
610
|
+
continue
|
|
611
|
+
nets = cr.nets
|
|
612
|
+
if nets is not None and (
|
|
613
|
+
dst_ip is None or not any(dst_ip.version == n.version and dst_ip in n for n in nets)
|
|
614
|
+
):
|
|
615
|
+
continue
|
|
616
|
+
name_inferred = False
|
|
617
|
+
if not cr.any_dst_name:
|
|
618
|
+
if name is None or not any(wildcard_match(p, name) for p in cr.dst_names):
|
|
619
|
+
continue
|
|
620
|
+
name_inferred = name in g.inferred_names
|
|
621
|
+
if name_inferred and action is Action.BLOCK:
|
|
622
|
+
# 红线:DNS 嗅探来的 IP→域名映射只是
|
|
623
|
+
# 近似(TTL 内有效,CDN 会误判),不得用于 BLOCK 决策
|
|
624
|
+
# 猜错域名就断掉一条本来正常的连接,代价远大于收益。
|
|
625
|
+
# 近似匹配只对 proxy/direct 开放,且日志里带 `name-inferred` 标记。
|
|
626
|
+
continue
|
|
627
|
+
upstream = cr.upstream
|
|
628
|
+
if action is Action.PROXY and sticky is not None:
|
|
629
|
+
upstream = sticky(pid, cr.rule_id, default=upstream)
|
|
630
|
+
reason = f"rule:{cr.rule_id}"
|
|
631
|
+
if name_inferred:
|
|
632
|
+
reason = f"rule:{cr.rule_id};{REASON_NAME_INFERRED}"
|
|
633
|
+
return Decision(action, rule_id=cr.rule_id, upstream=upstream, reason=reason)
|
|
634
|
+
|
|
635
|
+
if private:
|
|
636
|
+
# 私网未命中任何 keep_private_proxied 规则 → 直连(理由可解释)
|
|
637
|
+
fallback = profile.default_action
|
|
638
|
+
if fallback is Action.PROXY:
|
|
639
|
+
return Decision(Action.DIRECT, reason=REASON_GUARD_PRIVATE)
|
|
640
|
+
return Decision(fallback, reason=REASON_GUARD_PRIVATE)
|
|
641
|
+
|
|
642
|
+
return Decision(profile.default_action, reason=REASON_DEFAULT)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _first_proxy_rule(cp: CompiledProfile) -> CompiledRule | None:
|
|
646
|
+
for cr in cp.rules:
|
|
647
|
+
if cr.enabled and cr.action is Action.PROXY:
|
|
648
|
+
return cr
|
|
649
|
+
return None
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def fallback_decision(policy: str, profile: Profile | None, *, reason: str) -> Decision:
|
|
653
|
+
"""把 `direct | proxy | block` 策略串翻成一条 `Decision`。
|
|
654
|
+
|
|
655
|
+
两处"我们不知道该怎么办"的兜底共用它,语义必须一致:
|
|
656
|
+
|
|
657
|
+
* PID 未知(`service.on_unknown_pid`,路线 A 的查表竞态);
|
|
658
|
+
* 决策路径抛异常(`service.on_decide_error`)。
|
|
659
|
+
|
|
660
|
+
`proxy` 需要一个上游,取第一条 `action=proxy` 的启用规则(沿用同一约定);
|
|
661
|
+
一条都没有时退化成 `BLOCK`,不静默直连。
|
|
662
|
+
"""
|
|
663
|
+
name = (policy or "").strip().lower()
|
|
664
|
+
if name == "proxy":
|
|
665
|
+
cr = _first_proxy_rule(compiled(profile)) if profile is not None else None
|
|
666
|
+
if cr is None:
|
|
667
|
+
return Decision(Action.BLOCK, reason=f"{reason}:proxy-no-upstream")
|
|
668
|
+
return Decision(
|
|
669
|
+
Action.PROXY, rule_id=cr.rule.id, upstream=cr.rule.upstream, reason=f"{reason}:proxy"
|
|
670
|
+
)
|
|
671
|
+
if name == "block":
|
|
672
|
+
return Decision(Action.BLOCK, reason=f"{reason}:block")
|
|
673
|
+
return Decision(Action.DIRECT, reason=f"{reason}:direct")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
class NullSessions:
|
|
677
|
+
"""无会话表时的粘性退化实现(单测/纯决策路径用)。"""
|
|
678
|
+
|
|
679
|
+
def sticky_upstream(self, pid: int, rule_id: str, *, default: str = "") -> str:
|
|
680
|
+
return default
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# ------------------------------------------------------------ 可观测辅助 ----
|
|
684
|
+
def explain(conn: ConnRequest, profile: Profile, guards: GuardSet | None = None) -> list[dict[str, Any]]:
|
|
685
|
+
"""`cli rules --trace` 用:打印逐条规则的判定链路(不用于热路径)。"""
|
|
686
|
+
g = guards if guards is not None else GuardSet()
|
|
687
|
+
name = g.name_for_ip(conn.dst.addr)
|
|
688
|
+
trace: list[dict[str, Any]] = [
|
|
689
|
+
{"stage": "G1", "check": "private", "value": g.is_private(conn.dst.addr)},
|
|
690
|
+
{"stage": "G2", "check": "self-pid", "value": g.is_self_pid(conn.proc.pid)},
|
|
691
|
+
{"stage": "G3", "check": "upstream-endpoint", "value": g.is_upstream_endpoint(conn.dst)},
|
|
692
|
+
{"stage": "G4", "check": "loop-breaker", "value": g.breaker_tripped(conn.proc.pid, conn.dst)},
|
|
693
|
+
{"stage": "name", "check": "dst_name", "value": name},
|
|
694
|
+
]
|
|
695
|
+
ip = _parse_ip(conn.dst.addr)
|
|
696
|
+
private = g.is_private(conn.dst.addr)
|
|
697
|
+
for cr in compiled(profile).rules:
|
|
698
|
+
private_block = private and cr.action is Action.PROXY and not cr.keep_private_proxied
|
|
699
|
+
# 与 `decide()` 保持同一语义:带 `dst_ip` 的规则需要预先解析好的 IP,
|
|
700
|
+
# 禁用规则不参与判定,推断域名不得触发 BLOCK。
|
|
701
|
+
hit = cr.enabled and cr.match(conn, name, ip) and not private_block
|
|
702
|
+
if hit and not cr.any_dst_name and cr.action is Action.BLOCK and name in g.inferred_names:
|
|
703
|
+
hit = False
|
|
704
|
+
trace.append(
|
|
705
|
+
{
|
|
706
|
+
"stage": "rule",
|
|
707
|
+
"id": cr.rule_id,
|
|
708
|
+
"action": cr.action.value,
|
|
709
|
+
"matched": hit,
|
|
710
|
+
"enabled": cr.enabled,
|
|
711
|
+
}
|
|
712
|
+
)
|
|
713
|
+
return trace
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def stats_snapshot(profile: Profile, guards: GuardSet | None = None) -> dict[str, Any]:
|
|
717
|
+
g = guards if guards is not None else GuardSet()
|
|
718
|
+
out: dict[str, Any] = {
|
|
719
|
+
"rules": len(profile.rules),
|
|
720
|
+
"enabled_rules": sum(1 for r in profile.rules if r.enabled),
|
|
721
|
+
"upstreams": sorted(profile.upstreams),
|
|
722
|
+
"default_action": profile.default_action.value,
|
|
723
|
+
"self_pids": sorted(g.self_pids),
|
|
724
|
+
"upstream_endpoints": sorted(f"{h}:{p}" for h, p in g.upstream_endpoints),
|
|
725
|
+
"local_dns_servers": sorted(g.local_dns_servers),
|
|
726
|
+
"unknown_pid": g.unknown_pid,
|
|
727
|
+
}
|
|
728
|
+
if g.breaker is not None:
|
|
729
|
+
out["loop_breaker"] = g.breaker.stats()
|
|
730
|
+
return out
|