proxyctl 0.1.2__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyctl
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxyctl"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Proxy configuration lifecycle management for macOS and Linux"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -36,10 +36,11 @@ def _port_listening(port: int) -> bool:
36
36
  return False
37
37
 
38
38
 
39
- def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8) -> tuple:
39
+ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8,
40
+ proxy_port: int = 7890) -> tuple:
40
41
  """
41
42
  测试 URL 可达性。
42
- mode=proxy: 走 socks5h://127.0.0.1:7890
43
+ mode=proxy: 走 socks5h://127.0.0.1:{proxy_port}(需配置 mixed-port 或 socks-port)
43
44
  mode=direct: 绕过所有代理 (--noproxy '*')
44
45
  返回 (ok: bool, line: str),调用方负责 print。
45
46
  """
@@ -49,7 +50,7 @@ def _test_url(url: str, desc: str, mode: str = "proxy", timeout: int = 8) -> tup
49
50
  cmd = ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
50
51
  "--max-time", str(timeout)]
51
52
  if mode == "proxy":
52
- cmd += ["--proxy", "socks5h://127.0.0.1:7890"]
53
+ cmd += ["--proxy", f"socks5h://127.0.0.1:{proxy_port}"]
53
54
  else:
54
55
  cmd += ["--noproxy", "*"]
55
56
  cmd.append(url)
@@ -281,7 +282,8 @@ def _proxy_groups_section(api_base: str, api_secret: str,
281
282
  return True
282
283
 
283
284
 
284
- def _ipgeo(ip: str, cache_file: str, api_secret: str) -> str:
285
+ def _ipgeo(ip: str, cache_file: str, api_secret: str,
286
+ proxy_port: int = 7890) -> str:
285
287
  """查询 IP 归属地,带文件缓存。返回 'city,country|org' 格式。"""
286
288
  if not ip:
287
289
  return ""
@@ -293,7 +295,8 @@ def _ipgeo(ip: str, cache_file: str, api_secret: str) -> str:
293
295
  if k not in ("http_proxy", "https_proxy", "all_proxy",
294
296
  "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY")}
