proxyctl 0.5.4__tar.gz → 0.5.5__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.4 → proxyctl-0.5.5}/PKG-INFO +1 -1
- {proxyctl-0.5.4 → proxyctl-0.5.5}/man/proxyctl.1 +6 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/pyproject.toml +1 -1
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/_io.py +1 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/check.py +42 -17
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/cli.py +25 -7
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/completion.py +7 -6
- proxyctl-0.5.5/src/proxyctl/connections.py +466 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/explain.py +13 -4
- {proxyctl-0.5.4 → proxyctl-0.5.5}/.gitignore +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/LICENSE +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/README.md +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/autostart.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/core/plugin.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/status.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/subscription.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/suggest.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/suggest_rules.py +0 -0
- {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/trace.py +0 -0
|
@@ -131,6 +131,12 @@ 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]
|
|
135
|
+
把本机 App/进程的 TCP 连接与 \fIproxy_port\fR、mihomo \fB/connections\fR
|
|
136
|
+
API 的 \fIsourcePort\fR 连接详情关联。默认过滤 Codex / Claude / ChatGPT;
|
|
137
|
+
\fB--all\fR 查看所有进程。只读;非 mihomo 后端或本地 API 不可用时降级输出
|
|
138
|
+
unmatched。
|
|
139
|
+
.TP
|
|
134
140
|
\fBproxyctl trace\fR \fIDOMAIN\fR [\fB--json\fR]
|
|
135
141
|
4 阶段域名链路诊断:DNS 解析 / 规则匹配预测 / 连通性 / 实际连接。
|
|
136
142
|
.TP
|
|
@@ -251,17 +251,9 @@ def _proxy_groups_section(api_base: str, api_secret: str,
|
|
|
251
251
|
print(f" {CYAN}{gname}{NC}({gtype}) → {BOLD}{gnow}{NC} "
|
|
252
252
|
f"{ds(gnow_d)}ms [{count_str} of {total}]{tested_str}")
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
sub = (proxies.get(gnow) if g.get("type") in ("Selector", "Fallback")
|
|
258
|
-
else None)
|
|
259
|
-
now_is_subgroup = bool(sub and sub.get("all"))
|
|
260
|
-
if g.get("type") in ("Selector", "Fallback") and not now_is_subgroup:
|
|
261
|
-
print_members(gmembers, gnow)
|
|
262
|
-
elif g.get("type") not in ("Selector", "Fallback"):
|
|
263
|
-
print_members(gmembers, gnow)
|
|
264
|
-
if now_is_subgroup:
|
|
254
|
+
def print_sub_summary(sub_name: str, sub: dict, is_now: bool) -> bool:
|
|
255
|
+
"""打印一行子组 summary(含 → now 标记 + 健康度 + tested ago)。
|
|
256
|
+
返回该子组之前是否已展开过叶子。"""
|
|
265
257
|
sub_type = "url" if sub.get("type") == "URLTest" else "sel"
|
|
266
258
|
sub_now = sub.get("now", "?")
|
|
267
259
|
sub_now_d = get_delay(sub_now)
|
|
@@ -275,13 +267,46 @@ def _proxy_groups_section(api_base: str, api_secret: str,
|
|
|
275
267
|
if s_nodata: sc.append(f"{YELLOW}{s_nodata}—{NC}")
|
|
276
268
|
st = group_tested_ago(sub_members)
|
|
277
269
|
st_str = f" {DIM}{st}{NC}" if st else ""
|
|
278
|
-
already =
|
|
270
|
+
already = sub_name in expanded_subgroups
|
|
279
271
|
tail = f" {DIM}(详见上方){NC}" if already else ""
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
272
|
+
marker = "→" if is_now else " "
|
|
273
|
+
print(f" {marker}{CYAN}{sub_name}{NC}({sub_type}) → "
|
|
274
|
+
f"{BOLD}{sub_now}{NC} "
|
|
275
|
+
f"{ds(sub_now_d)}ms [{'/'.join(sc)} of {len(sub_members)}]"
|
|
276
|
+
f"{st_str}{tail}")
|
|
277
|
+
return already
|
|
278
|
+
|
|
279
|
+
# v0.5.5:selector/fallback 含子组时,每个子组都打一行 summary(让
|
|
280
|
+
# 用户看到兄弟子组存在 + 健康度),但只对 now 子组展开叶子(保留
|
|
281
|
+
# v0.5.3 精简意图)。混在子组里的真节点(如 local-N)按 4 列表格
|
|
282
|
+
# flush。selector/fallback 全为真节点 / 其他类型组:原 print_members
|
|
283
|
+
# 行为不变。
|
|
284
|
+
if g.get("type") in ("Selector", "Fallback"):
|
|
285
|
+
has_subgroup = any(proxies.get(m, {}).get("all") for m in gmembers)
|
|
286
|
+
if not has_subgroup:
|
|
287
|
+
print_members(gmembers, gnow)
|
|
288
|
+
else:
|
|
289
|
+
leaf_buf: list = []
|
|
290
|
+
|
|
291
|
+
def flush_leaves():
|
|
292
|
+
if leaf_buf:
|
|
293
|
+
print_members(leaf_buf, gnow)
|
|
294
|
+
leaf_buf.clear()
|
|
295
|
+
|
|
296
|
+
for m in gmembers:
|
|
297
|
+
sub = proxies.get(m, {})
|
|
298
|
+
if sub.get("all"):
|
|
299
|
+
flush_leaves()
|
|
300
|
+
already = print_sub_summary(m, sub, is_now=(m == gnow))
|
|
301
|
+
if m == gnow and not already:
|
|
302
|
+
print_members(sub.get("all", []),
|
|
303
|
+
sub.get("now", "?"))
|
|
304
|
+
expanded_subgroups.add(m)
|
|
305
|
+
else:
|
|
306
|
+
leaf_buf.append(m)
|
|
307
|
+
flush_leaves()
|
|
308
|
+
else:
|
|
309
|
+
print_members(gmembers, gnow)
|
|
285
310
|
# 顶层组本身展开完后也标记,避免被下层组重新展开
|
|
286
311
|
expanded_subgroups.add(gname)
|
|
287
312
|
|
|
@@ -70,8 +70,11 @@ def load_config() -> dict:
|
|
|
70
70
|
user_cfg = yaml.safe_load(f) or {}
|
|
71
71
|
cfg.update(user_cfg)
|
|
72
72
|
except Exception as e:
|
|
73
|
-
print(f"{YELLOW}警告:读取配置文件失败:{e}{NC}")
|
|
74
|
-
print(
|
|
73
|
+
print(f"{YELLOW}警告:读取配置文件失败:{e}{NC}", file=sys.stderr)
|
|
74
|
+
print(
|
|
75
|
+
f" 使用默认配置,可能需要在 {CONFIG_FILE} 中配置 api_secret",
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
75
78
|
return cfg
|
|
76
79
|
|
|
77
80
|
|
|
@@ -1501,8 +1504,17 @@ def cmd_help():
|
|
|
1501
1504
|
f"{DIM}一键 JSON + 关色 + 非交互{NC}")
|
|
1502
1505
|
print()
|
|
1503
1506
|
|
|
1507
|
+
print(f"{BOLD}常用示例{NC}")
|
|
1508
|
+
print(f" {CYAN}proxyctl doctor --json{NC} "
|
|
1509
|
+
f"{DIM}最快健康基线,agent 首选{NC}")
|
|
1510
|
+
print(f" {CYAN}proxyctl mode tun -n --json{NC} "
|
|
1511
|
+
f"{DIM}预览写操作 plan,不执行{NC}")
|
|
1512
|
+
print(f" {CYAN}proxyctl help trace{NC} "
|
|
1513
|
+
f"{DIM}查看单命令用法和示例{NC}")
|
|
1514
|
+
print()
|
|
1515
|
+
|
|
1504
1516
|
print(f"{BOLD}用法{NC} proxyctl <command> [args] "
|
|
1505
|
-
f"[--json|--plain] [--dry-run] [--no-color] [--quiet]")
|
|
1517
|
+
f"[--json|--plain] [--dry-run|-n] [--no-color] [--quiet]")
|
|
1506
1518
|
print()
|
|
1507
1519
|
|
|
1508
1520
|
# 按 group 元数据驱动渲染
|
|
@@ -1533,7 +1545,7 @@ def cmd_help():
|
|
|
1533
1545
|
print(f"{BOLD}全局 flag{NC}")
|
|
1534
1546
|
print(f" --json 输出 envelope JSON(schema v2)")
|
|
1535
1547
|
print(f" --plain 输出纯 TSV(audit/check 等支持表格的命令)")
|
|
1536
|
-
print(f" --dry-run
|
|
1548
|
+
print(f" --dry-run/-n 预演写操作(输出 plan,不真正执行)")
|
|
1537
1549
|
print(f" --no-color 关闭 ANSI(也读 NO_COLOR)")
|
|
1538
1550
|
print(f" --quiet/-q 压制非关键 stderr")
|
|
1539
1551
|
print(f" --help/-h 本帮助")
|
|
@@ -1647,6 +1659,7 @@ def cmd_version_print() -> None:
|
|
|
1647
1659
|
"lock_path_in_error": True,
|
|
1648
1660
|
"side_effects_enum": True,
|
|
1649
1661
|
"dry_run": True,
|
|
1662
|
+
"dry_run_short_flag": True,
|
|
1650
1663
|
"plain": True,
|
|
1651
1664
|
"flag_position_invariant": True,
|
|
1652
1665
|
"agents_md": True,
|
|
@@ -1751,7 +1764,7 @@ def _extract_global_flags(argv: list) -> tuple:
|
|
|
1751
1764
|
"""从 argv 中剥离全局 flag,返回 (剩余 argv, flag dict)。
|
|
1752
1765
|
|
|
1753
1766
|
clig.dev 原则:flag 位置无关 — 在任意位置出现的 --json / --no-color /
|
|
1754
|
-
--quiet / -q / --dry-run / --plain 都会被识别并剥离,剩余位置参数顺序保持不变。
|
|
1767
|
+
--quiet / -q / --dry-run / -n / --plain 都会被识别并剥离,剩余位置参数顺序保持不变。
|
|
1755
1768
|
"""
|
|
1756
1769
|
flags = {
|
|
1757
1770
|
"json": False, "no_color": False, "quiet": False,
|
|
@@ -1765,7 +1778,7 @@ def _extract_global_flags(argv: list) -> tuple:
|
|
|
1765
1778
|
flags["no_color"] = True
|
|
1766
1779
|
elif a in ("--quiet", "-q"):
|
|
1767
1780
|
flags["quiet"] = True
|
|
1768
|
-
elif a
|
|
1781
|
+
elif a in ("--dry-run", "-n"):
|
|
1769
1782
|
flags["dry_run"] = True
|
|
1770
1783
|
elif a == "--plain":
|
|
1771
1784
|
flags["plain"] = True
|
|
@@ -2732,6 +2745,10 @@ def _h_check(ctx):
|
|
|
2732
2745
|
cmd_check(ctx["backend"], ctx["api_base"], ctx["api_secret"],
|
|
2733
2746
|
ctx["config"], mode_str, registry=ctx["registry"])
|
|
2734
2747
|
|
|
2748
|
+
def _h_connections(ctx):
|
|
2749
|
+
from proxyctl.connections import cmd_connections
|
|
2750
|
+
cmd_connections(ctx["args"], ctx["backend"], ctx["config"])
|
|
2751
|
+
|
|
2735
2752
|
def _h_bench(ctx):
|
|
2736
2753
|
from proxyctl.check import cmd_bench
|
|
2737
2754
|
bench_groups = ctx["args"] or None
|
|
@@ -2898,6 +2915,7 @@ DISPATCH: dict = {
|
|
|
2898
2915
|
"status": _h_status,
|
|
2899
2916
|
"log": _h_log,
|
|
2900
2917
|
"check": _h_check,
|
|
2918
|
+
"connections": _h_connections,
|
|
2901
2919
|
"bench": _h_bench,
|
|
2902
2920
|
"fix": _h_fix,
|
|
2903
2921
|
"recover": _h_recover,
|
|
@@ -2965,4 +2983,4 @@ def _suggest_command_and_exit(unknown: str) -> None:
|
|
|
2965
2983
|
|
|
2966
2984
|
|
|
2967
2985
|
if __name__ == "__main__":
|
|
2968
|
-
main()
|
|
2986
|
+
main()
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
0.3.x 起补全:
|
|
5
5
|
|
|
6
6
|
- 全局 flag: --help / --version / --json / --no-color / --quiet
|
|
7
|
-
/ --dry-run / --plain
|
|
7
|
+
/ --dry-run / -n / --plain
|
|
8
8
|
- explain <topic> → topic 列表
|
|
9
9
|
- mode <tun|proxy> → 模式
|
|
10
10
|
- engine <mihomo|singbox> → 引擎
|
|
@@ -66,7 +66,7 @@ _proxyctl_complete() {{
|
|
|
66
66
|
|
|
67
67
|
# 第二个 token = 子命令
|
|
68
68
|
if [ ${{COMP_CWORD}} -eq 1 ]; then
|
|
69
|
-
COMPREPLY=( $(compgen -W "${{cmds}} help --help --version --json --plain --dry-run --no-color --quiet" -- "${{cur}}") )
|
|
69
|
+
COMPREPLY=( $(compgen -W "${{cmds}} help --help --version --json --plain --dry-run -n --no-color --quiet" -- "${{cur}}") )
|
|
70
70
|
return 0
|
|
71
71
|
fi
|
|
72
72
|
|
|
@@ -133,7 +133,7 @@ _proxyctl_complete() {{
|
|
|
133
133
|
local dry_run_cmds=({dry_run_cmds})
|
|
134
134
|
for c in "${{dry_run_cmds[@]}}"; do
|
|
135
135
|
if [ "$first" = "$c" ]; then
|
|
136
|
-
COMPREPLY=( $(compgen -W "--dry-run --json --no-color --quiet --help" -- "${{cur}}") )
|
|
136
|
+
COMPREPLY=( $(compgen -W "--dry-run -n --json --no-color --quiet --help" -- "${{cur}}") )
|
|
137
137
|
return 0
|
|
138
138
|
fi
|
|
139
139
|
done
|
|
@@ -142,7 +142,7 @@ _proxyctl_complete() {{
|
|
|
142
142
|
local plain_cmds=({plain_cmds})
|
|
143
143
|
for c in "${{plain_cmds[@]}}"; do
|
|
144
144
|
if [ "$first" = "$c" ]; then
|
|
145
|
-
COMPREPLY=( $(compgen -W "--plain --json --dry-run --help" -- "${{cur}}") )
|
|
145
|
+
COMPREPLY=( $(compgen -W "--plain --json --dry-run -n --help" -- "${{cur}}") )
|
|
146
146
|
return 0
|
|
147
147
|
fi
|
|
148
148
|
done
|
|
@@ -160,7 +160,7 @@ _proxyctl_complete() {{
|
|
|
160
160
|
fi
|
|
161
161
|
|
|
162
162
|
# 默认 flag 补全
|
|
163
|
-
COMPREPLY=( $(compgen -W "--help --json --plain --dry-run --no-color --quiet" -- "${{cur}}") )
|
|
163
|
+
COMPREPLY=( $(compgen -W "--help --json --plain --dry-run -n --no-color --quiet" -- "${{cur}}") )
|
|
164
164
|
}}
|
|
165
165
|
complete -F _proxyctl_complete proxyctl
|
|
166
166
|
"""
|
|
@@ -204,6 +204,7 @@ _proxyctl() {{
|
|
|
204
204
|
'--json[结构化 envelope v2 输出]' \\
|
|
205
205
|
'--plain[纯 TSV 输出(audit/check);与 --json 互斥]' \\
|
|
206
206
|
'--dry-run[预演写命令;输出 data.plan]' \\
|
|
207
|
+
'-n[预演写命令;输出 data.plan]' \\
|
|
207
208
|
'--no-color[关闭 ANSI 颜色]' \\
|
|
208
209
|
'--quiet[安静模式]' \\
|
|
209
210
|
'-q[安静模式]'
|
|
@@ -258,7 +259,7 @@ def _gen_fish() -> str:
|
|
|
258
259
|
"complete -c proxyctl -l version -d '显示版本'",
|
|
259
260
|
"complete -c proxyctl -l json -d 'envelope v2 输出'",
|
|
260
261
|
"complete -c proxyctl -l plain -d 'TSV 输出(audit/check)'",
|
|
261
|
-
"complete -c proxyctl -l dry-run -d '预演写命令'",
|
|
262
|
+
"complete -c proxyctl -s n -l dry-run -d '预演写命令'",
|
|
262
263
|
"complete -c proxyctl -l no-color -d '关闭 ANSI'",
|
|
263
264
|
"complete -c proxyctl -l quiet -d '安静模式'",
|
|
264
265
|
""]
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""proxyctl connections — join local app sockets with mihomo connections.
|
|
2
|
+
|
|
3
|
+
The command is intentionally read-only: it reads lsof/ps output and, for the
|
|
4
|
+
mihomo backend, the local Clash-compatible `/connections` controller endpoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from proxyctl import _io
|
|
19
|
+
from proxyctl._io import maybe_disable_module_colors
|
|
20
|
+
|
|
21
|
+
BOLD = "\033[1m"
|
|
22
|
+
DIM = "\033[2m"
|
|
23
|
+
GREEN = "\033[0;32m"
|
|
24
|
+
YELLOW = "\033[0;33m"
|
|
25
|
+
CYAN = "\033[0;36m"
|
|
26
|
+
NC = "\033[0m"
|
|
27
|
+
|
|
28
|
+
maybe_disable_module_colors(__name__)
|
|
29
|
+
|
|
30
|
+
DEFAULT_APP_FILTERS = ["Codex", "Claude", "ChatGPT"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class LocalConnection:
|
|
35
|
+
"""One local TCP connection reported by lsof.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
pid: Owning process id.
|
|
39
|
+
app: Short process name from lsof.
|
|
40
|
+
fd: File descriptor label from lsof, for example ``12u``.
|
|
41
|
+
source_port: Local ephemeral port used to connect to proxy_port.
|
|
42
|
+
target_host: Destination host from lsof's NAME field.
|
|
43
|
+
target_port: Destination port from lsof's NAME field.
|
|
44
|
+
raw_name: Original lsof NAME field.
|
|
45
|
+
process: Full executable path from ps, when available.
|
|
46
|
+
command: Full command line from ps, when available.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
pid: int
|
|
50
|
+
app: str
|
|
51
|
+
fd: str
|
|
52
|
+
source_port: int
|
|
53
|
+
target_host: str
|
|
54
|
+
target_port: int
|
|
55
|
+
raw_name: str
|
|
56
|
+
process: str = ""
|
|
57
|
+
command: str = ""
|
|
58
|
+
|
|
59
|
+
def matches_app(self, filters: list[str]) -> bool:
|
|
60
|
+
"""Return whether this row matches any requested ``--app`` filter."""
|
|
61
|
+
if not filters:
|
|
62
|
+
return True
|
|
63
|
+
haystack = " ".join(
|
|
64
|
+
[self.app, os.path.basename(self.process), self.process, self.command]
|
|
65
|
+
).lower()
|
|
66
|
+
return any(f.lower() in haystack for f in filters)
|
|
67
|
+
|
|
68
|
+
def to_dict(self, proxy_port: int) -> dict[str, Any]:
|
|
69
|
+
"""Convert the lsof row to the JSON contract used by this command."""
|
|
70
|
+
return {
|
|
71
|
+
"app": self.app,
|
|
72
|
+
"pid": self.pid,
|
|
73
|
+
"fd": self.fd,
|
|
74
|
+
"process": self.process,
|
|
75
|
+
"local_source_port": self.source_port,
|
|
76
|
+
"target_host": self.target_host,
|
|
77
|
+
"target_port": self.target_port,
|
|
78
|
+
"connects_proxy_port": self.target_port == proxy_port,
|
|
79
|
+
"raw_lsof_name": self.raw_name,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ConnectionArgs:
|
|
85
|
+
"""Parsed arguments for ``proxyctl connections``.
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
app_filters: Process/app filters; empty means all processes.
|
|
89
|
+
all_apps: Whether ``--all`` was explicitly requested.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
app_filters: list[str]
|
|
93
|
+
all_apps: bool = False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_args(args: list[str]) -> ConnectionArgs:
|
|
97
|
+
"""Parse ``proxyctl connections`` arguments.
|
|
98
|
+
|
|
99
|
+
Supported syntax:
|
|
100
|
+
``--app NAME`` may appear more than once.
|
|
101
|
+
``--all`` disables the default AI app filter.
|
|
102
|
+
"""
|
|
103
|
+
apps: list[str] = []
|
|
104
|
+
all_apps = False
|
|
105
|
+
idx = 0
|
|
106
|
+
while idx < len(args):
|
|
107
|
+
arg = args[idx]
|
|
108
|
+
if arg == "--all":
|
|
109
|
+
all_apps = True
|
|
110
|
+
idx += 1
|
|
111
|
+
continue
|
|
112
|
+
if arg == "--app":
|
|
113
|
+
if idx + 1 >= len(args):
|
|
114
|
+
_io.fail("connections --app 需要一个应用名",
|
|
115
|
+
hint="proxyctl connections --app Codex --json",
|
|
116
|
+
doc="agent-protocol", code=_io.USAGE,
|
|
117
|
+
cmd="connections")
|
|
118
|
+
apps.append(args[idx + 1])
|
|
119
|
+
idx += 2
|
|
120
|
+
continue
|
|
121
|
+
_io.fail(f"未识别 connections 参数:{arg}",
|
|
122
|
+
hints=["proxyctl connections --app Codex --app Claude",
|
|
123
|
+
"proxyctl connections --all --json",
|
|
124
|
+
"proxyctl connections --json"],
|
|
125
|
+
doc="agent-protocol", code=_io.USAGE, cmd="connections")
|
|
126
|
+
if all_apps and apps:
|
|
127
|
+
_io.fail("connections 的 --all 与 --app 不能同时使用",
|
|
128
|
+
hint="proxyctl connections --all --json",
|
|
129
|
+
doc="agent-protocol", code=_io.USAGE, cmd="connections")
|
|
130
|
+
return ConnectionArgs(
|
|
131
|
+
app_filters=[] if all_apps else (apps or DEFAULT_APP_FILTERS),
|
|
132
|
+
all_apps=all_apps,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_lsof_name(name: str) -> tuple[int, str, int] | None:
|
|
137
|
+
"""Parse lsof NAME into ``(source_port, target_host, target_port)``.
|
|
138
|
+
|
|
139
|
+
The format differs slightly across macOS/Linux and IPv4/IPv6, but the
|
|
140
|
+
stable part for established TCP is ``local:port->remote:port``.
|
|
141
|
+
"""
|
|
142
|
+
if "->" not in name:
|
|
143
|
+
return None
|
|
144
|
+
left, right = name.split("->", 1)
|
|
145
|
+
right = right.split(" ", 1)[0]
|
|
146
|
+
source_match = re.search(r":(\d+)$", left.strip("[]"))
|
|
147
|
+
target_match = re.search(r"(.+):(\d+)$", right.strip())
|
|
148
|
+
if not source_match or not target_match:
|
|
149
|
+
return None
|
|
150
|
+
target_host = target_match.group(1).strip("[]")
|
|
151
|
+
return int(source_match.group(1)), target_host, int(target_match.group(2))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_endpoint(endpoint: str) -> tuple[str, int] | None:
|
|
155
|
+
"""Parse ``host:port`` or ``[ipv6]:port`` endpoint text."""
|
|
156
|
+
endpoint = endpoint.strip()
|
|
157
|
+
if endpoint.startswith("["):
|
|
158
|
+
m = re.match(r"^\[([^\]]+)\]:(\d+)$", endpoint)
|
|
159
|
+
if not m:
|
|
160
|
+
return None
|
|
161
|
+
return m.group(1), int(m.group(2))
|
|
162
|
+
if ":" not in endpoint:
|
|
163
|
+
return None
|
|
164
|
+
host, port = endpoint.rsplit(":", 1)
|
|
165
|
+
try:
|
|
166
|
+
return host.strip("[]"), int(port)
|
|
167
|
+
except ValueError:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_lsof_fields(text: str) -> list[LocalConnection]:
|
|
172
|
+
"""Parse ``lsof -Fpcfn`` established TCP output."""
|
|
173
|
+
rows: list[LocalConnection] = []
|
|
174
|
+
pid: int | None = None
|
|
175
|
+
app = ""
|
|
176
|
+
fd = ""
|
|
177
|
+
for line in text.splitlines():
|
|
178
|
+
if not line:
|
|
179
|
+
continue
|
|
180
|
+
tag, value = line[0], line[1:]
|
|
181
|
+
if tag == "p":
|
|
182
|
+
try:
|
|
183
|
+
pid = int(value)
|
|
184
|
+
except ValueError:
|
|
185
|
+
pid = None
|
|
186
|
+
app = ""
|
|
187
|
+
fd = ""
|
|
188
|
+
elif tag == "c":
|
|
189
|
+
app = value
|
|
190
|
+
elif tag == "f":
|
|
191
|
+
fd = value
|
|
192
|
+
elif tag == "n" and pid is not None:
|
|
193
|
+
parsed = _parse_lsof_name(value)
|
|
194
|
+
if not parsed:
|
|
195
|
+
continue
|
|
196
|
+
source_port, target_host, target_port = parsed
|
|
197
|
+
rows.append(LocalConnection(
|
|
198
|
+
pid=pid, app=app, fd=fd, source_port=source_port,
|
|
199
|
+
target_host=target_host, target_port=target_port,
|
|
200
|
+
raw_name=value,
|
|
201
|
+
))
|
|
202
|
+
return rows
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_ss_lines(text: str) -> list[LocalConnection]:
|
|
206
|
+
"""Parse Linux ``ss -Htnp`` established TCP output."""
|
|
207
|
+
rows: list[LocalConnection] = []
|
|
208
|
+
proc_re = re.compile(r'"(?P<app>[^"]+)",pid=(?P<pid>\d+),fd=(?P<fd>\d+)')
|
|
209
|
+
for line in text.splitlines():
|
|
210
|
+
parts = line.split()
|
|
211
|
+
if len(parts) < 5 or parts[0] != "ESTAB":
|
|
212
|
+
continue
|
|
213
|
+
local = _parse_endpoint(parts[3])
|
|
214
|
+
peer = _parse_endpoint(parts[4])
|
|
215
|
+
if not local or not peer:
|
|
216
|
+
continue
|
|
217
|
+
proc_match = proc_re.search(" ".join(parts[5:]))
|
|
218
|
+
if proc_match:
|
|
219
|
+
app = proc_match.group("app")
|
|
220
|
+
pid = int(proc_match.group("pid"))
|
|
221
|
+
fd = proc_match.group("fd")
|
|
222
|
+
else:
|
|
223
|
+
app, pid, fd = "", 0, ""
|
|
224
|
+
rows.append(LocalConnection(
|
|
225
|
+
pid=pid,
|
|
226
|
+
app=app,
|
|
227
|
+
fd=fd,
|
|
228
|
+
source_port=local[1],
|
|
229
|
+
target_host=peer[0],
|
|
230
|
+
target_port=peer[1],
|
|
231
|
+
raw_name=f"{parts[3]}->{parts[4]}",
|
|
232
|
+
))
|
|
233
|
+
return rows
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def collect_lsof_connections(app_filters: list[str]) -> list[LocalConnection]:
|
|
237
|
+
"""Collect local established TCP connections for the requested apps."""
|
|
238
|
+
cmd = ["lsof", "-nP", "-iTCP", "-sTCP:ESTABLISHED", "-Fpcfn"]
|
|
239
|
+
try:
|
|
240
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
return collect_ss_connections(app_filters)
|
|
243
|
+
if proc.returncode == 127:
|
|
244
|
+
return collect_ss_connections(app_filters)
|
|
245
|
+
if proc.returncode not in (0, 1):
|
|
246
|
+
_io.fail("lsof 读取本机连接失败",
|
|
247
|
+
hints=[proc.stderr.strip()] if proc.stderr else None,
|
|
248
|
+
doc="troubleshooting", code=_io.DEPENDENCY_MISSING,
|
|
249
|
+
cmd="connections")
|
|
250
|
+
rows = parse_lsof_fields(proc.stdout)
|
|
251
|
+
_enrich_with_ps(rows)
|
|
252
|
+
return [row for row in rows if row.matches_app(app_filters)]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def collect_ss_connections(app_filters: list[str]) -> list[LocalConnection]:
|
|
256
|
+
"""Collect Linux established TCP connections with ``ss``."""
|
|
257
|
+
cmd = ["ss", "-Htnp"]
|
|
258
|
+
try:
|
|
259
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
|
260
|
+
except FileNotFoundError:
|
|
261
|
+
_io.fail("读取本机连接需要 lsof 或 ss",
|
|
262
|
+
hint="安装 lsof,或在 Linux 上安装 iproute2(ss)",
|
|
263
|
+
doc="troubleshooting", code=_io.DEPENDENCY_MISSING,
|
|
264
|
+
cmd="connections")
|
|
265
|
+
if proc.returncode != 0:
|
|
266
|
+
_io.fail("ss 读取本机连接失败",
|
|
267
|
+
hints=[proc.stderr.strip()] if proc.stderr else None,
|
|
268
|
+
doc="troubleshooting", code=_io.DEPENDENCY_MISSING,
|
|
269
|
+
cmd="connections")
|
|
270
|
+
rows = parse_ss_lines(proc.stdout)
|
|
271
|
+
_enrich_with_ps([row for row in rows if row.pid])
|
|
272
|
+
return [row for row in rows if row.matches_app(app_filters)]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _enrich_with_ps(rows: list[LocalConnection]) -> None:
|
|
276
|
+
"""Add process path and command line from ps; failures leave lsof data intact."""
|
|
277
|
+
seen: set[int] = set()
|
|
278
|
+
for row in rows:
|
|
279
|
+
if row.pid in seen:
|
|
280
|
+
continue
|
|
281
|
+
seen.add(row.pid)
|
|
282
|
+
process = _ps_field(row.pid, "comm=")
|
|
283
|
+
command = _ps_field(row.pid, "command=")
|
|
284
|
+
for same_pid in rows:
|
|
285
|
+
if same_pid.pid == row.pid:
|
|
286
|
+
same_pid.process = process
|
|
287
|
+
same_pid.command = command
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _ps_field(pid: int, field: str) -> str:
|
|
291
|
+
"""Read one ps output field for ``pid`` and return an empty string on failure."""
|
|
292
|
+
try:
|
|
293
|
+
proc = subprocess.run(["ps", "-p", str(pid), "-o", field],
|
|
294
|
+
capture_output=True, text=True, timeout=1)
|
|
295
|
+
except Exception:
|
|
296
|
+
return ""
|
|
297
|
+
if proc.returncode != 0:
|
|
298
|
+
return ""
|
|
299
|
+
return proc.stdout.strip()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def fetch_mihomo_connections(api_base: str, api_secret: str,
|
|
303
|
+
*, timeout: float = 1.0) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
304
|
+
"""Fetch local mihomo `/connections` data without using system proxies."""
|
|
305
|
+
url = f"{api_base.rstrip('/')}/connections"
|
|
306
|
+
headers = {}
|
|
307
|
+
if api_secret:
|
|
308
|
+
headers["Authorization"] = f"Bearer {api_secret}"
|
|
309
|
+
req = urllib.request.Request(url, headers=headers)
|
|
310
|
+
try:
|
|
311
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
312
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
313
|
+
payload = json.loads(resp.read())
|
|
314
|
+
except (urllib.error.URLError, OSError, TimeoutError,
|
|
315
|
+
json.JSONDecodeError) as exc:
|
|
316
|
+
return [], {"ok": False, "status": "error", "url": url,
|
|
317
|
+
"error": str(exc)}
|
|
318
|
+
conns = payload.get("connections")
|
|
319
|
+
if not isinstance(conns, list):
|
|
320
|
+
return [], {"ok": False, "status": "error", "url": url,
|
|
321
|
+
"error": "response missing connections[]"}
|
|
322
|
+
return conns, {"ok": True, "status": "ok", "url": url,
|
|
323
|
+
"error": None, "count": len(conns)}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _connection_source_port(conn: dict[str, Any]) -> int | None:
|
|
327
|
+
"""Return mihomo metadata.sourcePort as int when present."""
|
|
328
|
+
metadata = conn.get("metadata") or {}
|
|
329
|
+
port = metadata.get("sourcePort")
|
|
330
|
+
try:
|
|
331
|
+
return int(port)
|
|
332
|
+
except (TypeError, ValueError):
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _mihomo_detail(conn: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
337
|
+
"""Extract the stable mihomo fields needed by agents."""
|
|
338
|
+
if not conn:
|
|
339
|
+
return None
|
|
340
|
+
metadata = conn.get("metadata") or {}
|
|
341
|
+
return {
|
|
342
|
+
"host": metadata.get("host") or "",
|
|
343
|
+
"destination_ip": metadata.get("destinationIP") or "",
|
|
344
|
+
"destination_port": metadata.get("destinationPort"),
|
|
345
|
+
"network": metadata.get("network") or "",
|
|
346
|
+
"type": metadata.get("type") or "",
|
|
347
|
+
"rule": conn.get("rule") or "",
|
|
348
|
+
"rule_payload": conn.get("rulePayload") or "",
|
|
349
|
+
"chains": conn.get("chains") or [],
|
|
350
|
+
"upload": conn.get("upload"),
|
|
351
|
+
"download": conn.get("download"),
|
|
352
|
+
"start": conn.get("start") or "",
|
|
353
|
+
"id": conn.get("id") or "",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def build_report(backend_name: str, config: dict[str, Any],
|
|
358
|
+
parsed_args: ConnectionArgs) -> dict[str, Any]:
|
|
359
|
+
"""Build the full joined connections report."""
|
|
360
|
+
proxy_port = int(config.get("proxy_port", 7890))
|
|
361
|
+
api_base = config.get("api_base", "http://127.0.0.1:9090")
|
|
362
|
+
api_secret = config.get("api_secret", "")
|
|
363
|
+
|
|
364
|
+
local_rows = [
|
|
365
|
+
row for row in collect_lsof_connections(parsed_args.app_filters)
|
|
366
|
+
if row.source_port != proxy_port
|
|
367
|
+
]
|
|
368
|
+
api_status: dict[str, Any]
|
|
369
|
+
remote_by_port: dict[int, dict[str, Any]] = {}
|
|
370
|
+
proxy_rows = [row for row in local_rows if row.target_port == proxy_port]
|
|
371
|
+
if backend_name != "mihomo":
|
|
372
|
+
api_status = {
|
|
373
|
+
"ok": False, "status": "skipped", "url": None,
|
|
374
|
+
"error": f"connections join needs mihomo backend, current backend is {backend_name}",
|
|
375
|
+
"count": 0,
|
|
376
|
+
}
|
|
377
|
+
elif not proxy_rows:
|
|
378
|
+
api_status = {
|
|
379
|
+
"ok": True, "status": "skipped_no_proxy_connections", "url": None,
|
|
380
|
+
"error": None, "count": 0,
|
|
381
|
+
}
|
|
382
|
+
else:
|
|
383
|
+
remote_rows, api_status = fetch_mihomo_connections(api_base, api_secret)
|
|
384
|
+
remote_by_port = {
|
|
385
|
+
port: conn for conn in remote_rows
|
|
386
|
+
if (port := _connection_source_port(conn)) is not None
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
joined: list[dict[str, Any]] = []
|
|
390
|
+
for row in local_rows:
|
|
391
|
+
via_proxy = row.target_port == proxy_port
|
|
392
|
+
matched = remote_by_port.get(row.source_port) if via_proxy else None
|
|
393
|
+
if matched:
|
|
394
|
+
unmatched_reason = None
|
|
395
|
+
elif not via_proxy:
|
|
396
|
+
unmatched_reason = "not_proxyctl_proxy_port"
|
|
397
|
+
elif api_status["status"] == "skipped":
|
|
398
|
+
unmatched_reason = "backend_not_mihomo"
|
|
399
|
+
elif api_status["ok"] is False:
|
|
400
|
+
unmatched_reason = "mihomo_api_unavailable"
|
|
401
|
+
else:
|
|
402
|
+
unmatched_reason = "no_mihomo_source_port_match"
|
|
403
|
+
joined.append({
|
|
404
|
+
**row.to_dict(proxy_port),
|
|
405
|
+
"matched": matched is not None,
|
|
406
|
+
"unmatched_reason": unmatched_reason,
|
|
407
|
+
"mihomo": _mihomo_detail(matched),
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
proxy_count = sum(1 for row in local_rows if row.target_port == proxy_port)
|
|
411
|
+
return {
|
|
412
|
+
"proxy_port": proxy_port,
|
|
413
|
+
"backend": backend_name,
|
|
414
|
+
"apps": parsed_args.app_filters,
|
|
415
|
+
"all_apps": parsed_args.all_apps,
|
|
416
|
+
"api": api_status,
|
|
417
|
+
"connections": joined,
|
|
418
|
+
"summary": {
|
|
419
|
+
"local_count": len(local_rows),
|
|
420
|
+
"proxy_port_count": proxy_count,
|
|
421
|
+
"non_proxy_port_count": len(local_rows) - proxy_count,
|
|
422
|
+
"all_via_proxy_port": bool(local_rows) and proxy_count == len(local_rows),
|
|
423
|
+
"matched_count": sum(1 for item in joined if item["matched"]),
|
|
424
|
+
"unmatched_count": sum(1 for item in joined if not item["matched"]),
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def emit_human(report: dict[str, Any]) -> None:
|
|
430
|
+
"""Render a compact human-readable report."""
|
|
431
|
+
api = report["api"]
|
|
432
|
+
summary = report["summary"]
|
|
433
|
+
all_proxy = "yes" if summary["all_via_proxy_port"] else "no"
|
|
434
|
+
print(f"{BOLD}proxyctl connections{NC} backend={report['backend']} "
|
|
435
|
+
f"proxy_port={report['proxy_port']} all_via_proxy={all_proxy}")
|
|
436
|
+
if not api.get("ok"):
|
|
437
|
+
print(f" {YELLOW}degraded:{NC} {api.get('error')}")
|
|
438
|
+
if not report["connections"]:
|
|
439
|
+
print(f" {DIM}no local connections to proxy_port matched filters{NC}")
|
|
440
|
+
return
|
|
441
|
+
for item in report["connections"]:
|
|
442
|
+
status = f"{GREEN}matched{NC}" if item["matched"] else f"{YELLOW}unmatched{NC}"
|
|
443
|
+
target = "proxy" if item["connects_proxy_port"] else item["target_host"]
|
|
444
|
+
print(f" {CYAN}{item['app']}{NC} pid={item['pid']} fd={item['fd']} "
|
|
445
|
+
f"src={item['local_source_port']} -> {target}:{item['target_port']} "
|
|
446
|
+
f"{status}")
|
|
447
|
+
if item["matched"]:
|
|
448
|
+
m = item["mihomo"] or {}
|
|
449
|
+
dest = m.get("host") or m.get("destination_ip") or "?"
|
|
450
|
+
print(f" dest={dest} rule={m.get('rule') or '?'} "
|
|
451
|
+
f"payload={m.get('rule_payload') or '-'} "
|
|
452
|
+
f"chains={','.join(m.get('chains') or []) or '-'} "
|
|
453
|
+
f"up={m.get('upload')} down={m.get('download')} "
|
|
454
|
+
f"start={m.get('start') or '-'}")
|
|
455
|
+
else:
|
|
456
|
+
print(f" reason={item['unmatched_reason']}")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def cmd_connections(args: list[str], backend, config: dict[str, Any]) -> None:
|
|
460
|
+
"""Entry point for ``proxyctl connections``."""
|
|
461
|
+
parsed_args = parse_args(args)
|
|
462
|
+
report = build_report(backend.name, config, parsed_args)
|
|
463
|
+
if _io.is_json_mode():
|
|
464
|
+
_io.emit_json(_io.envelope("connections", data=report))
|
|
465
|
+
return
|
|
466
|
+
emit_human(report)
|
|
@@ -432,14 +432,14 @@ def _t_flags(backend, config) -> TopicCard:
|
|
|
432
432
|
return {
|
|
433
433
|
"topic": "flags",
|
|
434
434
|
"summary": (
|
|
435
|
-
"全局 flag 速查:--json / --plain / --dry-run / --no-color / --quiet。"
|
|
435
|
+
"全局 flag 速查:--json / --plain / --dry-run/-n / --no-color / --quiet。"
|
|
436
436
|
"全部位置无关;--json 与 --plain 互斥;--dry-run 仅对写命令有效。"
|
|
437
437
|
),
|
|
438
438
|
"file": "(no file)",
|
|
439
439
|
"edit": (
|
|
440
440
|
" --json envelope schema v2(含 meta.ts/elapsed_ms/request_id)\n"
|
|
441
441
|
" --plain 纯 TSV 输出(audit / check 等表格命令)\n"
|
|
442
|
-
" --dry-run
|
|
442
|
+
" --dry-run/-n 预演写命令的 plan(list[PlanStep]),不真正执行\n"
|
|
443
443
|
" --no-color 关闭 ANSI(也读 NO_COLOR / PROXYCTL_NO_COLOR)\n"
|
|
444
444
|
" --quiet/-q 压制非关键 stderr\n"
|
|
445
445
|
" --help/-h 单命令或全局帮助\n"
|
|
@@ -730,13 +730,13 @@ Step 5 proxyctl doctor --json # 当前健康基线
|
|
|
730
730
|
Step 6 proxyctl explain <topic> # 深入概念(topic 见下)
|
|
731
731
|
```
|
|
732
732
|
|
|
733
|
-
调用任何写命令前先加 `--dry-run --json
|
|
733
|
+
调用任何写命令前先加 `--dry-run --json`(或 `-n --json`)看 `data.plan`,确认无误再去掉。
|
|
734
734
|
|
|
735
735
|
## Capabilities — 能做什么(按副作用三分类)
|
|
736
736
|
|
|
737
737
|
| 类别 | sudo | 命令 |
|
|
738
738
|
|---|---|---|
|
|
739
|
-
| **只读** (side_effects=[]) | 否 | `status doctor
|
|
739
|
+
| **只读** (side_effects=[]) | 否 | `status doctor connections audit env log plugins explain agent-guide commands config path|get help version` |
|
|
740
740
|
| **只读 + 网络 IO** (network-io) | 否 | `check trace bench recover`(curl/HTTP,不改本地状态) |
|
|
741
741
|
| **写 proxyctl 自身配置** | 否 | `config set <key> <value>`(原子写 + .bak + YAML 校验) |
|
|
742
742
|
| **写引擎配置** (config-write) | 是 | `mode tun|proxy` `audit apply` |
|
|
@@ -1047,6 +1047,15 @@ COMMANDS_META: list[dict] = [
|
|
|
1047
1047
|
"args": [], "supports_json": True, "side_effects": ["network-io"],
|
|
1048
1048
|
"needs_sudo": False, "interactive": False, "exit_codes": [0, 1, 5, 7],
|
|
1049
1049
|
"examples": ["proxyctl check", "proxyctl check --json"]},
|
|
1050
|
+
{"name": "connections", "group": "diagnostic",
|
|
1051
|
+
"summary": "本机 App/进程连接与 proxy_port、mihomo /connections 按 sourcePort 关联",
|
|
1052
|
+
"args": [{"name": "--app", "required": False, "repeatable": True},
|
|
1053
|
+
{"name": "--all", "required": False}],
|
|
1054
|
+
"supports_json": True, "side_effects": [],
|
|
1055
|
+
"needs_sudo": False, "interactive": False, "exit_codes": [0, 2, 10],
|
|
1056
|
+
"examples": ["proxyctl connections --app Codex --app Claude",
|
|
1057
|
+
"proxyctl connections --all --json",
|
|
1058
|
+
"proxyctl connections --app Codex --json"]},
|
|
1050
1059
|
{"name": "trace", "group": "diagnostic", "summary": "域名链路诊断",
|
|
1051
1060
|
"args": [{"name": "domain", "required": True}],
|
|
1052
1061
|
"supports_json": True, "side_effects": ["network-io"],
|
|
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
|