SSH 隧道管理器:pywebview+paramiko 桌面应用
- 本地端口转发隧道管理(密码/私钥认证,自动重连,随应用启动) - 主机与隧道可视化配置,DPAPI 加密存储机密,known_hosts 校验 - 自定义无边框窗口,深色界面,托盘驻留(最小化/关闭到托盘) - 内置本地测试 SSH 服务端与端到端自测脚本
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.icon-build/
|
||||
.test_data/
|
||||
.test_host_key
|
||||
*.log
|
||||
.crash.log
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
pywebview>=5.0
|
||||
paramiko>=3.4
|
||||
pystray>=0.19
|
||||
pillow>=10.0
|
||||
+100
@@ -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]
|
||||
+143
@@ -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("<h1>TUNNEL-E2E-OK</h1>", 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())
|
||||
+109
@@ -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()
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -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: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
play: '<path d="m7 4 13 8-13 8Z"/>',
|
||||
stop: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
|
||||
chevron: '<path d="m6 9 6 6 6-6"/>',
|
||||
key: '<circle cx="8" cy="16" r="4"/><path d="m10.8 13.2 9.7-9.7"/><path d="M17 7l3 3"/>',
|
||||
lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
alert: '<circle cx="12" cy="12" r="9"/><path d="M12 8v4.5"/><path d="M12 16h.01"/>',
|
||||
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
|
||||
x: '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||
};
|
||||
const icon = (n, s = 14, w = 1.8) =>
|
||||
`<svg class="ic" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round">${I[n]}</svg>`;
|
||||
|
||||
/* ---------- 渲染 ---------- */
|
||||
|
||||
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 `
|
||||
<article class="tcard ${live ? "live" : ""}" data-id="${t.id}">
|
||||
<div class="head">
|
||||
<span class="dot ${meta.dot}"></span>
|
||||
<span class="grow name" title="${esc(t.name)}">${esc(t.name)}</span>
|
||||
<span class="status-text ${meta.cls}" data-status>${meta.text}${up}</span>
|
||||
<button class="ibtn" data-act="edit" title="编辑">${icon("edit")}</button>
|
||||
<button class="ibtn red" data-act="del" title="删除">${icon("trash")}</button>
|
||||
</div>
|
||||
${rt.status === "error" && rt.error ? `<div class="err-line" title="${esc(rt.error)}">${esc(rt.error)}</div>` : ""}
|
||||
<div class="mapline">
|
||||
<span class="addr">127.0.0.1:${t.local_port}</span>
|
||||
<svg class="ic arrow" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
|
||||
<span class="addr">${esc(t.remote_host)}:${t.remote_port}</span>
|
||||
<span class="grow"></span>
|
||||
<button class="ibtn" data-act="copy" title="复制本地地址">${icon("copy", 13)}</button>
|
||||
<button class="ibtn" data-act="open" title="在浏览器打开 http://127.0.0.1:${t.local_port}">${icon("external", 13)}</button>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
${icon(authIc, 12)}
|
||||
<span>${esc(t.host_label)}</span>
|
||||
<span class="sep">|</span>
|
||||
<span>累计连接 ${rt.conn_count || 0}</span>
|
||||
<span class="grow"></span>
|
||||
<button data-act="log" class="${open ? "open" : ""}">${icon("chevron", 12)} 日志</button>
|
||||
</div>
|
||||
<div class="acts">
|
||||
${live || conn
|
||||
? `<button class="btn sm subtle" data-act="stop">${icon("stop", 12, 2)} 断开</button>`
|
||||
: `<button class="btn sm primary" data-act="start">${icon("play", 12, 2)} 启动</button>`}
|
||||
</div>
|
||||
${open ? `<div class="logbox" data-logbox>${(t.logs || []).map(logLine).join("")}</div>` : ""}
|
||||
</article>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
const logLine = (e) =>
|
||||
`<div><span class="lt">${esc(e.t)}</span><span class="l-${esc(e.level)}">${esc(e.msg)}</span></div>`;
|
||||
|
||||
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 `
|
||||
<div class="hcard" data-id="${h.id}">
|
||||
<span class="icon-box">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>
|
||||
</span>
|
||||
<div class="info">
|
||||
<div class="hname">${esc(h.name)}</div>
|
||||
<div class="haddr">${esc(h.username)}@${esc(h.hostname)}:${h.port}</div>
|
||||
<div class="tags">
|
||||
<span class="tag">${icon(h.auth === "key" ? "key" : "lock", 11)} ${h.auth === "key" ? "私钥" : "密码"}</span>
|
||||
${h.note ? `<span class="tag">${esc(h.note)}</span>` : ""}
|
||||
</div>
|
||||
${used.length ? `<div class="used-by">被 ${used.length} 条隧道使用:${esc(used.map((t) => t.name).join("、"))}</div>` : ""}
|
||||
</div>
|
||||
<button class="ibtn" data-act="edit" title="编辑">${icon("edit")}</button>
|
||||
<button class="ibtn red" data-act="del" title="删除">${icon("trash")}</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/* ---------- 日志视图 ---------- */
|
||||
|
||||
const feedRow = (e) =>
|
||||
`<div class="row l-${esc(e.level)}"><span class="t">${esc(e.t)}</span><span class="src" title="${esc(e.tunnel_name)}">${esc(e.tunnel_name)}</span><span class="m">${esc(e.msg)}</span></div>`;
|
||||
|
||||
function renderFeed(clear) {
|
||||
const el = $("#logFeed");
|
||||
el.innerHTML = S.feed.length
|
||||
? S.feed.map(feedRow).join("")
|
||||
: `<div class="empty-feed">暂无日志 —— 启动一条隧道后,这里会记录连接与转发事件</div>`;
|
||||
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 = `<span class="t-ic">${icon(type === "error" ? "alert" : "check", 14, 2)}</span><span class="t-msg">${esc(msg)}</span>`;
|
||||
$("#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 = `
|
||||
<div class="modal" role="dialog">
|
||||
<div class="m-head"><h2>${esc(title)}</h2>
|
||||
<button class="ibtn" data-close>${icon("x", 14)}</button></div>
|
||||
<form novalidate>
|
||||
<div class="m-body">${body}</div>
|
||||
<div class="m-foot">${footer}</div>
|
||||
</form>
|
||||
</div>`;
|
||||
$("#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 ? `<button type="button" class="btn danger sm" data-side>${opts.dangerSide}</button><span class="grow"></span>` : `<span class="grow"></span>`}
|
||||
<button type="button" class="btn sm subtle" data-cancel>取消</button>
|
||||
<button type="submit" class="btn sm ${opts.okClass || "primary"}">${esc(okText)}</button>`;
|
||||
|
||||
/* ---------- 主机编辑弹窗 ---------- */
|
||||
|
||||
function openHostModal(existing, opts = {}) {
|
||||
const h = existing || {};
|
||||
const ctx = openModal({
|
||||
title: existing ? "编辑主机" : "新建主机",
|
||||
body: `
|
||||
<div class="field"><label>备注名称</label>
|
||||
<input class="ipt" name="name" placeholder="例如:公司跳板机" value="${esc(h.name || "")}"></div>
|
||||
<div class="row2-uneven">
|
||||
<div class="field"><label>主机地址</label>
|
||||
<input class="ipt mono" name="hostname" placeholder="例如 203.0.113.10 或 gw.example.com" value="${esc(h.hostname || "")}"></div>
|
||||
<div class="field"><label>SSH 端口</label>
|
||||
<input class="ipt mono" name="port" value="${esc(h.port || 22)}"></div>
|
||||
</div>
|
||||
<div class="field"><label>用户名</label>
|
||||
<input class="ipt" name="username" placeholder="SSH 登录用户" value="${esc(h.username || "")}"></div>
|
||||
<div class="field"><label>认证方式</label>
|
||||
<div class="seg" name="auth">
|
||||
<button type="button" data-v="password" class="${(h.auth || "password") === "password" ? "on" : ""}">${icon("lock", 13)} 密码</button>
|
||||
<button type="button" data-v="key" class="${h.auth === "key" ? "on" : ""}">${icon("key", 13)} 私钥</button>
|
||||
</div></div>
|
||||
<div data-auth="password" style="${h.auth === "key" ? "display:none" : ""}">
|
||||
<div class="field"><label>密码</label>
|
||||
<input class="ipt" type="password" name="password" placeholder="${h.has_password ? "已保存 — 留空则不修改" : "登录密码"}"
|
||||
autocomplete="new-password"></div>
|
||||
</div>
|
||||
<div data-auth="key" style="${h.auth === "key" ? "" : "display:none"}">
|
||||
<div class="field"><label>私钥文件</label>
|
||||
<div class="row2-uneven">
|
||||
<input class="ipt mono" name="key_path" placeholder="例如 C:\\Users\\你\\.ssh\\id_ed25519" value="${esc(h.key_path || "")}">
|
||||
<button type="button" class="btn" data-pick style="width:100%">浏览…</button>
|
||||
</div>
|
||||
<div class="hint">支持 OpenSSH 格式的私钥(id_ed25519 / id_rsa 等)</div></div>
|
||||
<div class="field"><label>私钥口令(可选)</label>
|
||||
<input class="ipt" type="password" name="key_passphrase" placeholder="${h.has_passphrase ? "已保存 — 留空则不修改" : "私钥若有口令"}"
|
||||
autocomplete="new-password"></div>
|
||||
</div>
|
||||
<div class="field"><label>备注(可选)</label>
|
||||
<input class="ipt" name="note" placeholder="" value="${esc(h.note || "")}"></div>
|
||||
<div class="probe-result" data-probe style="display:none"></div>
|
||||
`,
|
||||
footer: `<button type="button" class="btn sm subtle" data-probe-btn style="margin-right:auto">${icon("play", 12, 2)} 测试连接</button>
|
||||
${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)}<span>${esc(msg)}</span>`;
|
||||
};
|
||||
|
||||
$("[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) =>
|
||||
`<option value="${h.id}" ${t.host_id === h.id ? "selected" : ""}>${esc(h.name)}(${esc(h.username)}@${esc(h.hostname)})</option>`).join("");
|
||||
|
||||
const ctx = openModal({
|
||||
title: existing ? "编辑隧道" : "新建隧道",
|
||||
body: `
|
||||
<div class="field"><label>名称</label>
|
||||
<input class="ipt" name="name" placeholder="${esc(t.name || "例如 dev-web")}" value="${esc(existing ? t.name : "")}"></div>
|
||||
<div class="field"><label>SSH 主机</label>
|
||||
<div class="row2-uneven">
|
||||
<div class="sel-wrap"><select class="ipt" name="host_id">${hostOpts()}</select></div>
|
||||
<button type="button" class="btn" data-newhost style="width:100%">新主机…</button>
|
||||
</div></div>
|
||||
<div class="row2">
|
||||
<div class="field"><label>远程地址</label>
|
||||
<input class="ipt mono" name="remote_host" value="${esc(t.remote_host || "127.0.0.1")}">
|
||||
<div class="hint">服务在远程主机上监听的地址,只对本机开放的服务填 127.0.0.1</div></div>
|
||||
<div class="field"><label>远程端口</label>
|
||||
<input class="ipt mono" name="remote_port" placeholder="例如 3000" value="${esc(t.remote_port || "")}"></div>
|
||||
</div>
|
||||
<div class="field"><label>本地端口</label>
|
||||
<input class="ipt mono" name="local_port" placeholder="本机访问用的端口" value="${esc(t.local_port || "")}">
|
||||
<div class="hint">建立后,在本机打开 <b>127.0.0.1:本地端口</b>,等同于直接访问远程的这个服务</div></div>
|
||||
<div class="switch-row">
|
||||
<span class="sw-text"><span class="sw-title">自动重连</span><br>
|
||||
<span class="sw-desc">连接断开后自动重试,重试间隔逐渐拉长</span></span>
|
||||
<input type="checkbox" class="switch" name="autoreconnect" ${t.autoreconnect !== false && !("autoreconnect" in t) || t.autoreconnect ? "checked" : ""}>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<span class="sw-text"><span class="sw-title">随应用启动</span><br>
|
||||
<span class="sw-desc">打开管理器时自动建立这条隧道</span></span>
|
||||
<input type="checkbox" class="switch" name="autostart" ${t.autostart ? "checked" : ""}>
|
||||
</div>
|
||||
`,
|
||||
footer: footBtns(existing ? "保存" : "创建隧道"),
|
||||
});
|
||||
|
||||
const ov = ctx.overlay;
|
||||
if (!S.hosts.length) $("select[name=host_id]", ov).innerHTML = `<option value="">—— 请先添加主机 ——</option>`;
|
||||
|
||||
// 远程端口 → 本地端口跟随
|
||||
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) =>
|
||||
`<option value="${h.id}" ${h.id === id ? "selected" : ""}>${esc(h.name)}(${esc(h.username)}@${esc(h.hostname)})</option>`).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: `<div class="confirm-text">${html}</div>`,
|
||||
footer: `<span class="grow"></span>
|
||||
<button type="button" class="btn sm subtle" data-cancel>取消</button>
|
||||
<button type="button" class="btn sm danger" data-ok>删除</button>`,
|
||||
});
|
||||
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("删除隧道",
|
||||
`确定删除隧道 <b>${esc(t.name)}</b>?<br>映射关系 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("删除主机",
|
||||
`确定删除主机 <b>${esc(h.name)}</b>(${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");
|
||||
}
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
+117
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SSH 隧道管理器</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header id="titlebar" class="titlebar">
|
||||
<div class="drag-zone pywebview-drag-region" id="dragZone">
|
||||
<span class="logo"><img src="logo.svg" alt=""></span>
|
||||
<span class="title">SSH 隧道管理器</span>
|
||||
</div>
|
||||
<div class="win-controls">
|
||||
<button class="wbtn" id="btnMin" title="最小化">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
||||
</button>
|
||||
<button class="wbtn" id="btnMax" title="最大化 / 还原">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>
|
||||
</button>
|
||||
<button class="wbtn close" id="btnClose" title="关闭">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="app">
|
||||
<nav id="nav">
|
||||
<button class="nav-item on" data-view="tunnels">
|
||||
<svg class="ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="3.2"/><path d="M3 12h5.6M15.4 12H21"/></svg>
|
||||
隧道
|
||||
<span class="nav-badge" id="badgeTunnels"></span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="hosts">
|
||||
<svg class="ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>
|
||||
主机
|
||||
<span class="nav-badge" id="badgeHosts"></span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="logs">
|
||||
<svg class="ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9.5 13h5M9.5 17h3"/></svg>
|
||||
日志
|
||||
</button>
|
||||
<div class="nav-foot">
|
||||
<div class="conn-summary" id="connSummary">
|
||||
<span class="dot" id="connDot"></span>
|
||||
<span id="connText">没有运行中的隧道</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main id="main">
|
||||
<section class="view on" id="view-tunnels">
|
||||
<header class="view-head">
|
||||
<h1>隧道</h1>
|
||||
<span class="count" id="tunnelCount"></span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn primary" id="btnNewTunnel">
|
||||
<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
新建隧道
|
||||
</button>
|
||||
</header>
|
||||
<div class="card-grid" id="tunnelGrid"></div>
|
||||
<div class="empty" id="tunnelsEmpty" style="display:none">
|
||||
<div class="glyph">
|
||||
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="12" cy="12" r="3.4"/><path d="M2.5 12h4.2M17.3 12h4.2"/></svg>
|
||||
</div>
|
||||
<h2>还没有隧道</h2>
|
||||
<p>把远程主机上只对本机开放的服务,映射到你本机的一个端口</p>
|
||||
<button class="btn primary" id="btnEmptyNewTunnel">新建第一条隧道</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-hosts">
|
||||
<header class="view-head">
|
||||
<h1>主机</h1>
|
||||
<span class="count" id="hostCount"></span>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn" id="btnNewHost">
|
||||
<svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
新建主机
|
||||
</button>
|
||||
</header>
|
||||
<div class="host-list" id="hostList"></div>
|
||||
<div class="empty" id="hostsEmpty" style="display:none">
|
||||
<div class="glyph">
|
||||
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>
|
||||
</div>
|
||||
<h2>还没有主机</h2>
|
||||
<p>添加一台 SSH 主机后,就可以基于它建立端口隧道</p>
|
||||
<button class="btn primary" id="btnEmptyNewHost">添加主机</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-logs">
|
||||
<header class="view-head">
|
||||
<h1>日志</h1>
|
||||
<span class="count" id="logCount"></span>
|
||||
<span class="spacer"></span>
|
||||
<label class="switch-row" style="padding:0">
|
||||
<span class="sw-title" style="font-size:12px;color:var(--dim)">自动滚动</span>
|
||||
<input type="checkbox" class="switch" id="autoScroll" checked>
|
||||
</label>
|
||||
<button class="btn subtle sm" id="btnClearLogs">清空显示</button>
|
||||
</header>
|
||||
<div class="log-feed" id="logFeed"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modalRoot"></div>
|
||||
<div id="toastRoot"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<g fill="none">
|
||||
<rect width="256" height="256" fill="#242938" rx="60" />
|
||||
<path fill="#242938" fill-rule="evenodd" d="m203.819 68.835l-63.14-37.48a23.79 23.79 0 0 0-24.361 0l-63.14 37.48C45.642 73.31 41 81.575 41 90.522v74.961c0 8.945 4.643 17.215 12.18 21.689l63.14 37.473a23.8 23.8 0 0 0 12.179 3.354a23.8 23.8 0 0 0 12.178-3.354l63.14-37.473c7.536-4.474 12.182-12.744 12.182-21.689v-74.96c0-8.948-4.646-17.214-12.18-21.688" clip-rule="evenodd" />
|
||||
<path fill="#fff" fill-rule="evenodd" d="m118.527 220.808l-63.14-37.474c-6.176-3.666-10.013-10.506-10.013-17.852V90.523c0-7.346 3.837-14.186 10.01-17.85l63.143-37.48a19.55 19.55 0 0 1 9.972-2.747c3.495 0 6.943.95 9.973 2.747l63.14 37.48c5.204 3.089 8.714 8.438 9.701 14.437c-2.094-4.469-6.817-5.684-12.32-2.47l-59.734 36.897c-7.448 4.354-12.94 9.24-12.945 18.221v73.604c-.004 5.378 2.168 8.861 5.504 9.871c-1.096.19-2.201.322-3.319.322a19.55 19.55 0 0 1-9.972-2.747m85.292-151.974l-63.14-37.478A23.8 23.8 0 0 0 128.499 28a23.8 23.8 0 0 0-12.181 3.356l-63.14 37.478C45.642 73.308 41 81.576 41 90.524v74.958c0 8.945 4.643 17.215 12.18 21.689l63.14 37.475A23.84 23.84 0 0 0 128.499 228a23.83 23.83 0 0 0 12.178-3.354l63.142-37.475c7.536-4.474 12.18-12.744 12.18-21.689V90.523c0-8.947-4.644-17.215-12.18-21.689" clip-rule="evenodd" />
|
||||
<path fill="#47b353" fill-rule="evenodd" d="m187.267 172.729l-15.722 9.41c-.417.243-.723.516-.726 1.017v4.114c0 .503.338.712.754.467l15.966-9.703c.416-.243.48-.708.483-1.209v-3.629c0-.5-.338-.71-.755-.467" clip-rule="evenodd" />
|
||||
<path fill="#242938" fill-rule="evenodd" d="M153.788 138.098c.509-.258.928.059.935.725l.053 5.439c2.277-.906 4.255-1.148 6.047-.734c.389.104.561.633.402 1.261l-1.197 4.82c-.093.364-.298.732-.545.961a1.3 1.3 0 0 1-.315.234a.7.7 0 0 1-.472.077c-.818-.185-2.763-.61-5.823.94c-3.21 1.625-4.333 4.414-4.311 6.484c.027 2.472 1.295 3.221 5.673 3.296c5.834.097 8.355 2.646 8.416 8.522c.06 5.77-3.02 11.966-7.732 15.763l.104 5.384c.006.648-.415 1.391-.924 1.649l-3.189 1.837c-.511.258-.93-.06-.937-.708l-.055-5.296c-2.731 1.135-5.499 1.409-7.267.699c-.333-.13-.476-.622-.344-1.182l1.156-4.868c.092-.384.295-.768.571-1.012q.147-.142.299-.219c.183-.092.362-.112.514-.055c1.905.642 4.342.342 6.685-.844c2.977-1.506 4.968-4.542 4.937-7.558c-.029-2.737-1.51-3.874-5.113-3.901c-4.586.013-8.861-.891-8.932-7.642c-.057-5.558 2.833-11.342 7.408-14.999l-.057-5.435c-.007-.668.401-1.403.926-1.667z" clip-rule="evenodd" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
+630
@@ -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); }
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user