调研后采用 Hsiifu3/clash-verge-skill(MIT,本机 :9090 实测可用)vendoring 为 clash-api.py;本仓薄封装 clashctl 补 mode/生产 DIRECT/系统代理铁律/回归。 不装 clashtui/mihomosh 第二内核。
468 lines
16 KiB
Python
Executable file
468 lines
16 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Clash external-controller CLI (vendored).
|
|
|
|
Upstream: https://github.com/Hsiifu3/clash-verge-skill (scripts/clash-verge.py)
|
|
License: MIT · zero deps · works with ClashX Meta / Mihomo on :9090
|
|
Local defaults: API http://127.0.0.1:9090 (ClashX Meta); Verge socket optional.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import socket
|
|
import sys
|
|
import urllib.request
|
|
import urllib.error
|
|
from pathlib import Path
|
|
|
|
DEFAULT_SOCK = "/tmp/verge/verge-mihomo.sock"
|
|
DEFAULT_HTTP = "http://127.0.0.1:9090"
|
|
|
|
|
|
def _get_args():
|
|
"""Get connection args from environment or defaults."""
|
|
import os
|
|
sock = os.environ.get("CLASH_SOCK", DEFAULT_SOCK)
|
|
http = os.environ.get("CLASH_API", DEFAULT_HTTP)
|
|
secret = os.environ.get("CLASH_SECRET", "")
|
|
# ClashX Meta uses /tmp/clashx_meta_*.sock; Verge uses DEFAULT_SOCK
|
|
if sock and not Path(sock).exists():
|
|
try:
|
|
for p in sorted(Path("/tmp").glob("clashx_meta_*.sock")):
|
|
sock = str(p)
|
|
break
|
|
except OSError:
|
|
pass
|
|
# CLASH_PREFER_HTTP=1 → skip unix socket (ClashX Meta 系统代理场景默认走 :9090)
|
|
if os.environ.get("CLASH_PREFER_HTTP", "") in ("1", "true", "yes"):
|
|
sock = ""
|
|
return sock, http, secret
|
|
|
|
|
|
class UnixHTTPHandler(urllib.request.AbstractHTTPHandler):
|
|
"""HTTP handler for Unix domain sockets."""
|
|
|
|
def __init__(self, sock_path):
|
|
super().__init__()
|
|
self.sock_path = sock_path
|
|
|
|
def http_open(self, req):
|
|
return self.do_open(self._make_connection, req)
|
|
|
|
def _make_connection(self, host, **kwargs):
|
|
conn = UnixHTTPConnection(self.sock_path)
|
|
return conn
|
|
|
|
|
|
class UnixHTTPConnection:
|
|
"""Minimal HTTP/1.1 over Unix socket."""
|
|
|
|
def __init__(self, sock_path):
|
|
self.sock_path = sock_path
|
|
self._sock = None
|
|
self._response = None
|
|
self.timeout = 10
|
|
|
|
def request(self, method, url, body=None, headers=None):
|
|
headers = headers or {}
|
|
self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self._sock.settimeout(self.timeout)
|
|
self._sock.connect(self.sock_path)
|
|
path = url
|
|
lines = [f"{method} {path} HTTP/1.1", "Host: localhost", "Connection: close"]
|
|
if body:
|
|
lines.append(f"Content-Length: {len(body)}")
|
|
for k, v in headers.items():
|
|
lines.append(f"{k}: {v}")
|
|
lines.append("")
|
|
lines.append("")
|
|
raw = "\r\n".join(lines).encode()
|
|
if body:
|
|
raw = raw + (body.encode() if isinstance(body, str) else body)
|
|
self._sock.sendall(raw)
|
|
|
|
def getresponse(self):
|
|
data = b""
|
|
while True:
|
|
chunk = self._sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
self._sock.close()
|
|
return _RawResponse(data)
|
|
|
|
|
|
class _RawResponse:
|
|
"""Parse raw HTTP response."""
|
|
|
|
def __init__(self, data):
|
|
parts = data.split(b"\r\n\r\n", 1)
|
|
header_block = parts[0].decode(errors="replace")
|
|
self.body = parts[1] if len(parts) > 1 else b""
|
|
first_line = header_block.split("\r\n")[0]
|
|
self.status = int(first_line.split(" ", 2)[1])
|
|
self.reason = first_line.split(" ", 2)[2] if len(first_line.split(" ", 2)) > 2 else ""
|
|
self._headers = {}
|
|
for line in header_block.split("\r\n")[1:]:
|
|
if ": " in line:
|
|
k, v = line.split(": ", 1)
|
|
self._headers[k.lower()] = v
|
|
# Handle chunked transfer encoding
|
|
if self._headers.get("transfer-encoding", "").lower() == "chunked":
|
|
self.body = self._decode_chunked(self.body)
|
|
|
|
def _decode_chunked(self, data):
|
|
result = b""
|
|
while data:
|
|
line_end = data.find(b"\r\n")
|
|
if line_end == -1:
|
|
break
|
|
size_str = data[:line_end].decode().strip()
|
|
if not size_str:
|
|
data = data[line_end + 2:]
|
|
continue
|
|
chunk_size = int(size_str, 16)
|
|
if chunk_size == 0:
|
|
break
|
|
result += data[line_end + 2:line_end + 2 + chunk_size]
|
|
data = data[line_end + 2 + chunk_size + 2:]
|
|
return result
|
|
|
|
def read(self):
|
|
return self.body
|
|
|
|
def getheader(self, name, default=None):
|
|
return self._headers.get(name.lower(), default)
|
|
|
|
|
|
# ── API Client ───────────────────────────────────────────────────────
|
|
|
|
|
|
def api(method, path, body=None):
|
|
"""Call mihomo API. Prefers Unix socket, falls back to HTTP."""
|
|
sock, http_url, secret = _get_args()
|
|
headers = {"Content-Type": "application/json"}
|
|
if secret:
|
|
headers["Authorization"] = f"Bearer {secret}"
|
|
|
|
# Path("").exists() is True (cwd) — require non-empty sock path
|
|
if sock and Path(sock).exists():
|
|
try:
|
|
conn = UnixHTTPConnection(sock)
|
|
conn.request(method, path, body=json.dumps(body) if body else None, headers=headers)
|
|
resp = conn.getresponse()
|
|
raw = resp.read()
|
|
if resp.status >= 400:
|
|
print(f"API error {resp.status}: {raw.decode(errors='replace')}", file=sys.stderr)
|
|
sys.exit(1)
|
|
return json.loads(raw) if raw.strip() else {}
|
|
except OSError:
|
|
pass # stale socket → HTTP
|
|
|
|
url = http_url.rstrip("/") + path
|
|
data = json.dumps(body).encode() if body else None
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
raw = resp.read()
|
|
return json.loads(raw) if raw.strip() else {}
|
|
except urllib.error.HTTPError as e:
|
|
print(f"API error {e.code}: {e.read().decode(errors='replace')}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
# ── Commands ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def cmd_status(args):
|
|
"""Show overall status."""
|
|
ver = api("GET", "/version")
|
|
cfg = api("GET", "/configs")
|
|
conns = api("GET", "/connections")
|
|
proxies_data = api("GET", "/proxies")
|
|
|
|
nc = len(conns.get("connections", []))
|
|
up = conns.get("uploadTotal", 0)
|
|
down = conns.get("downloadTotal", 0)
|
|
|
|
print(f"Mihomo {ver.get('version', '?')}")
|
|
print(f"Mode: {cfg.get('mode', '?')}")
|
|
print(f"Mixed port: {cfg.get('mixed-port', '?')}")
|
|
tun = cfg.get("tun", {})
|
|
print(f"TUN: {'enabled' if tun.get('enable') else 'disabled'} ({tun.get('stack', '?')})")
|
|
print(f"Log level: {cfg.get('log-level', '?')}")
|
|
print(f"Connections: {nc} active")
|
|
print(f"Traffic: ↑ {_fmt_bytes(up)} ↓ {_fmt_bytes(down)}")
|
|
|
|
# Show current proxy selections
|
|
proxies = proxies_data.get("proxies", {})
|
|
group_types = ("Selector", "URLTest", "Fallback", "LoadBalance")
|
|
groups = {k: v for k, v in proxies.items() if v.get("type") in group_types}
|
|
if groups:
|
|
print(f"\nProxy groups:")
|
|
for name, g in sorted(groups.items()):
|
|
now = g.get("now", "-")
|
|
# Resolve chain: if 'now' is also a group, follow it
|
|
chain = [now]
|
|
seen = {name}
|
|
cur = now
|
|
while cur in proxies and proxies[cur].get("type") in group_types and cur not in seen:
|
|
seen.add(cur)
|
|
cur = proxies[cur].get("now", "")
|
|
if cur:
|
|
chain.append(cur)
|
|
chain_str = " → ".join(chain)
|
|
print(f" {name}: {chain_str}")
|
|
|
|
|
|
def cmd_mode(args):
|
|
"""Get or set proxy mode."""
|
|
if args.value:
|
|
api("PATCH", "/configs", {"mode": args.value})
|
|
print(f"Mode set to: {args.value}")
|
|
else:
|
|
cfg = api("GET", "/configs")
|
|
print(f"Mode: {cfg.get('mode', '?')}")
|
|
|
|
|
|
def cmd_groups(args):
|
|
"""List proxy groups."""
|
|
data = api("GET", "/proxies")
|
|
proxies = data.get("proxies", {})
|
|
group_types = ("Selector", "URLTest", "Fallback", "LoadBalance")
|
|
groups = {k: v for k, v in proxies.items() if v.get("type") in group_types}
|
|
for name, g in sorted(groups.items()):
|
|
now = g.get("now", "-")
|
|
n = len(g.get("all", []))
|
|
t = g.get("type", "?")
|
|
print(f" {name} ({t}): {now} [{n} nodes]")
|
|
|
|
|
|
def cmd_nodes(args):
|
|
"""List nodes in a proxy group."""
|
|
data = api("GET", f"/proxies/{_urlencode(args.group)}")
|
|
if "all" not in data:
|
|
print(f"'{args.group}' is not a group or not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
now = data.get("now", "")
|
|
print(f"Group: {args.group} ({data.get('type', '?')})")
|
|
print(f"Current: {now}\n")
|
|
for node_name in data.get("all", []):
|
|
marker = " ★" if node_name == now else ""
|
|
# Get delay info from history
|
|
node_data = api("GET", f"/proxies/{_urlencode(node_name)}")
|
|
history = node_data.get("history", [])
|
|
delay = history[-1].get("delay", 0) if history else 0
|
|
delay_str = f"{delay}ms" if delay > 0 else "N/A"
|
|
print(f" {node_name}{marker} ({delay_str})")
|
|
|
|
|
|
def cmd_select(args):
|
|
"""Select a node in a proxy group."""
|
|
api("PUT", f"/proxies/{_urlencode(args.group)}", {"name": args.node})
|
|
print(f"Switched '{args.group}' → {args.node}")
|
|
|
|
|
|
def cmd_delay(args):
|
|
"""Test delay for a node or group."""
|
|
url = args.url or "http://www.gstatic.com/generate_204"
|
|
timeout = args.timeout or 5000
|
|
target = args.target
|
|
result = api("GET", f"/proxies/{_urlencode(target)}/delay?timeout={timeout}&url={_urlencode(url)}")
|
|
d = result.get("delay", 0)
|
|
if d > 0:
|
|
print(f"{target}: {d}ms")
|
|
else:
|
|
print(f"{target}: timeout / unreachable")
|
|
|
|
|
|
def cmd_delay_group(args):
|
|
"""Test delay for all nodes in a group."""
|
|
url = args.url or "http://www.gstatic.com/generate_204"
|
|
timeout = args.timeout or 5000
|
|
data = api("GET", f"/proxies/{_urlencode(args.group)}")
|
|
if "all" not in data:
|
|
print(f"'{args.group}' is not a group.", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"Testing {len(data['all'])} nodes in '{args.group}'...\n")
|
|
results = []
|
|
for node_name in data.get("all", []):
|
|
try:
|
|
r = api("GET", f"/proxies/{_urlencode(node_name)}/delay?timeout={timeout}&url={_urlencode(url)}")
|
|
d = r.get("delay", 0)
|
|
except SystemExit:
|
|
d = 0
|
|
results.append((node_name, d))
|
|
# Sort by delay (0 = timeout, put at end)
|
|
results.sort(key=lambda x: (x[1] == 0, x[1]))
|
|
for name, d in results:
|
|
marker = " ★" if name == data.get("now", "") else ""
|
|
ds = f"{d}ms" if d > 0 else "timeout"
|
|
print(f" {name}{marker}: {ds}")
|
|
|
|
|
|
def cmd_conns(args):
|
|
"""List active connections."""
|
|
data = api("GET", "/connections")
|
|
conns = data.get("connections", [])
|
|
if not conns:
|
|
print("No active connections.")
|
|
return
|
|
up = data.get("uploadTotal", 0)
|
|
down = data.get("downloadTotal", 0)
|
|
print(f"Total: {len(conns)} connections ↑ {_fmt_bytes(up)} ↓ {_fmt_bytes(down)}\n")
|
|
# Sort by download speed desc
|
|
conns.sort(key=lambda c: c.get("download", 0), reverse=True)
|
|
limit = args.limit or 20
|
|
for c in conns[:limit]:
|
|
meta = c.get("metadata", {})
|
|
host = meta.get("host") or meta.get("destinationIP", "?")
|
|
port = meta.get("destinationPort", "")
|
|
chain = " → ".join(c.get("chains", []))
|
|
rule = c.get("rule", "")
|
|
dl = _fmt_bytes(c.get("download", 0))
|
|
ul = _fmt_bytes(c.get("upload", 0))
|
|
print(f" {host}:{port} ↑{ul} ↓{dl} [{chain}] ({rule})")
|
|
|
|
|
|
def cmd_conns_close(args):
|
|
"""Close connections."""
|
|
if args.id:
|
|
api("DELETE", f"/connections/{args.id}")
|
|
print(f"Closed connection {args.id}")
|
|
else:
|
|
api("DELETE", "/connections")
|
|
print("Closed all connections.")
|
|
|
|
|
|
def cmd_rules(args):
|
|
"""List rules."""
|
|
data = api("GET", "/rules")
|
|
rules = data.get("rules", [])
|
|
limit = args.limit or 30
|
|
print(f"Total: {len(rules)} rules (showing first {limit})\n")
|
|
for r in rules[:limit]:
|
|
print(f" {r.get('type','?')}: {r.get('payload','')} → {r.get('proxy','')}")
|
|
|
|
|
|
def cmd_dns(args):
|
|
"""Query DNS resolution."""
|
|
result = api("GET", f"/dns/query?name={_urlencode(args.domain)}&type={args.type}")
|
|
answers = result.get("Answer", [])
|
|
if not answers:
|
|
print(f"No DNS records for {args.domain}")
|
|
return
|
|
for a in answers:
|
|
print(f" {a.get('Name','')} {a.get('Type','')} {a.get('data','')}")
|
|
|
|
|
|
def cmd_flush_dns(args):
|
|
"""Flush DNS cache."""
|
|
api("POST", "/cache/flushdns")
|
|
print("DNS cache flushed.")
|
|
|
|
|
|
def cmd_restart(args):
|
|
"""Restart mihomo core."""
|
|
api("PUT", "/restart")
|
|
print("Core restarting...")
|
|
|
|
|
|
def cmd_upgrade_geo(args):
|
|
"""Update GeoIP/GeoSite databases."""
|
|
api("POST", "/configs/geo")
|
|
print("GeoIP/GeoSite update triggered.")
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _fmt_bytes(n):
|
|
for unit in ("B", "KB", "MB", "GB"):
|
|
if abs(n) < 1024:
|
|
return f"{n:.1f}{unit}"
|
|
n /= 1024
|
|
return f"{n:.1f}TB"
|
|
|
|
|
|
def _urlencode(s):
|
|
import urllib.parse
|
|
return urllib.parse.quote(s, safe="")
|
|
|
|
|
|
# ── Main ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description="Clash Verge CLI - control mihomo via API")
|
|
p.add_argument("--sock", help=f"Unix socket path (default: {DEFAULT_SOCK})")
|
|
p.add_argument("--api", help=f"HTTP API URL (default: {DEFAULT_HTTP})")
|
|
p.add_argument("--secret", help="API secret")
|
|
sub = p.add_subparsers(dest="cmd")
|
|
|
|
sub.add_parser("status", help="Show overall status")
|
|
|
|
s = sub.add_parser("mode", help="Get/set proxy mode")
|
|
s.add_argument("value", nargs="?", choices=["rule", "global", "direct"])
|
|
|
|
sub.add_parser("groups", help="List proxy groups")
|
|
|
|
s = sub.add_parser("nodes", help="List nodes in a group")
|
|
s.add_argument("group", help="Group name")
|
|
|
|
s = sub.add_parser("select", help="Select node in a group")
|
|
s.add_argument("group", help="Group name")
|
|
s.add_argument("node", help="Node name")
|
|
|
|
s = sub.add_parser("delay", help="Test node delay")
|
|
s.add_argument("target", help="Node or group name")
|
|
s.add_argument("--url", help="Test URL")
|
|
s.add_argument("--timeout", type=int, help="Timeout in ms (default: 5000)")
|
|
|
|
s = sub.add_parser("delay-group", help="Test all nodes in a group")
|
|
s.add_argument("group", help="Group name")
|
|
s.add_argument("--url", help="Test URL")
|
|
s.add_argument("--timeout", type=int, help="Timeout in ms (default: 5000)")
|
|
|
|
s = sub.add_parser("conns", help="List active connections")
|
|
s.add_argument("--limit", type=int, help="Max connections to show (default: 20)")
|
|
|
|
s = sub.add_parser("conns-close", help="Close connections")
|
|
s.add_argument("--id", help="Connection ID (omit to close all)")
|
|
|
|
s = sub.add_parser("rules", help="List rules")
|
|
s.add_argument("--limit", type=int, help="Max rules to show (default: 30)")
|
|
|
|
s = sub.add_parser("dns", help="Query DNS")
|
|
s.add_argument("domain", help="Domain to query")
|
|
s.add_argument("--type", default="A", help="Record type (default: A)")
|
|
|
|
sub.add_parser("flush-dns", help="Flush DNS cache")
|
|
sub.add_parser("restart", help="Restart mihomo core")
|
|
sub.add_parser("upgrade-geo", help="Update GeoIP/GeoSite databases")
|
|
|
|
args = p.parse_args()
|
|
|
|
# Override env from CLI args
|
|
import os
|
|
if args.sock:
|
|
os.environ["CLASH_SOCK"] = args.sock
|
|
if args.api:
|
|
os.environ["CLASH_API"] = args.api
|
|
if args.secret:
|
|
os.environ["CLASH_SECRET"] = args.secret
|
|
|
|
dispatch = {
|
|
"status": cmd_status, "mode": cmd_mode, "groups": cmd_groups,
|
|
"nodes": cmd_nodes, "select": cmd_select, "delay": cmd_delay,
|
|
"delay-group": cmd_delay_group, "conns": cmd_conns,
|
|
"conns-close": cmd_conns_close, "rules": cmd_rules,
|
|
"dns": cmd_dns, "flush-dns": cmd_flush_dns,
|
|
"restart": cmd_restart, "upgrade-geo": cmd_upgrade_geo,
|
|
}
|
|
if args.cmd in dispatch:
|
|
dispatch[args.cmd](args)
|
|
else:
|
|
p.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|