Files
ssh-e2e/cli.py
T
leefer 97d8b7ae89 跨平台支持:CLI 模式 + 三平台一键安装命令
- cli.py:无界面命令行(host/tunnel/list/test/start/remove),复用隧道引擎
- storage.py:配置目录跨平台(Win %APPDATA%,Linux ~/ssh-e2e/.config,macOS ~/ssh-e2e/Library),
  非 Windows 经 keyring+AES-GCM 加密机密,无后端时明文并提示
- install.sh / install.ps1:经 gitea.xbay.cc 一键拉取安装
- 修复:Store 未创建配置目录的崩溃(CLI 路径)
2026-09-27 19:59:25 +08:00

272 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""SSH 隧道管理器 — 命令行模式(跨平台,无界面)。
用法:
ssh-tunnel host add <名称> <user@host> [端口=22] [--password] [--key 路径]
ssh-tunnel tunnel add <名称> --host <主机名或ID> --lp 本地端口 --rp 远程端口 [--rh 远程地址=127.0.0.1]
ssh-tunnel start <隧道名或ID> [隧道...] # 前台运行,Ctrl+C 退出
ssh-tunnel stop-all # (与 start 配套,信号处理自动调用)
ssh-tunnel list # 列出主机与隧道
ssh-tunnel remove host|tunnel <名称或ID>
ssh-tunnel test <主机名或ID>
"""
from __future__ import annotations
import argparse
import getpass
import signal
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from engine import TunnelManager, probe_host # noqa: E402
from storage import Store, data_root # noqa: E402
def _print(msg: str) -> None:
print(msg, flush=True)
class _QuietEmit:
"""CLI 模式下把引擎事件打到终端。"""
def __init__(self):
self._last: dict[str, str] = {}
def __call__(self, ev: dict) -> None:
if ev.get("type") == "tunnel":
if self._last.get(ev["id"]) == ev["status"]:
return # 状态未变(如连接计数刷新),不重复打印
self._last[ev["id"]] = ev["status"]
t = ev["status"]
if t == "connected":
_print("[隧道] 已连接")
elif t == "error":
_print(f"[隧道] 连接失败: {ev.get('error', '')}")
elif ev.get("type") == "log":
e = ev["entry"]
lv = e["level"]
mark = {"info": "·", "success": "✔", "warn": "!", "error": "×"}.get(lv, "·")
_print(f"[{e['t']}] {mark} {e['msg']}")
def find_host(store: Store, ref: str):
hosts = store.data["hosts"]
return next((h for h in hosts if h["id"] == ref or h["name"] == ref), None)
def find_tunnel(store: Store, ref: str):
ts = store.data["tunnels"]
return next((t for t in ts if t["id"] == ref or t["name"] == ref), None)
def cmd_host_add(store, args) -> int:
if "@" not in args.target:
_print("错误: 目标格式应为 user@host")
return 1
user, host = args.target.rsplit("@", 1)
auth = "key" if args.key else "password"
if find_host(store, args.name):
_print(f"错误: 主机「{args.name}」已存在")
return 1
password = passphrase = ""
if auth == "password" and args.password:
password = getpass.getpass("登录密码: ")
if auth == "key" and args.key:
if not Path(args.key).expanduser().exists():
_print(f"错误: 私钥文件不存在: {args.key}")
return 1
passphrase = getpass.getpass("私钥口令(无则直接回车): ")
store.data["hosts"].append({
"id": Store.new_id(), "name": args.name, "hostname": host,
"port": args.port, "username": user, "auth": auth,
"password": password, "key_path": str(Path(args.key).expanduser()) if args.key else "",
"key_passphrase": passphrase, "note": "",
})
store.save()
_print(f"已添加主机 {user}@{host}:{args.port}(认证:{'私钥' if auth == 'key' else '密码'})")
return 0
def cmd_tunnel_add(store, args) -> int:
host = find_host(store, args.host)
if not host:
_print(f"错误: 找不到主机「{args.host}」,先执行 host add")
return 1
for t in store.data["tunnels"]:
if t["local_port"] == args.lp:
_print(f"错误: 本地端口 {args.lp} 已被隧道「{t['name']}」使用")
return 1
store.data["tunnels"].append({
"id": Store.new_id(), "name": args.name, "host_id": host["id"],
"local_port": args.lp, "remote_host": args.rh or "127.0.0.1",
"remote_port": args.rp, "autostart": False, "autoreconnect": True,
})
store.save()
_print(f"已添加隧道 {args.name}: 127.0.0.1:{args.lp} → {args.rh or '127.0.0.1'}:{args.rp}(经 {host['name']})")
_print(f"启动: ssh-tunnel start {args.name}")
return 0
def cmd_list(store, _args) -> int:
if store.warn_plaintext:
_print("提示: 当前平台无可用密钥后端,密码以明文保存于配置文件。")
_print("主机:")
if not store.data["hosts"]:
_print(" (无) 添加: ssh-tunnel host add <名称> user@host")
for h in store.data["hosts"]:
_print(f" {h['name']} {h['username']}@{h['hostname']}:{h['port']} "
f"{'私钥' if h.get('auth') == 'key' else '密码'}")
_print("隧道:")
if not store.data["tunnels"]:
_print(" (无) 添加: ssh-tunnel tunnel add <名称> --host <主机> --lp 本地端口 --rp 远程端口")
for t in store.data["tunnels"]:
h = next((x for x in store.data["hosts"] if x["id"] == t["host_id"]), None)
hn = h["name"] if h else "(主机缺失)"
_print(f" {t['name']} 127.0.0.1:{t['local_port']} → {t['remote_host']}:{t['remote_port']} 经 {hn}")
return 0
def cmd_test(store, args) -> int:
host = find_host(store, args.host)
if not host:
_print(f"错误: 找不到主机「{args.host}」")
return 1
_print(f"正在测试 {host['username']}@{host['hostname']}:{host['port']} ...")
ok, msg = probe_host(host, data_root() / "known_hosts")
_print(("✔ " if ok else "× ") + msg)
return 0 if ok else 1
def cmd_remove(store, args) -> int:
if args.kind == "host":
h = find_host(store, args.ref)
if not h:
_print(f"错误: 找不到主机「{args.ref}」")
return 1
used = [t["name"] for t in store.data["tunnels"] if t["host_id"] == h["id"]]
if used:
_print(f"错误: 主机被隧道使用: {', '.join(used)}")
return 1
store.data["hosts"].remove(h)
store.save()
_print(f"已删除主机 {h['name']}")
else:
t = find_tunnel(store, args.ref)
if not t:
_print(f"错误: 找不到隧道「{args.ref}」")
return 1
store.data["tunnels"].remove(t)
store.save()
_print(f"已删除隧道 {t['name']}")
return 0
def cmd_start(store, args) -> int:
refs = args.tunnels
if not refs: # 未指定则启动全部
refs = [t["name"] for t in store.data["tunnels"]]
if not refs:
_print("没有已配置的隧道。先用 tunnel add 添加。")
return 1
hosts_by_id = {h["id"]: h for h in store.data["hosts"]}
mgr = TunnelManager(_QuietEmit(), data_root()) chosen = []
for r in refs:
t = find_tunnel(store, r)
if not t:
_print(f"错误: 找不到隧道「{r}」")
return 1
chosen.append(t)
mgr.apply_config(chosen, hosts_by_id)
if store.warn_plaintext:
_print("提示: 当前平台无可用密钥后端,密码以明文保存于配置文件。")
names = ", ".join(t["name"] for t in chosen)
_print(f"启动隧道: {names} — Ctrl+C 退出")
stopping = False
def _stop(*_a):
nonlocal stopping
if not stopping:
stopping = True
_print("\n正在断开所有隧道 ...")
mgr.stop_all()
signal.signal(signal.SIGINT, _stop)
signal.signal(signal.SIGTERM, _stop)
for t in mgr.tunnels.values():
t.start()
try:
while not stopping:
time.sleep(0.3)
alive = [t for t in mgr.tunnels.values() if t.status != "stopped"]
if not alive:
_print("所有隧道已停止。")
return 1
except KeyboardInterrupt:
_stop()
return 0
def main() -> int:
p = argparse.ArgumentParser(prog="ssh-tunnel", description="SSH 隧道管理器(命令行)")
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("host", help="主机管理")
hsub = sp.add_subparsers(dest="sub", required=True)
ha = hsub.add_parser("add", help="添加主机")
ha.add_argument("name", help="备注名称")
ha.add_argument("target", help="user@host")
ha.add_argument("port", nargs="?", type=int, default=22)
ha.add_argument("--password", action="store_true", help="交互输入密码(默认)")
ha.add_argument("--key", help="私钥文件路径")
sp = sub.add_parser("tunnel", help="隧道管理")
tsub = sp.add_subparsers(dest="sub", required=True)
ta = tsub.add_parser("add", help="添加隧道")
ta.add_argument("name")
ta.add_argument("--host", required=True, help="主机名称或 ID")
ta.add_argument("--lp", type=int, required=True, help="本地端口")
ta.add_argument("--rp", type=int, required=True, help="远程端口")
ta.add_argument("--rh", default="127.0.0.1", help="远程地址(默认 127.0.0.1)")
sub.add_parser("list", help="列出主机与隧道")
sub.add_parser("stop-all", help="预留(前台模式退出时自动断开)")
st = sub.add_parser("start", help="前台启动隧道")
st.add_argument("tunnels", nargs="*", help="隧道名称或 ID(空=全部)")
ts = sub.add_parser("test", help="测试主机连通")
ts.add_argument("host")
rm = sub.add_parser("remove", help="删除主机或隧道")
rm.add_argument("kind", choices=["host", "tunnel"])
rm.add_argument("ref")
args = p.parse_args()
store = Store(data_root())
if args.cmd == "host":
return cmd_host_add(store, args)
if args.cmd == "tunnel":
return cmd_tunnel_add(store, args)
if args.cmd == "list":
return cmd_list(store, args)
if args.cmd == "test":
return cmd_test(store, args)
if args.cmd == "remove":
return cmd_remove(store, args)
if args.cmd == "start":
return cmd_start(store, args)
if args.cmd == "stop-all":
_print("隧道随前台进程退出自动断开,无需单独停止。")
return 0
return 1
if __name__ == "__main__":
sys.exit(main())