proxyctl 0.5.9__tar.gz → 0.5.10__tar.gz
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.
- {proxyctl-0.5.9 → proxyctl-0.5.10}/PKG-INFO +1 -1
- {proxyctl-0.5.9 → proxyctl-0.5.10}/pyproject.toml +1 -1
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/audit.py +28 -11
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/builtin_plugins/connectivity_basic.py +8 -5
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/check.py +66 -15
- {proxyctl-0.5.9 → proxyctl-0.5.10}/.gitignore +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/LICENSE +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/README.md +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/man/proxyctl.1 +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/_io.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/autostart.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/cli.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/completion.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/connections.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/connections_filters.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/connections_human.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/core/plugin.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/explain.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/status.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/subscription.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/suggest.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/suggest_rules.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/trace.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/traffic.py +0 -0
- {proxyctl-0.5.9 → proxyctl-0.5.10}/src/proxyctl/traffic_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyctl
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.10
|
|
4
4
|
Summary: Proxy configuration lifecycle management for macOS and Linux
|
|
5
5
|
Project-URL: Homepage, https://github.com/crhan/proxyctl
|
|
6
6
|
Project-URL: Issues, https://github.com/crhan/proxyctl/issues
|
|
@@ -180,8 +180,18 @@ def _is_covered(host: str, suffix_set: set) -> bool:
|
|
|
180
180
|
return any(".".join(parts[i:]) in suffix_set for i in range(len(parts)))
|
|
181
181
|
|
|
182
182
|
|
|
183
|
+
_AUDIT_DENY_OUTBOUNDS = {"block", "reject"}
|
|
184
|
+
|
|
185
|
+
|
|
183
186
|
def _load_rules() -> tuple:
|
|
184
|
-
"""从双 config 读取 direct/proxy 域名后缀规则集。
|
|
187
|
+
"""从双 config 读取 direct/proxy 域名后缀规则集。
|
|
188
|
+
|
|
189
|
+
分类策略:``direct`` outbound 归 direct;``block`` / ``reject`` 忽略;
|
|
190
|
+
其它任意 outbound 名(用户的命名组如 ``proxy`` / ``residential-tw`` /
|
|
191
|
+
个人命名组等)一律视为"代理类",归入 ``proxy_suffixes``。
|
|
192
|
+
这里**不 hardcode 任何具体的代理组名**——通用基线插件和 audit 都不
|
|
193
|
+
应假设用户配置里有某个特定名字的组。
|
|
194
|
+
"""
|
|
185
195
|
direct_suffixes: set = set()
|
|
186
196
|
proxy_suffixes: set = set()
|
|
187
197
|
|
|
@@ -189,12 +199,15 @@ def _load_rules() -> tuple:
|
|
|
189
199
|
try:
|
|
190
200
|
cfg = json.load(open(SB_CONFIG))
|
|
191
201
|
for rule in cfg.get("route", {}).get("rules", []):
|
|
192
|
-
ob = rule.get("outbound"
|
|
202
|
+
ob = (rule.get("outbound") or "").strip()
|
|
203
|
+
if not ob:
|
|
204
|
+
continue
|
|
205
|
+
ob_lc = ob.lower()
|
|
193
206
|
for s in rule.get("domain_suffix", []):
|
|
194
207
|
s = s.lstrip(".")
|
|
195
|
-
if
|
|
208
|
+
if ob_lc == "direct":
|
|
196
209
|
direct_suffixes.add(s)
|
|
197
|
-
elif
|
|
210
|
+
elif ob_lc not in _AUDIT_DENY_OUTBOUNDS:
|
|
198
211
|
proxy_suffixes.add(s)
|
|
199
212
|
except Exception:
|
|
200
213
|
pass
|
|
@@ -202,13 +215,17 @@ def _load_rules() -> tuple:
|
|
|
202
215
|
# mihomo YAML (文本扫描,无需解析完整 YAML)
|
|
203
216
|
try:
|
|
204
217
|
for line in open(MH_CONFIG):
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
218
|
+
# group(2) 取第一个 outbound 段(到下一个逗号或空白),
|
|
219
|
+
# 避免把 "proxy,no-resolve" 整体当作 outbound 名。
|
|
220
|
+
m = re.match(r'\s*-\s+DOMAIN-SUFFIX,([^,]+),([^,\s]+)', line, re.I)
|
|
221
|
+
if not m:
|
|
222
|
+
continue
|
|
223
|
+
dom, target = m.group(1), m.group(2)
|
|
224
|
+
target_lc = target.lower()
|
|
225
|
+
if target_lc == "direct":
|
|
226
|
+
direct_suffixes.add(dom)
|
|
227
|
+
elif target_lc not in _AUDIT_DENY_OUTBOUNDS:
|
|
228
|
+
proxy_suffixes.add(dom)
|
|
212
229
|
except Exception:
|
|
213
230
|
pass
|
|
214
231
|
|
|
@@ -3,12 +3,17 @@
|
|
|
3
3
|
提供一组跨所有用户都通用的连通性测试点:
|
|
4
4
|
- 海外(走代理):google / github
|
|
5
5
|
- 国内(直连):baidu
|
|
6
|
-
- AI 线路:openai / anthropic
|
|
6
|
+
- AI 线路:openai / anthropic
|
|
7
7
|
|
|
8
8
|
本机特例(discord/telegram、企业内网等)请走用户插件。
|
|
9
9
|
|
|
10
10
|
代理组:默认提供 "proxy"(绝大多数 mihomo/sing-box 配置都有这个组)。
|
|
11
|
-
|
|
11
|
+
命名组(如 claude / residential-* 等)和针对它们的 expected_proxy 校验、
|
|
12
|
+
出口探测都属本机特例,请放到 ~/.config/proxyctl/plugins/ 的用户插件里,
|
|
13
|
+
不要 hardcode 进这个通用基线插件。
|
|
14
|
+
|
|
15
|
+
出口探测:默认只提供 proxy(走主代理)/ direct(绕代理)两路对照,
|
|
16
|
+
足够做分流校验;其它命名出口由用户插件按 name 追加或覆盖。
|
|
12
17
|
"""
|
|
13
18
|
|
|
14
19
|
from proxyctl.core.plugin import CheckTarget, OutboundProbe, Plugin
|
|
@@ -30,14 +35,12 @@ class ConnectivityBasic(Plugin):
|
|
|
30
35
|
CheckTarget(name="baidu", url="https://www.baidu.com", mode="direct"),
|
|
31
36
|
CheckTarget(name="anthropic",
|
|
32
37
|
url="https://api.anthropic.com/v1/models",
|
|
33
|
-
mode="proxy",
|
|
34
|
-
expected_proxy="claude"),
|
|
38
|
+
mode="proxy"),
|
|
35
39
|
]
|
|
36
40
|
|
|
37
41
|
def check_outbound_probes(self, ctx: dict) -> list[OutboundProbe]:
|
|
38
42
|
return [
|
|
39
43
|
OutboundProbe(name="proxy", mode="proxy", url="https://api.ipify.org"),
|
|
40
|
-
OutboundProbe(name="claude", mode="proxy", url="https://ipinfo.io/ip"),
|
|
41
44
|
OutboundProbe(name="direct",
|
|
42
45
|
mode="direct",
|
|
43
46
|
url="https://myip.ipip.net",
|
|
@@ -35,6 +35,11 @@ def _strip_ansi(s: str) -> str:
|
|
|
35
35
|
return _ANSI_RE.sub("", s).strip()
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def _visible_len(s: str) -> int:
|
|
39
|
+
"""Return printable width for simple check output alignment."""
|
|
40
|
+
return len(_ANSI_RE.sub("", s))
|
|
41
|
+
|
|
42
|
+
|
|
38
43
|
def _port_listening(port: int) -> bool:
|
|
39
44
|
try:
|
|
40
45
|
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
@@ -162,11 +167,15 @@ def _target_uses_expected_proxy(api_base: str, api_secret: str, url: str,
|
|
|
162
167
|
|
|
163
168
|
|
|
164
169
|
def _connectivity_line_value(mode: str) -> str:
|
|
165
|
-
"""Return the default route-line label for one check target mode.
|
|
170
|
+
"""Return the default route-line label for one check target mode.
|
|
171
|
+
|
|
172
|
+
proxy 模式默认值是 em dash ("—") 而非问号——含义是"反查不到活动连接",
|
|
173
|
+
比裸 "?" 信息密度更高、视觉更安静。真实反查命中时会被实际链路覆盖。
|
|
174
|
+
"""
|
|
166
175
|
if mode == "direct":
|
|
167
176
|
return "direct"
|
|
168
177
|
if mode == "proxy":
|
|
169
|
-
return "
|
|
178
|
+
return "—"
|
|
170
179
|
return "-"
|
|
171
180
|
|
|
172
181
|
|
|
@@ -174,7 +183,8 @@ def _append_route_column(line: str, route_line: str,
|
|
|
174
183
|
route_chain: str = "") -> str:
|
|
175
184
|
"""Append the observed route in the same style as Mihomo chains."""
|
|
176
185
|
value = route_chain or route_line or "-"
|
|
177
|
-
|
|
186
|
+
pad = max(2, 74 - _visible_len(line))
|
|
187
|
+
return f"{line}{' ' * pad}{DIM}via{NC} {value:<32s}"
|
|
178
188
|
|
|
179
189
|
|
|
180
190
|
def _dedupe_check_targets(targets: list) -> list:
|
|
@@ -202,17 +212,23 @@ def _dedupe_check_targets(targets: list) -> list:
|
|
|
202
212
|
|
|
203
213
|
|
|
204
214
|
def _dedupe_outbound_probes(probes: list) -> list:
|
|
205
|
-
"""
|
|
206
|
-
|
|
207
|
-
|
|
215
|
+
"""Collapse outbound probes that share a (name, mode) identity.
|
|
216
|
+
|
|
217
|
+
A probe answers one question — "what IP does this egress show?" — so two
|
|
218
|
+
probes with the same ``name`` and ``mode`` are the same egress row even if
|
|
219
|
+
they hit different URLs (e.g. a builtin ``https://myip.ipip.net`` vs a user
|
|
220
|
+
plugin's ``http://`` variant). User plugins load after builtins, so the
|
|
221
|
+
later probe wins on conflict (本机特例覆盖通用基线), while the original
|
|
222
|
+
display position is preserved so row order stays stable across runs.
|
|
223
|
+
"""
|
|
224
|
+
out: list = []
|
|
225
|
+
index: dict = {} # (name, mode) -> position in out
|
|
208
226
|
for probe in probes:
|
|
209
|
-
key = (getattr(probe, "name", ""),
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
getattr(probe, "extract_re", ""))
|
|
213
|
-
if key in seen:
|
|
227
|
+
key = (getattr(probe, "name", ""), getattr(probe, "mode", ""))
|
|
228
|
+
if key in index:
|
|
229
|
+
out[index[key]] = probe # later (user) probe overrides earlier
|
|
214
230
|
continue
|
|
215
|
-
|
|
231
|
+
index[key] = len(out)
|
|
216
232
|
out.append(probe)
|
|
217
233
|
return out
|
|
218
234
|
|
|
@@ -1192,14 +1208,49 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
1192
1208
|
parts = target.url.removeprefix("tcp:").rsplit(":", 1)
|
|
1193
1209
|
ok, line = _test_tcp(parts[0], int(parts[1]), target.name)
|
|
1194
1210
|
else:
|
|
1211
|
+
# proxy 模式:在 curl 测试**进行中**并发轮询 mihomo 的
|
|
1212
|
+
# /connections,趁连接还活着抓到自己请求走的链路。
|
|
1213
|
+
# curl 短连接测完即从 mihomo 连接表移除,如果等 curl 结束
|
|
1214
|
+
# 后再查(旧实现),几乎抓不到自己刚发的请求 —— 这是
|
|
1215
|
+
# google/github/openai 的 via 列长期显示 "?" 的根因。
|
|
1216
|
+
host = urllib.parse.urlparse(target.url).hostname or ""
|
|
1217
|
+
captured: dict = {}
|
|
1218
|
+
stop_evt = threading.Event()
|
|
1219
|
+
poller = None
|
|
1220
|
+
if target.mode == "proxy" and host:
|
|
1221
|
+
def _poll():
|
|
1222
|
+
# Event.wait(timeout) 在 set 时立即返回 True,
|
|
1223
|
+
# 不会被 sleep 拖住停止响应。
|
|
1224
|
+
while not stop_evt.is_set():
|
|
1225
|
+
r = _route_from_connections(
|
|
1226
|
+
_api_connections(api, api_secret), host)
|
|
1227
|
+
if r.get("found"):
|
|
1228
|
+
captured.update(r)
|
|
1229
|
+
return
|
|
1230
|
+
stop_evt.wait(0.08)
|
|
1231
|
+
poller = threading.Thread(target=_poll, daemon=True)
|
|
1232
|
+
poller.start()
|
|
1233
|
+
|
|
1195
1234
|
ok, line = _test_url(target.url, target.name,
|
|
1196
1235
|
target.mode, target.timeout,
|
|
1197
1236
|
proxy_port=proxy_port)
|
|
1237
|
+
|
|
1238
|
+
if poller is not None:
|
|
1239
|
+
stop_evt.set()
|
|
1240
|
+
poller.join(timeout=0.5)
|
|
1241
|
+
|
|
1198
1242
|
expected = getattr(target, "expected_proxy", "")
|
|
1199
1243
|
if target.mode == "proxy" and ok:
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1244
|
+
# 优先取 curl 期间并发抓到的;抓不到时回退 _target_route,
|
|
1245
|
+
# 它可能从外部进程的活动长连接里命中(如用户的浏览器/
|
|
1246
|
+
# Claude Code 正在访问同一 host)。两者都没找到时
|
|
1247
|
+
# route_line 保留默认的 "—" 占位(_connectivity_line_value
|
|
1248
|
+
# 提供的友好兜底),而不是裸 "?"。
|
|
1249
|
+
route = (captured if captured.get("found")
|
|
1250
|
+
else _target_route(api, api_secret, target.url))
|
|
1251
|
+
if route.get("found"):
|
|
1252
|
+
route_line = str(route.get("line") or route_line)
|
|
1253
|
+
route_chain = str(route.get("chain") or "")
|
|
1203
1254
|
if expected:
|
|
1204
1255
|
route_ok, route_msg = _route_matches_expected_proxy(
|
|
1205
1256
|
route, expected)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|