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.
Files changed (29) hide show
  1. {proxyctl-0.5.4 → proxyctl-0.5.5}/PKG-INFO +1 -1
  2. {proxyctl-0.5.4 → proxyctl-0.5.5}/man/proxyctl.1 +6 -0
  3. {proxyctl-0.5.4 → proxyctl-0.5.5}/pyproject.toml +1 -1
  4. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/_io.py +1 -0
  5. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/check.py +42 -17
  6. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/cli.py +25 -7
  7. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/completion.py +7 -6
  8. proxyctl-0.5.5/src/proxyctl/connections.py +466 -0
  9. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/explain.py +13 -4
  10. {proxyctl-0.5.4 → proxyctl-0.5.5}/.gitignore +0 -0
  11. {proxyctl-0.5.4 → proxyctl-0.5.5}/LICENSE +0 -0
  12. {proxyctl-0.5.4 → proxyctl-0.5.5}/README.md +0 -0
  13. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/__init__.py +0 -0
  14. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/audit.py +0 -0
  15. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/autostart.py +0 -0
  16. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  17. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  18. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  19. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/core/__init__.py +0 -0
  20. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/core/plugin.py +0 -0
  21. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/engine/__init__.py +0 -0
  22. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/engine/base.py +0 -0
  23. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/engine/mihomo.py +0 -0
  24. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/engine/singbox.py +0 -0
  25. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/status.py +0 -0
  26. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/subscription.py +0 -0
  27. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/suggest.py +0 -0
  28. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/suggest_rules.py +0 -0
  29. {proxyctl-0.5.4 → proxyctl-0.5.5}/src/proxyctl/trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.5.4
3
+ Version: 0.5.5
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxyctl"
3
- version = "0.5.4"
3
+ version = "0.5.5"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -72,6 +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
76
  )
76
77
 
77
78
 
@@ -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
- # v0.5.4:selector/fallback 类型 + now 指向子组时,
255
- # 跳过顶层成员列表(避免跟子组节点重复),只展开 now 那一个子组。
256
- # now 指向真节点时仍打印顶层成员(用户可能想看其他可选项)。
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 = gnow in expanded_subgroups
270
+ already = sub_name in expanded_subgroups
279
271
  tail = f" {DIM}(详见上方){NC}" if already else ""
280
- print(f" {CYAN}{gnow}{NC}({sub_type}) {BOLD}{sub_now}{NC} "
281
- f"{ds(sub_now_d)}ms [{'/'.join(sc)} of {len(sub_members)}]{st_str}{tail}")
282
- if not already:
283
- print_members(sub_members, sub_now)
284
- expanded_subgroups.add(gnow)
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(f" 使用默认配置,可能需要在 {CONFIG_FILE} 中配置 api_secret")
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 预演写操作(输出 plan,不真正执行)")
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 == "--dry-run":
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 预演写命令的 plan(list[PlanStep]),不真正执行\n"
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` `data.plan`,确认无误再去掉。
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 check trace bench audit env log plugins explain agent-guide commands config path|get help version` |
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