- 本地端口转发隧道管理(密码/私钥认证,自动重连,随应用启动) - 主机与隧道可视化配置,DPAPI 加密存储机密,known_hosts 校验 - 自定义无边框窗口,深色界面,托盘驻留(最小化/关闭到托盘) - 内置本地测试 SSH 服务端与端到端自测脚本
710 lines
32 KiB
JavaScript
710 lines
32 KiB
JavaScript
/* 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");
|
|
}
|
|
})();
|