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.
- {proxyctl-0.1.2 → proxyctl-0.1.4}/PKG-INFO +1 -1
- {proxyctl-0.1.2 → proxyctl-0.1.4}/pyproject.toml +1 -1
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/check.py +24 -13
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/cli.py +56 -12
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/status.py +22 -5
- {proxyctl-0.1.2 → proxyctl-0.1.4}/.gitignore +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/LICENSE +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/README.md +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/__init__.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/audit.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/builtin_plugins/__init__.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/builtin_plugins/connectivity_basic.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/builtin_plugins/corp_network.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/core/__init__.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/core/plugin.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/engine/__init__.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/engine/base.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/engine/mihomo.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/engine/singbox.py +0 -0
- {proxyctl-0.1.2 → proxyctl-0.1.4}/src/proxyctl/trace.py +0 -0
|
@@ -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
|
|
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:
|
|
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:
|
|
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
|
|
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",
|
|
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:
|
|
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"), (
|
|
527
|
-
else [(
|
|
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(
|
|
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 =
|
|
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.
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|