Compare commits
7 Commits
fix/scroll
...
dev-202601
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c484cb876c | ||
|
|
51ac667a40 | ||
|
|
1d3745ed5f | ||
|
|
1d59be2576 | ||
|
|
ec04334a21 | ||
|
|
57e3641ec5 | ||
|
|
8258ad6e95 |
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
@@ -878,11 +879,15 @@ export const useSftpState = (
|
||||
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 +3201,3 @@ export const useSftpState = (
|
||||
stableMethods,
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.sftp")}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.sftpSudo || false}
|
||||
onCheckedChange={(val) => update("sftpSudo", val)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.appearance")}
|
||||
|
||||
@@ -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<HostFormProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sftp-sudo" className="text-base">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sftp-sudo"
|
||||
checked={formData.sftpSudo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, sftpSudo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Label>{t("hostForm.auth.method")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
|
||||
@@ -254,6 +254,7 @@ interface SFTPModalProps {
|
||||
keySource?: 'generated' | 'imported';
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -521,6 +522,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
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<SFTPModalProps> = ({
|
||||
credentials.keySource,
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
@@ -689,6 +692,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
keySource: credentials.keySource,
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
|
||||
|
||||
@@ -1079,6 +1079,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keySource: resolvedAuth.key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sftpSudo: host.sftpSudo,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -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 {
|
||||
|
||||
155
to-do.md
Normal file
155
to-do.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Netcatty Feature TODO List
|
||||
|
||||
项目地址: https://github.com/binaricat/Netcatty
|
||||
|
||||
## 功能需求清单
|
||||
|
||||
### 1. GB18030编码支持 🔤
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 支持操作文件名为GB18030编码的文件
|
||||
- 实现动态编码切换,无需断开重连即可生效
|
||||
- 解决目前市面上工具需要重新连接才能应用编码设置的问题
|
||||
|
||||
**技术要点**:
|
||||
- SFTP文件列表的编码转换
|
||||
- 文件名编码自动检测/手动切换
|
||||
- 保持连接状态下的编码切换
|
||||
|
||||
---
|
||||
|
||||
### 2. SFTP的sudo提权支持 🔐 (已完成)
|
||||
- [x] 实现sudo提权功能
|
||||
- [x] 研究HexHub的实现原理
|
||||
- [x] 实现密码的安全存储与自动填写
|
||||
- [x] 完成sudo命令的SFTP封装
|
||||
- [x] 优化权限提升的交互流程
|
||||
- [x] 验证功能稳定性
|
||||
|
||||
---
|
||||
|
||||
### 3. trzsz协议支持 📁
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 集成trzsz文件传输协议
|
||||
- 参考项目: https://github.com/trzsz/trzsz
|
||||
- 解决electerm和tabby现有实现中的稳定性问题
|
||||
|
||||
**已知问题**:
|
||||
- electerm和tabby支持trzsz但偶尔无法正常收发文件
|
||||
- 具体bug现象待补充
|
||||
|
||||
**技术要点**:
|
||||
- trzsz协议完整实现
|
||||
- 文件传输的错误处理和重试机制
|
||||
- 传输进度显示
|
||||
- 大文件传输稳定性测试
|
||||
|
||||
---
|
||||
|
||||
### 4. 终端性能优化 ⚡
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 解决基于xtermjs的终端在大量滚屏时的性能问题
|
||||
- 确保高速输出场景下键盘输入的实时响应
|
||||
|
||||
**核心问题**:
|
||||
- 大量刷屏时`Ctrl+C`信号发不出去
|
||||
- tmux切换窗口命令无响应
|
||||
- 输入延迟严重
|
||||
|
||||
**技术要点**:
|
||||
- 终端渲染性能优化
|
||||
- 输入处理与渲染分离
|
||||
- 虚拟滚动/缓冲区管理
|
||||
- 输入队列优先级处理
|
||||
- 压力测试场景设计
|
||||
|
||||
---
|
||||
|
||||
### 5. X11 Forwarding支持 🖥️
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 支持X11图形界面转发
|
||||
- 能够在SSH连接中运行远程图形应用程序
|
||||
|
||||
**技术要点**:
|
||||
- X11转发的SSH配置
|
||||
- 本地X Server集成或推荐
|
||||
- 跨平台兼容性(Windows/macOS/Linux)
|
||||
- 连接配置UI
|
||||
|
||||
---
|
||||
|
||||
### 6. Terminal到SFTP目录定位 🎯
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 在Terminal界面时,点击右上角按钮
|
||||
- 自动切换到SFTP视图并定位到当前工作目录
|
||||
- 实现Terminal和SFTP之间的上下文联动
|
||||
|
||||
**已知问题**:
|
||||
- 之前尝试实现但未成功
|
||||
|
||||
**技术要点**:
|
||||
- 获取当前shell的工作目录(`pwd`命令)
|
||||
- Terminal和SFTP视图的状态同步
|
||||
- 异步目录切换的UI反馈
|
||||
- 处理特殊路径(软链接、权限不足等)
|
||||
|
||||
**实现思路**:
|
||||
1. 通过发送`pwd`命令获取当前目录
|
||||
2. 解析命令输出结果
|
||||
3. 触发SFTP视图切换
|
||||
4. 异步加载目标目录内容
|
||||
|
||||
---
|
||||
|
||||
## 开发注意事项 ⚠️
|
||||
|
||||
### 质量要求
|
||||
- 充分的单元测试和集成测试
|
||||
- 避免"按下葫芦起了瓢"的问题
|
||||
- 每个功能都要有完整的测试用例
|
||||
|
||||
### 性能考虑
|
||||
- 避免频繁的AI token消耗
|
||||
- 代码review和人工测试相结合
|
||||
- 建立性能基准测试
|
||||
|
||||
### 用户体验
|
||||
- 这些都是"可以没有但有了方便很多"的功能
|
||||
- 注重细节和边界情况处理
|
||||
- 提供清晰的错误提示和操作引导
|
||||
|
||||
---
|
||||
|
||||
## 实现优先级建议
|
||||
|
||||
### Phase 1 - 核心功能完善
|
||||
- [ ] GB18030编码支持
|
||||
- [ ] 终端性能优化
|
||||
- [ ] Terminal到SFTP目录定位
|
||||
|
||||
### Phase 2 - 高级特性
|
||||
- [ ] SFTP的sudo提权支持
|
||||
- [ ] trzsz协议支持
|
||||
|
||||
### Phase 3 - 扩展功能
|
||||
- [ ] X11 Forwarding支持
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
- trzsz项目: https://github.com/trzsz/trzsz
|
||||
- 竞品分析: WinSCP, HexHub, electerm, tabby
|
||||
- 技术栈: xtermjs (需要性能优化方案)
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-01-09
|
||||
Reference in New Issue
Block a user