commit 27a3b4c6a8e67c36e2fa36bfdf5f2d93b12478be Author: leefer Date: Thu Sep 24 17:14:59 2026 +0800 SSH 隧道管理器:pywebview+paramiko 桌面应用 - 本地端口转发隧道管理(密码/私钥认证,自动重连,随应用启动) - 主机与隧道可视化配置,DPAPI 加密存储机密,known_hosts 校验 - 自定义无边框窗口,深色界面,托盘驻留(最小化/关闭到托盘) - 内置本地测试 SSH 服务端与端到端自测脚本 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 0000000..a0e07aa Binary files /dev/null and b/ui/app.ico differ 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 0000000..a1d3540 Binary files /dev/null and b/ui/icon-256.png differ diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..e51280e --- /dev/null +++ b/ui/index.html @@ -0,0 +1,117 @@ + + + + + +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