proxyctl 0.5.5__tar.gz → 0.5.7__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.5 → proxyctl-0.5.7}/PKG-INFO +1 -1
- {proxyctl-0.5.5 → proxyctl-0.5.7}/man/proxyctl.1 +25 -4
- {proxyctl-0.5.5 → proxyctl-0.5.7}/pyproject.toml +1 -1
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/_io.py +2 -2
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/builtin_plugins/connectivity_basic.py +11 -4
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/check.py +117 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/cli.py +9 -2
- proxyctl-0.5.7/src/proxyctl/connections.py +908 -0
- proxyctl-0.5.7/src/proxyctl/connections_filters.py +317 -0
- proxyctl-0.5.7/src/proxyctl/connections_human.py +239 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/core/plugin.py +1 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/explain.py +51 -8
- proxyctl-0.5.7/src/proxyctl/traffic.py +984 -0
- proxyctl-0.5.7/src/proxyctl/traffic_store.py +257 -0
- proxyctl-0.5.5/src/proxyctl/connections.py +0 -466
- {proxyctl-0.5.5 → proxyctl-0.5.7}/.gitignore +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/LICENSE +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/README.md +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/autostart.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/completion.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/status.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/subscription.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/suggest.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/suggest_rules.py +0 -0
- {proxyctl-0.5.5 → proxyctl-0.5.7}/src/proxyctl/trace.py +0 -0
|
@@ -131,11 +131,32 @@ Linux systemctl --user)。
|
|
|
131
131
|
4 阶段健康检查:basic / groups / connectivity / outbound-ip。
|
|
132
132
|
\fB--plain\fR 输出 TSV(stage / ok / detail)。
|
|
133
133
|
.TP
|
|
134
|
-
\fBproxyctl connections\fR [\fB--app\fR \fINAME\fR ...] [\fB--all\fR] [\fB--json\fR]
|
|
134
|
+
\fBproxyctl connections\fR [\fB--host\fR \fITEXT\fR ...] [\fB--chain\fR \fITEXT\fR ...] [\fB--route\fR \fIKIND\fR ...] [\fB--preset\fR \fINAME\fR ...] [\fB--agent\fR \fINAME\fR ...] [\fB--query\fR \fITEXT\fR ...] [\fB--app\fR \fINAME\fR ...] [\fB--all\fR] [\fB--json\fR]
|
|
135
135
|
把本机 App/进程的 TCP 连接与 \fIproxy_port\fR、mihomo \fB/connections\fR
|
|
136
|
-
API 的 \fIsourcePort\fR
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
API 的 \fIsourcePort\fR 连接详情关联。默认不做 AI 工具过滤,输出全部可关联
|
|
137
|
+
目的站点;\fB--host\fR 按 host/rulePayload/IP 过滤,\fB--chain\fR /
|
|
138
|
+
\fB--line\fR 按 Mihomo 链路节点过滤,\fB--route\fR 按 route_kind 过滤
|
|
139
|
+
(proxy/direct/reject/unknown,也接受 代理/直连/拒绝/未知),\fB--preset\fR
|
|
140
|
+
使用预设(如 ai/openai/anthropic/coding),\fB--agent\fR 按工具过滤(如
|
|
141
|
+
codex/claude/chatgpt/openclaw),\fB--query\fR / \fB--filter\fR 做全文过滤。
|
|
142
|
+
\fB--app\fR 保留兼容,用于进程/App 过滤;\fB--all\fR 为兼容 no-op。
|
|
143
|
+
macOS 上还会用 \fBnetstat -anv\fR 反查被
|
|
144
|
+
Network/System Extension 接管后的 socket owner,并在 JSON 中输出
|
|
145
|
+
\fBproxy_owner_connections\fR 与 \fBproxy_owner_groups\fR。后者按
|
|
146
|
+
rulePayload / host / IP 聚合目的地,输出 app_contexts / candidate_contexts,
|
|
147
|
+
并标出 mixed_chains / mixed_route_kind。
|
|
148
|
+
只读;非 mihomo 后端或本地 API 不可用时降级输出 unmatched。
|
|
149
|
+
.TP
|
|
150
|
+
\fBproxyctl traffic\fR [snapshot|sample|watch|report] [\fB--by\fR \fIline,app\fR] [\fB--interval\fR \fISEC\fR] [\fB--count\fR \fIN\fR] [\fB--since\fR \fI1h\fR] [\fB--store\fR \fIDIR\fR] [\fB--host\fR \fITEXT\fR ...] [\fB--chain\fR \fITEXT\fR ...] [\fB--route\fR \fIKIND\fR ...] [\fB--preset\fR \fINAME\fR ...] [\fB--agent\fR \fINAME\fR ...] [\fB--json\fR]
|
|
151
|
+
按当前活跃 Mihomo \fB/connections\fR 的 upload/download 统计流量快照。
|
|
152
|
+
默认 \fB--by line\fR 按首个链路节点统计线路;也可用
|
|
153
|
+
\fB--by line,app\fR 进一步按软件归因。软件归因分可信度:
|
|
154
|
+
可见进程为 process/socket-owner,被 Network/System Extension 遮蔽时按目的域名
|
|
155
|
+
给出 inferred 候选。\fBsample\fR 记录一次连接计数器增量,
|
|
156
|
+
\fBwatch\fR 按间隔重复记录,\fBreport\fR 读取已记录增量并按
|
|
157
|
+
\fB--since\fR 汇总。人类输出模式下 \fBwatch\fR 每次采样都会实时打印一行进度;
|
|
158
|
+
\fB--json\fR 保持最终单个 envelope。首次看到的连接只建立基线,不回填此前已经
|
|
159
|
+
产生的字节;未运行 sample/watch 的时间段无法补算。
|
|
139
160
|
.TP
|
|
140
161
|
\fBproxyctl trace\fR \fIDOMAIN\fR [\fB--json\fR]
|
|
141
162
|
4 阶段域名链路诊断:DNS 解析 / 规则匹配预测 / 连通性 / 实际连接。
|
|
@@ -72,7 +72,7 @@ _COLOR_NAMES = ("RED", "GREEN", "YELLOW", "CYAN", "BOLD", "DIM", "NC")
|
|
|
72
72
|
_COLOR_MODULES_KNOWN = (
|
|
73
73
|
"proxyctl.cli", "proxyctl.status", "proxyctl.check",
|
|
74
74
|
"proxyctl.trace", "proxyctl.audit", "proxyctl.explain",
|
|
75
|
-
"proxyctl.connections",
|
|
75
|
+
"proxyctl.connections", "proxyctl.connections_human", "proxyctl.traffic",
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
|
|
@@ -399,7 +399,7 @@ def lock_paths(lock_dir: str | None = None) -> dict[str, str]:
|
|
|
399
399
|
home = os.path.expanduser("~")
|
|
400
400
|
base = lock_dir or os.path.join(home, ".config", "proxyctl")
|
|
401
401
|
# 已知锁名(与 cli._exec_with_lock 一致)
|
|
402
|
-
names = ["system", "config", "daemon"]
|
|
402
|
+
names = ["system", "config", "daemon", "traffic"]
|
|
403
403
|
return {n: os.path.join(base, f".lock.{n}") for n in names}
|
|
404
404
|
|
|
405
405
|
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
提供一组跨所有用户都通用的连通性测试点:
|
|
4
4
|
- 海外(走代理):google / github
|
|
5
5
|
- 国内(直连):baidu
|
|
6
|
+
- AI 线路:anthropic(走 claude 规则组)
|
|
6
7
|
|
|
7
|
-
本机特例(discord/
|
|
8
|
+
本机特例(discord/telegram、企业内网等)请走用户插件。
|
|
8
9
|
|
|
9
10
|
代理组:默认提供 "proxy"(绝大多数 mihomo/sing-box 配置都有这个组)。
|
|
10
11
|
其他组(claude / residential-* 等)由用户插件提供。
|
|
@@ -24,12 +25,18 @@ class ConnectivityBasic(Plugin):
|
|
|
24
25
|
CheckTarget(name="google", url="https://www.google.com", mode="proxy"),
|
|
25
26
|
CheckTarget(name="github", url="https://github.com", mode="proxy"),
|
|
26
27
|
CheckTarget(name="baidu", url="https://www.baidu.com", mode="direct"),
|
|
28
|
+
CheckTarget(name="anthropic",
|
|
29
|
+
url="https://api.anthropic.com",
|
|
30
|
+
mode="proxy",
|
|
31
|
+
expected_proxy="claude"),
|
|
27
32
|
]
|
|
28
33
|
|
|
29
34
|
def check_outbound_probes(self, ctx: dict) -> list[OutboundProbe]:
|
|
30
|
-
# 只提供 proxy 出口探测——direct 探测严重依赖本地网络环境
|
|
31
|
-
# (国内用户用 myip.ipip.net 这种国内服务才稳,海外用户用 ipify 更好),
|
|
32
|
-
# 因此 direct 探测由用户插件提供。
|
|
33
35
|
return [
|
|
34
36
|
OutboundProbe(name="proxy", mode="proxy", url="https://api.ipify.org"),
|
|
37
|
+
OutboundProbe(name="claude", mode="proxy", url="https://ipinfo.io/ip"),
|
|
38
|
+
OutboundProbe(name="direct",
|
|
39
|
+
mode="direct",
|
|
40
|
+
url="https://myip.ipip.net",
|
|
41
|
+
extract_re=r"\d+\.\d+\.\d+\.\d+"),
|
|
35
42
|
]
|
|
@@ -76,6 +76,43 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
|
|
|
76
76
|
return False, f" {YELLOW}?{NC} {desc:<18s} {url:<44s} {YELLOW}{code}{NC}"
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def _api_connections(api_base: str, api_secret: str) -> list[dict]:
|
|
80
|
+
r = subprocess.run(
|
|
81
|
+
["curl", "-s", "--noproxy", "*",
|
|
82
|
+
"-H", f"Authorization: Bearer {api_secret}",
|
|
83
|
+
f"{api_base.rstrip('/')}/connections"],
|
|
84
|
+
capture_output=True, text=True, timeout=2
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(r.stdout or "{}")
|
|
88
|
+
except Exception:
|
|
89
|
+
return []
|
|
90
|
+
conns = data.get("connections")
|
|
91
|
+
return conns if isinstance(conns, list) else []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _target_uses_expected_proxy(api_base: str, api_secret: str, url: str,
|
|
95
|
+
expected_proxy: str) -> tuple[bool, str]:
|
|
96
|
+
host = urllib.parse.urlparse(url).hostname or ""
|
|
97
|
+
if not host or not expected_proxy:
|
|
98
|
+
return True, ""
|
|
99
|
+
|
|
100
|
+
for _ in range(3):
|
|
101
|
+
for conn in _api_connections(api_base, api_secret):
|
|
102
|
+
meta = conn.get("metadata") or {}
|
|
103
|
+
conn_host = meta.get("host") or ""
|
|
104
|
+
if conn_host != host and not conn_host.endswith("." + host):
|
|
105
|
+
continue
|
|
106
|
+
chains = conn.get("chains") or []
|
|
107
|
+
actual = chains[-1] if chains else ""
|
|
108
|
+
chain_str = " → ".join(reversed(chains)) if chains else "?"
|
|
109
|
+
if actual.lower() == expected_proxy.lower():
|
|
110
|
+
return True, f" via {chain_str}"
|
|
111
|
+
return False, f" expected {expected_proxy}, got {actual or '?'}"
|
|
112
|
+
time.sleep(0.2)
|
|
113
|
+
return False, f" expected {expected_proxy}, no active connection found"
|
|
114
|
+
|
|
115
|
+
|
|
79
116
|
def _test_dns(desc: str, server: str, domain: str) -> tuple:
|
|
80
117
|
"""通用 DNS 可达性测试:dig @server domain(用于 CheckTarget mode='dns')。
|
|
81
118
|
|
|
@@ -102,6 +139,73 @@ def _test_tcp(host: str, port: int, desc: str) -> tuple:
|
|
|
102
139
|
return False, f" {RED}✗{NC} {desc:<18s} {addr:<44s} {RED}unreachable{NC}"
|
|
103
140
|
|
|
104
141
|
|
|
142
|
+
def _rule_target_groups_from_config(config_path: str) -> list[str]:
|
|
143
|
+
"""Return proxy groups that are used as mihomo rule targets.
|
|
144
|
+
|
|
145
|
+
`check` is a maintenance view: if a top-level rule can route traffic into a
|
|
146
|
+
group, that group should be visible even when it is not a child of `proxy`.
|
|
147
|
+
"""
|
|
148
|
+
if not config_path or not os.path.isfile(config_path):
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
import yaml
|
|
153
|
+
with open(config_path, encoding="utf-8") as f:
|
|
154
|
+
data = yaml.safe_load(f) or {}
|
|
155
|
+
except Exception:
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
group_names: set[str] = set()
|
|
162
|
+
for group in data.get("proxy-groups") or []:
|
|
163
|
+
if isinstance(group, dict) and isinstance(group.get("name"), str):
|
|
164
|
+
group_names.add(group["name"])
|
|
165
|
+
|
|
166
|
+
if not group_names:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
# Mihomo rule strings use comma-separated fields; the policy is normally
|
|
170
|
+
# the last field, except trailing options such as `no-resolve`.
|
|
171
|
+
option_tokens = {"no-resolve", "src", "dst", "in", "out"}
|
|
172
|
+
targets: list[str] = []
|
|
173
|
+
seen: set[str] = set()
|
|
174
|
+
|
|
175
|
+
def add_target(name: str) -> None:
|
|
176
|
+
if name in group_names and name not in seen:
|
|
177
|
+
seen.add(name)
|
|
178
|
+
targets.append(name)
|
|
179
|
+
|
|
180
|
+
for rule in data.get("rules") or []:
|
|
181
|
+
if isinstance(rule, str):
|
|
182
|
+
parts = [p.strip() for p in rule.split(",") if p.strip()]
|
|
183
|
+
for token in reversed(parts[1:]):
|
|
184
|
+
if token.lower() in option_tokens:
|
|
185
|
+
continue
|
|
186
|
+
add_target(token)
|
|
187
|
+
break
|
|
188
|
+
elif isinstance(rule, dict):
|
|
189
|
+
for key in ("policy", "target", "proxy", "outbound"):
|
|
190
|
+
value = rule.get(key)
|
|
191
|
+
if isinstance(value, str):
|
|
192
|
+
add_target(value)
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
return targets
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _merge_check_groups(base: list[str] | None, rule_targets: list[str]) -> list[str]:
|
|
199
|
+
"""Merge plugin groups and config-derived rule target groups preserving order."""
|
|
200
|
+
merged: list[str] = []
|
|
201
|
+
seen: set[str] = set()
|
|
202
|
+
for name in list(base or []) + list(rule_targets or []):
|
|
203
|
+
if name and name not in seen:
|
|
204
|
+
seen.add(name)
|
|
205
|
+
merged.append(name)
|
|
206
|
+
return merged
|
|
207
|
+
|
|
208
|
+
|
|
105
209
|
def _proxy_groups_section(api_base: str, api_secret: str,
|
|
106
210
|
groups: list[str] | None = None,
|
|
107
211
|
collect_into: list | None = None) -> bool:
|
|
@@ -909,6 +1013,10 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
909
1013
|
groups = []
|
|
910
1014
|
if registry is not None:
|
|
911
1015
|
groups = registry.collect("check_groups")
|
|
1016
|
+
groups = _merge_check_groups(
|
|
1017
|
+
groups,
|
|
1018
|
+
_rule_target_groups_from_config(getattr(engine, "config_file", "")),
|
|
1019
|
+
)
|
|
912
1020
|
groups_data: list = []
|
|
913
1021
|
_proxy_groups_section(api, api_secret, groups=groups,
|
|
914
1022
|
collect_into=groups_data if as_json else None)
|
|
@@ -975,6 +1083,15 @@ def cmd_check(engine, api: str, api_secret: str,
|
|
|
975
1083
|
ok, line = _test_url(target.url, target.name,
|
|
976
1084
|
target.mode, target.timeout,
|
|
977
1085
|
proxy_port=proxy_port)
|
|
1086
|
+
expected = getattr(target, "expected_proxy", "")
|
|
1087
|
+
if ok and expected:
|
|
1088
|
+
route_ok, route_msg = _target_uses_expected_proxy(
|
|
1089
|
+
api, api_secret, target.url, expected)
|
|
1090
|
+
ok = route_ok
|
|
1091
|
+
if route_ok:
|
|
1092
|
+
line += f" {GREEN}{route_msg}{NC}"
|
|
1093
|
+
else:
|
|
1094
|
+
line += f" {RED}{route_msg}{NC}"
|
|
978
1095
|
except Exception as e:
|
|
979
1096
|
ok, line = False, f" {RED}✗{NC} {target.name} error: {e}"
|
|
980
1097
|
results[idx] = (line, ok)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
支持 Mihomo 后端(首发)/ Sing-box 后端(预留)
|
|
4
4
|
|
|
5
|
-
用法:proxyctl [start|stop|restart|status|log|check|
|
|
6
|
-
mode|audit|trace|bench|dns-lock|dns-unlock]
|
|
5
|
+
用法:proxyctl [start|stop|restart|status|log|check|connections|traffic|
|
|
6
|
+
fix|recover|mode|audit|trace|bench|dns-lock|dns-unlock]
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import functools
|
|
@@ -1680,6 +1680,8 @@ def cmd_version_print() -> None:
|
|
|
1680
1680
|
"doctor_since_filter": True, # 0.5.0: --since <version> 屏蔽未来规则
|
|
1681
1681
|
"suggestions_ignore_file": True, # 0.5.0: ~/.config/proxyctl/suggestions.ignore
|
|
1682
1682
|
"proxy_group_dead_check": True, # 0.5.0: proxy_group.mostly_dead 规则
|
|
1683
|
+
"traffic_snapshot": True, # 0.5.6+: active /connections traffic grouping
|
|
1684
|
+
"traffic_history": True, # 0.5.6+: sample/watch/report recorded deltas
|
|
1683
1685
|
},
|
|
1684
1686
|
}
|
|
1685
1687
|
_io.emit_json(_io.envelope("version", data=data))
|
|
@@ -2749,6 +2751,10 @@ def _h_connections(ctx):
|
|
|
2749
2751
|
from proxyctl.connections import cmd_connections
|
|
2750
2752
|
cmd_connections(ctx["args"], ctx["backend"], ctx["config"])
|
|
2751
2753
|
|
|
2754
|
+
def _h_traffic(ctx):
|
|
2755
|
+
from proxyctl.traffic import cmd_traffic
|
|
2756
|
+
cmd_traffic(ctx["args"], ctx["backend"], ctx["config"])
|
|
2757
|
+
|
|
2752
2758
|
def _h_bench(ctx):
|
|
2753
2759
|
from proxyctl.check import cmd_bench
|
|
2754
2760
|
bench_groups = ctx["args"] or None
|
|
@@ -2916,6 +2922,7 @@ DISPATCH: dict = {
|
|
|
2916
2922
|
"log": _h_log,
|
|
2917
2923
|
"check": _h_check,
|
|
2918
2924
|
"connections": _h_connections,
|
|
2925
|
+
"traffic": _h_traffic,
|
|
2919
2926
|
"bench": _h_bench,
|
|
2920
2927
|
"fix": _h_fix,
|
|
2921
2928
|
"recover": _h_recover,
|