proxyctl 0.5.3__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.3 → proxyctl-0.5.5}/PKG-INFO +1 -1
  2. {proxyctl-0.5.3 → proxyctl-0.5.5}/man/proxyctl.1 +6 -0
  3. {proxyctl-0.5.3 → proxyctl-0.5.5}/pyproject.toml +1 -1
  4. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/_io.py +1 -0
  5. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/check.py +42 -17
  6. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/cli.py +54 -29
  7. {proxyctl-0.5.3 → 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.3 → proxyctl-0.5.5}/src/proxyctl/explain.py +38 -10
  10. {proxyctl-0.5.3 → proxyctl-0.5.5}/.gitignore +0 -0
  11. {proxyctl-0.5.3 → proxyctl-0.5.5}/LICENSE +0 -0
  12. {proxyctl-0.5.3 → proxyctl-0.5.5}/README.md +0 -0
  13. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/__init__.py +0 -0
  14. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/audit.py +0 -0
  15. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/autostart.py +0 -0
  16. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/__init__.py +0 -0
  17. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
  18. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
  19. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/core/__init__.py +0 -0
  20. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/core/plugin.py +0 -0
  21. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/engine/__init__.py +0 -0
  22. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/engine/base.py +0 -0
  23. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/engine/mihomo.py +0 -0
  24. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/engine/singbox.py +0 -0
  25. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/status.py +0 -0
  26. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/subscription.py +0 -0
  27. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/suggest.py +0 -0
  28. {proxyctl-0.5.3 → proxyctl-0.5.5}/src/proxyctl/suggest_rules.py +0 -0
  29. {proxyctl-0.5.3 → 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.3
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.3"
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
@@ -2010,32 +2023,39 @@ def _plan_engine(backend, target: str) -> list[dict]:
2010
2023
 
2011
2024
 
2012
2025
  def _plan_fix(backend, config) -> list[dict]:
2013
- """fix 的 plan
2026
+ """fix 的 plan(v0.5.4:按平台分支)。
2027
+
2028
+ Linux:cmd_fix 仅走 Clash API 热重载,不改系统 DNS(systemd-resolved
2029
+ 上的 127.0.0.53 stub 不该被 networksetup 替换)。plan 同步只出 http_put。
2030
+ macOS:保留 DNS 重写 + 缓存清理 + 热重载三步。
2014
2031
 
2015
2032
  networksetup / scutil 迭代型操作不可逐条 argv 化(每个网络服务调一次),
2016
- 标 action=system_op + 描述性 target。http_put 是单次精确请求,target
2017
- 为完整 URL。cmd_fix 实际还会调插件 route_hooks + 第二次 curl 清 fakeip,
2018
- contract test 不做 subprocess 子集校验(plugin 可注入),只校验
2019
- placeholder 已消除(< / > 字符不出现)。
2033
+ 标 action=system_op + 描述性 target。contract test 不做 subprocess 子集
2034
+ 校验(plugin 可注入),只校验 placeholder 已消除。
2020
2035
  """
2021
2036
  api_base = config.get('api_base', 'http://127.0.0.1:9090')
2022
- return [
2023
- {"action": "system_op",
2024
- "target": "networksetup -setdnsservers (per service) 127.0.0.1",
2025
- "summary": "重置系统 DNS 指向 127.0.0.1(对抗 DHCP 续租)",
2026
- "reversible": True, "requires_sudo": True,
2027
- "side_effects": ["system"]},
2028
- {"action": "system_op",
2029
- "target": "scutil DNS reset + dscacheutil -flushcache",
2030
- "summary": "清 macOS DNS 缓存(含 fakeip 表)",
2031
- "reversible": True, "requires_sudo": True,
2032
- "side_effects": ["cache"]},
2033
- {"action": "http_put",
2034
- "target": f"{api_base}/configs?force=true",
2035
- "summary": "向 Clash API 发热重载请求(不重启进程)",
2036
- "reversible": True,
2037
- "side_effects": ["network-io"]},
2038
- ]
2037
+ plan = []
2038
+ if IS_MACOS:
2039
+ plan.extend([
2040
+ {"action": "system_op",
2041
+ "target": "networksetup -setdnsservers (per service) 127.0.0.1",
2042
+ "summary": "重置系统 DNS 指向 127.0.0.1(对抗 DHCP 续租)",
2043
+ "reversible": True, "requires_sudo": True,
2044
+ "side_effects": ["system"]},
2045
+ {"action": "system_op",
2046
+ "target": "scutil DNS reset + dscacheutil -flushcache",
2047
+ "summary": "清 macOS DNS 缓存(含 fakeip 表)",
2048
+ "reversible": True, "requires_sudo": True,
2049
+ "side_effects": ["cache"]},
2050
+ ])
2051
+ plan.append({
2052
+ "action": "http_put",
2053
+ "target": f"{api_base}/configs?force=true",
2054
+ "summary": "向 Clash API 发热重载请求(不重启进程)",
2055
+ "reversible": True,
2056
+ "side_effects": ["network-io"],
2057
+ })
2058
+ return plan
2039
2059
 
2040
2060
 
2041
2061
  def _plan_audit_apply(days: int, backend, config: dict) -> list[dict]:
@@ -2725,6 +2745,10 @@ def _h_check(ctx):
2725
2745
  cmd_check(ctx["backend"], ctx["api_base"], ctx["api_secret"],
2726
2746
  ctx["config"], mode_str, registry=ctx["registry"])
2727
2747
 
2748
+ def _h_connections(ctx):
2749
+ from proxyctl.connections import cmd_connections
2750
+ cmd_connections(ctx["args"], ctx["backend"], ctx["config"])
2751
+
2728
2752
  def _h_bench(ctx):
2729
2753
  from proxyctl.check import cmd_bench
2730
2754
  bench_groups = ctx["args"] or None
@@ -2891,6 +2915,7 @@ DISPATCH: dict = {
2891
2915
  "status": _h_status,
2892
2916
  "log": _h_log,
2893
2917
  "check": _h_check,
2918
+ "connections": _h_connections,
2894
2919
  "bench": _h_bench,
2895
2920
  "fix": _h_fix,
2896
2921
  "recover": _h_recover,
@@ -2958,4 +2983,4 @@ def _suggest_command_and_exit(unknown: str) -> None:
2958
2983
 
2959
2984
 
2960
2985
  if __name__ == "__main__":
2961
- 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"],
@@ -1544,6 +1553,12 @@ def cmd_doctor(args: list, backend, config) -> None:
1544
1553
  port = config.get("proxy_port", 7890)
1545
1554
  api_base = config.get("api_base", "http://127.0.0.1:9090")
1546
1555
 
1556
+ # mode 先取(dns_ok 判定 + 显示均依赖;v0.5.4 起)
1557
+ try:
1558
+ mode_str = get_mode(backend)
1559
+ except Exception:
1560
+ mode_str = "unknown"
1561
+
1547
1562
  if suggest_only:
1548
1563
  # Fast path:跳过所有 5 项 score 探测(最慢可累计 5-10s)
1549
1564
  engine_up = None
@@ -1560,7 +1575,7 @@ def cmd_doctor(args: list, backend, config) -> None:
1560
1575
  else:
1561
1576
  engine_up = bool(service_running(backend))
1562
1577
  port_listen = _tcp_open("127.0.0.1", port)
1563
- dns_ok = _dns_points_to_loopback()
1578
+ dns_ok = _dns_check_ok(mode_str)
1564
1579
  system_proxy_ok = _system_proxy_points_to_loopback(port)
1565
1580
  connectivity_ok = (_quick_connectivity(api_base, port)
1566
1581
  if engine_up else False)
@@ -1575,10 +1590,6 @@ def cmd_doctor(args: list, backend, config) -> None:
1575
1590
  hint = _doctor_hint(flags_for_human)
1576
1591
 
1577
1592
  # informational extra(不计分)
1578
- try:
1579
- mode_str = get_mode(backend)
1580
- except Exception:
1581
- mode_str = "unknown"
1582
1593
  try:
1583
1594
  held = _io.held_lock_names()
1584
1595
  except Exception:
@@ -1643,7 +1654,9 @@ def cmd_doctor(args: list, backend, config) -> None:
1643
1654
  f"{DIM}engine={backend.name}{ev_tag} mode={mode_str} port={port}{NC}")
1644
1655
  print(f" {icon(engine_up)} engine_up ({backend.name} 服务运行中)")
1645
1656
  print(f" {icon(port_listen)} port_listen (127.0.0.1:{port})")
1646
- print(f" {icon(dns_ok)} dns_ok (系统 DNS 127.0.0.1)")
1657
+ dns_desc = ("proxy 模式无需 DNS 劫持" if mode_str == "proxy"
1658
+ else "系统 DNS 含 127.0.0.1")
1659
+ print(f" {icon(dns_ok)} dns_ok ({dns_desc})")
1647
1660
  print(f" {icon(system_proxy_ok)} system_proxy_ok (macOS HTTP/HTTPS proxy)")
1648
1661
  print(f" {icon(connectivity_ok)} connectivity_ok (https://www.google.com via proxy)")
1649
1662
  if held:
@@ -1765,6 +1778,21 @@ def _dns_points_to_loopback() -> bool:
1765
1778
  return False
1766
1779
 
1767
1780
 
1781
+ def _dns_check_ok(mode: str) -> bool:
1782
+ """doctor dns_ok 判定(v0.5.4:mode-aware)。
1783
+
1784
+ proxy 模式无需系统 DNS 指向 127.0.0.1——流量走 HTTP/SOCKS 代理,
1785
+ mihomo 不在 :53 监听,系统 DNS 用 systemd-resolved / DHCP 默认即可。
1786
+ 旧逻辑在 Ubuntu(DNS=127.0.0.53 stub)+ proxy 模式下永远判 ✗
1787
+ 并建议 `proxyctl fix`——但 fix 的 DNS 改写只对 macOS TUN 模式有意义。
1788
+
1789
+ tun / mixed / 未知模式:保留旧的"系统 DNS 含 127.0.0.1"判定。
1790
+ """
1791
+ if mode == "proxy":
1792
+ return True
1793
+ return _dns_points_to_loopback()
1794
+
1795
+
1768
1796
  def _system_proxy_points_to_loopback(port: int) -> bool:
1769
1797
  """macOS:networksetup -getwebproxy 是否指向 127.0.0.1:port。Linux:跳过判 True。"""
1770
1798
  if sys.platform != "darwin":
File without changes
File without changes
File without changes
File without changes
File without changes