From 27a3b4c6a8e67c36e2fa36bfdf5f2d93b12478be Mon Sep 17 00:00:00 2001 From: leefer Date: Thu, 24 Sep 2026 17:14:59 +0800 Subject: [PATCH] =?UTF-8?q?SSH=20=E9=9A=A7=E9=81=93=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=99=A8:pywebview+paramiko=20=E6=A1=8C=E9=9D=A2=E5=BA=94?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 本地端口转发隧道管理(密码/私钥认证,自动重连,随应用启动) - 主机与隧道可视化配置,DPAPI 加密存储机密,known_hosts 校验 - 自定义无边框窗口,深色界面,托盘驻留(最小化/关闭到托盘) - 内置本地测试 SSH 服务端与端到端自测脚本 --- .gitignore | 8 + README.md | 53 ++++ engine.py | 356 ++++++++++++++++++++++++ main.py | 488 ++++++++++++++++++++++++++++++++ requirements.txt | 4 + storage.py | 100 +++++++ test_e2e.py | 143 ++++++++++ test_sshd.py | 109 ++++++++ ui/app.ico | Bin 0 -> 28491 bytes ui/app.js | 709 +++++++++++++++++++++++++++++++++++++++++++++++ ui/icon-256.png | Bin 0 -> 8686 bytes ui/index.html | 117 ++++++++ ui/logo.svg | 9 + ui/style.css | 630 +++++++++++++++++++++++++++++++++++++++++ 启动.bat | 19 ++ 15 files changed, 2745 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 engine.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 storage.py create mode 100644 test_e2e.py create mode 100644 test_sshd.py create mode 100644 ui/app.ico create mode 100644 ui/app.js create mode 100644 ui/icon-256.png create mode 100644 ui/index.html create mode 100644 ui/logo.svg create mode 100644 ui/style.css create mode 100644 启动.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..969b028 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +.icon-build/ +.test_data/ +.test_host_key +*.log +.crash.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c22efc --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# SSH 隧道管理器 + +Windows 桌面小工具,用于可视化管理 SSH 本地端口转发。 + +典型场景:远程主机上有个服务只监听它自己的 `127.0.0.1:3000`,公网访问不到。 +用本工具建一条隧道后,在本机打开 `http://127.0.0.1:3000` 即可直接访问该服务。 + +## 运行 + +双击 **`启动.bat`**。首次运行会自动创建虚拟环境并安装依赖(pywebview、paramiko), +之后秒开。也可以手动: + +```bat +python -m venv .venv +.venv\Scripts\python -m pip install pywebview paramiko +.venv\Scripts\python main.py +``` + +要求:Windows 10/11(自带 WebView2 运行时,如缺失请安装 +[Edge WebView2 Runtime](https://developer.microsoft.com/microsoft-edge/webview2/))、Python 3.10+。 + +## 使用 + +1. **主机**:添加一台 SSH 主机(地址、端口、用户名;认证支持密码或私钥,可点「测试连接」验证)。 +2. **隧道**:新建一条隧道,选择主机,填远程地址(一般 `127.0.0.1`)、远程端口和本地端口,点「启动」。 +3. 本机访问 `127.0.0.1:本地端口` 即等于访问远程服务;卡片上的「打开」按钮可直接在浏览器打开。 + +其他:断线自动重连(间隔递增)、随应用启动、运行时长与累计连接数、每条隧道及全局日志。 +点「最小化」或「关闭」会驻留到**系统托盘**(隧道在后台保持运行):左键托盘图标恢复窗口, +右键菜单可显示或退出;首次最小化会有气泡提示。 +配置保存在 `%APPDATA%\SshTunnelManager\config.json`,密码与私钥口令经 Windows DPAPI +加密后落盘(绑定当前 Windows 用户,明文不落盘);主机公钥记录在同目录 `known_hosts`, +主机密钥变更时会拒绝连接。 + +## 文件结构 + +``` +main.py 入口:pywebview 窗口与 JS API +engine.py 隧道引擎:paramiko 转发、保活、自动重连 +storage.py 配置存储:JSON + DPAPI 加密 +ui/ 前端界面(原生 WebView2 渲染) +test_sshd.py 本地测试用 SSH 服务端(自测) +test_e2e.py 端到端自测脚本(需先启动 test_sshd) +``` + +## 自测 + +```bat +:: 窗口 1 +.venv\Scripts\python test_sshd.py +:: 窗口 2(会在本机 3210 起一个模拟"远程服务",经隧道 13000 访问) +.venv\Scripts\python test_e2e.py +``` diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..edc9256 --- /dev/null +++ b/engine.py @@ -0,0 +1,356 @@ +"""SSH 隧道引擎:基于 paramiko 的本地端口转发,含自动重连与状态事件。""" +from __future__ import annotations + +import select +import socket +import threading +import time +from collections import deque + +import paramiko + +_POLL = 0.5 # 监听 socket 的 accept 轮询间隔 +_KEEPALIVE = 15 # SSH 保活秒数 + + +def friendly_error(e: Exception) -> str: + if isinstance(e, paramiko.BadHostKeyException): + return "主机密钥校验失败:该主机的主机密钥与已记录的不一致(主机可能被重装)。" + if isinstance(e, paramiko.AuthenticationException): + return "SSH 认证失败:请检查用户名、密码或私钥。" + if isinstance(e, paramiko.SSHException): + msg = str(e) + if "negotiation" in msg or "timed out" in msg or "Timeout" in msg: + return "SSH 握手超时:远程主机无响应。" + if "not a valid" in msg.lower() or ("key" in msg.lower() and "invalid" in msg.lower()): + return f"密钥无效:{msg}" + return f"SSH 错误:{msg}" + if isinstance(e, socket.gaierror): + return f"无法解析主机名:{getattr(e, 'args', [''])[0]}" + if isinstance(e, ConnectionRefusedError): + return "连接被拒绝:远程主机的 SSH 端口不可达(服务未运行或防火墙拦截)。" + if isinstance(e, (socket.timeout, TimeoutError)): + return "连接超时:远程主机无响应。" + winerr = getattr(e, "winerror", None) + if winerr == 10048: + return "本地端口绑定失败:该端口已被其他程序占用。" + if winerr == 10013: + return "本地端口绑定失败:权限不足(可换一个 >1024 的端口)。" + return str(e) or e.__class__.__name__ + + +class Tunnel: + """一条本地转发隧道。生命周期:stopped → connecting → connected ⇄ error。""" + + def __init__(self, cfg: dict, host: dict | None, emit, known_hosts_path): + self.cfg = dict(cfg) + self.host = dict(host) if host else None + self._emit = emit + self.known_hosts_path = known_hosts_path + + self.status = "stopped" + self.error = "" + self.started_at: float | None = None + self.conn_count = 0 + self.logs: deque = deque(maxlen=200) + + self._desired = False + self._thread: threading.Thread | None = None + self._client: paramiko.SSHClient | None = None + self._listener: socket.socket | None = None + self._wakeup = threading.Event() + + # ---------- 状态 ---------- + + def snapshot(self) -> dict: + return { + "id": self.cfg["id"], + "status": self.status, + "error": self.error, + "started_at": self.started_at, + "conn_count": self.conn_count, + } + + def _log(self, level: str, msg: str) -> None: + entry = {"t": time.strftime("%H:%M:%S"), "level": level, "msg": msg} + self.logs.append(entry) + try: + self._emit({"type": "log", "tunnel_id": self.cfg["id"], "entry": entry}) + except Exception: + pass + + def _set(self, status: str, error: str = "") -> None: + self.status = status + self.error = error + try: + self._emit({"type": "tunnel", **self.snapshot()}) + except Exception: + pass + + # ---------- 控制 ---------- + + def start(self) -> None: + if self._desired: + return + if not self.host: + self._set("error", "隧道未绑定有效的主机") + return + self._desired = True + self._wakeup.clear() + self._set("connecting") + self._thread = threading.Thread( + target=self._run, daemon=True, name=f"tunnel-{self.cfg['id']}" + ) + self._thread.start() + + def stop(self) -> None: + self._desired = False + self._wakeup.set() + listener, self._listener = self._listener, None + if listener: + try: + listener.close() + except OSError: + pass + client, self._client = self._client, None + if client: + try: + client.close() + except Exception: + pass + thread = self._thread + if thread and thread.is_alive() and thread is not threading.current_thread(): + thread.join(timeout=3) + self._set("stopped") + self.started_at = None + + # ---------- 主循环 ---------- + + def _run(self) -> None: + backoff = 1.0 + while self._desired: + try: + self._serve_once() + if not self._desired: + return + # 连接意外断开 + self._log("warn", "SSH 连接已断开") + if not self.cfg.get("autoreconnect", True): + self._set("error", "连接已断开(未开启自动重连)") + return + self._set("connecting", "连接断开,正在重连") + except Exception as e: # noqa: BLE001 + if not self._desired: + return + msg = friendly_error(e) + self._log("error", msg) + if not self.cfg.get("autoreconnect", True): + self._set("error", msg) + return + self._set("connecting", msg) + self._wakeup.wait(backoff) + backoff = min(backoff * 2, 15) + self._set("stopped") + + def _serve_once(self) -> None: + host = self.host + cfg = self.cfg + client = paramiko.SSHClient() + self._client = client + try: + if self.known_hosts_path.exists(): + client.load_host_keys(str(self.known_hosts_path)) + else: + self.known_hosts_path.write_text("", encoding="utf-8") + client.load_host_keys(str(self.known_hosts_path)) + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + port = int(host.get("port") or 22) + kw = dict( + hostname=host["hostname"], port=port, username=host.get("username"), + timeout=12, banner_timeout=12, auth_timeout=12, + allow_agent=False, look_for_keys=False, + ) + if host.get("auth") == "key": + kw["key_filename"] = host.get("key_path") or None + if host.get("key_passphrase"): + kw["passphrase"] = host["key_passphrase"] + else: + kw["password"] = host.get("password") + self._log("info", f"正在连接 {host.get('username')}@{host['hostname']}:{port} ...") + client.connect(**kw) + try: + client.save_host_keys(str(self.known_hosts_path)) + except Exception: + pass + transport = client.get_transport() + transport.set_keepalive(_KEEPALIVE) + + local_port = int(cfg["local_port"]) + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", local_port)) + listener.listen(32) + listener.settimeout(_POLL) + self._listener = listener + + self.started_at = time.time() + self._set("connected") + self._log("info", + f"隧道已建立: 127.0.0.1:{local_port} → {cfg['remote_host']}:{cfg['remote_port']}") + + while self._desired and transport.is_active(): + try: + sock, _addr = listener.accept() + except socket.timeout: + continue + threading.Thread(target=self._pipe, args=(sock, transport), + daemon=True).start() + finally: + listener = self._listener + self._listener = None + if listener: + try: + listener.close() + except OSError: + pass + try: + client.close() + except Exception: + pass + self._client = None + + def _pipe(self, sock: socket.socket, transport) -> None: + cfg = self.cfg + self.conn_count += 1 + self._set("connected") # 顺带刷新连接计数 + dest = (cfg["remote_host"], int(cfg["remote_port"])) + try: + try: + chan = transport.open_channel("direct-tcpip", dest, sock.getpeername(), timeout=10) + except Exception as e: + self._log("warn", f"打开远程通道失败 ({dest[0]}:{dest[1]}): {friendly_error(e)}") + return + if chan is None: + self._log("warn", f"远程拒绝了到 {dest[0]}:{dest[1]} 的转发请求") + return + self._pump(sock, chan) + finally: + for c in (sock,): + try: + c.close() + except OSError: + pass + + @staticmethod + def _pump(sock: socket.socket, chan) -> None: + try: + while True: + r, _, _ = select.select([sock, chan], [], [], 1.0) + if not r: + if chan.closed or chan.eof_received: + break + continue + if sock in r: + data = sock.recv(65536) + if not data: + break + chan.sendall(data) + if chan in r: + data = chan.recv(65536) + if not data: + break + sock.sendall(data) + except Exception: + pass + finally: + try: + chan.close() + except Exception: + pass + try: + sock.close() + except OSError: + pass + + +def _opsig(cfg: dict) -> tuple: + return (cfg.get("host_id"), int(cfg.get("local_port") or 0), + cfg.get("remote_host"), int(cfg.get("remote_port") or 0), + bool(cfg.get("autoreconnect", True))) + + +def _hostsig(h: dict | None) -> tuple: + if not h: + return () + return (h.get("hostname"), h.get("port"), h.get("username"), h.get("auth"), + h.get("password"), h.get("key_path"), h.get("key_passphrase")) + + +class TunnelManager: + def __init__(self, emit, data_dir): + self._emit = emit + self.known_hosts_path = data_dir / "known_hosts" + self.tunnels: dict[str, Tunnel] = {} + + def apply_config(self, tunnels_cfg: list, hosts_by_id: dict) -> None: + alive_ids = {t["id"] for t in tunnels_cfg} + for tid in list(self.tunnels): + if tid not in alive_ids: + self.tunnels.pop(tid).stop() + + for cfg in tunnels_cfg: + host = hosts_by_id.get(cfg.get("host_id")) + cur = self.tunnels.get(cfg["id"]) + if cur is None: + tun = Tunnel(cfg, host, self._emit, self.known_hosts_path) + self.tunnels[cfg["id"]] = tun + if cfg.get("autostart"): + tun.start() + else: + changed = (_opsig(cfg) != _opsig(cur.cfg) + or _hostsig(host) != _hostsig(cur.host)) + cur.cfg = dict(cfg) + cur.host = dict(host) if host else None + if changed and cur.status != "stopped": + cur.stop() + cur.start() + + def stop_all(self, *_args) -> None: + for tun in list(self.tunnels.values()): + tun.stop() + + +def probe_host(host: dict, known_hosts_path) -> tuple[bool, str]: + """测试一台主机的连通性与认证,不影响隧道。""" + client = paramiko.SSHClient() + try: + if known_hosts_path.exists(): + client.load_host_keys(str(known_hosts_path)) + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + port = int(host.get("port") or 22) + kw = dict( + hostname=host["hostname"], port=port, username=host.get("username"), + timeout=8, banner_timeout=8, auth_timeout=8, + allow_agent=False, look_for_keys=False, + ) + if host.get("auth") == "key": + kw["key_filename"] = host.get("key_path") or None + if host.get("key_passphrase"): + kw["passphrase"] = host["key_passphrase"] + else: + kw["password"] = host.get("password") + client.connect(**kw) + try: + client.save_host_keys(str(known_hosts_path)) + except Exception: + pass + version = client.get_transport().remote_version + return True, f"认证成功 · 服务端 {version}" + except Exception as e: # noqa: BLE001 + return False, friendly_error(e) + finally: + try: + client.close() + except Exception: + pass diff --git a/main.py b/main.py new file mode 100644 index 0000000..9e2e3ce --- /dev/null +++ b/main.py @@ -0,0 +1,488 @@ +"""SSH 隧道管理器 — Windows 桌面入口(pywebview + paramiko)。""" +from __future__ import annotations + +import ctypes +import json +import os +import subprocess +from pathlib import Path + +import webview + +from engine import TunnelManager, probe_host +from storage import Store + +APP_DIR_NAME = "SshTunnelManager" +APP_TITLE = "SSH 隧道管理器" +UI_PATH = Path(__file__).parent / "ui" / "index.html" +ICON_PATH = Path(__file__).parent / "ui" / "app.ico" +TRAY_ICON_PATH = Path(__file__).parent / "ui" / "icon-256.png" +_SENSITIVE = ("password", "key_passphrase") + + +def data_dir() -> Path: + base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") + d = Path(base) / APP_DIR_NAME + d.mkdir(parents=True, exist_ok=True) + return d + + +def _port_ok(v) -> bool: + try: + return 1 <= int(v) <= 65535 + except (TypeError, ValueError): + return False + + +class TrayController: + """系统托盘:窗口最小化/关闭后驻留托盘,可恢复或真正退出。""" + + def __init__(self, api: "Api"): + self._api = api + self._icon = None + self._hinted = False + + def start(self) -> None: + try: + from PIL import Image + from pystray import Icon, Menu, MenuItem + image = Image.open(TRAY_ICON_PATH) + menu = Menu( + MenuItem("显示主窗口", self._on_show, default=True), + Menu.SEPARATOR, + MenuItem("退出", self._on_exit), + ) + self._icon = Icon("ssh-tunnel-manager", image, APP_TITLE, menu) + self._icon.run_detached() + self._icon.visible = True + except Exception: # noqa: BLE001 托盘失败不影响主窗口 + self._icon = None + + def _on_show(self, *_args) -> None: + self._api.show_main_window() + + def _on_exit(self, *_args) -> None: + self._api.exit_application() + + def notify_minimized_once(self) -> None: + if self._hinted or not self._icon: + return + self._hinted = True + try: + self._icon.notify("已最小化到系统托盘,隧道在后台保持运行", APP_TITLE) + except Exception: + pass + + def stop(self) -> None: + icon, self._icon = self._icon, None + if icon: + try: + icon.stop() + except Exception: + pass + + +class Api: + """暴露给前端 JS 的接口。所有方法返回 {"ok": bool, ...}。""" + + def __init__(self, store: Store, manager: TunnelManager): + self._store = store + self._manager = manager + self._win: webview.Window | None = None + self._maximized = False + self._really_exiting = False + self.tray = TrayController(self) + + # ---------- 事件推送 ---------- + + def _emit(self, ev: dict) -> None: + if not self._win: + return + try: + self._win.evaluate_js("window.__bus && window.__bus(%s)" + % json.dumps(ev, ensure_ascii=False)) + except Exception: + pass + + def _push_state(self) -> None: + self._emit({"type": "state", "state": self._state()}) + + # ---------- 序列化 ---------- + + def _state(self) -> dict: + hosts_by_id = {h["id"]: h for h in self._store.data["hosts"]} + hosts = [self._host_out(h) for h in self._store.data["hosts"]] + tunnels = [] + for t in self._store.data["tunnels"]: + out = {k: t.get(k) for k in + ("id", "name", "host_id", "local_port", "remote_host", + "remote_port", "autostart", "autoreconnect")} + host = hosts_by_id.get(t.get("host_id")) + out["host_label"] = (f"{host['username']}@{host['hostname']}:{host.get('port', 22)}" + if host else "(主机不存在)") + out["host_auth"] = host.get("auth") if host else None + tun = self._manager.tunnels.get(t["id"]) + out["runtime"] = tun.snapshot() if tun else { + "id": t["id"], "status": "stopped", "error": "", + "started_at": None, "conn_count": 0} + out["logs"] = list(tun.logs)[-60:] if tun else [] + tunnels.append(out) + return { + "hosts": hosts, + "tunnels": tunnels, + "feed": self._feed(), + "config_dir": str(self._store.data_dir), + } + + @staticmethod + def _host_out(h: dict) -> dict: + return { + "id": h["id"], "name": h.get("name"), "hostname": h.get("hostname"), + "port": h.get("port", 22), "username": h.get("username"), + "auth": h.get("auth", "password"), "key_path": h.get("key_path", ""), + "note": h.get("note", ""), + "has_password": bool(h.get("password")), + "has_passphrase": bool(h.get("key_passphrase")), + } + + def _feed(self) -> list: + rows = [] + for tun in self._manager.tunnels.values(): + for e in tun.logs: + rows.append({"tunnel_id": tun.cfg["id"], + "tunnel_name": tun.cfg.get("name", tun.cfg["id"]), **e}) + rows.sort(key=lambda r: r["t"]) + return rows[-300:] + + def _apply(self) -> None: + self._manager.apply_config( + self._store.data["tunnels"], + {h["id"]: h for h in self._store.data["hosts"]}) + + @staticmethod + def _fail(error: str) -> dict: + return {"ok": False, "error": error} + + # ---------- JS 接口 ---------- + + def get_state(self) -> dict: + return {"ok": True, "state": self._state()} + + def save_host(self, p: dict) -> dict: + try: + hostname = (p.get("hostname") or "").strip() + username = (p.get("username") or "").strip() + port = p.get("port") or 22 + if not hostname: + return self._fail("请填写主机地址") + if not username: + return self._fail("请填写用户名") + if not _port_ok(port): + return self._fail("SSH 端口须为 1-65535") + auth = p.get("auth") or "password" + if auth == "key" and not (p.get("key_path") or "").strip(): + return self._fail("请选择私钥文件") + + hosts = self._store.data["hosts"] + old = next((h for h in hosts if h["id"] == p.get("id")), None) + rec = { + "id": old["id"] if old else self._store.new_id(), + "name": (p.get("name") or "").strip() or hostname, + "hostname": hostname, + "port": int(port), + "username": username, + "auth": auth, + "password": (p.get("password") or "").strip(), + "key_path": (p.get("key_path") or "").strip(), + "key_passphrase": (p.get("key_passphrase") or ""), + "note": (p.get("note") or "").strip(), + } + if old: + # 留空 = 保持原值;切换认证方式时清掉另一侧的机密 + for k in _SENSITIVE: + if rec[k] == "": + rec[k] = old.get(k, "") + if auth == "key": + rec["password"] = "" + else: + rec["key_passphrase"] = "" + hosts[hosts.index(old)] = rec + else: + hosts.append(rec) + self._store.save() + self._apply() + return {"ok": True, "id": rec["id"], "state": self._state()} + except Exception as e: # noqa: BLE001 + return self._fail(str(e)) + + def delete_host(self, host_id: str) -> dict: + used = [t["name"] for t in self._store.data["tunnels"] if t.get("host_id") == host_id] + if used: + return self._fail("该主机正被隧道使用:" + "、".join(used)) + self._store.data["hosts"] = [h for h in self._store.data["hosts"] if h["id"] != host_id] + self._store.save() + self._apply() + return {"ok": True, "state": self._state()} + + def save_tunnel(self, p: dict) -> dict: + try: + host_id = p.get("host_id") + if not host_id or host_id not in {h["id"] for h in self._store.data["hosts"]}: + return self._fail("请选择主机(可在「主机」页先添加)") + local_port = p.get("local_port") + remote_port = p.get("remote_port") + if not _port_ok(local_port): + return self._fail("本地端口须为 1-65535") + if not _port_ok(remote_port): + return self._fail("远程端口须为 1-65535") + remote_host = (p.get("remote_host") or "").strip() or "127.0.0.1" + tid = p.get("id") + for t in self._store.data["tunnels"]: + if t["id"] != tid and int(t["local_port"]) == int(local_port): + return self._fail(f"本地端口 {local_port} 已被隧道「{t['name']}」使用") + + tunnels = self._store.data["tunnels"] + old = next((t for t in tunnels if t["id"] == tid), None) + rec = { + "id": old["id"] if old else self._store.new_id(), + "name": (p.get("name") or "").strip() or f"{remote_host}:{remote_port}", + "host_id": host_id, + "local_port": int(local_port), + "remote_host": remote_host, + "remote_port": int(remote_port), + "autostart": bool(p.get("autostart")), + "autoreconnect": bool(p.get("autoreconnect", True)), + } + if old: + tunnels[tunnels.index(old)] = rec + else: + tunnels.append(rec) + self._store.save() + self._apply() + return {"ok": True, "id": rec["id"], "state": self._state()} + except Exception as e: # noqa: BLE001 + return self._fail(str(e)) + + def delete_tunnel(self, tunnel_id: str) -> dict: + tun = self._manager.tunnels.pop(tunnel_id, None) + if tun: + tun.stop() + self._store.data["tunnels"] = [t for t in self._store.data["tunnels"] if t["id"] != tunnel_id] + self._store.save() + return {"ok": True, "state": self._state()} + + def start_tunnel(self, tunnel_id: str) -> dict: + self._apply() + tun = self._manager.tunnels.get(tunnel_id) + if not tun: + return self._fail("隧道不存在") + tun.start() + return {"ok": True} + + def stop_tunnel(self, tunnel_id: str) -> dict: + tun = self._manager.tunnels.get(tunnel_id) + if not tun: + return {"ok": True} + tun.stop() + return {"ok": True} + + def probe_host(self, p: dict) -> dict: + """用表单当前值(空密码沿用已存值)测试连接。""" + try: + old = next((h for h in self._store.data["hosts"] if h["id"] == p.get("id")), None) + host = { + "hostname": (p.get("hostname") or "").strip(), + "port": int(p.get("port") or 22), + "username": (p.get("username") or "").strip(), + "auth": p.get("auth") or "password", + "password": (p.get("password") or ""), + "key_path": (p.get("key_path") or "").strip(), + "key_passphrase": (p.get("key_passphrase") or ""), + } + if old: + for k in _SENSITIVE: + if host[k] == "": + host[k] = old.get(k, "") + if not host["hostname"]: + return self._fail("请先填写主机地址") + ok, msg = probe_host(host, self._manager.known_hosts_path) + return {"ok": ok, "message": msg} + except Exception as e: # noqa: BLE001 + return self._fail(str(e)) + + def open_url(self, url: str) -> dict: + if not (url.startswith("http://") or url.startswith("https://")): + return self._fail("仅允许打开 http(s) 链接") + try: + os.startfile(url) # noqa: S606 + return {"ok": True} + except Exception as e: # noqa: BLE001 + return self._fail(str(e)) + + def copy_text(self, text: str) -> dict: + try: + CF_UNICODETEXT = 13 + GMEM_MOVEABLE = 0x0002 + u32 = ctypes.WinDLL("user32") + k32 = ctypes.WinDLL("kernel32") + if not u32.OpenClipboard(None): + return self._fail("剪贴板被占用") + try: + u32.EmptyClipboard() + buf = ctypes.create_unicode_buffer(text) + h = k32.GlobalAlloc(GMEM_MOVEABLE, ctypes.sizeof(buf)) + ptr = k32.GlobalLock(h) + ctypes.memmove(ptr, buf, ctypes.sizeof(buf)) + k32.GlobalUnlock(h) + u32.SetClipboardData(CF_UNICODETEXT, h) + finally: + u32.CloseClipboard() + return {"ok": True} + except Exception as e: # noqa: BLE001 + return self._fail(str(e)) + + def pick_key_file(self) -> dict: + try: + import tkinter as tk + from tkinter import filedialog + root = tk.Tk() + root.withdraw() + root.attributes("-topmost", True) + path = filedialog.askopenfilename( + parent=root, title="选择私钥文件", + filetypes=[("私钥 / 所有文件", "*.*")]) + root.destroy() + return {"ok": True, "path": path} + except Exception as e: # noqa: BLE001 + return self._fail(str(e)) + + # ---------- 窗口控制 ---------- + + def win_minimize(self) -> dict: + """标题栏「最小化」:隐藏到系统托盘。""" + self.hide_main_window() + return {"ok": True} + + def win_maximize(self) -> dict: + if self._win: + if self._maximized: + self._win.restore() + else: + self._win.maximize() + self._maximized = not self._maximized + return {"ok": True} + + def win_close(self) -> dict: + """标题栏「关闭」:隐藏到系统托盘,隧道保持运行;退出走托盘菜单。""" + self.hide_main_window() + return {"ok": True} + + # ---------- 托盘联动(供 TrayController / 事件调用) ---------- + + def hide_main_window(self) -> None: + if self._win: + try: + self._win.hide() + except Exception: + pass + self.tray.notify_minimized_once() + + def show_main_window(self) -> None: + if self._win: + try: + self._win.show() + except Exception: + pass + + def exit_application(self) -> None: + self._really_exiting = True + self.tray.stop() + try: + self._manager.stop_all() + except Exception: + pass + if self._win: + try: + self._win.destroy() + except Exception: + pass + + +def main() -> None: + dd = data_dir() + store = Store(dd) + holder: dict = {} + manager = TunnelManager(lambda ev: holder["api"]._emit(ev), dd) + api = Api(store, manager) + holder["api"] = api + + # 随应用启动的隧道在窗口出现前就拉起 + api._apply() + + win = webview.create_window( + "SSH 隧道管理器", str(UI_PATH), js_api=api, + width=1120, height=740, min_size=(900, 580), + frameless=True, easy_drag=False, + background_color="#111418", + ) + api._win = win + win.events.closed += api._manager.stop_all + + def on_closing(*_args): + """Alt+F4 / 系统关窗请求也转为隐藏到托盘。""" + if api._really_exiting: + return None + api.hide_main_window() + return False + + try: + win.events.closing += on_closing + except Exception: + pass + + def after_start(): + # 任务栏 / Alt+Tab / 窗口图标 + try: + import clr + clr.AddReference("System.Drawing") + from System.Drawing import Icon + win.native.Icon = Icon(str(ICON_PATH)) + except Exception: + pass + api.tray.start() + + try: + webview.start(after_start) + except Exception as e: # noqa: BLE001 + msg = ("启动失败:未检测到 WebView2 运行时。\n\n" + "请安装 Microsoft Edge WebView2 Runtime 后重试。\n" + "下载: https://developer.microsoft.com/microsoft-edge/webview2/\n\n" + f"详细错误: {e}") + ctypes.windll.user32.MessageBoxW(None, msg, "SSH 隧道管理器", 0x10) + raise SystemExit(1) from e + + +if __name__ == "__main__": + try: + main() + except SystemExit: + raise + except Exception: # noqa: BLE001 + # pythonw 无控制台,任何未捕获异常都必须可见,否则表现为"闪退" + import traceback + tb = traceback.format_exc() + log_path = None + try: + log_path = data_dir() / "crash.log" + log_path.write_text(tb, encoding="utf-8") + except Exception: + pass + msg = "程序遇到错误,已退出。" + if log_path: + msg += f"\n\n详细日志:{log_path}" + try: + ctypes.windll.user32.MessageBoxW( + None, msg + "\n\n" + tb[-900:], "SSH 隧道管理器", 0x10) + except Exception: + pass + raise SystemExit(1) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0149d67 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pywebview>=5.0 +paramiko>=3.4 +pystray>=0.19 +pillow>=10.0 diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..d9cc782 --- /dev/null +++ b/storage.py @@ -0,0 +1,100 @@ +"""配置存储:JSON 文件 + DPAPI 加密敏感字段(密码/口令绑定当前 Windows 用户)。""" +from __future__ import annotations + +import base64 +import ctypes +import json +import os +import threading +import uuid +from ctypes import wintypes +from pathlib import Path + +_CRYPTPROTECT_UI_FORBIDDEN = 0x1 +_SENSITIVE = ("password", "key_passphrase") + + +class _DataBlob(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.c_void_p)] + + +def protect(text: str) -> str: + raw = text.encode("utf-8") + buf = ctypes.create_string_buffer(raw, len(raw)) + src = _DataBlob(len(raw), ctypes.cast(buf, ctypes.c_void_p)) + dst = _DataBlob() + ok = ctypes.windll.crypt32.CryptProtectData( + ctypes.byref(src), None, None, None, None, + _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(dst), + ) + if not ok: + raise OSError("DPAPI 加密失败") + try: + return "dpapi:" + base64.b64encode(ctypes.string_at(dst.pbData, dst.cbData)).decode("ascii") + finally: + ctypes.windll.kernel32.LocalFree(ctypes.c_void_p(dst.pbData)) + + +def unprotect(text: str) -> str: + if not text.startswith("dpapi:"): + return text + raw = base64.b64decode(text[6:]) + buf = ctypes.create_string_buffer(raw, len(raw)) + src = _DataBlob(len(raw), ctypes.cast(buf, ctypes.c_void_p)) + dst = _DataBlob() + ok = ctypes.windll.crypt32.CryptUnprotectData( + ctypes.byref(src), None, None, None, None, 0, ctypes.byref(dst), + ) + if not ok: + raise OSError("DPAPI 解密失败:配置可能属于其他 Windows 用户") + try: + return ctypes.string_at(dst.pbData, dst.cbData).decode("utf-8") + finally: + ctypes.windll.kernel32.LocalFree(ctypes.c_void_p(dst.pbData)) + + +class Store: + """hosts/tunnels 配置的读写。内存中敏感字段为明文,落盘前用 DPAPI 加密。""" + + def __init__(self, data_dir: Path): + self.data_dir = data_dir + self.path = data_dir / "config.json" + self._lock = threading.Lock() + self.data = self._load() + + def _load(self) -> dict: + data = {"hosts": [], "tunnels": []} + if self.path.exists(): + try: + loaded = json.loads(self.path.read_text(encoding="utf-8")) + data["hosts"] = loaded.get("hosts") or [] + data["tunnels"] = loaded.get("tunnels") or [] + except Exception: + try: + self.path.rename(self.path.with_suffix(".json.broken")) + except OSError: + pass + for h in data["hosts"]: + for k in _SENSITIVE: + v = h.get(k) + if isinstance(v, str) and v.startswith("dpapi:"): + try: + h[k] = unprotect(v) + except Exception: + h[k] = "" + return data + + def save(self) -> None: + with self._lock: + out = json.loads(json.dumps(self.data)) # 深拷贝 + for h in out["hosts"]: + for k in _SENSITIVE: + if h.get(k): + h[k] = protect(h[k]) + tmp = self.path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, self.path) + + @staticmethod + def new_id() -> str: + return uuid.uuid4().hex[:8] diff --git a/test_e2e.py b/test_e2e.py new file mode 100644 index 0000000..65e6f77 --- /dev/null +++ b/test_e2e.py @@ -0,0 +1,143 @@ +"""端到端验证:走真实的 Api 代码路径建立隧道,再请求经隧道转发的 HTTP 服务。 +前置: 先运行 `python test_sshd.py`(127.0.0.1:2222, test/test123)。 +用法: .venv/Scripts/python test_e2e.py +""" +import socket +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).parent +sys.path.insert(0, str(ROOT)) + +TEST_DIR = ROOT / ".test_data" +LOCAL_PORT = 13000 # 隧道本机端口 +SVC_PORT = 3210 # 模拟"只对远程主机本机开放"的服务端口 + + +def wait_port(port: int, expect_open: bool, timeout: float = 15.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + s = socket.socket() + s.settimeout(0.4) + try: + s.connect(("127.0.0.1", port)) + s.close() + if expect_open: + return True + except OSError: + if not expect_open: + return True + finally: + s.close() + time.sleep(0.25) + return False + + +def tunnel_status(api, tid: str) -> str: + for t in api.get_state()["state"]["tunnels"]: + if t["id"] == tid: + return t["runtime"]["status"] + return "gone" + + +def main() -> int: + import shutil + shutil.rmtree(TEST_DIR, ignore_errors=True) + TEST_DIR.mkdir(parents=True) + + from storage import Store + from engine import TunnelManager + from main import Api + + events = [] + manager = TunnelManager(lambda ev: events.append(ev), TEST_DIR) + store = Store(TEST_DIR) + api = Api(store, manager) + + # 1) 后台起"远程服务" + www = TEST_DIR / "www" + www.mkdir(exist_ok=True) + (www / "index.html").write_text("

