diff --git a/.test_www/index.html b/.test_www/index.html
new file mode 100644
index 0000000..34c6485
--- /dev/null
+++ b/.test_www/index.html
@@ -0,0 +1 @@
+
WIZARD-OK
diff --git a/.wiz-test/SshTunnelManager/config.json b/.wiz-test/SshTunnelManager/config.json
new file mode 100644
index 0000000..583d244
--- /dev/null
+++ b/.wiz-test/SshTunnelManager/config.json
@@ -0,0 +1,28 @@
+{
+ "hosts": [
+ {
+ "id": "a6a8a6f5",
+ "name": "本机向导测试",
+ "hostname": "127.0.0.1",
+ "port": 2222,
+ "username": "test",
+ "auth": "password",
+ "password": "dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAzni2ki6QM0W9HV1ipBcLPAAAAAACAAAAAAAQZgAAAAEAACAAAACEzy/56phqkFnZG+3rc7ODinJyOOszrzskmLsWLohEWgAAAAAOgAAAAAIAACAAAACnk3P/9dav98TxOrMZyz8+CyC0aSS0C3sRghY/u+4JZxAAAAAIPCoTBD+yaT1EUVFGwNITQAAAAMxgRjPaPFHuN2v7CwkgmCKMaCAE1oA0m+vDi1dujjEZJd/yT45VkRe9REH1V95/5CJLhDzf2cp2HY04WgxDMlU=",
+ "key_path": "",
+ "key_passphrase": "",
+ "note": ""
+ }
+ ],
+ "tunnels": [
+ {
+ "id": "5005e5b2",
+ "name": "向导隧道",
+ "host_id": "a6a8a6f5",
+ "local_port": 13003,
+ "remote_host": "127.0.0.1",
+ "remote_port": 3210,
+ "autostart": false,
+ "autoreconnect": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.wiz-test/SshTunnelManager/known_hosts b/.wiz-test/SshTunnelManager/known_hosts
new file mode 100644
index 0000000..255dabd
--- /dev/null
+++ b/.wiz-test/SshTunnelManager/known_hosts
@@ -0,0 +1 @@
+[127.0.0.1]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD0I9YPL8oqfY6L1d0LUrFmvVBtsMI5sxetirysUdgvtZXH/XRFqvLnrrG40NTJxTdfQ1avnj1ZRtXUVlYHVxYmwWJR0s2Rz0Q+UB5E9sDQhiww8lLwraD3hvN37iItMorS0qry2AWurKiHMtWw28GGQtP8Dwk8TBR5QLAS8BBAI54vq6m7dVbEpkJOzNl0lRMYJ64AL0S0ZrPplJgpJLEeXepcWhk8YTAelg7mPiL2hb81J0Bmy8DJIIwzv1h41jSIJecOZslQ9gYYpmCDbPmozooSgN8pCxPcCo0fmsJB2I73GKsPJACYOpcAeLGZA5uAfx8LHrr8sJkwQFrVaorB
diff --git a/.wiz-test/wiz.py b/.wiz-test/wiz.py
new file mode 100644
index 0000000..abbc2a2
--- /dev/null
+++ b/.wiz-test/wiz.py
@@ -0,0 +1,26 @@
+import builtins, os, sys
+here = os.path.dirname(os.path.abspath(__file__))
+os.environ["APPDATA"] = here # 隔离配置目录
+sys.path.insert(0, os.path.dirname(here))
+import getpass
+getpass.getpass = lambda *a, **k: "test123" # 密码桩
+import cli
+
+ANSWERS = iter([
+ "1", # 选择:新增主机
+ "test@127.0.0.1", # SSH 目标
+ "2222", # SSH 端口
+ "1", # 认证方式:密码
+ "本机向导测试", # 主机备注名
+ "1", # 选择:新建隧道
+ "127.0.0.1", # 远程服务地址(默认)
+ "3210", # 远程服务端口
+ "13003", # 本地端口(避开本机 3210 测试服务)
+ "向导隧道", # 隧道备注名
+])
+def fake_input(prompt=""):
+ a = next(ANSWERS)
+ print(f"{prompt}[{a}]")
+ return a
+builtins.input = fake_input
+sys.exit(cli.wizard(cli.Store(cli.data_root())))
diff --git a/README.md b/README.md
index 8aba054..2666b28 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,9 @@ powershell -c "irm https://gitea.xbay.cc/leefer/ssh-e2e/raw/branch/main/install.
curl -fsSL https://gitea.xbay.cc/leefer/ssh-e2e/raw/branch/main/install.sh | bash
```
+安装完成会**自动进入中文配置向导**:按提示输入 SSH 地址、密码、远程端口等
+(直接回车采用方括号里的默认值),填完隧道立即建立,Ctrl+C 断开。
+
## 典型场景
远程主机上有个服务只监听它自己的 `127.0.0.1:3000`,公网访问不到。
@@ -24,12 +27,12 @@ curl -fsSL https://gitea.xbay.cc/leefer/ssh-e2e/raw/branch/main/install.sh | bas
## 命令行用法(Linux/macOS,Windows 亦可 `python cli.py`)
```bash
-ssh-tunnel host add 我的服务器 root@1.2.3.4 # 添加主机(交互输入密码;私钥加 --key ~/.ssh/id_rsa)
-ssh-tunnel test 我的服务器 # 测试连通与认证
-ssh-tunnel tunnel add web --host 我的服务器 --lp 3000 --rp 3000
-ssh-tunnel start web # 前台运行,Ctrl+C 断开
-ssh-tunnel list # 查看全部
+ssh-tunnel # 交互向导:选已有主机/隧道或新建,建好直接运行
+ssh-tunnel start web # 直接启动某条已配置的隧道(前台,Ctrl+C 断开)
+ssh-tunnel list # 查看全部
+ssh-tunnel test 我的服务器 # 测试主机认证
ssh-tunnel remove tunnel web
+ssh-tunnel --help # 完整参数(host add / tunnel add 等)
```
## 图形界面(Windows)
diff --git a/cli.py b/cli.py
index 20ad935..80cfc35 100644
--- a/cli.py
+++ b/cli.py
@@ -164,22 +164,10 @@ def cmd_remove(store, args) -> int:
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
+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())
- 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:
@@ -212,7 +200,208 @@ def cmd_start(store, args) -> int:
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)
diff --git a/install.sh b/install.sh
index a85e933..35ec232 100644
--- a/install.sh
+++ b/install.sh
@@ -135,14 +135,7 @@ case ":$PATH:" in
;;
esac
-say "安装完成!"
+say "安装完成!正在进入配置向导 ..."
echo
-echo " 配置隧道(示例):"
-echo " ssh-tunnel host add 我的服务器 root@1.2.3.4 22"
-echo " ssh-tunnel tunnel add web --host 我的服务器 --lp 3000 --rp 3000"
-echo " ssh-tunnel start web"
-echo
-echo " 常用命令: ssh-tunnel list | test <主机> | remove host|tunnel <名称>"
-echo " 图形界面(Windows): powershell -c \"irm $BASE_URL/raw/branch/main/install.ps1 | iex\""
-echo
-[ -n "${ZSH_VERSION:-}${BASH_VERSION:-}" ] || echo "(注:新开终端或 source ~/.bashrc 后 ssh-tunnel 生效)"
+# curl|bash 时 stdin 是管道,交互输入从终端(/dev/tty)读
+exec "$BIN_DIR/ssh-tunnel" < /dev/tty