295
297
  r = subprocess.run(
296
- ["curl", "-s", "--max-time", "6", "--proxy", "socks5h://127.0.0.1:7890",
298
+ ["curl", "-s", "--max-time", "6",
299
+ "--proxy", f"socks5h://127.0.0.1:{proxy_port}",
297
300
  f"https://ipinfo.io/{ip}/json"],
298
301
  capture_output=True, text=True, env=env, timeout=10
299
302
  )
@@ -428,11 +431,11 @@ def cmd_bench(api: str, api_secret: str, groups: list = None,
428
431
  _proxy_groups_section(api, api_secret, groups=list(group_members.keys()))
429
432
 
430
433
 
431
- def _fetch_probe(probe, env_clean: dict) -> str:
434
+ def _fetch_probe(probe, env_clean: dict, proxy_port: int = 7890) -> str:
432
435
  """根据 OutboundProbe 配置发起一次 IP 查询,返回提取后的 IP。"""
433
436
  cmd = ["curl", "-s", "--max-time", str(probe.timeout)]
434
437
  if probe.mode == "proxy":
435
- cmd += ["--proxy", "socks5h://127.0.0.1:7890"]
438
+ cmd += ["--proxy", f"socks5h://127.0.0.1:{proxy_port}"]
436
439
  else:
437
440
  cmd += ["--noproxy", "*"]
438
441
  cmd.append(probe.url)
@@ -478,9 +481,13 @@ def cmd_check(engine, api: str, api_secret: str,
478
481
  probes = registry.collect("check_outbound_probes", ctx={})
479
482
  probe_ips: dict[str, str] = {p.name: "" for p in probes}
480
483
 
484
+ # 临时常量:probes 在端口解析之前启动,所以这里用 config 直接拿
485
+ _probe_proxy_port = int(config.get("proxy_port", 7890))
486
+
481
487
  def _make_fetcher(probe):
482
488
  def _run():
483
- probe_ips[probe.name] = _fetch_probe(probe, env_clean)
489
+ probe_ips[probe.name] = _fetch_probe(probe, env_clean,
490
+ proxy_port=_probe_proxy_port)
484
491
  return _run
485
492
 
486
493
  ip_threads = [threading.Thread(target=_make_fetcher(p)) for p in probes]
@@ -521,10 +528,13 @@ def cmd_check(engine, api: str, api_secret: str,
521
528
  print(f" {RED}✗{NC} daemon not running — 执行 proxyctl start")
522
529
  return
523
530
 
524
- # 端口检测(proxy 模式不检查 53
531
+ # 端口检测(proxy 模式不检查 53)— 端口来自 config + api_base
532
+ from urllib.parse import urlparse
533
+ proxy_port = int(config.get("proxy_port", 7890))
534
+ api_port = urlparse(api).port or 9090
525
535
  dns_hijack = mode in ("tun", "mixed")
526
- check_ports = [(53, "dns"), (7890, "proxy"), (9090, "api")] if dns_hijack \
527
- else [(7890, "proxy"), (9090, "api")]
536
+ check_ports = [(53, "dns"), (proxy_port, "proxy"), (api_port, "api")] if dns_hijack \
537
+ else [(proxy_port, "proxy"), (api_port, "api")]
528
538
  ok_ports, fail_ports = [], []
529
539
  for port, desc in check_ports:
530
540
  if _port_listening(port):
@@ -710,7 +720,8 @@ def cmd_check(engine, api: str, api_secret: str,
710
720
  ok, line = _test_tcp(parts[0], int(parts[1]), target.name)
711
721
  else:
712
722
  ok, line = _test_url(target.url, target.name,
713
- target.mode, target.timeout)
723
+ target.mode, target.timeout,
724
+ proxy_port=proxy_port)
714
725
  except Exception as e:
715
726
  ok, line = False, f" {RED}✗{NC} {target.name} error: {e}"
716
727
  results[idx] = (line, ok)
@@ -737,7 +748,7 @@ def cmd_check(engine, api: str, api_secret: str,
737
748
  print(f" {YELLOW}—{NC} 无出口探测项")
738
749
  for probe in probes:
739
750
  ip = probe_ips.get(probe.name, "")
740
- geo = _ipgeo(ip, cache_file, api_secret)
751
+ geo = _ipgeo(ip, cache_file, api_secret, proxy_port=proxy_port)
741
752
  print(f" {probe.name:<7s}{_fmt_ip(ip, geo)}")
742
753
 
743
754
  # 分流校验:当同时有 proxy 出口和 direct 出口时比较
@@ -43,6 +43,8 @@ DEFAULTS = {
43
43
  "api_secret": "", # 必填:Clash API Bearer token
44
44
  "config_dir": os.path.join(HOME, ".config"),
45
45
  "dns_lock_label": "com.proxyctl.dns-lock",
46
+ # 引擎对外暴露的 HTTP/SOCKS mixed-port(应与 mihomo config 的 port/mixed-port 一致)
47
+ "proxy_port": 7890,
46
48
  }
47
49
 
48
50
  SCRIPTS_DIR = os.path.dirname(os.path.realpath(__file__))
@@ -514,13 +516,38 @@ def _wait_ready(backend: Backend):
514
516
  """
515
517
  if IS_MACOS:
516
518
  wait_port(53, timeout=10)
517
- wait_port(7890, timeout=10)
519
+ wait_port(int(backend.config.get("proxy_port", DEFAULTS["proxy_port"])),
520
+ timeout=10)
518
521
  time.sleep(3)
519
522
 
520
523
 
524
+ # ── 路由钩子调度 ──────────────────────────────────────────────────────────────
525
+
526
+ def _apply_route_hooks(registry, ctx: dict, action: str) -> None:
527
+ """调度所有插件的 RouteHook。action ∈ {'activate', 'deactivate'}。
528
+
529
+ 单个 hook 失败只打 warning,不中断主流程——路由注入是辅助优化,
530
+ 不能让某个插件挂了拖死 start/stop。
531
+ """
532
+ if registry is None:
533
+ return
534
+ hooks = registry.collect("route_hooks")
535
+ for h in hooks:
536
+ fn = getattr(h, action, None)
537
+ if fn is None:
538
+ continue
539
+ try:
540
+ fn(ctx)
541
+ except Exception as e:
542
+ sys.stderr.write(
543
+ f"[route_hook warning] {h.name}.{action} failed: "
544
+ f"{type(e).__name__}: {e}\n"
545
+ )
546
+
547
+
521
548
  # ── 命令:start ───────────────────────────────────────────────────────────────
522
549
 
523
- def cmd_start(backend: Backend, config: dict):
550
+ def cmd_start(backend: Backend, config: dict, registry=None):
524
551
  r = service_start(backend, config)
525
552
  if r.returncode != 0:
526
553
  print(f"{RED}✗{NC} {backend.name} 启动失败")
@@ -538,10 +565,18 @@ def cmd_start(backend: Backend, config: dict):
538
565
  proxy_activate()
539
566
  print("系统代理 → 127.0.0.1:7890 (已激活)")
540
567
 
568
+ _apply_route_hooks(registry,
569
+ {"engine": backend.name, "config": config, "phase": "start"},
570
+ "activate")
571
+
541
572
 
542
573
  # ── 命令:stop ────────────────────────────────────────────────────────────────
543
574
 
544
- def cmd_stop(backend: Backend, config: dict):
575
+ def cmd_stop(backend: Backend, config: dict, registry=None):
576
+ _apply_route_hooks(registry,
577
+ {"engine": backend.name, "config": config, "phase": "stop"},
578
+ "deactivate")
579
+
545
580
  if IS_MACOS:
546
581
  dns_lock_stop(config)
547
582
  dns_deactivate(config)
@@ -554,7 +589,7 @@ def cmd_stop(backend: Backend, config: dict):
554
589
 
555
590
  # ── 命令:restart ─────────────────────────────────────────────────────────────
556
591
 
557
- def cmd_restart(backend: Backend, config: dict, *, clean: bool = False):
592
+ def cmd_restart(backend: Backend, config: dict, *, clean: bool = False, registry=None):
558
593
  if clean and os.path.isfile(backend.cache_file):
559
594
  os.remove(backend.cache_file)
560
595
  # 人工介入后清掉 watchdog 的失败状态,避免误判为"还在触顶窗口内"
@@ -578,10 +613,14 @@ def cmd_restart(backend: Backend, config: dict, *, clean: bool = False):
578
613
  else:
579
614
  proxy_deactivate()
580
615
 
616
+ _apply_route_hooks(registry,
617
+ {"engine": backend.name, "config": config, "phase": "restart"},
618
+ "activate")
619
+
581
620
 
582
621
  # ── 命令:fix ─────────────────────────────────────────────────────────────────
583
622
 
584
- def cmd_fix(backend: Backend, config: dict):
623
+ def cmd_fix(backend: Backend, config: dict, registry=None):
585
624
  """修复引擎状态:运行中则重注入 DNS/代理,已停止则还原系统配置。"""
586
625
  api_base = config.get("api_base", DEFAULTS["api_base"])
587
626
  api_secret = config.get("api_secret", "")
@@ -602,6 +641,11 @@ def cmd_fix(backend: Backend, config: dict):
602
641
  if get_mode(backend) == "proxy":
603
642
  proxy_activate()
604
643
  print(f" {GREEN}✓{NC} 系统代理 → 127.0.0.1:7890")
644
+
645
+ _apply_route_hooks(registry,
646
+ {"engine": backend.name, "config": config,
647
+ "phase": "fix"},
648
+ "activate")
605
649
  else:
606
650
  print(f"{BOLD}[引擎运行中] 尝试热重载配置{NC}")
607
651
 
@@ -1159,7 +1203,7 @@ def cmd_env(config: dict, unset: bool = False):
1159
1203
  print(f"unset {var};")
1160
1204
  return
1161
1205
 
1162
- port = 7890 # mixed-port
1206
+ port = int(config.get("proxy_port", DEFAULTS["proxy_port"])) # mixed-port
1163
1207
  proxy_http = f"http://127.0.0.1:{port}"
1164
1208
  proxy_socks = f"socks5://127.0.0.1:{port}"
1165
1209
  no_proxy = "localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
@@ -1176,7 +1220,7 @@ def cmd_env(config: dict, unset: bool = False):
1176
1220
 
1177
1221
  # ── 帮助 ──────────────────────────────────────────────────────────────────────
1178
1222
 
1179
- VERSION = "0.1.2"
1223
+ VERSION = "0.1.4"
1180
1224
 
1181
1225
  def cmd_help(verbose: bool = False):
1182
1226
  """打印帮助信息
@@ -1280,13 +1324,13 @@ def main():
1280
1324
  cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
1281
1325
 
1282
1326
  if cmd == "start":
1283
- cmd_start(backend, config)
1327
+ cmd_start(backend, config, registry=registry)
1284
1328
  elif cmd == "stop":
1285
- cmd_stop(backend, config)
1329
+ cmd_stop(backend, config, registry=registry)
1286
1330
  elif cmd == "restart":
1287
- cmd_restart(backend, config)
1331
+ cmd_restart(backend, config, registry=registry)
1288
1332
  elif cmd == "restart-clean":
1289
- cmd_restart(backend, config, clean=True)
1333
+ cmd_restart(backend, config, clean=True, registry=registry)
1290
1334
  elif cmd == "status":
1291
1335
  from proxyctl.status import cmd_status
1292
1336
  mode_str = get_mode(backend)
@@ -1303,7 +1347,7 @@ def main():
1303
1347
  default_groups = registry.collect("check_groups") if registry else None
1304
1348
  cmd_bench(api_base, api_secret, bench_groups, default_groups=default_groups)
1305
1349
  elif cmd == "fix":
1306
- cmd_fix(backend, config)
1350
+ cmd_fix(backend, config, registry=registry)
1307
1351
  elif cmd == "recover":
1308
1352
  cmd_recover(backend, config)
1309
1353
  elif cmd == "dns-lock":
@@ -94,10 +94,17 @@ def _gather_engine(engine) -> dict:
94
94
  return {"pid": pid, "runs": runs, "daemon_up": daemon_up, "etime": etime}
95
95
 
96
96
 
97
- def _gather_ports(claude_proxy_label: str) -> dict:
98
- """采集端口监听状态。"""
99
- ports = [(p, desc, _port_listening(p))
100
- for p, desc in [(7890, "proxy"), (9090, "api")]]
97
+ def _gather_ports(claude_proxy_label: str,
98
+ port_list: list[tuple[int, str]] | None = None) -> dict:
99
+ """采集端口监听状态。
100
+
101
+ Args:
102
+ port_list: [(port, desc), ...],由调用方根据 config/api_base 解析后传入。
103
+ None 时回退到 [(7890,"proxy"), (9090,"api")] 以兼容旧调用。
104
+ """
105
+ if port_list is None:
106
+ port_list = [(7890, "proxy"), (9090, "api")]
107
+ ports = [(p, desc, _port_listening(p)) for p, desc in port_list]
101
108
  cp_running = False
102
109
  cp_pid = ""
103
110
  cp_port = False
@@ -475,10 +482,20 @@ def cmd_status(engine, api: str, api_secret: str,
475
482
  dns_lock_label = config.get("dns_lock_label", "com.proxyctl.dns-lock")
476
483
  claude_proxy_label = config.get("claude_proxy_label", "com.proxyctl.claude-proxy")
477
484
 
485
+ # 端口列表:proxy 取 config.proxy_port,api 从 api_base URL 解析
486
+ proxy_port = int(config.get("proxy_port", 7890))
487
+ api_port = 9090
488
+ try:
489
+ from urllib.parse import urlparse
490
+ api_port = urlparse(api).port or 9090
491
+ except Exception:
492
+ pass
493
+ port_list = [(proxy_port, "proxy"), (api_port, "api")]
494
+
478
495
  # 全部 section 并发采集,拿到一个打印一个
479
496
  with ThreadPoolExecutor(max_workers=8) as pool:
480
497
  f_engine = pool.submit(_gather_engine, engine)
481
- f_ports = pool.submit(_gather_ports, claude_proxy_label)
498
+ f_ports = pool.submit(_gather_ports, claude_proxy_label, port_list)
482
499
  f_tun = pool.submit(_gather_tun, engine, True)
483
500
  f_proxy = pool.submit(_gather_proxy_settings)
484
501
  f_dns = pool.submit(_gather_dns, dns_lock_label)
File without changes
File without changes
File without changes
File without changes
File without changes