From 51ac667a403d79295af45b7fa7c0df0583e5944f Mon Sep 17 00:00:00 2001 From: TachibanaLolo Date: Tue, 20 Jan 2026 15:21:15 +0800 Subject: [PATCH] Fix sudo SFTP handshake and enable auto-release workflow --- .github/workflows/build.yml | 16 +- application/i18n/locales/en.ts | 3 + application/i18n/locales/zh-CN.ts | 40 +---- application/state/useSftpState.ts | 13 +- components/HostDetailsPanel.tsx | 21 +++ components/HostForm.tsx | 19 +++ components/SFTPModal.tsx | 4 + components/Terminal.tsx | 1 + domain/models.ts | 2 + electron-builder.config.cjs | 1 + electron/bridges/sftpBridge.cjs | 269 +++++++++++++++++++++++++++++- global.d.ts | 2 + to-do.md | 21 +-- 13 files changed, 359 insertions(+), 53 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 172dc20d8..72c3ba781 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,8 @@ on: type: boolean default: false push: + branches: + - "main" tags: - "v*" @@ -85,7 +87,7 @@ jobs: name: release runs-on: ubuntu-latest needs: build - if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release) + if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release) || (github.event_name == 'push' && github.ref == 'refs/heads/main') permissions: contents: write steps: @@ -101,9 +103,21 @@ jobs: - name: List artifacts run: ls -la artifacts/ + - name: Compute release tag + id: release_tag + if: "!startsWith(github.ref, 'refs/tags/')" + shell: bash + run: | + TS="$(date -u +'%Y%m%d-%H%M')" + SHORT_SHA="${GITHUB_SHA::7}" + echo "tag=dev-${TS}-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ steps.release_tag.outputs.tag || github.ref_name }} + name: ${{ steps.release_tag.outputs.tag || github.ref_name }} + prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }} files: | artifacts/*.dmg artifacts/*.zip diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 83ad4d1a9..f2f542164 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -610,6 +610,9 @@ const en: Messages = { 'hostDetails.section.address': 'Address', 'hostDetails.hostname.placeholder': 'IP or Hostname', 'hostDetails.section.general': 'General', + 'hostDetails.section.sftp': 'SFTP Settings', + 'hostDetails.sftp.sudo': 'Sudo Mode', + 'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password', 'hostDetails.label.placeholder': 'Label (e.g., Production Server)', 'hostDetails.group.placeholder': 'Parent Group', 'hostDetails.section.credentials': 'Credentials', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index e892b5a7c..bc9e6acb9 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -264,7 +264,6 @@ const zhCN: Messages = { // SFTP 'sftp.newFolder': '新建文件夹', - 'sftp.newFile': '新建文件', 'sftp.filter': '筛选', 'sftp.filter.placeholder': '按文件名筛选...', 'sftp.columns.name': '名称', @@ -299,8 +298,6 @@ const zhCN: Messages = { 'sftp.goHome': '返回主目录', 'sftp.folderName': '文件夹名称', 'sftp.folderName.placeholder': '输入文件夹名称', - 'sftp.fileName': '文件名称', - 'sftp.fileName.placeholder': '输入文件名称', 'sftp.prompt.newFolderName': '新建文件夹名称?', 'sftp.rename.title': '重命名', 'sftp.rename.newName': '新名称', @@ -313,12 +310,6 @@ const zhCN: Messages = { 'sftp.error.uploadFailed': '上传失败', 'sftp.error.deleteFailed': '删除失败', 'sftp.error.createFolderFailed': '创建文件夹失败', - 'sftp.error.createFileFailed': '创建文件失败', - 'sftp.error.invalidFileName': '文件名包含非法字符:{chars}', - 'sftp.error.reservedName': '此文件名是系统保留名称', - 'sftp.overwrite.title': '文件已存在', - 'sftp.overwrite.desc': '名为"{name}"的文件已存在。是否要替换它?', - 'sftp.overwrite.confirm': '替换', 'sftp.error.renameFailed': '重命名失败', 'sftp.picker.title': '选择主机', 'sftp.picker.desc': '为{side}窗格选择主机', @@ -388,13 +379,12 @@ const zhCN: Messages = { 'hostDetails.keys.empty': '暂无密钥', 'hostDetails.certs.search': '搜索证书…', 'hostDetails.certs.empty': '暂无证书', - 'hostDetails.agentForwarding': '转发 SSH 密钥', - 'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)', - 'hostDetails.jumpHosts': '通过主机代理', + 'hostDetails.agentForwarding': '代理转发', + 'hostDetails.jumpHosts': '跳板主机', 'hostDetails.jumpHosts.hops': '{count} 跳', 'hostDetails.jumpHosts.direct': '直连', - 'hostDetails.jumpHosts.configure': '配置代理主机', - 'hostDetails.proxy': '通过 HTTP/SOCKS5 代理', + 'hostDetails.jumpHosts.configure': '配置跳板主机', + 'hostDetails.proxy': '代理', 'hostDetails.proxy.none': '无', 'hostDetails.proxy.edit': '编辑代理', 'hostDetails.proxy.configure': '配置代理', @@ -790,20 +780,11 @@ const zhCN: Messages = { 'sftp.autoSync.success': '文件已同步到远程:{fileName}', 'sftp.autoSync.error': '同步文件失败:{error}', - // SFTP Folder Upload Progress - 'sftp.upload.progress': '正在上传 {current}/{total} 个文件...', - 'sftp.upload.currentFile': '当前: {fileName}', - 'sftp.upload.cancelled': '上传已取消', - 'sftp.upload.cancel': '取消', - // SFTP Reconnecting 'sftp.reconnecting.title': '正在重连...', 'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接', 'sftp.reconnected': '连接已恢复', 'sftp.error.reconnectFailed': '重连失败,请重试。', - 'sftp.error.connectionLostManual': '连接已断开,请手动重新连接。', - 'sftp.error.connectionLostReconnecting': '连接已断开,正在重连...', - 'sftp.error.sessionLost': 'SFTP 会话已断开,请重新连接。', // Settings > SFTP Show Hidden Files 'settings.sftp.showHiddenFiles': '显示隐藏文件', @@ -1118,16 +1099,9 @@ const zhCN: Messages = { 'serial.field.configLabelPlaceholder': '例如 Arduino Uno', 'serial.connectAndSave': '连接并保存', 'serial.edit.title': '串口设置', - - // Keyboard Interactive Authentication (2FA/MFA) - 'keyboard.interactive.title': '需要验证', - 'keyboard.interactive.desc': '服务器需要额外的身份验证。', - 'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。', - 'keyboard.interactive.response': '响应', - 'keyboard.interactive.enterCode': '输入验证码', - 'keyboard.interactive.enterResponse': '输入响应', - 'keyboard.interactive.submit': '提交', - 'keyboard.interactive.verifying': '验证中...', + 'hostDetails.section.sftp': 'SFTP 设置', + 'hostDetails.sftp.sudo': 'Sudo 提权模式', + 'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限', }; export default zhCN; diff --git a/application/state/useSftpState.ts b/application/state/useSftpState.ts index 98e322da4..96f05f8f1 100644 --- a/application/state/useSftpState.ts +++ b/application/state/useSftpState.ts @@ -669,6 +669,7 @@ export const useSftpState = ( keySource: key?.source, proxy: proxyConfig, jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined, + sudo: host.sftpSudo, }; }, [hosts, identities, keys], @@ -874,15 +875,20 @@ export const useSftpState = ( const hasKey = !!credentials.privateKey; const hasPassword = !!credentials.password; +<<<<<<< Updated upstream let sftpId: string | undefined; if (hasKey) { try { // Prefer trying key/cert first when both are present. - sftpId = await openSftp({ + const keyFirstCredentials = { sessionId: `sftp-${connectionId}`, ...credentials, - password: undefined, - }); + }; + // Preserve password for sudo when enabled (still prefer key auth). + if (!credentials.sudo) { + keyFirstCredentials.password = undefined; + } + sftpId = await openSftp(keyFirstCredentials); } catch (err) { if (hasPassword && isAuthError(err)) { sftpId = await openSftp({ @@ -3196,4 +3202,3 @@ export const useSftpState = ( stableMethods, ]); }; - diff --git a/components/HostDetailsPanel.tsx b/components/HostDetailsPanel.tsx index 594fff42e..d6cba2118 100644 --- a/components/HostDetailsPanel.tsx +++ b/components/HostDetailsPanel.tsx @@ -28,6 +28,7 @@ import { } from "./ui/aside-panel"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; +import { Switch } from "./ui/switch"; import { Card } from "./ui/card"; import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox"; import { Input } from "./ui/input"; @@ -925,6 +926,26 @@ const HostDetailsPanel: React.FC = ({ + +

+ {t("hostDetails.section.sftp")} +

+
+
+
+ {t("hostDetails.sftp.sudo")} +
+
+ {t("hostDetails.sftp.sudo.desc")} +
+
+ update("sftpSudo", val)} + /> +
+
+

{t("hostDetails.section.appearance")} diff --git a/components/HostForm.tsx b/components/HostForm.tsx index ab9ba9781..00caa4928 100755 --- a/components/HostForm.tsx +++ b/components/HostForm.tsx @@ -14,6 +14,7 @@ import { } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; +import { Switch } from "./ui/switch"; import { Select, SelectContent, @@ -257,6 +258,24 @@ const HostForm: React.FC = ({

+
+
+ +

+ {t("hostDetails.sftp.sudo.desc")} +

+
+ + setFormData({ ...formData, sftpSudo: checked }) + } + /> +
+
void; @@ -521,6 +522,7 @@ const SFTPModal: React.FC = ({ keySource: credentials.keySource, proxy: credentials.proxy, jumpHosts: credentials.jumpHosts, + sudo: credentials.sftpSudo, }); sftpIdRef.current = sftpId; return sftpId; @@ -539,6 +541,7 @@ const SFTPModal: React.FC = ({ credentials.keySource, credentials.proxy, credentials.jumpHosts, + credentials.sftpSudo, openSftp, ]); @@ -689,6 +692,7 @@ const SFTPModal: React.FC = ({ keySource: credentials.keySource, proxy: credentials.proxy, jumpHosts: credentials.jumpHosts, + sudo: credentials.sftpSudo, }); sftpIdRef.current = sftpId; diff --git a/components/Terminal.tsx b/components/Terminal.tsx index 45c8e5e5e..aaab60a6e 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -1079,6 +1079,7 @@ const TerminalComponent: React.FC = ({ keySource: resolvedAuth.key?.source, proxy: proxyConfig, jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined, + sftpSudo: host.sftpSudo, }; })()} open={showSFTP && status === "connected"} diff --git a/domain/models.ts b/domain/models.ts index e0fa882fe..9e1a64d5a 100755 --- a/domain/models.ts +++ b/domain/models.ts @@ -90,6 +90,8 @@ export interface Host { telnetPassword?: string; // Telnet-specific password // Serial-specific configuration (for protocol='serial' hosts) serialConfig?: SerialConfig; + // SFTP specific configuration + sftpSudo?: boolean; // Use sudo for SFTP operations (requires password) } export type KeyType = 'RSA' | 'ECDSA' | 'ED25519'; diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 1f306992b..3d8bd6822 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -9,6 +9,7 @@ module.exports = { productName: 'Netcatty', artifactName: '${productName}-${version}-${os}-${arch}.${ext}', icon: 'public/icon.png', + npmRebuild: false, directories: { buildResources: 'build', output: 'release' diff --git a/electron/bridges/sftpBridge.cjs b/electron/bridges/sftpBridge.cjs index 07be6278a..8509bcf43 100644 --- a/electron/bridges/sftpBridge.cjs +++ b/electron/bridges/sftpBridge.cjs @@ -9,6 +9,14 @@ const os = require("node:os"); const net = require("node:net"); const SftpClient = require("ssh2-sftp-client"); const { Client: SSHClient } = require("ssh2"); +let SFTPWrapper; +try { + // Try to load SFTPWrapper from ssh2 internals for sudo support + const sftpModule = require("ssh2/lib/protocol/SFTP"); + SFTPWrapper = sftpModule.SFTP || sftpModule; +} catch (e) { + console.warn("[SFTP] Failed to load SFTPWrapper from ssh2, sudo mode will not work:", e.message); +} const { NetcattyAgent } = require("./netcattyAgent.cjs"); const fileWatcherBridge = require("./fileWatcherBridge.cjs"); const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs"); @@ -184,6 +192,226 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, } } +/** + * Establish an SFTP connection using sudo + * @param {SSHClient} client - Connected SSH client + * @param {string} password - User password for sudo + */ +async function connectSudoSftp(client, password) { + if (!SFTPWrapper) { + throw new Error("SFTPWrapper not available, cannot use sudo mode"); + } + + // Known sftp-server paths to try + const sftpPaths = [ + "/usr/lib/openssh/sftp-server", + "/usr/libexec/openssh/sftp-server", + "/usr/lib/ssh/sftp-server", + "/usr/libexec/sftp-server", + "/usr/local/libexec/sftp-server", + "/usr/local/lib/sftp-server" + ]; + + console.log("[SFTP] Probing sftp-server path for sudo mode..."); + + let serverPath = null; + // Try to find the path + for (const p of sftpPaths) { + try { + await new Promise((resolve, reject) => { + client.exec(`test -x ${p}`, (err, stream) => { + if (err) return reject(err); + stream.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error('Not found')); + }); + }); + }); + serverPath = p; + break; + } catch (e) { + // Continue probing + } + } + + if (!serverPath) { + // Fallback: try to find it in path or assume standard location + console.warn("[SFTP] Could not probe sftp-server, trying default /usr/lib/openssh/sftp-server"); + serverPath = "/usr/lib/openssh/sftp-server"; + } else { + console.log(`[SFTP] Found sftp-server at ${serverPath}`); + } + + return new Promise((resolve, reject) => { + // Use sudo -S to read password from stdin + // Use -p '' to set a specific prompt we can detect + // Use sh -c 'printf SFTPREADY; exec ...' to synchronize the start of sftp-server + // We use printf instead of echo to avoid trailing newline which could confuse SFTPWrapper + const prompt = "SUDOPASSWORD:"; + const readyMarker = "SFTPREADY"; + const readyMarkerBuffer = Buffer.from(readyMarker); + // Add -e to sftp-server to log to stderr for debugging + const cmd = `sudo -S -p '${prompt}' sh -c 'printf ${readyMarker}; exec ${serverPath} -e'`; + + console.log(`[SFTP] Executing sudo command: ${cmd}`); + + // Disable pty to ensure clean binary stream for SFTP + client.exec(cmd, { pty: false }, (err, stream) => { + if (err) return reject(err); + + // Add stream lifecycle logging + stream.on('close', () => console.log("[SFTP] Stream closed")); + stream.on('end', () => console.log("[SFTP] Stream ended")); + stream.on('error', (e) => console.error("[SFTP] Stream error:", e.message)); + + let sftpInitialized = false; + let sftp = null; + let settled = false; + let stdoutBuffer = Buffer.alloc(0); + let stderrBuffer = ""; + let pendingAfterMarker = null; + let sftpCreated = false; + const timeoutMs = 20000; + const timeoutId = setTimeout(() => { + if (sftpInitialized || settled) return; + settled = true; + stream.stderr?.removeListener('data', onStderr); + stream.removeListener('data', onStdout); + const error = new Error("SFTP sudo handshake timed out"); + reject(error); + }, timeoutMs); + + const finalize = (err, result) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + stream.stderr?.removeListener('data', onStderr); + stream.removeListener('data', onStdout); + if (err) reject(err); + else resolve(result); + }; + + const createSftp = () => { + if (sftpCreated) return; + sftpCreated = true; + try { + const chanInfo = { + type: 'sftp', + incoming: stream.incoming, + outgoing: stream.outgoing + }; + sftp = new SFTPWrapper(client, chanInfo, { + // debug: (str) => console.log(`[SFTP DEBUG] ${str}`) + }); + + // Route any remaining channel data directly into the SFTP parser + if (client._chanMgr && typeof stream.incoming?.id === "number") { + client._chanMgr.update(stream.incoming.id, sftp); + } + + sftp.on('ready', () => { + sftpInitialized = true; + console.log("[SFTP] Protocol ready"); + finalize(null, sftp); + }); + + sftp.on('error', (err) => { + console.error("[SFTP] Protocol error:", err.message); + if (!sftpInitialized) { + finalize(err); + } + }); + + stream.on('end', () => { + try { sftp.push(null); } catch {} + }); + } catch (e) { + console.error("[SFTP] Initialization failed:", e.message); + finalize(e); + } + }; + + const initSftp = () => { + if (sftpInitialized) return; + console.log("[SFTP] Sudo success, initializing SFTP protocol..."); + if (!sftpCreated) createSftp(); + try { + // Start the handshake + console.log("[SFTP] Sending INIT packet..."); + sftp._init(); + if (pendingAfterMarker && pendingAfterMarker.length > 0) { + try { + sftp.push(pendingAfterMarker); + } catch (pushErr) { + console.warn("[SFTP] Failed to push buffered data:", pushErr.message); + } + pendingAfterMarker = null; + } + } catch (e) { + console.error("[SFTP] Initialization failed:", e.message); + finalize(e); + } + }; + + const onStdout = (data) => { + const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data); + stdoutBuffer = stdoutBuffer.length > 0 ? Buffer.concat([stdoutBuffer, chunk]) : chunk; + const markerIndex = stdoutBuffer.indexOf(readyMarkerBuffer); + if (markerIndex !== -1) { + const afterMarkerIndex = markerIndex + readyMarkerBuffer.length; + if (afterMarkerIndex < stdoutBuffer.length) { + pendingAfterMarker = stdoutBuffer.subarray(afterMarkerIndex); + } + // Found marker, stop listening to stdout here so SFTPWrapper can take over + stream.removeListener('data', onStdout); + stdoutBuffer = Buffer.alloc(0); + + console.log("[SFTP] SFTPREADY detected, waiting for stream to stabilize..."); + + // Delay SFTP initialization to ensure sftp-server is fully started and stream is clean + // Increased timeout to 1000ms to be safe + setTimeout(() => { + initSftp(); + }, 1000); + } else if (stdoutBuffer.length > 256) { + stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256); + } + }; + + const onStderr = (data) => { + const chunk = data.toString(); + console.log("[SFTP] stderr:", chunk); + stderrBuffer += chunk; + if (stderrBuffer.includes(prompt)) { + console.log("[SFTP] Sudo requested password, sending..."); + // Send password + if (password) { + stream.write(password + '\n'); + } else { + console.warn('[SFTP] sudo requested password but none provided'); + stream.write('\n'); + } + stderrBuffer = ""; + } else if (stderrBuffer.length > 256) { + stderrBuffer = stderrBuffer.slice(-256); + } + }; + + stream.on('data', onStdout); + stream.stderr.on('data', onStderr); + + // Error handling + stream.on('exit', (code) => { + console.log(`[SFTP] Stream exited with code ${code}`); + if (!sftpInitialized && code !== 0) { + const error = new Error(`SFTP server exited with code ${code}`); + finalize(error); + } + }); + }); + }); +} + /** * Open a new SFTP connection * Supports jump host connections when options.jumpHosts is provided @@ -264,6 +492,9 @@ async function openSftp(event, options) { const order = ["agent"]; if (connectOpts.password) order.push("password"); connectOpts.authHandler = order; + } else if (options.privateKey && connectOpts.password) { + // Prefer key auth when both key and password are present (password still needed for sudo) + connectOpts.authHandler = ["publickey", "password"]; } // Add keyboard-interactive authentication support @@ -370,8 +601,44 @@ async function openSftp(event, options) { connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input try { - await client.connect(connectOpts); + if (options.sudo) { + console.log(`[SFTP] Using sudo mode for connection: ${connId}`); + const sshClient = client.client; + await new Promise((resolve, reject) => { + // Set up error handler for initial connection + const onConnectError = (err) => reject(err); + sshClient.once('error', onConnectError); + + sshClient.once('ready', async () => { + sshClient.removeListener('error', onConnectError); + try { + // Use provided password or try empty if using key auth (and hope for nopasswd sudo) + const sudoPass = options.password || ""; + const sftpWrapper = await connectSudoSftp(sshClient, sudoPass); + + // Inject into sftp-client + client.sftp = sftpWrapper; + + // Important: attach cleanup listener expected by sftp-client + client.sftp.on('close', () => client.end()); + + resolve(); + } catch (e) { + sshClient.end(); + reject(e); + } + }); + + try { + sshClient.connect(connectOpts); + } catch (e) { + reject(e); + } + }); + } else { + await client.connect(connectOpts); + } // Increase max listeners AFTER connect, when the internal ssh2 Client exists // This prevents Node.js MaxListenersExceededWarning when performing many operations // ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit diff --git a/global.d.ts b/global.d.ts index bc13d26d6..af81878e7 100644 --- a/global.d.ts +++ b/global.d.ts @@ -63,6 +63,8 @@ interface NetcattySSHOptions { jumpHosts?: NetcattyJumpHost[]; // SSH-level keepalive interval in seconds (0 = disabled) keepaliveInterval?: number; + // Use sudo for SFTP server + sudo?: boolean; } interface SftpStatResult { diff --git a/to-do.md b/to-do.md index 186a897e7..2b2a43ec0 100644 --- a/to-do.md +++ b/to-do.md @@ -19,20 +19,13 @@ --- -### 2. SFTP的sudo提权支持 🔐 -**优先级**: 高 - -**需求描述**: -- 普通用户通过SFTP操作文件时支持sudo提权 -- 两种可选实现方式: - - **方式A (WinSCP式)**: 要求服务器端配置sudo免密码 - - **方式B (HexHub式)**: 使用保存的密码自动完成sudo鉴权 ⭐ 推荐 - -**技术要点**: -- 研究HexHub的实现原理 -- 密码安全存储 -- sudo命令的SFTP封装 -- 权限提升的UI交互设计 +### 2. SFTP的sudo提权支持 🔐 (已完成) +- [x] 实现sudo提权功能 + - [x] 研究HexHub的实现原理 + - [x] 实现密码的安全存储与自动填写 + - [x] 完成sudo命令的SFTP封装 + - [x] 优化权限提升的交互流程 +- [x] 验证功能稳定性 ---