#!/usr/bin/env python3 """SSH 隧道管理器 — 命令行模式(跨平台,无界面)。 用法: ssh-tunnel host add <名称> [端口=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 run_foreground(store: Store, chosen: list) -> int: """前台运行选中的隧道,Ctrl+C 退出。""" hosts_by_id = {h["id"]: h for h in store.data["hosts"]} mgr = TunnelManager(_QuietEmit(), data_root()) 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 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 chosen = [] for r in refs: t = find_tunnel(store, r) if not t: _print(f"错误: 找不到隧道「{r}」") return 1 chosen.append(t) return run_foreground(store, chosen) # ---------- 交互向导 ---------- def _ensure_tty_stdin() -> None: """curl|bash 场景下 stdin 是管道,交互输入改从终端读。""" if sys.stdin.isatty(): return try: sys.stdin = open("/dev/tty", "r") # noqa: SIM115 except OSError: pass def _ask(prompt: str, default: str = "", allow_empty: bool = False) -> str: while True: try: v = input(prompt).strip() except EOFError: if default or allow_empty: return default raise if v: return v if default: return default if allow_empty: return "" _print(" (不能为空,请重新输入)") def _ask_port(prompt: str, default: int | None = None) -> int: while True: raw = _ask(prompt, str(default) if default else "") try: p = int(raw) except ValueError: _print(" 端口必须是数字,请重新输入") continue if 1 <= p <= 65535: return p _print(" 端口范围 1-65535,请重新输入") def _ask_secret(prompt: str) -> str: try: return getpass.getpass(prompt) except EOFError: return "" def _wizard_pick_host(store: Store): """选择已有主机或新建;返回主机 dict 或 None(取消)。""" hosts = store.data["hosts"] _print("\n── 第 1 步:选择 SSH 主机 ──") for i, h in enumerate(hosts, 1): _print(f" {i}. {h['name']} ({h['username']}@{h['hostname']}:{h['port']})") _print(f" {len(hosts) + 1}. 新增主机") while True: sel = _ask(f"请选择 [1-{len(hosts) + 1}]", str(len(hosts) + 1 if not hosts else "1")) if not sel.isdigit() or not (1 <= int(sel) <= len(hosts) + 1): _print(" 无效选择,请重新输入") continue n = int(sel) if n <= len(hosts): return hosts[n - 1] return _wizard_new_host(store) def _wizard_new_host(store: Store): _print("\n新增主机(带 * 的为必填):") while True: target = _ask(" * SSH 登录目标 (格式 用户名@主机,如 root@1.2.3.4): ") if "@" in target and len(target.split("@")) == 2 and target.split("@")[1]: break _print(" 格式不对,应类似 root@1.2.3.4") user, hostaddr = target.rsplit("@", 1) port = _ask_port(" SSH 端口 [22]: ", 22) auth = _ask(" 认证方式 1) 密码 2) 私钥 [1]: ", "1") while auth not in ("1", "2"): auth = _ask(" 请输入 1 或 2: ", "1") rec = { "id": Store.new_id(), "name": "", "hostname": hostaddr, "port": port, "username": user, "auth": "key" if auth == "2" else "password", "password": "", "key_path": "", "key_passphrase": "", "note": "", } if rec["auth"] == "key": rec["key_path"] = _ask(" * 私钥文件路径 (如 ~/.ssh/id_rsa): ") rec["key_passphrase"] = _ask_secret(" 私钥口令 (没有则直接回车): ") else: rec["password"] = _ask_secret(" * 登录密码 (输入时不显示): ") rec["name"] = _ask(" 主机备注名 [默认取地址]: ", hostaddr) # 连通测试,失败可重试输入 while True: _print(f"正在测试 {user}@{hostaddr}:{port} ...") ok, msg = probe_host(rec, data_root() / "known_hosts") _print((" ✔ " if ok else " ✘ ") + msg) if ok: break retry = _ask(" 连接失败。重试请回车,修改密码输入 p,放弃输入 q [回车重试]: ", "") if retry.lower() == "q": return None if retry.lower() == "p" and rec["auth"] == "password": rec["password"] = _ask_secret(" * 重新输入登录密码: ") store.data["hosts"].append(rec) store.save() _print(f"已保存主机「{rec['name']}」") return rec def _wizard_pick_tunnel(store: Store, host: dict): """选择该主机的已有隧道或新建;返回隧道 dict 或 None。""" ts = [t for t in store.data["tunnels"] if t["host_id"] == host["id"]] _print("\n── 第 2 步:配置隧道 ──") for i, t in enumerate(ts, 1): _print(f" {i}. {t['name']} (127.0.0.1:{t['local_port']} → {t['remote_host']}:{t['remote_port']})") _print(f" {len(ts) + 1}. 新建隧道") while True: sel = _ask(f"请选择 [1-{len(ts) + 1}]", str(len(ts) + 1 if not ts else "1")) if not sel.isdigit() or not (1 <= int(sel) <= len(ts) + 1): _print(" 无效选择,请重新输入") continue n = int(sel) if n <= len(ts): return ts[n - 1] return _wizard_new_tunnel(store, host) def _wizard_new_tunnel(store: Store, host: dict): _print("\n新建隧道:把远程主机上的服务映射到本机端口") _print(" (远程服务只监听 127.0.0.1 时,远程地址保持默认即可)") rh = _ask(" 远程服务地址 [127.0.0.1]: ", "127.0.0.1") rp = _ask_port(" * 远程服务端口 (如 3000): ") lp = _ask_port(f" 本地端口 [{rp}]: ", rp) for t in store.data["tunnels"]: if t["local_port"] == lp: _print(f" 本地端口 {lp} 已被隧道「{t['name']}」占用") lp = _ask_port(" 换一个本地端口: ") name = _ask(f" 隧道备注名 [{rh}:{rp}]: ", f"{rh}:{rp}") rec = { "id": Store.new_id(), "name": name, "host_id": host["id"], "local_port": lp, "remote_host": rh, "remote_port": rp, "autostart": False, "autoreconnect": True, } store.data["tunnels"].append(rec) store.save() _print(f"已保存隧道「{name}」") return rec def wizard(store: Store) -> int: _ensure_tty_stdin() _print("╭─────────────────────────────╮") _print("│ SSH 隧道配置向导 │") _print("╰─────────────────────────────╯") _print("按中文提示输入,直接回车采用 [方括号] 里的默认值;Ctrl+C 随时退出。") _print(f"配置文件: {store.path}") if store.warn_plaintext: _print("提示: 当前平台密码以明文保存于配置文件(权限已设为仅本用户可读)。") host = _wizard_pick_host(store) if not host: _print("已取消。") return 1 tunnel = _wizard_pick_tunnel(store, host) if not tunnel: _print("已取消。") return 1 _print(f"\n隧道即将建立: 本机 127.0.0.1:{tunnel['local_port']}" f" ←→ 远程 {tunnel['remote_host']}:{tunnel['remote_port']}(经 {host['name']})") code = run_foreground(store, [tunnel]) if code == 0: _print(f"隧道已断开。下次直接运行: ssh-tunnel start {tunnel['name']}") return code def main() -> int: if len(sys.argv) == 1: # 无参数:直接进入交互向导 return wizard(Store(data_root())) 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__": try: sys.exit(main()) except KeyboardInterrupt: sys.exit(130) except SystemExit: raise except Exception: # noqa: BLE001 出错必须可见,禁止无声退出 import traceback sys.stderr.write("\n发生错误,已退出:\n" + traceback.format_exc()) sys.exit(1)