TUNNEL-E2E-OK

", encoding="utf-8") + svc = subprocess.Popen( + [sys.executable, "-m", "http.server", str(SVC_PORT), "--directory", str(www)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + assert wait_port(SVC_PORT, True), "http 服务未启动" + + # 2) 起本地测试 SSH 服务端 + sshd = subprocess.Popen([sys.executable, str(ROOT / "test_sshd.py")], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + assert wait_port(2222, True), "test_sshd 未启动" + + try: + # 3) 通过 Api 添加主机和隧道 + r = api.save_host({"name": "本机测试", "hostname": "127.0.0.1", "port": 2222, + "username": "test", "auth": "password", "password": "test123"}) + assert r["ok"], r + host_id = r["id"] + + # 密码错误的情况应失败 + r = api.probe_host({"hostname": "127.0.0.1", "port": 2222, "username": "test", + "auth": "password", "password": "wrong"}) + assert r["ok"] is False, f"错误密码竟能通过: {r}" + print("[PASS] 错误密码被正确拒绝:", r["message"]) + + r = api.probe_host({"hostname": "127.0.0.1", "port": 2222, "username": "test", + "auth": "password", "password": "test123"}) + assert r["ok"], r + print("[PASS] 主机测试连接:", r["message"]) + + r = api.save_tunnel({"host_id": host_id, "name": "e2e", "local_port": LOCAL_PORT, + "remote_host": "127.0.0.1", "remote_port": SVC_PORT, + "autostart": False, "autoreconnect": True}) + assert r["ok"], r + tid = r["id"] + + # 4) 启动隧道并等待建立 + assert api.start_tunnel(tid)["ok"] + deadline = time.time() + 20 + while time.time() < deadline and tunnel_status(api, tid) != "connected": + time.sleep(0.3) + assert tunnel_status(api, tid) == "connected", \ + "隧道未能建立: " + json_events_tail(events) + print(f"[PASS] 隧道已建立 127.0.0.1:{LOCAL_PORT} → 127.0.0.1:{SVC_PORT}") + + # 5) 经隧道请求 HTTP 服务 + with urllib.request.urlopen(f"http://127.0.0.1:{LOCAL_PORT}", timeout=10) as resp: + body = resp.read().decode() + assert "TUNNEL-E2E-OK" in body, body + print("[PASS] 经隧道取回内容:", body.strip()) + + # 6) 断开后端口应关闭 + api.stop_tunnel(tid) + deadline = time.time() + 10 + while time.time() < deadline and tunnel_status(api, tid) != "stopped": + time.sleep(0.2) + assert tunnel_status(api, tid) == "stopped" + assert wait_port(LOCAL_PORT, False, timeout=5), "断开后本地端口仍可连接" + print("[PASS] 断开隧道,本地端口已释放") + + # 7) DPAPI 加密落盘校验 + store.save() + raw = (TEST_DIR / "config.json").read_text(encoding="utf-8") + assert "test123" not in raw and "dpapi:" in raw + print("[PASS] 密码已以 DPAPI 加密存储,明文不落盘") + + print("\n全部通过 ✔") + return 0 + finally: + manager.stop_all() + svc.terminate() + sshd.terminate() + + +def json_events_tail(events) -> str: + import json + lines = [e for e in events if e.get("type") == "log"][-6:] + return "\n".join(json.dumps(e, ensure_ascii=False) for e in lines) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_sshd.py b/test_sshd.py new file mode 100644 index 0000000..a177270 --- /dev/null +++ b/test_sshd.py @@ -0,0 +1,109 @@ +"""本地测试用 SSH 服务端:仅用于自测隧道引擎。 +监听 127.0.0.1:2222,接受用户 test / 密码 test123,支持 direct-tcpip 端口转发。 +用法: python test_sshd.py +""" +import select +import socket +import threading +from pathlib import Path + +import paramiko + +HOST, PORT = "127.0.0.1", 2222 +USER, PW = "test", "test123" +KEY_PATH = Path(__file__).parent / ".test_host_key" + + +def get_host_key() -> paramiko.RSAKey: + if KEY_PATH.exists(): + return paramiko.RSAKey.from_private_key_file(str(KEY_PATH)) + key = paramiko.RSAKey.generate(2048) + key.write_private_key_file(str(KEY_PATH)) + return key + + +class Server(paramiko.ServerInterface): + def __init__(self): + self.dest: dict[int, tuple] = {} + + def get_allowed_auths(self, username): + return "password" + + def check_auth_password(self, username, password): + return paramiko.AUTH_SUCCESSFUL if (username, password) == (USER, PW) else paramiko.AUTH_FAILED + + def check_channel_request(self, kind, chanid): + return paramiko.OPEN_SUCCEEDED + + def check_channel_direct_tcpip_request(self, chanid, origin, destination): + self.dest[chanid] = tuple(destination) + return paramiko.OPEN_SUCCEEDED + + +def pump(a, b) -> None: + try: + while True: + r, _, _ = select.select([a, b], [], [], 1.0) + if not r: + if hasattr(a, "closed") and a.closed: + break + continue + if b in r: + data = b.recv(65536) + if not data: + break + a.sendall(data) + if a in r: + data = a.recv(65536) + if not data: + break + b.sendall(data) + except Exception: + pass + finally: + try: a.close() + except Exception: pass + try: b.close() + except Exception: pass + + +def handle(conn: socket.socket, key) -> None: + t = paramiko.Transport(conn) + t.add_server_key(key) + server = Server() + try: + t.start_server(server=server) + except paramiko.SSHException: + return + chan = t.accept(20) + if chan is None: + return + dest = server.dest.get(chan.get_id()) + if not dest: + chan.close() + return + try: + upstream = socket.create_connection(dest, timeout=10) + except OSError: + chan.close() + return + pump(chan, upstream) + + +def main() -> None: + key = get_host_key() + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(8) + print(f"test sshd listening on {HOST}:{PORT} (login {USER}/{PW})") + try: + while True: + conn, _addr = srv.accept() + threading.Thread(target=handle, args=(conn, key), daemon=True).start() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/ui/app.ico b/ui/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..a0e07aabea10d70110785b620924ad60db731855 GIT binary patch literal 28491 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 literal 0 HcmV?d00001 diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 0000000..bd1ff18 --- /dev/null +++ b/ui/app.js @@ -0,0 +1,709 @@ +/* SSH 隧道管理器 — 前端逻辑 + 运行于 pywebview(调用 Python API)或浏览器(Mock,便于调试界面) */ +"use strict"; + +/* ---------- 工具 ---------- */ + +const $ = (s, el = document) => el.querySelector(s); +const $$ = (s, el = document) => [...el.querySelectorAll(s)]; +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const isPort = (v) => /^\d+$/.test(String(v).trim()) && +v >= 1 && +v <= 65535; +const withTimeout = (p, ms, msg) => Promise.race([ + p, + new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms)), +]); + +function fmtUptime(s) { + s = Math.max(0, Math.floor(s)); + const h = String(Math.floor(s / 3600)).padStart(2, "0"); + const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0"); + const sec = String(s % 60).padStart(2, "0"); + return `${h}:${m}:${sec}`; +} + +/* ---------- 状态 ---------- */ + +const S = { + hosts: [], + tunnels: [], // 每项含 cfg 字段 + runtime + logs + host_label + feed: [], + view: "tunnels", + openLogs: new Set(), +}; + +/* ---------- API 桥 ---------- */ + +function nativeApi() { + return (typeof window.pywebview === "object" && window.pywebview && window.pywebview.api) || null; +} + +async function call(name, ...args) { + const a = nativeApi(); + if (a) { + const r = await a[name](...args); + if (r && r.ok === false) throw new Error(r.error || "操作失败"); + return r; + } + return MockApi[name](...args); +} + +/* Python 事件入口 */ +window.__bus = (ev) => { + try { handleEvent(ev); } catch (e) { console.error(e); } +}; + +function handleEvent(ev) { + if (ev.type === "state") { applyState(ev.state); return; } + if (ev.type === "tunnel") { + const t = S.tunnels.find((x) => x.id === ev.id); + if (t) { t.runtime = { status: ev.status, error: ev.error, started_at: ev.started_at, conn_count: ev.conn_count }; renderTunnels(); renderSummary(); } + return; + } + if (ev.type === "log") { + const t = S.tunnels.find((x) => x.id === ev.tunnel_id); + if (t) { + t.logs.push(ev.entry); + if (S.openLogs.has(t.id)) appendLogBox(t); + } + appendFeedRow({ tunnel_name: t ? t.name : "?", ...ev.entry }); + if (S.view === "logs") maybeScrollFeed(); + } +} + +function applyState(state) { + S.hosts = state.hosts || []; + S.tunnels = state.tunnels || []; + S.feed = state.feed || []; + renderAll(); +} + +/* ---------- Mock(浏览器调试用) ---------- */ + +const MockApi = (() => { + let hosts = [ + { id: "h1", name: "开发服务器", hostname: "192.168.8.20", port: 22, username: "deploy", auth: "password", key_path: "", note: "", has_password: true, has_passphrase: false }, + { id: "h2", name: "跳板机", hostname: "gw.example.com", port: 2222, username: "root", auth: "key", key_path: "C:\\Users\\me\\.ssh\\id_ed25519", note: "", has_password: false, has_passphrase: true }, + ]; + let tunnels = [ + { id: "t1", name: "grafana", host_id: "h1", local_port: 3000, remote_host: "127.0.0.1", remote_port: 3000, autostart: false, autoreconnect: true, host_label: "deploy@192.168.8.20:22", host_auth: "password", runtime: { id: "t1", status: "connected", error: "", started_at: Date.now() / 1000 - 4372, conn_count: 12 }, logs: [ + { t: "10:21:07", level: "info", msg: "正在连接 deploy@192.168.8.20:22 ..." }, + { t: "10:21:08", level: "success", msg: "隧道已建立: 127.0.0.1:3000 → 127.0.0.1:3000" }, + { t: "10:24:11", level: "info", msg: "接受连接 → 127.0.0.1:3000" }, + ] }, + { id: "t2", name: "mysql-debug", host_id: "h2", local_port: 3307, remote_host: "127.0.0.1", remote_port: 3306, autostart: true, autoreconnect: true, host_label: "root@gw.example.com:2222", host_auth: "key", runtime: { id: "t2", status: "stopped", error: "", started_at: null, conn_count: 0 }, logs: [] }, + ]; + let feed = tunnels.flatMap((t) => t.logs.map((e) => ({ tunnel_name: t.name, ...e }))); + const snap = () => ({ hosts: structuredClone(hosts), tunnels: structuredClone(tunnels), feed: feed.slice(-300) }); + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + + function pushLog(id, level, msg) { + const t = tunnels.find((x) => x.id === id); + if (!t) return; + const e = { t: new Date().toTimeString().slice(0, 8), level, msg }; + t.logs.push(e); + feed.push({ tunnel_name: t.name, ...e }); + window.__bus({ type: "log", tunnel_id: id, entry: e }); + } + + return { + async get_state() { return { ok: true, state: snap() }; }, + async save_host(p) { + const rec = { id: p.id || "h" + Math.random().toString(36).slice(2, 6), name: p.name || p.hostname, hostname: p.hostname, port: +p.port || 22, username: p.username, auth: p.auth, key_path: p.key_path || "", note: p.note || "", has_password: !!p.password || (p.id && hosts.find((h) => h.id === p.id)?.has_password && !p.password === false), has_passphrase: false }; + if (!p.id && p.auth === "password") rec.has_password = !!p.password; + if (p.id && !p.password) rec.has_password = hosts.find((h) => h.id === p.id).has_password; + const i = hosts.findIndex((h) => h.id === rec.id); + if (i >= 0) hosts[i] = rec; else hosts.push(rec); + tunnels.forEach((t) => { if (t.host_id === rec.id) t.host_label = `${rec.username}@${rec.hostname}:${rec.port}`; }); + return { ok: true, id: rec.id, state: snap() }; + }, + async delete_host(id) { hosts = hosts.filter((h) => h.id !== id); return { ok: true, state: snap() }; }, + async save_tunnel(p) { + const host = hosts.find((h) => h.id === p.host_id); + const rec = { id: p.id || "t" + Math.random().toString(36).slice(2, 6), name: p.name || `${p.remote_host}:${p.remote_port}`, host_id: p.host_id, local_port: +p.local_port, remote_host: p.remote_host || "127.0.0.1", remote_port: +p.remote_port, autostart: !!p.autostart, autoreconnect: p.autoreconnect !== false, host_label: host ? `${host.username}@${host.hostname}:${host.port}` : "?", host_auth: host?.auth, runtime: { id: p.id || "t0", status: "stopped", error: "", started_at: null, conn_count: 0 } }; + const i = tunnels.findIndex((t) => t.id === rec.id); + if (i >= 0) { rec.runtime = tunnels[i].runtime; tunnels[i] = rec; } else tunnels.push(rec); + return { ok: true, id: rec.id, state: snap() }; + }, + async delete_tunnel(id) { tunnels = tunnels.filter((t) => t.id !== id); feed = feed.filter((f) => f.tunnel_id !== id); return { ok: true, state: snap() }; }, + async start_tunnel(id) { + const t = tunnels.find((x) => x.id === id); + t.runtime = { ...t.runtime, status: "connecting", error: "" }; + window.__bus({ type: "tunnel", ...t.runtime }); + pushLog(id, "info", `正在连接 ${t.host_label} ...`); + await wait(1200); + if (t.runtime.status !== "connecting") return { ok: true }; + t.runtime = { ...t.runtime, status: "connected", started_at: Date.now() / 1000 }; + window.__bus({ type: "tunnel", ...t.runtime }); + pushLog(id, "success", `隧道已建立: 127.0.0.1:${t.local_port} → ${t.remote_host}:${t.remote_port}`); + return { ok: true }; + }, + async stop_tunnel(id) { + const t = tunnels.find((x) => x.id === id); + t.runtime = { ...t.runtime, status: "stopped", started_at: null }; + window.__bus({ type: "tunnel", ...t.runtime }); + pushLog(id, "info", "隧道已断开"); + return { ok: true }; + }, + async probe_host(p) { await wait(900); return p.hostname && p.username ? { ok: true, message: "认证成功 · 服务端 SSH-2.0-OpenSSH_9.6" } : { ok: false, message: "请先填写主机地址" }; }, + async open_url(url) { window.open(url, "_blank"); return { ok: true }; }, + async copy_text() { return { ok: true }; }, + async pick_key_file() { return { ok: true, path: "C:\\Users\\me\\.ssh\\id_ed25519" }; }, + async win_minimize() { return { ok: true }; }, + async win_maximize() { return { ok: true }; }, + async win_close() { return { ok: true }; }, + }; +})(); + +/* ---------- 图标 ---------- */ + +const I = { + edit: '', + trash: '', + copy: '', + external: '', + play: '', + stop: '', + chevron: '', + key: '', + lock: '', + check: '', + alert: '', + folder: '', + x: '', +}; +const icon = (n, s = 14, w = 1.8) => + `${I[n]}`; + +/* ---------- 渲染 ---------- */ + +const STATUS_META = { + stopped: { text: "已停止", cls: "", dot: "" }, + connecting: { text: "连接中…", cls: "amber", dot: "amber pulse" }, + connected: { text: "已连接", cls: "green", dot: "green" }, + error: { text: "连接失败", cls: "red", dot: "red" }, +}; + +function renderAll() { + renderTunnels(); + renderHosts(); + renderFeed(true); + renderSummary(); + $("#badgeHosts").textContent = S.hosts.length || ""; + $("#logCount").textContent = S.feed.length ? `${S.feed.length} 条` : ""; +} + +function renderTunnels() { + const grid = $("#tunnelGrid"); + const running = S.tunnels.filter((t) => t.runtime.status === "connected").length; + $("#tunnelCount").textContent = S.tunnels.length ? `${S.tunnels.length} 条 · ${running} 条运行中` : ""; + $("#tunnelsEmpty").style.display = S.tunnels.length ? "none" : ""; + grid.style.display = S.tunnels.length ? "" : "none"; + const b = $("#badgeTunnels"); + b.textContent = running || ""; + b.classList.toggle("live", running > 0); + + grid.innerHTML = S.tunnels.map((t) => { + const rt = t.runtime; + const meta = STATUS_META[rt.status] || STATUS_META.stopped; + const live = rt.status === "connected"; + const conn = rt.status === "connecting"; + const up = live && rt.started_at ? ` · ${fmtUptime(Date.now() / 1000 - rt.started_at)}` : ""; + const authIc = t.host_auth === "key" ? "key" : "lock"; + const open = S.openLogs.has(t.id); + return ` +
+
+ + ${esc(t.name)} + ${meta.text}${up} + + +
+ ${rt.status === "error" && rt.error ? `
${esc(rt.error)}
` : ""} +
+ 127.0.0.1:${t.local_port} + + ${esc(t.remote_host)}:${t.remote_port} + + + +
+
+ ${icon(authIc, 12)} + ${esc(t.host_label)} + | + 累计连接 ${rt.conn_count || 0} + + +
+
+ ${live || conn + ? `` + : ``} +
+ ${open ? `
${(t.logs || []).map(logLine).join("")}
` : ""} +
`; + }).join(""); +} + +const logLine = (e) => + `
${esc(e.t)}${esc(e.msg)}
`; + +function appendLogBox(t) { + const box = $(`#tunnelGrid .tcard[data-id="${t.id}"] [data-logbox]`); + if (!box) return; + box.innerHTML = (t.logs || []).map(logLine).join(""); + box.scrollTop = box.scrollHeight; +} + +function renderHosts() { + const list = $("#hostList"); + $("#hostCount").textContent = S.hosts.length ? `${S.hosts.length} 台` : ""; + $("#hostsEmpty").style.display = S.hosts.length ? "none" : ""; + list.style.display = S.hosts.length ? "" : "none"; + list.innerHTML = S.hosts.map((h) => { + const used = S.tunnels.filter((t) => t.host_id === h.id); + return ` +
+ + + +
+
${esc(h.name)}
+
${esc(h.username)}@${esc(h.hostname)}:${h.port}
+
+ ${icon(h.auth === "key" ? "key" : "lock", 11)} ${h.auth === "key" ? "私钥" : "密码"} + ${h.note ? `${esc(h.note)}` : ""} +
+ ${used.length ? `
被 ${used.length} 条隧道使用:${esc(used.map((t) => t.name).join("、"))}
` : ""} +
+ + +
`; + }).join(""); +} + +/* ---------- 日志视图 ---------- */ + +const feedRow = (e) => + `
${esc(e.t)}${esc(e.tunnel_name)}${esc(e.msg)}
`; + +function renderFeed(clear) { + const el = $("#logFeed"); + el.innerHTML = S.feed.length + ? S.feed.map(feedRow).join("") + : `
暂无日志 —— 启动一条隧道后,这里会记录连接与转发事件
`; + maybeScrollFeed(true); +} + +function appendFeedRow(e) { + S.feed.push(e); + if (S.feed.length > 400) S.feed.shift(); + const el = $("#logFeed"); + const empty = $(".empty-feed", el); + if (empty) empty.remove(); + el.insertAdjacentHTML("beforeend", feedRow(e)); + while (el.children.length > 400) el.firstChild.remove(); + if (S.view === "logs") maybeScrollFeed(); +} + +function maybeScrollFeed(force) { + if (!$("#autoScroll").checked && !force) return; + const el = $("#logFeed"); + el.scrollTop = el.scrollHeight; +} + +/* ---------- 顶部/侧栏摘要 ---------- */ + +function renderSummary() { + const running = S.tunnels.filter((t) => t.runtime.status === "connected").length; + const connecting = S.tunnels.filter((t) => t.runtime.status === "connecting").length; + const dot = $("#connDot"), text = $("#connText"); + dot.className = "dot" + (running ? " green" : connecting ? " amber pulse" : ""); + text.textContent = running + ? `${running} 条隧道运行中` + : connecting ? "正在连接…" : "没有运行中的隧道"; +} + +/* ---------- Toast ---------- */ + +function toast(msg, type = "ok") { + const el = document.createElement("div"); + el.className = `toast ${type}`; + el.innerHTML = `${icon(type === "error" ? "alert" : "check", 14, 2)}${esc(msg)}`; + $("#toastRoot").appendChild(el); + setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 300); }, 3600); +} + +/* ---------- 弹窗框架(支持叠加) ---------- */ + +const modalStack = []; + +function openModal({ title, body, footer }) { + const overlay = document.createElement("div"); + overlay.className = "overlay"; + overlay.innerHTML = ` + `; + $("#modalRoot").appendChild(overlay); + const ctx = { overlay, onOk: null, onClose: null }; + modalStack.push(ctx); + const close = () => closeModal(ctx); + overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); + $("[data-close]", overlay).addEventListener("click", close); + $$("[data-cancel]", overlay).forEach((b) => b.addEventListener("click", close)); + $("form", overlay).addEventListener("submit", (e) => { + e.preventDefault(); + if (ctx.onOk) ctx.onOk(); + }); + const first = $("input, select", overlay); + if (first) first.focus(); + ctx.close = close; + return ctx; +} + +function closeModal(ctx) { + const c = ctx || modalStack[modalStack.length - 1]; + if (!c) return; + const i = modalStack.indexOf(c); + if (i >= 0) modalStack.splice(i, 1); + if (c.onClose) c.onClose(); + c.overlay.remove(); +} + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modalStack.length) closeModal(); +}); + +const footBtns = (okText, opts = {}) => ` + ${opts.dangerSide ? `` : ``} + + `; + +/* ---------- 主机编辑弹窗 ---------- */ + +function openHostModal(existing, opts = {}) { + const h = existing || {}; + const ctx = openModal({ + title: existing ? "编辑主机" : "新建主机", + body: ` +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
支持 OpenSSH 格式的私钥(id_ed25519 / id_rsa 等)
+
+
+
+
+
+ + `, + footer: ` + ${footBtns(existing ? "保存" : "添加")}`, + }); + + const ov = ctx.overlay; + let auth = h.auth || "password"; + const segBtns = $$("[name=auth] button", ov); + segBtns.forEach((b) => b.addEventListener("click", () => { + auth = b.dataset.v; + segBtns.forEach((x) => x.classList.toggle("on", x === b)); + $("[data-auth=password]", ov).style.display = auth === "password" ? "" : "none"; + $("[data-auth=key]", ov).style.display = auth === "key" ? "" : "none"; + })); + + $("[data-pick]", ov).addEventListener("click", async () => { + try { + const r = await call("pick_key_file"); + if (r && r.path) $("input[name=key_path]", ov).value = r.path; + } catch (e) { toast(e.message, "error"); } + }); + + const collect = () => ({ + id: h.id, name: v("name"), hostname: v("hostname"), port: v("port"), + username: v("username"), auth, + password: $("input[name=password]", ov).value, + key_path: v("key_path"), key_passphrase: $("input[name=key_passphrase]", ov).value, + note: v("note"), + has_password: !!h.has_password, has_passphrase: !!h.has_passphrase, + }); + function v(name) { return $(`input[name=${name}]`, ov).value.trim(); } + + const showProbe = (ok, msg) => { + const el = $("[data-probe]", ov); + el.style.display = ""; + el.className = `probe-result ${ok ? "ok" : "bad"}`; + el.innerHTML = `${icon(ok ? "check" : "alert", 14, 2)}${esc(msg)}`; + }; + + $("[data-probe-btn]", ov).addEventListener("click", async () => { + const btn = $("[data-probe-btn]", ov); + btn.disabled = true; + btn.textContent = "测试中…"; + try { const r = await call("probe_host", collect()); showProbe(r.ok, r.message || r.error || ""); } + catch (e) { showProbe(false, e.message); } + btn.disabled = false; + btn.innerHTML = `${icon("play", 12, 2)} 测试连接`; + }); + + ctx.onOk = async () => { + const p = collect(); + let bad = false; + if (!p.hostname) { markErr("hostname"), bad = true; } + if (!p.username) { markErr("username"), bad = true; } + if (!isPort(p.port)) { markErr("port"), bad = true; } + if (bad) return; + try { + const r = await call("save_host", p); + applyState(r.state); + toast(existing ? "主机已更新" : "主机已添加"); + closeModal(ctx); + if (opts.onSaved) opts.onSaved(r.id); + } catch (e) { toast(e.message, "error"); } + }; + function markErr(name) { $(`input[name=${name}]`, ov).classList.add("err"); } + return ctx; +} + +/* ---------- 隧道编辑弹窗 ---------- */ + +function openTunnelModal(existing) { + const t = existing || {}; + const hostOpts = () => S.hosts.map((h) => + ``).join(""); + + const ctx = openModal({ + title: existing ? "编辑隧道" : "新建隧道", + body: ` +
+
+
+
+
+ +
+
+
+ +
服务在远程主机上监听的地址,只对本机开放的服务填 127.0.0.1
+
+
+
+
+ +
建立后,在本机打开 127.0.0.1:本地端口,等同于直接访问远程的这个服务
+
+ 自动重连
+ 连接断开后自动重试,重试间隔逐渐拉长
+ +
+
+ 随应用启动
+ 打开管理器时自动建立这条隧道
+ +
+ `, + footer: footBtns(existing ? "保存" : "创建隧道"), + }); + + const ov = ctx.overlay; + if (!S.hosts.length) $("select[name=host_id]", ov).innerHTML = ``; + + // 远程端口 → 本地端口跟随 + const rp = $("input[name=remote_port]", ov), lp = $("input[name=local_port]", ov); + rp.addEventListener("input", () => { if (!lp.dataset.touched && isPort(rp.value)) lp.value = rp.value; }); + lp.addEventListener("input", () => { lp.dataset.touched = "1"; }); + + $("[data-newhost]", ov).addEventListener("click", () => { + openHostModal(null, { + onSaved: (id) => { + const sel = $("select[name=host_id]", ov); + sel.innerHTML = S.hosts.map((h) => + ``).join(""); + }, + }); + }); + + ctx.onOk = async () => { + const v = (n) => $(`[name=${n}]`, ov).value.trim(); + let bad = false; + if (!isPort(v("remote_port"))) { markErr("remote_port"); bad = true; } + if (!isPort(v("local_port"))) { markErr("local_port"); bad = true; } + if (!v("host_id")) { toast("请选择主机(或先新建一台)", "error"); bad = true; } + if (bad) return; + try { + const r = await call("save_tunnel", { + id: t.id, name: v("name"), host_id: v("host_id"), + remote_host: v("remote_host") || "127.0.0.1", remote_port: v("remote_port"), + local_port: v("local_port"), + autostart: $("[name=autostart]", ov).checked, + autoreconnect: $("[name=autoreconnect]", ov).checked, + }); + applyState(r.state); + toast(existing ? "隧道已更新" : "隧道已创建,点「启动」建立连接"); + closeModal(ctx); + } catch (e) { toast(e.message, "error"); } + }; + function markErr(n) { $(`[name=${n}]`, ov).classList.add("err"); } +} + +/* ---------- 删除确认 ---------- */ + +function confirmDanger(title, html) { + return new Promise((resolve) => { + const ctx = openModal({ + title, + body: `
${html}
`, + footer: ` + + `, + }); + ctx.onClose = () => resolve(false); + $("[data-ok]", ctx.overlay).addEventListener("click", () => { + ctx.onClose = null; resolve(true); closeModal(ctx); + }); + }); +} + +/* ---------- 事件绑定 ---------- */ + +function switchView(name) { + S.view = name; + $$(".nav-item").forEach((b) => b.classList.toggle("on", b.dataset.view === name)); + $$(".view").forEach((v) => v.classList.toggle("on", v.id === `view-${name}`)); + if (name === "logs") maybeScrollFeed(true); +} + +function bindOnce() { + $$(".nav-item").forEach((b) => b.addEventListener("click", () => switchView(b.dataset.view))); + + $("#btnNewTunnel").addEventListener("click", () => openTunnelModal()); + $("#btnEmptyNewTunnel").addEventListener("click", () => openTunnelModal()); + $("#btnNewHost").addEventListener("click", () => openHostModal()); + $("#btnEmptyNewHost").addEventListener("click", () => openHostModal()); + + $("#btnClearLogs").addEventListener("click", () => { S.feed = []; renderFeed(); }); + + // 隧道卡片(事件委托) + $("#tunnelGrid").addEventListener("click", async (e) => { + const card = e.target.closest(".tcard"); + if (!card) return; + const t = S.tunnels.find((x) => x.id === card.dataset.id); + if (!t) return; + const act = e.target.closest("[data-act]")?.dataset.act; + if (!act) return; + try { + if (act === "start") await call("start_tunnel", t.id); + else if (act === "stop") await call("stop_tunnel", t.id); + else if (act === "edit") openTunnelModal(t); + else if (act === "del") { + const ok = await confirmDanger("删除隧道", + `确定删除隧道 ${esc(t.name)}?
映射关系 127.0.0.1:${t.local_port} → ${esc(t.remote_host)}:${t.remote_port} 将被移除。`); + if (!ok) return; + const r = await call("delete_tunnel", t.id); + applyState(r.state); + toast("隧道已删除"); + } else if (act === "copy") { + await call("copy_text", `http://127.0.0.1:${t.local_port}`); + toast("已复制 " + `http://127.0.0.1:${t.local_port}`); + } else if (act === "open") { + await call("open_url", `http://127.0.0.1:${t.local_port}`); + } else if (act === "log") { + S.openLogs.has(t.id) ? S.openLogs.delete(t.id) : S.openLogs.add(t.id); + renderTunnels(); + } + } catch (err) { toast(err.message, "error"); } + }); + + // 主机卡片 + $("#hostList").addEventListener("click", async (e) => { + const card = e.target.closest(".hcard"); + if (!card) return; + const h = S.hosts.find((x) => x.id === card.dataset.id); + if (!h) return; + const act = e.target.closest("[data-act]")?.dataset.act; + if (!act) return; + try { + if (act === "edit") openHostModal(h); + else if (act === "del") { + const used = S.tunnels.filter((t) => t.host_id === h.id); + if (used.length) { toast(`该主机正被「${used.map((t) => t.name).join("、")}」使用,请先删除相关隧道`, "error"); return; } + const ok = await confirmDanger("删除主机", + `确定删除主机 ${esc(h.name)}(${esc(h.username)}@${esc(h.hostname)})?`); + if (!ok) return; + const r = await call("delete_host", h.id); + applyState(r.state); + toast("主机已删除"); + } + } catch (err) { toast(err.message, "error"); } + }); + + // 窗口控制 + $("#btnMin").addEventListener("click", () => call("win_minimize").catch(() => {})); + $("#btnMax").addEventListener("click", () => call("win_maximize").catch(() => {})); + $("#btnClose").addEventListener("click", () => call("win_close").catch(() => {})); + $("#dragZone").addEventListener("dblclick", () => call("win_maximize").catch(() => {})); + + // 运行时长刷新 + setInterval(() => { + $$("#tunnelGrid .tcard").forEach((card) => { + const t = S.tunnels.find((x) => x.id === card.dataset.id); + if (!t || t.runtime.status !== "connected" || !t.runtime.started_at) return; + const st = $("[data-status]", card); + st.textContent = `已连接 · ${fmtUptime(Date.now() / 1000 - t.runtime.started_at)}`; + }); + }, 1000); +} + +/* ---------- 启动 ---------- */ + +function whenReady() { + return new Promise((resolve) => { + if (nativeApi()) return resolve(); + window.addEventListener("pywebviewready", () => resolve(), { once: true }); + setTimeout(resolve, 1500); // 浏览器 Mock 兜底 + }); +} + +(async function init() { + bindOnce(); + renderAll(); + await whenReady(); + try { + const r = await withTimeout(call("get_state"), 5000, "连接应用后端超时"); + applyState(r.state); + } catch (e) { + toast("加载配置失败:" + e.message, "error"); + } +})(); diff --git a/ui/icon-256.png b/ui/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..a1d35401cee8b15c2e7bdf8e7a65c03bcfc2840a GIT binary patch literal 8686 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 v0xs(`9tUrA8idaA4LqoT36?6 + + + + +SSH 隧道管理器 + + + + +
+
+ + SSH 隧道管理器 +
+
+ + + +
+
+ +
+ + +
+
+
+

