/* SSH 隧道管理器 — 前端逻辑 运行于 pywebview(调用 Python API)或浏览器(Mock,便于调试界面) */ "use strict"; /* ---------- 工具 ---------- */ const $ = (s, el = document) => el.querySelector(s); const $$ = (s, el = document) => [...el.querySelectorAll(s)]; const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const isPort = (v) => /^\d+$/.test(String(v).trim()) && +v >= 1 && +v <= 65535; const withTimeout = (p, ms, msg) => Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error(msg)), ms)), ]); function fmtUptime(s) { s = Math.max(0, Math.floor(s)); const h = String(Math.floor(s / 3600)).padStart(2, "0"); const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0"); const sec = String(s % 60).padStart(2, "0"); return `${h}:${m}:${sec}`; } /* ---------- 状态 ---------- */ const S = { hosts: [], tunnels: [], // 每项含 cfg 字段 + runtime + logs + host_label feed: [], view: "tunnels", openLogs: new Set(), }; /* ---------- API 桥 ---------- */ function nativeApi() { return (typeof window.pywebview === "object" && window.pywebview && window.pywebview.api) || null; } async function call(name, ...args) { const a = nativeApi(); if (a) { const r = await a[name](...args); if (r && r.ok === false) throw new Error(r.error || "操作失败"); return r; } return MockApi[name](...args); } /* Python 事件入口 */ window.__bus = (ev) => { try { handleEvent(ev); } catch (e) { console.error(e); } }; function handleEvent(ev) { if (ev.type === "state") { applyState(ev.state); return; } if (ev.type === "tunnel") { const t = S.tunnels.find((x) => x.id === ev.id); if (t) { t.runtime = { status: ev.status, error: ev.error, started_at: ev.started_at, conn_count: ev.conn_count }; renderTunnels(); renderSummary(); } return; } if (ev.type === "log") { const t = S.tunnels.find((x) => x.id === ev.tunnel_id); if (t) { t.logs.push(ev.entry); if (S.openLogs.has(t.id)) appendLogBox(t); } appendFeedRow({ tunnel_name: t ? t.name : "?", ...ev.entry }); if (S.view === "logs") maybeScrollFeed(); } } function applyState(state) { S.hosts = state.hosts || []; S.tunnels = state.tunnels || []; S.feed = state.feed || []; renderAll(); } /* ---------- Mock(浏览器调试用) ---------- */ const MockApi = (() => { let hosts = [ { id: "h1", name: "开发服务器", hostname: "192.168.8.20", port: 22, username: "deploy", auth: "password", key_path: "", note: "", has_password: true, has_passphrase: false }, { id: "h2", name: "跳板机", hostname: "gw.example.com", port: 2222, username: "root", auth: "key", key_path: "C:\\Users\\me\\.ssh\\id_ed25519", note: "", has_password: false, has_passphrase: true }, ]; let tunnels = [ { id: "t1", name: "grafana", host_id: "h1", local_port: 3000, remote_host: "127.0.0.1", remote_port: 3000, autostart: false, autoreconnect: true, host_label: "deploy@192.168.8.20:22", host_auth: "password", runtime: { id: "t1", status: "connected", error: "", started_at: Date.now() / 1000 - 4372, conn_count: 12 }, logs: [ { t: "10:21:07", level: "info", msg: "正在连接 deploy@192.168.8.20:22 ..." }, { t: "10:21:08", level: "success", msg: "隧道已建立: 127.0.0.1:3000 → 127.0.0.1:3000" }, { t: "10:24:11", level: "info", msg: "接受连接 → 127.0.0.1:3000" }, ] }, { id: "t2", name: "mysql-debug", host_id: "h2", local_port: 3307, remote_host: "127.0.0.1", remote_port: 3306, autostart: true, autoreconnect: true, host_label: "root@gw.example.com:2222", host_auth: "key", runtime: { id: "t2", status: "stopped", error: "", started_at: null, conn_count: 0 }, logs: [] }, ]; let feed = tunnels.flatMap((t) => t.logs.map((e) => ({ tunnel_name: t.name, ...e }))); const snap = () => ({ hosts: structuredClone(hosts), tunnels: structuredClone(tunnels), feed: feed.slice(-300) }); const wait = (ms) => new Promise((r) => setTimeout(r, ms)); function pushLog(id, level, msg) { const t = tunnels.find((x) => x.id === id); if (!t) return; const e = { t: new Date().toTimeString().slice(0, 8), level, msg }; t.logs.push(e); feed.push({ tunnel_name: t.name, ...e }); window.__bus({ type: "log", tunnel_id: id, entry: e }); } return { async get_state() { return { ok: true, state: snap() }; }, async save_host(p) { const rec = { id: p.id || "h" + Math.random().toString(36).slice(2, 6), name: p.name || p.hostname, hostname: p.hostname, port: +p.port || 22, username: p.username, auth: p.auth, key_path: p.key_path || "", note: p.note || "", has_password: !!p.password || (p.id && hosts.find((h) => h.id === p.id)?.has_password && !p.password === false), has_passphrase: false }; if (!p.id && p.auth === "password") rec.has_password = !!p.password; if (p.id && !p.password) rec.has_password = hosts.find((h) => h.id === p.id).has_password; const i = hosts.findIndex((h) => h.id === rec.id); if (i >= 0) hosts[i] = rec; else hosts.push(rec); tunnels.forEach((t) => { if (t.host_id === rec.id) t.host_label = `${rec.username}@${rec.hostname}:${rec.port}`; }); return { ok: true, id: rec.id, state: snap() }; }, async delete_host(id) { hosts = hosts.filter((h) => h.id !== id); return { ok: true, state: snap() }; }, async save_tunnel(p) { const host = hosts.find((h) => h.id === p.host_id); const rec = { id: p.id || "t" + Math.random().toString(36).slice(2, 6), name: p.name || `${p.remote_host}:${p.remote_port}`, host_id: p.host_id, local_port: +p.local_port, remote_host: p.remote_host || "127.0.0.1", remote_port: +p.remote_port, autostart: !!p.autostart, autoreconnect: p.autoreconnect !== false, host_label: host ? `${host.username}@${host.hostname}:${host.port}` : "?", host_auth: host?.auth, runtime: { id: p.id || "t0", status: "stopped", error: "", started_at: null, conn_count: 0 } }; const i = tunnels.findIndex((t) => t.id === rec.id); if (i >= 0) { rec.runtime = tunnels[i].runtime; tunnels[i] = rec; } else tunnels.push(rec); return { ok: true, id: rec.id, state: snap() }; }, async delete_tunnel(id) { tunnels = tunnels.filter((t) => t.id !== id); feed = feed.filter((f) => f.tunnel_id !== id); return { ok: true, state: snap() }; }, async start_tunnel(id) { const t = tunnels.find((x) => x.id === id); t.runtime = { ...t.runtime, status: "connecting", error: "" }; window.__bus({ type: "tunnel", ...t.runtime }); pushLog(id, "info", `正在连接 ${t.host_label} ...`); await wait(1200); if (t.runtime.status !== "connecting") return { ok: true }; t.runtime = { ...t.runtime, status: "connected", started_at: Date.now() / 1000 }; window.__bus({ type: "tunnel", ...t.runtime }); pushLog(id, "success", `隧道已建立: 127.0.0.1:${t.local_port} → ${t.remote_host}:${t.remote_port}`); return { ok: true }; }, async stop_tunnel(id) { const t = tunnels.find((x) => x.id === id); t.runtime = { ...t.runtime, status: "stopped", started_at: null }; window.__bus({ type: "tunnel", ...t.runtime }); pushLog(id, "info", "隧道已断开"); return { ok: true }; }, async probe_host(p) { await wait(900); return p.hostname && p.username ? { ok: true, message: "认证成功 · 服务端 SSH-2.0-OpenSSH_9.6" } : { ok: false, message: "请先填写主机地址" }; }, async open_url(url) { window.open(url, "_blank"); return { ok: true }; }, async copy_text() { return { ok: true }; }, async pick_key_file() { return { ok: true, path: "C:\\Users\\me\\.ssh\\id_ed25519" }; }, async win_minimize() { return { ok: true }; }, async win_maximize() { return { ok: true }; }, async win_close() { return { ok: true }; }, }; })(); /* ---------- 图标 ---------- */ const I = { edit: '', trash: '', copy: '', external: '', play: '', stop: '', chevron: '', key: '', lock: '', check: '', alert: '', folder: '', x: '', }; const icon = (n, s = 14, w = 1.8) => `${I[n]}`; /* ---------- 渲染 ---------- */ const STATUS_META = { stopped: { text: "已停止", cls: "", dot: "" }, connecting: { text: "连接中…", cls: "amber", dot: "amber pulse" }, connected: { text: "已连接", cls: "green", dot: "green" }, error: { text: "连接失败", cls: "red", dot: "red" }, }; function renderAll() { renderTunnels(); renderHosts(); renderFeed(true); renderSummary(); $("#badgeHosts").textContent = S.hosts.length || ""; $("#logCount").textContent = S.feed.length ? `${S.feed.length} 条` : ""; } function renderTunnels() { const grid = $("#tunnelGrid"); const running = S.tunnels.filter((t) => t.runtime.status === "connected").length; $("#tunnelCount").textContent = S.tunnels.length ? `${S.tunnels.length} 条 · ${running} 条运行中` : ""; $("#tunnelsEmpty").style.display = S.tunnels.length ? "none" : ""; grid.style.display = S.tunnels.length ? "" : "none"; const b = $("#badgeTunnels"); b.textContent = running || ""; b.classList.toggle("live", running > 0); grid.innerHTML = S.tunnels.map((t) => { const rt = t.runtime; const meta = STATUS_META[rt.status] || STATUS_META.stopped; const live = rt.status === "connected"; const conn = rt.status === "connecting"; const up = live && rt.started_at ? ` · ${fmtUptime(Date.now() / 1000 - rt.started_at)}` : ""; const authIc = t.host_auth === "key" ? "key" : "lock"; const open = S.openLogs.has(t.id); return `
${esc(t.name)} ${meta.text}${up}
${rt.status === "error" && rt.error ? `
${esc(rt.error)}
` : ""}
127.0.0.1:${t.local_port} ${esc(t.remote_host)}:${t.remote_port}
${icon(authIc, 12)} ${esc(t.host_label)} | 累计连接 ${rt.conn_count || 0}
${live || conn ? `` : ``}
${open ? `
${(t.logs || []).map(logLine).join("")}
` : ""}
`; }).join(""); } const logLine = (e) => `
${esc(e.t)}${esc(e.msg)}
`; function appendLogBox(t) { const box = $(`#tunnelGrid .tcard[data-id="${t.id}"] [data-logbox]`); if (!box) return; box.innerHTML = (t.logs || []).map(logLine).join(""); box.scrollTop = box.scrollHeight; } function renderHosts() { const list = $("#hostList"); $("#hostCount").textContent = S.hosts.length ? `${S.hosts.length} 台` : ""; $("#hostsEmpty").style.display = S.hosts.length ? "none" : ""; list.style.display = S.hosts.length ? "" : "none"; list.innerHTML = S.hosts.map((h) => { const used = S.tunnels.filter((t) => t.host_id === h.id); return `
${esc(h.name)}
${esc(h.username)}@${esc(h.hostname)}:${h.port}
${icon(h.auth === "key" ? "key" : "lock", 11)} ${h.auth === "key" ? "私钥" : "密码"} ${h.note ? `${esc(h.note)}` : ""}
${used.length ? `
被 ${used.length} 条隧道使用:${esc(used.map((t) => t.name).join("、"))}
` : ""}
`; }).join(""); } /* ---------- 日志视图 ---------- */ const feedRow = (e) => `
${esc(e.t)}${esc(e.tunnel_name)}${esc(e.msg)}
`; function renderFeed(clear) { const el = $("#logFeed"); el.innerHTML = S.feed.length ? S.feed.map(feedRow).join("") : `
暂无日志 —— 启动一条隧道后,这里会记录连接与转发事件
`; maybeScrollFeed(true); } function appendFeedRow(e) { S.feed.push(e); if (S.feed.length > 400) S.feed.shift(); const el = $("#logFeed"); const empty = $(".empty-feed", el); if (empty) empty.remove(); el.insertAdjacentHTML("beforeend", feedRow(e)); while (el.children.length > 400) el.firstChild.remove(); if (S.view === "logs") maybeScrollFeed(); } function maybeScrollFeed(force) { if (!$("#autoScroll").checked && !force) return; const el = $("#logFeed"); el.scrollTop = el.scrollHeight; } /* ---------- 顶部/侧栏摘要 ---------- */ function renderSummary() { const running = S.tunnels.filter((t) => t.runtime.status === "connected").length; const connecting = S.tunnels.filter((t) => t.runtime.status === "connecting").length; const dot = $("#connDot"), text = $("#connText"); dot.className = "dot" + (running ? " green" : connecting ? " amber pulse" : ""); text.textContent = running ? `${running} 条隧道运行中` : connecting ? "正在连接…" : "没有运行中的隧道"; } /* ---------- Toast ---------- */ function toast(msg, type = "ok") { const el = document.createElement("div"); el.className = `toast ${type}`; el.innerHTML = `${icon(type === "error" ? "alert" : "check", 14, 2)}${esc(msg)}`; $("#toastRoot").appendChild(el); setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 300); }, 3600); } /* ---------- 弹窗框架(支持叠加) ---------- */ const modalStack = []; function openModal({ title, body, footer }) { const overlay = document.createElement("div"); overlay.className = "overlay"; overlay.innerHTML = ` `; $("#modalRoot").appendChild(overlay); const ctx = { overlay, onOk: null, onClose: null }; modalStack.push(ctx); const close = () => closeModal(ctx); overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); }); $("[data-close]", overlay).addEventListener("click", close); $$("[data-cancel]", overlay).forEach((b) => b.addEventListener("click", close)); $("form", overlay).addEventListener("submit", (e) => { e.preventDefault(); if (ctx.onOk) ctx.onOk(); }); const first = $("input, select", overlay); if (first) first.focus(); ctx.close = close; return ctx; } function closeModal(ctx) { const c = ctx || modalStack[modalStack.length - 1]; if (!c) return; const i = modalStack.indexOf(c); if (i >= 0) modalStack.splice(i, 1); if (c.onClose) c.onClose(); c.overlay.remove(); } document.addEventListener("keydown", (e) => { if (e.key === "Escape" && modalStack.length) closeModal(); }); const footBtns = (okText, opts = {}) => ` ${opts.dangerSide ? `` : ``} `; /* ---------- 主机编辑弹窗 ---------- */ function openHostModal(existing, opts = {}) { const h = existing || {}; const ctx = openModal({ title: existing ? "编辑主机" : "新建主机", body: `
支持 OpenSSH 格式的私钥(id_ed25519 / id_rsa 等)
`, footer: ` ${footBtns(existing ? "保存" : "添加")}`, }); const ov = ctx.overlay; let auth = h.auth || "password"; const segBtns = $$("[name=auth] button", ov); segBtns.forEach((b) => b.addEventListener("click", () => { auth = b.dataset.v; segBtns.forEach((x) => x.classList.toggle("on", x === b)); $("[data-auth=password]", ov).style.display = auth === "password" ? "" : "none"; $("[data-auth=key]", ov).style.display = auth === "key" ? "" : "none"; })); $("[data-pick]", ov).addEventListener("click", async () => { try { const r = await call("pick_key_file"); if (r && r.path) $("input[name=key_path]", ov).value = r.path; } catch (e) { toast(e.message, "error"); } }); const collect = () => ({ id: h.id, name: v("name"), hostname: v("hostname"), port: v("port"), username: v("username"), auth, password: $("input[name=password]", ov).value, key_path: v("key_path"), key_passphrase: $("input[name=key_passphrase]", ov).value, note: v("note"), has_password: !!h.has_password, has_passphrase: !!h.has_passphrase, }); function v(name) { return $(`input[name=${name}]`, ov).value.trim(); } const showProbe = (ok, msg) => { const el = $("[data-probe]", ov); el.style.display = ""; el.className = `probe-result ${ok ? "ok" : "bad"}`; el.innerHTML = `${icon(ok ? "check" : "alert", 14, 2)}${esc(msg)}`; }; $("[data-probe-btn]", ov).addEventListener("click", async () => { const btn = $("[data-probe-btn]", ov); btn.disabled = true; btn.textContent = "测试中…"; try { const r = await call("probe_host", collect()); showProbe(r.ok, r.message || r.error || ""); } catch (e) { showProbe(false, e.message); } btn.disabled = false; btn.innerHTML = `${icon("play", 12, 2)} 测试连接`; }); ctx.onOk = async () => { const p = collect(); let bad = false; if (!p.hostname) { markErr("hostname"), bad = true; } if (!p.username) { markErr("username"), bad = true; } if (!isPort(p.port)) { markErr("port"), bad = true; } if (bad) return; try { const r = await call("save_host", p); applyState(r.state); toast(existing ? "主机已更新" : "主机已添加"); closeModal(ctx); if (opts.onSaved) opts.onSaved(r.id); } catch (e) { toast(e.message, "error"); } }; function markErr(name) { $(`input[name=${name}]`, ov).classList.add("err"); } return ctx; } /* ---------- 隧道编辑弹窗 ---------- */ function openTunnelModal(existing) { const t = existing || {}; const hostOpts = () => S.hosts.map((h) => ``).join(""); const ctx = openModal({ title: existing ? "编辑隧道" : "新建隧道", body: `
服务在远程主机上监听的地址,只对本机开放的服务填 127.0.0.1
建立后,在本机打开 127.0.0.1:本地端口,等同于直接访问远程的这个服务
自动重连
连接断开后自动重试,重试间隔逐渐拉长
随应用启动
打开管理器时自动建立这条隧道
`, footer: footBtns(existing ? "保存" : "创建隧道"), }); const ov = ctx.overlay; if (!S.hosts.length) $("select[name=host_id]", ov).innerHTML = ``; // 远程端口 → 本地端口跟随 const rp = $("input[name=remote_port]", ov), lp = $("input[name=local_port]", ov); rp.addEventListener("input", () => { if (!lp.dataset.touched && isPort(rp.value)) lp.value = rp.value; }); lp.addEventListener("input", () => { lp.dataset.touched = "1"; }); $("[data-newhost]", ov).addEventListener("click", () => { openHostModal(null, { onSaved: (id) => { const sel = $("select[name=host_id]", ov); sel.innerHTML = S.hosts.map((h) => ``).join(""); }, }); }); ctx.onOk = async () => { const v = (n) => $(`[name=${n}]`, ov).value.trim(); let bad = false; if (!isPort(v("remote_port"))) { markErr("remote_port"); bad = true; } if (!isPort(v("local_port"))) { markErr("local_port"); bad = true; } if (!v("host_id")) { toast("请选择主机(或先新建一台)", "error"); bad = true; } if (bad) return; try { const r = await call("save_tunnel", { id: t.id, name: v("name"), host_id: v("host_id"), remote_host: v("remote_host") || "127.0.0.1", remote_port: v("remote_port"), local_port: v("local_port"), autostart: $("[name=autostart]", ov).checked, autoreconnect: $("[name=autoreconnect]", ov).checked, }); applyState(r.state); toast(existing ? "隧道已更新" : "隧道已创建,点「启动」建立连接"); closeModal(ctx); } catch (e) { toast(e.message, "error"); } }; function markErr(n) { $(`[name=${n}]`, ov).classList.add("err"); } } /* ---------- 删除确认 ---------- */ function confirmDanger(title, html) { return new Promise((resolve) => { const ctx = openModal({ title, body: `
${html}
`, footer: ` `, }); ctx.onClose = () => resolve(false); $("[data-ok]", ctx.overlay).addEventListener("click", () => { ctx.onClose = null; resolve(true); closeModal(ctx); }); }); } /* ---------- 事件绑定 ---------- */ function switchView(name) { S.view = name; $$(".nav-item").forEach((b) => b.classList.toggle("on", b.dataset.view === name)); $$(".view").forEach((v) => v.classList.toggle("on", v.id === `view-${name}`)); if (name === "logs") maybeScrollFeed(true); } function bindOnce() { $$(".nav-item").forEach((b) => b.addEventListener("click", () => switchView(b.dataset.view))); $("#btnNewTunnel").addEventListener("click", () => openTunnelModal()); $("#btnEmptyNewTunnel").addEventListener("click", () => openTunnelModal()); $("#btnNewHost").addEventListener("click", () => openHostModal()); $("#btnEmptyNewHost").addEventListener("click", () => openHostModal()); $("#btnClearLogs").addEventListener("click", () => { S.feed = []; renderFeed(); }); // 隧道卡片(事件委托) $("#tunnelGrid").addEventListener("click", async (e) => { const card = e.target.closest(".tcard"); if (!card) return; const t = S.tunnels.find((x) => x.id === card.dataset.id); if (!t) return; const act = e.target.closest("[data-act]")?.dataset.act; if (!act) return; try { if (act === "start") await call("start_tunnel", t.id); else if (act === "stop") await call("stop_tunnel", t.id); else if (act === "edit") openTunnelModal(t); else if (act === "del") { const ok = await confirmDanger("删除隧道", `确定删除隧道 ${esc(t.name)}?
映射关系 127.0.0.1:${t.local_port} → ${esc(t.remote_host)}:${t.remote_port} 将被移除。`); if (!ok) return; const r = await call("delete_tunnel", t.id); applyState(r.state); toast("隧道已删除"); } else if (act === "copy") { await call("copy_text", `http://127.0.0.1:${t.local_port}`); toast("已复制 " + `http://127.0.0.1:${t.local_port}`); } else if (act === "open") { await call("open_url", `http://127.0.0.1:${t.local_port}`); } else if (act === "log") { S.openLogs.has(t.id) ? S.openLogs.delete(t.id) : S.openLogs.add(t.id); renderTunnels(); } } catch (err) { toast(err.message, "error"); } }); // 主机卡片 $("#hostList").addEventListener("click", async (e) => { const card = e.target.closest(".hcard"); if (!card) return; const h = S.hosts.find((x) => x.id === card.dataset.id); if (!h) return; const act = e.target.closest("[data-act]")?.dataset.act; if (!act) return; try { if (act === "edit") openHostModal(h); else if (act === "del") { const used = S.tunnels.filter((t) => t.host_id === h.id); if (used.length) { toast(`该主机正被「${used.map((t) => t.name).join("、")}」使用,请先删除相关隧道`, "error"); return; } const ok = await confirmDanger("删除主机", `确定删除主机 ${esc(h.name)}(${esc(h.username)}@${esc(h.hostname)})?`); if (!ok) return; const r = await call("delete_host", h.id); applyState(r.state); toast("主机已删除"); } } catch (err) { toast(err.message, "error"); } }); // 窗口控制 $("#btnMin").addEventListener("click", () => call("win_minimize").catch(() => {})); $("#btnMax").addEventListener("click", () => call("win_maximize").catch(() => {})); $("#btnClose").addEventListener("click", () => call("win_close").catch(() => {})); $("#dragZone").addEventListener("dblclick", () => call("win_maximize").catch(() => {})); // 运行时长刷新 setInterval(() => { $$("#tunnelGrid .tcard").forEach((card) => { const t = S.tunnels.find((x) => x.id === card.dataset.id); if (!t || t.runtime.status !== "connected" || !t.runtime.started_at) return; const st = $("[data-status]", card); st.textContent = `已连接 · ${fmtUptime(Date.now() / 1000 - t.runtime.started_at)}`; }); }, 1000); } /* ---------- 启动 ---------- */ function whenReady() { return new Promise((resolve) => { if (nativeApi()) return resolve(); window.addEventListener("pywebviewready", () => resolve(), { once: true }); setTimeout(resolve, 1500); // 浏览器 Mock 兜底 }); } (async function init() { bindOnce(); renderAll(); await whenReady(); try { const r = await withTimeout(call("get_state"), 5000, "连接应用后端超时"); applyState(r.state); } catch (e) { toast("加载配置失败:" + e.message, "error"); } })();