Files
ssh-e2e/cli.py
T
leefer 078b272eb1 中文交互向导:ssh-tunnel 无参数进入,装完自动进入,填完直接运行
- 向导:选/建主机(实时连通测试,可重试)→ 选/建隧道(端口智能默认)→ 立即前台运行
- install.sh 结尾 exec 进入向导(stdin 从 /dev/tty 读,兼容 curl|bash)
- cli.py 抽出 run_foreground 供 start 与向导复用;修复向导新建隧道分支不可达
2026-09-27 20:15:21 +08:00

462 lines
17 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 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 随时退出。")
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__":
sys.exit(main())