From 4e1aebf26ec9c896ccb6b9c339410580da8bbe8c Mon Sep 17 00:00:00 2001 From: weidu12123 Date: Fri, 16 Oct 2026 22:34:52 +0800 Subject: [PATCH] fix: repair the issues found in the pre-release review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Downloading a file from the local host through the transfer engine always failed: the download branch had no LOCAL_HOST_ID case, so it opened SFTP against 127.0.0.1:0 (reproduced: "SSH 连接失败: 地址无效 (os error 10049)"). The file browser's own download button goes through that path, so it was broken in the UI too. The local branch now reuses stream_upload_to_host, which already had one, keeping the temp-file/resume/SHA-256 guarantees. - remove_item only refused the literal "/", so a Windows drive root listed by the file browser ("此电脑" returns "C:/") could be deleted recursively. New is_root_like() also covers drive roots, "." and "..", shared with compress_item, plus unit tests. - dir_cache grew without bound; capped at 256 entries, oldest evicted first. - ExecTaskManager capped task count but not buffered bytes: 60 running tasks could hold 120 MiB. Added a 32 MiB budget that evicts finished tasks only. - Web sessions were only ever inserted, never expired, and every auth-settings save minted another one. Now a token->issued map with a 30-day TTL, pruned on every issue. Frontend: - A failed pane creation left the stage spinning "正在连接" forever (no retry, and the standalone window has no tab bar to escape through). The failure is now kept on the tab and rendered as an error with a retry button. - The transfer-queue poller only cleared its 350 ms interval on success, so a 401 or a backend restart left it polling ~3 req/s indefinitely. - The MCP call detail fetch had no sequence guard, so clicking two rows quickly showed the wrong call's args and result. - Only three call sites handled 401. Since the session table lives in memory, every daemon restart invalidates all sessions, so with auth enabled this is routine rather than an edge case. All protected requests now go through apiFetch and return to the login screen. - check-update read data.message, which the backend never returns (it returns error), so failures only ever showed a bare "检查更新失败". Docs: corrected the Claude Code setup commands (missing --transport http, and x-vps-switch-key is not a header the server reads), fixed the Setup.exe PATH claim, added measured memory numbers for both platforms, and added a build-from-source section -- a clean clone cannot compile, since frontend/dist is ignored while RustEmbed treats its absence as a hard error. Co-Authored-By: Claude Opus 4.8 --- README.md | 77 +++++++++- frontend/src/App.vue | 148 +++++++++++++------ frontend/src/components/FileBrowserPanel.vue | 77 +++++++--- frontend/src/components/TerminalStage.vue | 29 ++++ src/api.rs | 33 ++++- src/files.rs | 112 +++++++++++++- src/main.rs | 2 +- src/mcp.rs | 43 ++++-- src/ssh.rs | 14 ++ 9 files changed, 441 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 96e8a28..78c6a1e 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ VPS Switch 是一个本地优先的主机管理工具。它集成了终端、文 前往 [**GitHub Releases**](https://github.com/weidu12123/vps-switch/releases/latest) 获取最新版本: ### Windows -- **`vps-switch-0.1.0-windows-x64-setup.exe`(推荐)**:标准向导安装程序,自动配置快捷方式与系统环境变量。 +- **`vps-switch-0.1.0-windows-x64-setup.exe`(推荐)**:标准向导安装程序,自动配置开始菜单与桌面快捷方式。 - **`vps-switch-0.1.0-windows-x64-portable.zip`**:免安装绿色便携版,解压后双击 `vps-switch.exe` 即可使用。 -- **`vps-switch-0.1.0-x86_64.msi`**:Windows 原生 MSI 包,适合命令行自动化部署。 +- **`vps-switch-0.1.0-x86_64.msi`**:Windows 原生 MSI 包,适合命令行自动化部署(会把安装目录加进系统 PATH)。 > *Windows 首次运行提示:因未购买商业代码签名证书,Windows SmartScreen 可能会提示“未知发布者”,点击「更多信息」→「仍要运行」即可正常使用。* @@ -90,12 +90,16 @@ sudo systemctl enable --now vps-switch ```bash # 本机模式 -claude mcp add vps-switch http://127.0.0.1:18800/mcp +claude mcp add --transport http --scope user vps-switch http://127.0.0.1:18800/mcp -# 远端模式(附带 API Key) -claude mcp add vps-switch http://<服务器IP>:18800/mcp -H "x-vps-switch-key: YOUR_API_KEY" +# 远端模式(附带 API Key,走 Authorization: Bearer) +claude mcp add --transport http --scope user vps-switch http://<服务器IP>:18800/mcp \ + -H "Authorization: Bearer YOUR_API_KEY" ``` +> `--transport http` 不能省:不写时 `claude mcp add` 按 stdio 处理,会把 URL 当成命令。 +> 远端鉴权只认 `Authorization: Bearer ` 与 `X-API-Key: ` 两种头,可在设置页复制现成的配置 JSON。 + ### Agent 可调用的核心工具 | 工具名称 | 作用说明 | @@ -112,8 +116,69 @@ claude mcp add vps-switch http://<服务器IP>:18800/mcp -H "x-vps-switch-key: Y ## 技术架构 -- **后端**:Rust 2021 + Tokio + Axum(单一常驻进程,内存占用 ~30MB) +- **后端**:Rust 2021 + Tokio + Axum(单一常驻进程) - **SSH 底座**:纯异步 `russh` 0.63 + `russh-sftp`(单主机多 channel 复用与跳板机穿透) - **终端引擎**:`alacritty_terminal` 权威网格 + 随包 Windows ConPTY + 二进制 WebSocket - **前端**:Vue 3 + TypeScript + Tailwind CSS + xterm.js + CodeMirror 6 - **本地存储**:单文件 SQLite(`rusqlite` bundled)+ 关键凭据 AES-GCM 本地加密 + +### 内存占用 + +#### Linux 无头部署(实测,Ubuntu 24.04 / 8 核) + +| 状态 | RSS | +|---|---| +| 空闲常驻 | **9.7 MB** | +| 一次本机探针之后 | 10.4 MB | +| 并发 8 条大输出命令之后 | 39.7 MB | +| 打开无头终端 pane 之后 | 41.2 MB | + +空闲基线就是 10 MB 上下,这也是把 VPS Switch 当常驻服务跑在 128 MB 小机器上仍然宽裕的原因。 + +第三行的涨幅来自命令输出缓冲:每条任务 stdout/stderr 各留最多 1 MiB,且**结果会刻意留在任务表里** +(agent 之后要能取回),所以并发大输出之后不会立刻回落——这是取舍,不是泄漏。 + +> 顺带一提:`top` 的 VIRT 列会显示几百 MB 到 1 GB,那是地址空间不是内存占用(线程栈与 +> 预留缓冲),看 RSS 即可。 + +#### Windows 桌面(实测) + +| 状态 | 提交内存 | +|---|---| +| 开机自启 / 启动即最小化,窗口从未打开 | **0 个 WebView2 进程**,后端 5 MB | +| 窗口打开 | WebView2 约 197 MB | +| 关闭到托盘 | WebView2 约 180 MB | + +窗口一旦创建就会拉起一整套 WebView2(Chromium),这是 WebView 壳的固定开销,不是后端占的。 +所以做了两件事把它压到最小: + +- **启动即最小化时不创建 WebView**,点开才建——开机自启后躺在托盘里的常态因此只占几 MB; +- **关到托盘时把 WebView2 切到低内存档**(`ICoreWebView2_19::SetMemoryUsageTargetLevel`), + 实测再降 10–20%,且页面不重载,标签页与终端现场都保留。 + +所以"它占多少内存"取决于窗口开着还是躺在托盘里——后者才是它绝大多数时间的状态。 +如果完全不要这个壳,Linux 无头部署那条路是 10 MB。 + + +--- + +## 从源码构建 + +仓库里不含构建产物,直接 `cargo build` 会失败(`frontend/dist` 与随包的 ConPTY 二进制都不入库)。 + +```bash +# 1. 前端:先产出 frontend/dist(RustEmbed 在编译期读取它,缺了会编译报错) +cd frontend +pnpm install +pnpm build +cd .. + +# 2. 随包的 Windows ConPTY:从 Windows Terminal 发布包取这两个文件放到仓库根目录 +# (portable-pty 按 DLL 搜索顺序从 exe 同级目录加载 conpty.dll) +# conpty.dll OpenConsole.exe + +# 3. 后端 +cargo build --release # 产物在 target/release/vps-switch.exe +``` + +Linux 无头版只需 `cargo build --release`,前端已内嵌进单一二进制。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9784ad0..af31831 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -25,6 +25,9 @@ interface OpenSessionTab { rightWing: RightWingMode; splitDirection: 'row' | 'col'; panes: PaneMeta[]; + /// 建 pane 失败时的原因。留在标签上,让舞台能渲染成"失败 + 重试", + /// 而不是永远转着"正在连接"(独立窗口模式下没有标签栏,转圈就是死路)。 + error?: string | null; } // 独立系统终端原生窗口模式判定 (?standalone=1&hostId=...) @@ -124,6 +127,8 @@ const loadingMcpCalls = ref(false); const expandedCallId = ref(null); const callDetail = ref<{ args: string; result: string } | null>(null); const loadingCallDetail = ref(false); +/// 详情请求的序号:快速点两行时,先发的请求后到会把后一行的正文盖掉(同 loadDir 的 navSeq) +let callDetailSeq = 0; const MCP_CALL_LIMIT = 500; const mcpCallFilters = ref({ from: '', to: '', host: '', tool: '', ok: '' }); const mcpCallFiltered = computed( @@ -173,7 +178,6 @@ const updateStatus = ref<{ publishedAt?: string; releaseNotes?: string; releaseUrl?: string; - message?: string; }>({ checking: false, checked: false, @@ -374,13 +378,30 @@ async function handleLogout() { activePage.value = 'workspace'; } +/// 所有受保护的后端请求都走这里:401 表示会话已失效,直接退回登录页。 +/// 之前只有三处处理 401,其余把 401 当普通错误弹个 toast 就算了——而会话表存在内存里, +/// **服务每次重启都会清空**,所以开了登录保护时这不是边缘情况:用户会停在一个已经死掉的 +/// 工作台里,点什么都没反应。返回 null 表示请求已作废,调用方 `if (!res) return;` 即可。 +async function apiFetch(input: string, init?: RequestInit): Promise { + const res = await fetch(input, init); + if (res.status === 401) { + authenticated.value = false; + activePage.value = 'workspace'; + return null; + } + return res; +} + +/// 子面板(文件管理器等)发现会话失效时调这里,走同一条回登录页的路 +function handleSessionExpired() { + authenticated.value = false; + activePage.value = 'workspace'; +} + async function loadSettings() { try { - const res = await fetch('/api/settings'); - if (res.status === 401) { - authenticated.value = false; - return; - } + const res = await apiFetch('/api/settings'); + if (!res) return; const data = await res.json(); if (data.ok && data.settings) { const s = data.settings; @@ -411,7 +432,8 @@ async function loadSettings() { async function loadGuardList() { try { - const res = await fetch('/api/guard'); + const res = await apiFetch('/api/guard'); + if (!res) return; const data = await res.json(); if (data.ok) { guardContent.value = data.content || ''; @@ -425,11 +447,12 @@ async function loadGuardList() { async function saveGuardList() { savingGuard.value = true; try { - const res = await fetch('/api/guard', { + const res = await apiFetch('/api/guard', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: guardContent.value }), }); + if (!res) return; const data = await res.json(); if (!data.ok) { showToast(data.error || '保存命令黑名单失败', 'err'); @@ -460,11 +483,8 @@ async function loadMcpCalls() { if (f.tool) q.set('tool', f.tool); if (f.ok) q.set('ok', f.ok); - const res = await fetch(`/api/mcp-calls?${q.toString()}`); - if (res.status === 401) { - authenticated.value = false; - return; - } + const res = await apiFetch(`/api/mcp-calls?${q.toString()}`); + if (!res) return; const data = await res.json(); if (data.ok) { mcpCalls.value = Array.isArray(data.calls) ? data.calls : []; @@ -493,16 +513,20 @@ async function toggleCall(id: string) { expandedCallId.value = id; callDetail.value = null; loadingCallDetail.value = true; + const seq = ++callDetailSeq; try { - const res = await fetch(`/api/mcp-calls/${encodeURIComponent(id)}`); + const res = await apiFetch(`/api/mcp-calls/${encodeURIComponent(id)}`); + if (!res) return; const data = await res.json(); + // 迟到的响应属于上一行,丢弃——否则展开的是 B,显示的却是 A 的参数与返回 + if (seq !== callDetailSeq) return; if (data.ok && data.call) { callDetail.value = { args: data.call.args || '', result: data.call.result || '' }; } } catch { // ignore } finally { - loadingCallDetail.value = false; + if (seq === callDetailSeq) loadingCallDetail.value = false; } } @@ -525,7 +549,8 @@ watch(activeSettingsTab, (tab) => { async function loadAutostartSettings() { try { - const res = await fetch('/api/system/autostart'); + const res = await apiFetch('/api/system/autostart'); + if (!res) return; const data = await res.json(); if (data.ok) { autostartEnabled.value = Boolean(data.autostart); @@ -542,7 +567,7 @@ async function saveAutostartSettings() { const prevCloseAction = closeAction.value; savingAutostart.value = true; try { - const res = await fetch('/api/system/autostart', { + const res = await apiFetch('/api/system/autostart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -550,6 +575,7 @@ async function saveAutostartSettings() { closeAction: closeAction.value, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) { autostartEnabled.value = prevAutostart; @@ -571,7 +597,8 @@ async function saveAutostartSettings() { async function loadAboutInfo() { try { - const res = await fetch('/api/system/about'); + const res = await apiFetch('/api/system/about'); + if (!res) return; const data = await res.json(); if (data.ok) { appAbout.value = data; @@ -585,7 +612,8 @@ async function loadAboutInfo() { async function checkAppUpdate() { updateStatus.value.checking = true; try { - const res = await fetch('/api/system/check-update', { method: 'POST' }); + const res = await apiFetch('/api/system/check-update', { method: 'POST' }); + if (!res) return; const data = await res.json(); if (data.ok) { updateStatus.value = { @@ -597,7 +625,6 @@ async function checkAppUpdate() { publishedAt: data.publishedAt, releaseNotes: data.releaseNotes, releaseUrl: data.releaseUrl, - message: data.message, }; if (data.hasUpdate) { showToast(`发现新版本 ${data.latestVersion}!`); @@ -605,7 +632,17 @@ async function checkAppUpdate() { showToast('当前已是最新版本'); } } else { - showToast('检查更新失败', 'err'); + // 后端在失败分支返回的是 error(不是 message),且带着 gh 的原始 stdout/stderr 与退出码—— + // 只回一句"检查更新失败"等于把唯一有用的排查信息丢了。 + const detail = data.error || data.stderr || ''; + updateStatus.value = { + checking: false, + checked: true, + hasUpdate: false, + currentVersion: data.currentVersion || updateStatus.value.currentVersion, + latestVersion: data.latestVersion || updateStatus.value.currentVersion, + }; + showToast(detail ? `检查更新失败: ${detail}` : '检查更新失败', 'err'); } } catch (e) { showToast(`检查更新出错: ${(e as Error).message}`, 'err'); @@ -620,7 +657,7 @@ async function saveMcpSettings(regenerateKey = false) { const prevRequireKey = mcpRequireApiKey.value; savingMcp.value = true; try { - const res = await fetch('/api/settings/mcp', { + const res = await apiFetch('/api/settings/mcp', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -629,6 +666,7 @@ async function saveMcpSettings(regenerateKey = false) { regenerateKey, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) { mcpEnabled.value = prevEnabled; @@ -655,7 +693,7 @@ async function saveAuthSettings() { } savingAuth.value = true; try { - const res = await fetch('/api/settings/auth', { + const res = await apiFetch('/api/settings/auth', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -664,6 +702,7 @@ async function saveAuthSettings() { password: settingAdminPassword.value || undefined, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) { showToast(data.error || '保存登录设置失败', 'err'); @@ -684,7 +723,8 @@ async function saveAuthSettings() { async function triggerCliSync() { try { - const res = await fetch('/api/mcp/status', { method: 'POST' }); + const res = await apiFetch('/api/mcp/status', { method: 'POST' }); + if (!res) return; const data = await res.json(); if (!data.ok || !data.status) { showToast(data.error || '写入本机 CLI 配置失败', 'err'); @@ -804,11 +844,8 @@ function subscribeUiEvents() { async function fetchHosts() { loading.value = true; try { - const res = await fetch('/api/hosts'); - if (res.status === 401) { - authenticated.value = false; - return; - } + const res = await apiFetch('/api/hosts'); + if (!res) return; const data = await res.json(); if (data.ok && Array.isArray(data.hosts)) { hosts.value = data.hosts; @@ -823,8 +860,8 @@ async function fetchHosts() { async function restoreExistingTerminals() { try { - const res = await fetch('/api/terminals'); - if (!res.ok) return; + const res = await apiFetch('/api/terminals'); + if (!res || !res.ok) return; const data = await res.json(); if (!data.ok || !Array.isArray(data.panes)) return; const byHost = new Map(); @@ -869,9 +906,10 @@ async function checkSingleHost(host: PublicHost) { next.add(host.id); checkingIds.value = next; try { - const res = await fetch(`/api/hosts/${encodeURIComponent(host.id)}/check`, { + const res = await apiFetch(`/api/hosts/${encodeURIComponent(host.id)}/check`, { method: 'POST', }); + if (!res) return; const data = await res.json(); if (data.ok && data.host) { replaceHostInList(data.host); @@ -900,7 +938,8 @@ async function checkAllHosts() { checkingAll.value = true; showToast('正在并发探索全部主机在线状态与实时硬件快照...'); try { - const res = await fetch('/api/hosts/check-all', { method: 'POST' }); + const res = await apiFetch('/api/hosts/check-all', { method: 'POST' }); + if (!res) return; const data = await res.json(); if (data.ok && Array.isArray(data.hosts)) { hosts.value = data.hosts; @@ -946,7 +985,7 @@ async function createPaneForTab(tab: OpenSessionTab, cwd?: string | null) { paneCreationInFlight.add(tab.hostId); paneCreationCount.value += 1; try { - const res = await fetch('/api/terminals', { + const res = await apiFetch('/api/terminals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -956,24 +995,37 @@ async function createPaneForTab(tab: OpenSessionTab, cwd?: string | null) { cwd: cwd || undefined, }), }); + if (!res) return; const data = await res.json(); if (!data.ok || !data.pane) { - showToast(data.error || '启动终端失败', 'err'); + // 建不出来就把原因留在标签上,交给舞台渲染成"连接失败 + 重试"。 + // 旧行为只弹一次 toast 就 return,panes 永远是空,舞台就一直转"正在连接"—— + // 主机离线、密码错、会话过期都会撞上,独立窗口模式下连标签栏都没有,无路可退。 + tab.error = (data.error as string) || '启动终端失败'; + showToast(tab.error, 'err'); return; } + tab.error = null; tab.panes.push(data.pane as PaneMeta); const host = hostById(tab.hostId); if (host && host.status !== 'online') { host.status = 'online'; } } catch (e) { - showToast(`启动终端失败: ${(e as Error).message}`, 'err'); + tab.error = `启动终端失败: ${(e as Error).message}`; + showToast(tab.error, 'err'); } finally { paneCreationInFlight.delete(tab.hostId); paneCreationCount.value = Math.max(0, paneCreationCount.value - 1); } } +/// 舞台上的「重试」按钮:清掉失败态后重新建 pane +async function retryPaneForTab(tab: OpenSessionTab) { + tab.error = null; + await createPaneForTab(tab); +} + async function activateSessionTab(tab: OpenSessionTab) { activePage.value = 'workspace'; syncCurrentTabState(); @@ -1006,11 +1058,12 @@ async function closeSessionTab(hostId: string) { async function handlePopoutTerminal(hostId: string) { try { - const res = await fetch('/api/window/open-terminal', { + const res = await apiFetch('/api/window/open-terminal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId }), }); + if (!res) return; const data = await res.json(); if (data.ok) { const h = hostById(hostId); @@ -1133,11 +1186,12 @@ async function handleReconnectPane(paneId: string) { paneCreationInFlight.add(tab.hostId); paneCreationCount.value += 1; try { - const res = await fetch('/api/terminals', { + const res = await apiFetch('/api/terminals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId: tab.hostId, cols: 100, rows: 30, cwd: cwd || undefined }), }); + if (!res) return; const data = await res.json(); if (!data.ok || !data.pane) { showToast(data.error || '重连失败', 'err'); @@ -1179,11 +1233,12 @@ async function handleModalSubmit(payload: HostUpsertPayload, editingId: string | try { const url = editingId ? `/api/hosts/${encodeURIComponent(editingId)}` : '/api/hosts'; const method = editingId ? 'PUT' : 'POST'; - const res = await fetch(url, { + const res = await apiFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); + if (!res) return; const data = await res.json(); if (!data.ok) { showToast(data.error || '保存失败', 'err'); @@ -1203,11 +1258,12 @@ async function handleModalSubmit(payload: HostUpsertPayload, editingId: string | async function handlePatchMeta(host: PublicHost, patch: Record) { try { - const res = await fetch(`/api/hosts/${encodeURIComponent(host.id)}/meta`, { + const res = await apiFetch(`/api/hosts/${encodeURIComponent(host.id)}/meta`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); + if (!res) return; const data = await res.json(); if (!data.ok || !data.host) { showToast(data.error || '更新失败', 'err'); @@ -1230,9 +1286,10 @@ async function confirmDeleteHost() { if (!host) return; deletingHost.value = null; try { - const res = await fetch(`/api/hosts/${encodeURIComponent(host.id)}`, { + const res = await apiFetch(`/api/hosts/${encodeURIComponent(host.id)}`, { method: 'DELETE', }); + if (!res) return; const data = await res.json(); if (!data.ok) { // 删不掉就得说,不能静默关掉对话框让人以为删了 @@ -1295,11 +1352,12 @@ async function onDrop(e: DragEvent, target: PublicHost) { currentIds.splice(toIdx, 0, fromId); try { - const res = await fetch('/api/hosts/reorder', { + const res = await apiFetch('/api/hosts/reorder', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orderedIds: currentIds }), }); + if (!res) return; const data = await res.json(); if (!data.ok || !Array.isArray(data.hosts)) { showToast(data.error || '排序未保存', 'err'); @@ -1442,9 +1500,11 @@ onBeforeUnmount(() => { :right-wing="null" :creating="creatingPane" :standalone="true" + :error="tab.error" @split="handleSplitTerminal" @close-pane="handleClosePane" @reconnect-pane="handleReconnectPane" + @retry="retryPaneForTab(tab)" @toggle-dark="toggleTheme" @meta-update="handlePaneMetaUpdate" /> @@ -2848,9 +2908,11 @@ onBeforeUnmount(() => { :right-wing="detailHostId === tab.hostId ? rightWing : null" :creating="creatingPane" :standalone="false" + :error="tab.error" @split="handleSplitTerminal" @close-pane="handleClosePane" @reconnect-pane="handleReconnectPane" + @retry="retryPaneForTab(tab)" @toggle-dark="terminalDark = !terminalDark" @toggle-right-wing="toggleRightWing" @collapse-stage="switchToHomeView" @@ -2868,6 +2930,7 @@ onBeforeUnmount(() => { :right-wing="detailHostId === stageHost.id ? rightWing : null" @close="switchToHomeView" @toggle-detail="toggleRightWing('detail')" + @session-expired="handleSessionExpired" @toast="showToast" /> @@ -2903,6 +2966,7 @@ onBeforeUnmount(() => { :terminal-cwd="activeSessionTab?.panes[activeSessionTab.panes.length - 1]?.cwd" @close="rightWing = null" @expand-full="openFilesStage(rightWingHost)" + @session-expired="handleSessionExpired" @toast="showToast" /> diff --git a/frontend/src/components/FileBrowserPanel.vue b/frontend/src/components/FileBrowserPanel.vue index 34f944a..17aa8dc 100644 --- a/frontend/src/components/FileBrowserPanel.vue +++ b/frontend/src/components/FileBrowserPanel.vue @@ -55,8 +55,21 @@ const emit = defineEmits<{ expandFull: []; toggleDetail: []; toast: [text: string, kind?: 'ok' | 'err']; + /// 会话失效(401):面板自己没有登录态,交给 App.vue 跳回登录页 + sessionExpired: []; }>(); +/// 面板里所有请求都走这里:401 表示会话已失效(服务重启会清空内存里的会话表), +/// 交给父组件处理,别在这里弹一句看不懂的"读取目录失败"就完事。 +async function apiFetch(input: string, init?: RequestInit): Promise { + const res = await fetch(input, init); + if (res.status === 401) { + emit('sessionExpired'); + return null; + } + return res; +} + const currentPath = ref(''); const pathInput = ref(''); const editingPath = ref(false); @@ -120,6 +133,9 @@ const inlinePrompt = ref<{ // 实时传输队列 const transfers = ref([]); let pollTimer: ReturnType | null = null; +/// 连续多少次拉取传输队列失败就放弃轮询(见 fetchTransfers) +const MAX_POLL_FAILURES = 5; +let pollFailures = 0; // 可点击的面包屑分段:如 /root/123 -> [/] [root] [123];本机 Windows -> [此电脑] [C:] [Users] const breadcrumbs = computed(() => { @@ -219,7 +235,8 @@ async function prefetchDir(targetPath: string | null | undefined) { inflightPrefetch.add(key); try { const url = `/api/files/list?hostId=${encodeURIComponent(hostId)}&path=${encodeURIComponent(targetPath)}`; - const res = await fetch(url); + const res = await apiFetch(url); + if (!res) return; const data = await res.json(); if (data.ok) { storeDirCache(hostId, targetPath, { @@ -323,7 +340,8 @@ async function loadDir( try { const refreshParam = opts.forceRefresh ? '&refresh=true' : ''; const url = `/api/files/list?hostId=${encodeURIComponent(hostId)}&path=${encodeURIComponent(reqPath)}${refreshParam}`; - const res = await fetch(url, { signal: controller.signal }); + const res = await apiFetch(url, { signal: controller.signal }); + if (!res) return; const data = await res.json(); if (seq !== navSeq) return; @@ -364,7 +382,7 @@ async function handleCompress(item: FileEntry) { busyActionPath.value = item.path; emit('toast', `正在压缩 ${item.name}...`); try { - const res = await fetch('/api/files/compress', { + const res = await apiFetch('/api/files/compress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -372,6 +390,7 @@ async function handleCompress(item: FileEntry) { path: item.path, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) { emit('toast', data.error || '压缩失败', 'err'); @@ -394,7 +413,7 @@ async function handleExtract(item: FileEntry) { busyActionPath.value = item.path; emit('toast', `正在解压 ${item.name}...`); try { - const res = await fetch('/api/files/extract', { + const res = await apiFetch('/api/files/extract', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -402,6 +421,7 @@ async function handleExtract(item: FileEntry) { path: item.path, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) { emit('toast', data.error || '解压失败', 'err'); @@ -418,24 +438,35 @@ async function handleExtract(item: FileEntry) { } } +function stopTransferPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +} + async function fetchTransfers() { try { - const res = await fetch(`/api/transfers?hostId=${encodeURIComponent(props.host.id)}`); + const res = await apiFetch(`/api/transfers?hostId=${encodeURIComponent(props.host.id)}`); + if (!res) return; const data = await res.json(); if (data.ok && Array.isArray(data.tasks)) { + pollFailures = 0; transfers.value = data.tasks; const hasRunning = data.tasks.some((t: TransferTask) => t.state === 'running'); - if (!hasRunning && pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } + if (!hasRunning) stopTransferPolling(); + return; } + // 拿到非 ok(会话过期 401 / 后端重启 500)不能让 350ms 的定时器一直打下去: + // 旧写法只在成功分支里 clear,出错时这个循环会以约 3 req/s 永远轮询下去。 + if (++pollFailures >= MAX_POLL_FAILURES) stopTransferPolling(); } catch { - // ignore + if (++pollFailures >= MAX_POLL_FAILURES) stopTransferPolling(); } } function ensureTransferPolling() { + pollFailures = 0; void fetchTransfers(); if (pollTimer) return; pollTimer = setInterval(() => { @@ -444,7 +475,7 @@ function ensureTransferPolling() { } async function clearCompletedTransfers() { - await fetch('/api/transfers', { method: 'DELETE' }); + await apiFetch('/api/transfers', { method: 'DELETE' }); await fetchTransfers(); } @@ -464,7 +495,8 @@ async function openFileEditor(item: FileEntry) { editorLoading.value = true; try { const url = `/api/files/read?hostId=${encodeURIComponent(props.host.id)}&path=${encodeURIComponent(item.path)}`; - const res = await fetch(url); + const res = await apiFetch(url); + if (!res) return; const data = await res.json(); if (!data.ok) { emit('toast', data.error || '打开文件失败', 'err'); @@ -484,7 +516,7 @@ async function saveFileEditor() { if (!editorPath.value || editorSaving.value) return; editorSaving.value = true; try { - const res = await fetch('/api/files/write', { + const res = await apiFetch('/api/files/write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -493,6 +525,7 @@ async function saveFileEditor() { content: editorContent.value, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) { emit('toast', data.error || '保存失败', 'err'); @@ -548,10 +581,11 @@ async function uploadFiles(fileList: FileList | File[]) { size: String(file.size), }); try { - const res = await fetch(`/api/files/upload?${q.toString()}`, { + const res = await apiFetch(`/api/files/upload?${q.toString()}`, { method: 'POST', body: file, }); + if (!res) return; const data = await res.json(); if (!data.ok) { emit('toast', `${file.name} 上传失败: ${data.error}`, 'err'); @@ -622,7 +656,7 @@ async function submitInlineAction() { if (act.kind === 'mkdir') { if (!val) return; const target = joinPath(currentPath.value, val); - const res = await fetch('/api/files/mkdir', { + const res = await apiFetch('/api/files/mkdir', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -630,6 +664,7 @@ async function submitInlineAction() { path: target, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) throw new Error(data.error); highlightTarget = target; @@ -637,7 +672,7 @@ async function submitInlineAction() { } else if (act.kind === 'touch') { if (!val) return; const newPath = joinPath(currentPath.value, val); - const res = await fetch('/api/files/write', { + const res = await apiFetch('/api/files/write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -646,6 +681,7 @@ async function submitInlineAction() { content: '', }), }); + if (!res) return; const data = await res.json(); if (!data.ok) throw new Error(data.error); highlightTarget = newPath; @@ -656,7 +692,7 @@ async function submitInlineAction() { return; } const newPath = joinPath(currentPath.value, val); - const res = await fetch('/api/files/rename', { + const res = await apiFetch('/api/files/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -665,12 +701,13 @@ async function submitInlineAction() { newPath, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) throw new Error(data.error); highlightTarget = newPath; emit('toast', `已重命名为 ${val}`); } else if (act.kind === 'delete' && act.target) { - const res = await fetch('/api/files/remove', { + const res = await apiFetch('/api/files/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -679,6 +716,7 @@ async function submitInlineAction() { isDir: act.target.isDir, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) throw new Error(data.error); if (editorPath.value === act.target.path) { @@ -686,7 +724,7 @@ async function submitInlineAction() { } emit('toast', `已删除 ${act.target.name}`); } else if (act.kind === 'download' && act.target) { - const res = await fetch('/api/transfers', { + const res = await apiFetch('/api/transfers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -696,6 +734,7 @@ async function submitInlineAction() { remotePath: act.target.path, }), }); + if (!res) return; const data = await res.json(); if (!data.ok) throw new Error(data.error || '发起下载失败'); inlinePrompt.value = null; diff --git a/frontend/src/components/TerminalStage.vue b/frontend/src/components/TerminalStage.vue index 1e1085c..9b68460 100644 --- a/frontend/src/components/TerminalStage.vue +++ b/frontend/src/components/TerminalStage.vue @@ -11,12 +11,16 @@ const props = defineProps<{ rightWing: 'detail' | 'files' | null; creating: boolean; standalone?: boolean; + /// 建 pane 失败的原因(由 App.vue 留在标签上)。有值且还没有 pane 时, + /// 渲染成"连接失败 + 重试",而不是无限转圈。 + error?: string | null; }>(); const emit = defineEmits<{ split: [direction: 'row' | 'col']; closePane: [paneId: string]; reconnectPane: [paneId: string]; + retry: []; toggleDark: []; toggleRightWing: [mode: 'files' | 'detail']; collapseStage: []; @@ -210,6 +214,31 @@ const activePane = computed(() => { + +
+
+ + + + + 连接 {{ host.name }} 终端失败 +
+

+ {{ error }} +

+ +
+
, pub inspector: Arc, pub exec_tasks: Arc, - pub sessions: Mutex>, + /// 已签发的 Web 会话令牌 → 签发时刻。用 map 而不是 set 是为了能按 TTL 淘汰: + /// 之前只增不删,而且每次保存鉴权设置都会再塞一个,长驻进程里会一直涨。 + pub sessions: Mutex>, pub port: u16, /// 服务是否绑在本机回环。绑回环时 MCP 端点免 API Key(本机优先,对齐 1Shell); /// 只有真正对外部署(HOST=0.0.0.0)时才强制校验 Key。 @@ -1056,6 +1058,25 @@ async fn api_clear_transfers(State(state): State>) -> impl IntoRes // ==================== 登录鉴权、设置页与 MCP JSON-RPC 2.0 端点 ==================== const SESSION_COOKIE_NAME: &str = "vps_switch_session"; +/// Web 会话有效期,与登录 Cookie 的 Max-Age 对齐(30 天)。 +const SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 3600); + +/// 登记新令牌,顺手淘汰过期会话——所有签发入口都走这里,保证会话表不会无界增长。 +fn register_session(state: &AppState, token: String) { + let mut sessions = state.sessions.lock().unwrap(); + sessions.retain(|_, issued| issued.elapsed() < SESSION_TTL); + sessions.insert(token, Instant::now()); +} + +fn session_is_valid(state: &AppState, token: &str) -> bool { + state + .sessions + .lock() + .unwrap() + .get(token) + .map(|issued| issued.elapsed() < SESSION_TTL) + .unwrap_or(false) +} fn extract_session_token(headers: &HeaderMap) -> Option { if let Some(auth) = headers @@ -1093,7 +1114,7 @@ fn is_request_authenticated(state: &AppState, headers: &HeaderMap) -> bool { let Some(token) = extract_session_token(headers) else { return false; }; - state.sessions.lock().unwrap().contains(&token) + session_is_valid(state, &token) } async fn require_web_auth( @@ -1146,7 +1167,7 @@ async fn api_auth_login( Uuid::new_v4().simple(), Uuid::new_v4().simple() ); - state.sessions.lock().unwrap().insert(token.clone()); + register_session(&state, token.clone()); let cookie = format!( "{SESSION_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000" ); @@ -1440,7 +1461,7 @@ async fn api_update_auth_settings( Uuid::new_v4().simple(), Uuid::new_v4().simple() ); - state.sessions.lock().unwrap().insert(token.clone()); + register_session(&state, token.clone()); let cookie = format!( "{SESSION_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000" ); diff --git a/src/files.rs b/src/files.rs index 8568de9..0d4749a 100644 --- a/src/files.rs +++ b/src/files.rs @@ -536,6 +536,23 @@ pub async fn rename_item( Ok(()) } +/// 是否是"根"这类不该被当作普通目标处理的路径:POSIX 的 `/`,Windows 的盘符根 +/// (`C:` / `C:/` / `C:\`),以及 `.` / `..`。 +/// +/// 判据:统一斜杠、去掉尾部斜杠之后,要么为空(`/`、`//`、空串),要么就是一个盘符。 +/// 此前只挡了字面量 `"/"`,而 Windows 上文件管理器的"此电脑"会把盘符当条目列出来 +/// (`list_local_dir` 返回 `C:/`),删除按钮对每一行都渲染,于是能一路走到 +/// `remove_dir_all("C:/")`。 +fn is_root_like(path: &str) -> bool { + let t = path.trim().replace('\\', "/"); + let t = t.trim_end_matches('/'); + if t.is_empty() || t == "." || t == ".." { + return true; + } + let b = t.as_bytes(); + b.len() == 2 && b[1] == b':' && b[0].is_ascii_alphabetic() +} + pub async fn remove_item( pool: &SshPool, db: &Database, @@ -543,8 +560,8 @@ pub async fn remove_item( target_path: &str, is_dir: bool, ) -> Result<()> { - if target_path == "/" || target_path.trim().is_empty() { - return Err(anyhow!("禁止删除根目录")); + if is_root_like(target_path) { + return Err(anyhow!("禁止删除根目录或当前目录({target_path})")); } if host_id == LOCAL_HOST_ID { if is_dir { @@ -651,8 +668,9 @@ pub async fn compress_item( target_path: &str, ) -> Result { let clean = target_path.trim_end_matches(['/', '\\']); - if clean.is_empty() || clean == "/" || clean == "drives" { - return Err(anyhow!("不能直接压缩根目录")); + // "drives" 是 Windows 上"此电脑"那个虚拟层,不是真实目录 + if is_root_like(clean) || clean == "drives" { + return Err(anyhow!("不能直接压缩根目录或当前目录")); } if host_id == LOCAL_HOST_ID { @@ -1194,6 +1212,63 @@ pub async fn transfer_local_remote_file( ) .await?; + transfers + .list_tasks(None) + .into_iter() + .find(|t| t.id == task_id) + .ok_or_else(|| anyhow!("读取传输任务状态失败")) + } else if host_id == LOCAL_HOST_ID { + // 本机主机上的「下载」就是本机内部复制:源和目标都在本机。 + // 此前这里没有本机分支,直接去开 SFTP——而本机主机的 addr 是 127.0.0.1、port 是 0, + // 于是必然失败(实测 "SSH 连接失败: 请求的地址无效 (os error 10049)"),本机文件 + // 永远下载不下来。这里复用同一套流式引擎(临时文件 + 残片对账续传 + 原子替换 + + // SHA-256 闭环),只把「读远端」换成「读本机文件」。 + let src = PathBuf::from(remote_path); + let meta = tokio::fs::metadata(&src) + .await + .with_context(|| format!("本机源文件不存在: {remote_path}"))?; + let total = meta.len(); + let filename = src + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("download.bin") + .to_string(); + + let dest = PathBuf::from(local_path); + let dest_dir = dest + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + let dest_name = dest + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow!("无效的本机落点: {local_path}"))? + .to_string(); + + let task_id = transfers.create_task( + host_id, + &host.name, + "download", + &filename, + Some(local_path.to_string()), + remote_path, + total, + ); + let src_file = tokio::fs::File::open(&src).await?; + stream_upload_to_host( + pool, + db, + transfers, + &task_id, + LOCAL_HOST_ID, + &dest_dir, + &dest_name, + total, + src_file, + ) + .await?; + transfers .list_tasks(None) .into_iter() @@ -1452,3 +1527,32 @@ pub async fn transfer_remote_to_remote( .find(|t| t.id == task_id) .ok_or_else(|| anyhow!("读取跨主机传输任务状态失败")) } + +#[cfg(test)] +mod tests { + use super::*; + + /// 必须拦下:删掉就不可逆的"根"类目标。 + /// `C:/` 那几条是实测过的漏洞——文件管理器把盘符当条目列出来,删除按钮对每行都渲染, + /// 旧判定只挡字面量 `"/"`,于是能一路走到 `remove_dir_all("C:/")`。 + #[test] + fn root_like_targets_are_rejected() { + for p in [ + "/", "//", "///", "", " ", ".", "..", "./", "../", "C:", "C:/", "C:\\", "c:/", + "D:\\", " Z: ", + ] { + assert!(is_root_like(p), "应判为根但放过了: {p:?}"); + } + } + + /// 必须放行:正常目录与文件。误拦这些,文件管理器就没法用了。 + #[test] + fn ordinary_paths_are_not_root_like() { + for p in [ + "/etc", "/var/log/nginx.old", "/root", "C:/Users", "C:/Users/x/file.txt", + "E:/1shell4.7/vps-switch", "./build", "../sibling", "drives", "a:/x", "/dev", + ] { + assert!(!is_root_like(p), "误判为根: {p:?}"); + } + } +} diff --git a/src/main.rs b/src/main.rs index 44e1cef..5342b21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -203,7 +203,7 @@ fn main() -> Result<()> { transfers: Arc::new(TransferManager::new()), inspector: Arc::new(InspectorEngine::new()), exec_tasks: exec_tasks.clone(), - sessions: std::sync::Mutex::new(std::collections::HashSet::new()), + sessions: std::sync::Mutex::new(std::collections::HashMap::new()), port, bind_is_loopback: bind_ip.is_loopback(), }); diff --git a/src/mcp.rs b/src/mcp.rs index 7d2ff86..e9367a6 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -139,6 +139,10 @@ pub struct ExecTaskManager { const MAX_OUTPUT_BYTES: usize = 1024 * 1024; /// 任务表保留上限,超出后按开始时间淘汰最旧的已完成任务 const MAX_TASKS: usize = 60; +/// 任务表里 stdout + stderr 缓冲的合计上限。 +/// 单任务最多 2 MiB,60 个全在跑就是 120 MiB——而"一次 fan-out 到整支机队"正是这个工具的 +/// 主打用法。所以条数之外再压一道字节预算,超了就淘汰最旧的**已结束**任务。 +const MAX_TOTAL_BUFFER_BYTES: usize = 32 * 1024 * 1024; fn append_capped(buf: &mut Vec, chunk: &[u8]) { if buf.len() > MAX_OUTPUT_BYTES || chunk.is_empty() { @@ -158,26 +162,33 @@ impl ExecTaskManager { Self::default() } - /// 淘汰最旧的已完成任务,保证长驻进程的任务表不会无界增长 + /// 淘汰最旧的已完成任务,保证长驻进程的任务表不会无界增长。 + /// 两个上限任一超标就淘汰,且**只淘汰已结束的任务**——跑着的绝不动, + /// 所以极端情况下(几十个任务同时还在跑)字节数仍可能短暂超预算,这是刻意的取舍: + /// 宁可短暂占多一点,也不掐掉正在执行的任务。 fn evict_old_finished(&self, map: &mut HashMap>>) { - if map.len() <= MAX_TASKS { + let mut total_bytes = 0usize; + let mut finished: Vec<(Instant, String, usize)> = Vec::new(); + for (id, task) in map.iter() { + let guard = task.lock().unwrap(); + let bytes = guard.stdout.len() + guard.stderr.len(); + total_bytes += bytes; + if guard.state != "running" { + finished.push((guard.started_inst, id.clone(), bytes)); + } + } + if map.len() <= MAX_TASKS && total_bytes <= MAX_TOTAL_BUFFER_BYTES { return; } - let mut finished: Vec<(Instant, String)> = map - .iter() - .filter_map(|(id, task)| { - let guard = task.lock().unwrap(); - if guard.state == "running" { - None - } else { - Some((guard.started_inst, id.clone())) - } - }) - .collect(); - finished.sort(); - let remove_n = map.len().saturating_sub(MAX_TASKS); - for (_, id) in finished.into_iter().take(remove_n) { + finished.sort_by(|a, b| a.0.cmp(&b.0)); + let mut removed = 0usize; + for (_, id, bytes) in finished { + if map.len() - removed <= MAX_TASKS && total_bytes <= MAX_TOTAL_BUFFER_BYTES { + break; + } map.remove(&id); + removed += 1; + total_bytes = total_bytes.saturating_sub(bytes); } } diff --git a/src/ssh.rs b/src/ssh.rs index 5c25728..164d395 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -35,6 +35,10 @@ impl client::Handler for SshClientHandler { /// 导致 channel open 失败。闸门只约束执行/探针类短通道;终端 pane 与 SFTP 不排队。 const MAX_CONCURRENT_EXEC_PER_HOST: usize = 6; +/// 目录缓存条数上限。缓存只为让"点进子目录/返回上级"瞬开,长驻进程里长时间乱逛会让它 +/// 无界增长(每条的 DirListing 带着整目录的条目)。超限就按时间淘汰最旧的,不清空。 +const DIR_CACHE_MAX_ENTRIES: usize = 256; + /// 单主机 SSH 与 SFTP 连接池: /// - 终端、执行、SFTP 文件浏览、高速上传下载、探针全部共用同一条底层 SSH 连接; /// - 通道窗口拉大到 16 MiB + SFTP 64 路并发流水线(255 KiB 大包),彻底跑满跨境高 RTT 带宽。 @@ -120,6 +124,16 @@ impl SshPool { (now, listing.clone()), ); } + if guard.len() > DIR_CACHE_MAX_ENTRIES { + let mut by_age: Vec<(Instant, (String, String))> = guard + .iter() + .map(|(key, (ts, _))| (*ts, key.clone())) + .collect(); + by_age.sort_by(|a, b| a.0.cmp(&b.0)); + for (_, key) in by_age.into_iter().take(guard.len() - DIR_CACHE_MAX_ENTRIES) { + guard.remove(&key); + } + } } pub async fn invalidate_dir_cache(&self, host_id: &str) {