隧道

+ + + +
+
+ +
+ +
+
+

主机

+ + + +
+
+ +
+ +
+
+

日志

+ + + + +
+
+
+
+
+ +
+
+ + + + diff --git a/ui/logo.svg b/ui/logo.svg new file mode 100644 index 0000000..fbe6421 --- /dev/null +++ b/ui/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/style.css b/ui/style.css new file mode 100644 index 0000000..f345fce --- /dev/null +++ b/ui/style.css @@ -0,0 +1,630 @@ +/* SSH 隧道管理器 — 界面样式 + 原则:石墨深色底、单一点缀色、无渐变无发光、系统字体 */ + +:root { + --bg: #111418; + --surface: #171b21; + --surface-2: #1d232b; + --surface-3: #242b34; + --border: #28303a; + --border-strong: #384250; + --text: #e2e7ee; + --dim: #97a1ad; + --faint: #5f6a76; + --accent: #2fbfa1; + --accent-strong: #43d6b5; + --accent-dim: rgba(47, 191, 161, 0.13); + --on-accent: #07130f; + --green: #46c46e; + --green-dim: rgba(70, 196, 110, 0.12); + --amber: #d9a23c; + --amber-dim: rgba(217, 162, 60, 0.12); + --red: #e2564d; + --red-dim: rgba(226, 86, 77, 0.12); + --r-lg: 10px; + --r-md: 8px; + --r-sm: 6px; + --mono: "Cascadia Mono", "JetBrains Mono", "Consolas", ui-monospace, monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { height: 100%; } + +body { + font: 13px/1.55 "Segoe UI Variable Text", "Segoe UI", "Microsoft YaHei UI", + system-ui, sans-serif; + background: var(--bg); + color: var(--text); + overflow: hidden; + user-select: none; + cursor: default; +} + +::selection { background: var(--accent-dim); color: var(--text); } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--surface-3); + border: 3px solid transparent; + border-radius: 6px; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { background-color: var(--border-strong); } + +button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; } +input, select { font: inherit; color: var(--text); } +svg.ic { flex: none; vertical-align: -2px; } + +.mono { font-family: var(--mono); } + +/* ---------- 标题栏 ---------- */ + +.titlebar { + height: 42px; + display: flex; + align-items: center; + -webkit-app-region: no-drag; +} + +.titlebar .drag-zone { + display: flex; + align-items: center; + gap: 9px; + flex: 1; + height: 100%; + padding-left: 14px; +} + +.titlebar .logo { + width: 24px; + height: 24px; + border-radius: 7px; + overflow: hidden; + flex: none; +} +.titlebar .logo img { width: 100%; height: 100%; display: block; } + +.titlebar .title { + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0.2px; + color: var(--dim); +} + +.win-controls { display: flex; height: 100%; } + +.wbtn { + width: 44px; + height: 100%; + display: grid; + place-items: center; + color: var(--dim); + transition: background 0.12s, color 0.12s; +} +.wbtn:hover { background: var(--surface-2); color: var(--text); } +.wbtn.close:hover { background: #d0342c; color: #fff; } + +/* ---------- 布局 ---------- */ + +#app { display: flex; height: calc(100% - 42px); } + +nav#nav { + width: 172px; + flex: none; + display: flex; + flex-direction: column; + padding: 10px 10px 12px; + border-right: 1px solid var(--border); +} + +.nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 10px; + border-radius: var(--r-md); + color: var(--dim); + font-size: 13px; + margin-bottom: 2px; + transition: background 0.12s, color 0.12s; + text-align: left; +} +.nav-item:hover { background: var(--surface); color: var(--text); } +.nav-item.on { + background: var(--surface-2); + color: var(--text); + font-weight: 500; +} +.nav-item.on .ic { color: var(--accent); } +.nav-item .nav-badge { + margin-left: auto; + font-size: 11px; + font-family: var(--mono); + color: var(--faint); +} +.nav-item .nav-badge.live { color: var(--green); } + +.nav-foot { margin-top: auto; padding: 0 6px; } + +.conn-summary { + display: flex; + align-items: center; + gap: 7px; + font-size: 11.5px; + color: var(--faint); + border-top: 1px solid var(--border); + padding-top: 12px; +} + +main#main { flex: 1; min-width: 0; overflow-y: auto; } + +.view { display: none; padding: 22px 26px 30px; } +.view.on { display: block; } + +.view-head { + display: flex; + align-items: center; + margin-bottom: 16px; + gap: 12px; +} +.view-head h1 { font-size: 16px; font-weight: 600; } +.view-head .count { + font-family: var(--mono); + font-size: 12px; + color: var(--faint); + margin-top: 1px; +} +.view-head .spacer { flex: 1; } + +/* ---------- 通用控件 ---------- */ + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: var(--r-md); + background: var(--surface-2); + border: 1px solid var(--border-strong); + font-size: 12.5px; + font-weight: 500; + color: var(--text); + transition: background 0.12s, border-color 0.12s, color 0.12s; + white-space: nowrap; +} +.btn:hover { background: var(--surface-3); } +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); + font-weight: 600; +} +.btn.primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); } +.btn.sm { height: 28px; padding: 0 11px; font-size: 12px; border-radius: var(--r-sm); } +.btn.subtle { background: transparent; border-color: var(--border); color: var(--dim); } +.btn.subtle:hover { color: var(--text); background: var(--surface-2); } +.btn.danger { color: var(--red); border-color: rgba(226, 86, 77, 0.4); } +.btn.danger:hover { background: var(--red-dim); border-color: var(--red); } +.btn:disabled { opacity: 0.45; pointer-events: none; } + +.ibtn { + width: 28px; + height: 28px; + border-radius: var(--r-sm); + display: inline-grid; + place-items: center; + color: var(--faint); + transition: background 0.12s, color 0.12s; + flex: none; +} +.ibtn:hover { background: var(--surface-3); color: var(--text); } +.ibtn.red:hover { background: var(--red-dim); color: var(--red); } + +.field { margin-bottom: 13px; } +.field > label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--dim); + margin-bottom: 6px; +} +.field .hint { + font-size: 11.5px; + color: var(--faint); + margin-top: 5px; + line-height: 1.45; +} + +.ipt { + width: 100%; + height: 34px; + padding: 0 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + outline: none; + transition: border-color 0.12s, box-shadow 0.12s; +} +textarea.ipt { height: auto; padding: 8px 10px; resize: vertical; } +.ipt:hover { border-color: var(--border-strong); } +.ipt:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +.ipt::placeholder { color: var(--faint); } +.ipt.err { border-color: var(--red); } +select.ipt { appearance: none; padding-right: 28px; cursor: pointer; } +.sel-wrap { position: relative; } +.sel-wrap::after { + content: ""; + position: absolute; + right: 11px; top: 13px; + width: 7px; height: 7px; + border-right: 1.6px solid var(--faint); + border-bottom: 1.6px solid var(--faint); + transform: rotate(45deg); + pointer-events: none; +} + +.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; } +.row2-uneven { display: grid; grid-template-columns: 1fr 110px; gap: 0 12px; } + +.seg { + display: flex; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 2px; + gap: 2px; +} +.seg button { + flex: 1; + height: 28px; + border-radius: var(--r-sm); + color: var(--dim); + font-size: 12.5px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: background 0.12s, color 0.12s; +} +.seg button:hover { color: var(--text); } +.seg button.on { + background: var(--surface-3); + color: var(--text); + box-shadow: inset 0 0 0 1px var(--border-strong); +} + +.switch-row { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 0; +} +.switch-row .sw-text { flex: 1; } +.switch-row .sw-title { font-size: 12.5px; } +.switch-row .sw-desc { font-size: 11.5px; color: var(--faint); } + +.switch { + position: relative; + width: 34px; height: 20px; + flex: none; + appearance: none; + background: var(--surface-3); + border: 1px solid var(--border-strong); + border-radius: 20px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.switch::after { + content: ""; + position: absolute; + top: 2px; left: 2px; + width: 14px; height: 14px; + border-radius: 50%; + background: var(--dim); + transition: left 0.15s, background 0.15s; +} +.switch:checked { background: var(--accent-dim); border-color: var(--accent); } +.switch:checked::after { left: 16px; background: var(--accent); } + +/* ---------- 隧道卡片 ---------- */ + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(430px, 1fr)); + gap: 14px; +} + +.tcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 14px 16px 13px; + display: flex; + flex-direction: column; + gap: 10px; + transition: border-color 0.15s; +} +.tcard:hover { border-color: var(--border-strong); } +.tcard.live { border-color: rgba(70, 196, 110, 0.25); } + +.tcard .head { display: flex; align-items: center; gap: 8px; } +.tcard .name { + font-size: 13.5px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tcard .head .grow { flex: 1; min-width: 0; } + +.dot { + width: 8px; height: 8px; + border-radius: 50%; + flex: none; + background: var(--faint); +} +.dot.green { background: var(--green); box-shadow: 0 0 0 3px var(--green-dim); } +.dot.amber { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-dim); } +.dot.red { background: var(--red); box-shadow: 0 0 0 3px var(--red-dim); } +.dot.pulse { animation: pulse 1.6s ease-in-out infinite; } +@keyframes pulse { 50% { opacity: 0.35; } } + +.status-text { font-size: 12px; color: var(--dim); white-space: nowrap; } +.status-text.green { color: var(--green); } +.status-text.amber { color: var(--amber); } +.status-text.red { color: var(--red); } + +.err-line { + font-size: 11.5px; + color: var(--red); + background: var(--red-dim); + border: 1px solid rgba(226, 86, 77, 0.25); + border-radius: var(--r-sm); + padding: 6px 9px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mapline { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 12px; + color: var(--dim); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 7px 8px 7px 11px; +} +.mapline .addr { color: var(--text); } +.mapline .arrow { color: var(--accent); flex: none; } +.mapline .grow { flex: 1; } + +.meta-row { + display: flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + color: var(--faint); +} +.meta-row .ic { color: var(--faint); } +.meta-row .sep { margin: 0 4px; color: var(--border-strong); } +.meta-row .grow { flex: 1; } +.meta-row button { + color: var(--faint); + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11.5px; + padding: 2px 6px; + border-radius: var(--r-sm); + transition: color 0.12s, background 0.12s; +} +.meta-row button:hover { color: var(--text); background: var(--surface-2); } +.meta-row button .ic { transition: transform 0.15s; } +.meta-row button.open .ic { transform: rotate(180deg); } + +.acts { display: flex; align-items: center; gap: 8px; } +.acts .grow { flex: 1; } + +.logbox { + font-family: var(--mono); + font-size: 11px; + line-height: 1.7; + color: var(--dim); + background: #0d1013; + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 8px 11px; + max-height: 130px; + overflow-y: auto; + user-select: text; +} +.logbox .lt { color: var(--faint); margin-right: 8px; } +.logbox .l-warn { color: var(--amber); } +.logbox .l-error { color: var(--red); } + +/* ---------- 主机列表 ---------- */ + +.host-list { display: flex; flex-direction: column; gap: 10px; max-width: 860px; } + +.hcard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + transition: border-color 0.15s; +} +.hcard:hover { border-color: var(--border-strong); } +.hcard .icon-box { + width: 34px; height: 34px; + border-radius: var(--r-md); + background: var(--surface-2); + border: 1px solid var(--border); + display: grid; + place-items: center; + color: var(--dim); + flex: none; +} +.hcard .info { flex: 1; min-width: 0; } +.hcard .hname { font-weight: 600; font-size: 13px; } +.hcard .haddr { + font-family: var(--mono); + font-size: 11.5px; + color: var(--dim); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hcard .tags { display: flex; gap: 6px; margin-top: 5px; } +.hcard .used-by { font-size: 11.5px; color: var(--faint); margin-top: 5px; } + +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10.5px; + padding: 1px 7px; + border-radius: 20px; + border: 1px solid var(--border-strong); + color: var(--dim); + background: var(--surface-2); +} + +/* ---------- 日志视图 ---------- */ + +.log-feed { + background: #0d1013; + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 12px 16px; + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.85; + height: calc(100vh - 175px); + overflow-y: auto; + user-select: text; +} +.log-feed .row { display: flex; gap: 12px; } +.log-feed .t { color: var(--faint); flex: none; width: 62px; } +.log-feed .src { color: var(--dim); flex: none; width: 130px; overflow: hidden; text-overflow: ellipsis; } +.log-feed .m { color: var(--dim); word-break: break-all; } +.log-feed .l-info .m { color: var(--dim); } +.log-feed .l-success .m { color: var(--green); } +.log-feed .l-warn .m { color: var(--amber); } +.log-feed .l-error .m { color: var(--red); } +.log-feed .empty-feed { color: var(--faint); text-align: center; padding-top: 60px; } + +/* ---------- 空状态 ---------- */ + +.empty { + border: 1px dashed var(--border-strong); + border-radius: var(--r-lg); + padding: 56px 20px; + text-align: center; + color: var(--faint); +} +.empty .glyph { color: var(--faint); opacity: 0.75; margin-bottom: 12px; } +.empty h2 { font-size: 13.5px; font-weight: 600; color: var(--dim); margin-bottom: 5px; } +.empty p { font-size: 12px; margin-bottom: 16px; } + +/* ---------- 弹窗 ---------- */ + +.overlay { + position: fixed; + inset: 0; + background: rgba(5, 7, 9, 0.62); + display: grid; + place-items: center; + z-index: 50; + animation: fadein 0.14s ease; +} +@keyframes fadein { from { opacity: 0; } } + +.modal { + width: 480px; + max-width: calc(100vw - 48px); + max-height: calc(100vh - 80px); + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: 12px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); + animation: rise 0.16s ease; +} +@keyframes rise { from { transform: translateY(8px); opacity: 0; } } + +.modal .m-head { + display: flex; + align-items: center; + padding: 15px 18px 13px; + border-bottom: 1px solid var(--border); +} +.modal .m-head h2 { font-size: 14px; font-weight: 600; flex: 1; } +.modal .m-body { padding: 16px 18px 4px; } +.modal .m-foot { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 18px 16px; +} +.modal .m-foot .grow { flex: 1; } + +.probe-result { + display: flex; + gap: 7px; + align-items: flex-start; + font-size: 12px; + border-radius: var(--r-md); + padding: 8px 10px; + margin-bottom: 13px; + line-height: 1.5; + word-break: break-all; +} +.probe-result.ok { background: var(--green-dim); color: var(--green); } +.probe-result.bad { background: var(--red-dim); color: var(--red); } + +.confirm-text { font-size: 13px; color: var(--dim); line-height: 1.6; padding-bottom: 10px; } +.confirm-text b { color: var(--text); } + +/* ---------- Toast ---------- */ + +#toastRoot { + position: fixed; + right: 16px; + bottom: 16px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 100; +} +.toast { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 220px; + max-width: 380px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-left: 3px solid var(--accent); + border-radius: var(--r-md); + padding: 10px 13px; + font-size: 12.5px; + box-shadow: 0 8px 26px rgba(0, 0, 0, 0.35); + animation: rise 0.18s ease; +} +.toast.error { border-left-color: var(--red); } +.toast .t-msg { flex: 1; word-break: break-all; } +.toast .t-ic { margin-top: 1px; } +.toast.ok .t-ic { color: var(--accent); } +.toast.error .t-ic { color: var(--red); } +.toast.out { transition: opacity 0.25s, transform 0.25s; opacity: 0; transform: translateX(12px); } diff --git a/启动.bat b/启动.bat new file mode 100644 index 0000000..4c9fbc7 --- /dev/null +++ b/启动.bat @@ -0,0 +1,19 @@ +@echo off +setlocal +cd /d "%~dp0" + +if not exist ".venv\Scripts\pythonw.exe" ( + echo First run: setting up environment, please wait... + python -m venv .venv + if errorlevel 1 goto fail + ".venv\Scripts\python.exe" -m pip install --quiet --disable-pip-version-check -r requirements.txt + if errorlevel 1 goto fail +) + +start "" ".venv\Scripts\pythonw.exe" main.py +exit /b 0 + +:fail +echo Setup failed. Please make sure Python 3.10+ is installed and network is available. +pause +exit /b 1