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) {