Compare commits

..
Author SHA1 Message Date
bincxz 5918f91132 Improves 2FA and SSH authentication handling
Enhances keyboard-interactive (2FA/MFA) authentication by implementing a queue-based system, allowing multiple concurrent requests to be processed sequentially.

Previously, password prompts during keyboard-interactive authentication were auto-filled if a saved password was available. This change removes the auto-fill behavior to prevent issues with custom or ambiguous prompt texts, instead providing a user-initiated "Use saved password" option in the UI.

Increases the connection timeout to 120 seconds to provide ample time for users to complete 2FA challenges. A new UI indicator shows when additional 2FA requests are pending.

Also, refines SSH authentication logic to strictly respect explicit password authentication, preventing unintended attempts to use private keys when password authentication is selected.
2026-01-20 17:59:42 +08:00
陈大猫 7347b04461 Merge pull request #98 from binaricat:copilot/add-ioskeleymono-font-support
Add Ioskeley Mono font support
2026-01-20 17:16:29 +08:00
copilot-swe-agent[bot]andbinaricat d8990dd4b1 Add Ioskeley Mono font support to terminal fonts configuration
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-20 09:04:09 +00:00
copilot-swe-agent[bot] 538dd71084 Initial plan 2026-01-20 08:58:32 +00:00
陈大猫 c43f485bee Merge pull request #96 from AkarinServer/pr/sftp-sudo
Add SFTP sudo mode support and fix sudo handshake
2026-01-20 16:57:31 +08:00
bincxz 839cce58ac Enhances SFTP Sudo usability and diagnostics
Adds client-side warnings to alert users when SFTP Sudo is enabled but a password is not configured, particularly for key-based authentication. This helps prevent connection issues by prompting users to address the missing password proactively.

Improves server-side error messages for SFTP Sudo failures, providing more specific diagnostic information for issues such as platform unavailability, handshake timeouts, and various exit codes (e.g., incorrect password, missing sftp-server, TTY requirement). This makes troubleshooting connection problems more effective.
2026-01-20 16:55:34 +08:00
TachibanaLolo 1324bf95cb Add SFTP sudo mode support and fix handshake 2026-01-20 15:27:44 +08:00
17 changed files with 611 additions and 804 deletions

No files matched your search

+1 -15
View File
@@ -8,8 +8,6 @@ on:
type: boolean
default: false
push:
branches:
- "main"
tags:
- "v*"
@@ -87,7 +85,7 @@ jobs:
name: release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
steps:
@@ -103,21 +101,9 @@ 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
+20 -10
View File
@@ -151,8 +151,8 @@ function App({ settings }: { settings: SettingsState }) {
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication state (2FA/MFA)
const [keyboardInteractiveRequest, setKeyboardInteractiveRequest] = useState<KeyboardInteractiveRequest | null>(null);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
const {
theme,
@@ -301,13 +301,15 @@ function App({ settings }: { settings: SettingsState }) {
const unsubscribe = bridge.onKeyboardInteractive((request) => {
console.log('[App] Keyboard-interactive request received:', request);
setKeyboardInteractiveRequest({
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
hostname: request.hostname,
});
savedPassword: request.savedPassword,
}]);
});
return () => {
@@ -321,7 +323,8 @@ function App({ settings }: { settings: SettingsState }) {
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
setKeyboardInteractiveRequest(null);
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle keyboard-interactive cancel
@@ -330,7 +333,8 @@ function App({ settings }: { settings: SettingsState }) {
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, [], true);
}
setKeyboardInteractiveRequest(null);
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
@@ -661,7 +665,7 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
const { username, hostname: localHost } = systemInfoRef.current;
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
@@ -679,7 +683,7 @@ function App({ settings }: { settings: SettingsState }) {
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
addConnectionLog({
@@ -1032,12 +1036,18 @@ function App({ settings }: { settings: SettingsState }) {
</Suspense>
)}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) */}
{/* Global Keyboard-Interactive Authentication Modal (2FA/MFA) - processes queue */}
<KeyboardInteractiveModal
request={keyboardInteractiveRequest}
request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel}
/>
{/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && (
<div className="fixed bottom-4 right-4 z-50 bg-muted/90 backdrop-blur-sm text-sm px-3 py-1.5 rounded-full border shadow-sm">
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
</div>
);
}
+5
View File
@@ -613,6 +613,7 @@ const en: Messages = {
'hostDetails.section.sftp': 'SFTP Settings',
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
@@ -1142,6 +1143,10 @@ const en: Messages = {
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
};
export default en;
+41 -7
View File
@@ -264,6 +264,7 @@ const zhCN: Messages = {
// SFTP
'sftp.newFolder': '新建文件夹',
'sftp.newFile': '新建文件',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.columns.name': '名称',
@@ -298,6 +299,8 @@ const zhCN: Messages = {
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.fileName': '文件名称',
'sftp.fileName.placeholder': '输入文件名称',
'sftp.prompt.newFolderName': '新建文件夹名称?',
'sftp.rename.title': '重命名',
'sftp.rename.newName': '新名称',
@@ -310,6 +313,12 @@ 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}窗格选择主机',
@@ -361,6 +370,10 @@ const zhCN: Messages = {
'hostDetails.section.address': '地址',
'hostDetails.hostname.placeholder': 'IP 或 主机名',
'hostDetails.section.general': '通用',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
'hostDetails.label.placeholder': '名称(例如:Production Server)',
'hostDetails.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
@@ -379,12 +392,13 @@ const zhCN: Messages = {
'hostDetails.keys.empty': '暂无密钥',
'hostDetails.certs.search': '搜索证书…',
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '代理转发',
'hostDetails.jumpHosts': '跳板主机',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
'hostDetails.jumpHosts.configure': '配置跳板主机',
'hostDetails.proxy': '代理',
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5 代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
@@ -780,11 +794,20 @@ 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': '显示隐藏文件',
@@ -1099,9 +1122,20 @@ const zhCN: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
'hostDetails.section.sftp': 'SFTP 设置',
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
// 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': '验证中...',
'keyboard.interactive.fill': '填入',
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
};
export default zhCN;
+5
View File
@@ -944,6 +944,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCheckedChange={(val) => update("sftpSudo", val)}
/>
</div>
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
<p className="text-xs text-amber-500">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
+5
View File
@@ -266,6 +266,11 @@ const HostForm: React.FC<HostFormProps> = ({
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</p>
{formData.sftpSudo && authType === "key" && (
<p className="text-xs text-amber-500 mt-1">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</div>
<Switch
id="sftp-sudo"
+17 -6
View File
@@ -28,6 +28,7 @@ export interface KeyboardInteractiveRequest {
instructions: string;
prompts: KeyboardInteractivePrompt[];
hostname?: string;
savedPassword?: string | null;
}
interface KeyboardInteractiveModalProps {
@@ -137,11 +138,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
value={responses[index] || ""}
onChange={(e) => handleResponseChange(index, e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isPassword
? t("keyboard.interactive.enterCode")
: t("keyboard.interactive.enterResponse")
}
placeholder=""
className={isPassword ? "pr-10" : undefined}
autoFocus={index === 0}
disabled={isSubmitting}
@@ -149,7 +146,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
{isPassword && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => toggleShowPassword(index)}
disabled={isSubmitting}
>
@@ -157,6 +154,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</button>
)}
</div>
{/* Use saved password button - shown below input, right-aligned */}
{isPassword && request.savedPassword && !responses[index] && (
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
onClick={() => handleResponseChange(index, request.savedPassword!)}
disabled={isSubmitting}
>
<KeyRound size={12} />
<span>{t("keyboard.interactive.useSavedPassword")}</span>
</button>
</div>
)}
</div>
);
})}
+2 -1
View File
@@ -128,7 +128,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleBroadcast,
onBroadcastInput,
}) => {
const CONNECTION_TIMEOUT = 12000;
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const containerRef = useRef<HTMLDivElement>(null);
@@ -218,12 +218,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
identities: ctx.identities,
override: pendingAuth
? {
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
: null,
});
@@ -247,12 +247,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const proxyConfig = ctx.host.proxyConfig
? {
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
}
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
}
: undefined;
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
@@ -348,9 +348,12 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
let id: string;
const hasKeyMaterial = !!key?.privateKey;
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
const authMethod = resolvedAuth.authMethod;
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
const hasPassword = !!effectivePassword;
if (hasKeyMaterial) {
try {
id = await startAttempt({ key });
+6 -5
View File
@@ -57,11 +57,12 @@ export const resolveHostAuth = (args: {
host.username?.trim() ||
"";
const keyId =
override?.keyId ||
identity?.keyId ||
host.identityFileId ||
undefined;
// Don't load key when explicit password auth is requested
// This ensures user's auth method selection is strictly respected
const keyId = override?.authMethod === 'password'
? undefined
: (override?.keyId || identity?.keyId || host.identityFileId || undefined);
const key = keyId ? keys.find((k) => k.id === keyId) : undefined;
-1
View File
@@ -9,7 +9,6 @@ module.exports = {
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
icon: 'public/icon.png',
npmRebuild: false,
directories: {
buildResources: 'build',
output: 'release'
+10 -47
View File
@@ -90,60 +90,21 @@ async function startPortForward(event, payload) {
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[PortForward] Auto-answering password prompt at index ${i}`);
responses[i] = password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[PortForward] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[PortForward] Merged responses, finishing keyboard-interactive`);
finish(responses);
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, tunnelId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts that need user input`);
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
@@ -152,9 +113,11 @@ async function startPortForward(event, payload) {
instructions: instructions || "",
prompts: promptsData,
hostname: hostname,
savedPassword: password || null,
});
});
conn.on('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
+26 -57
View File
@@ -199,7 +199,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
*/
async function connectSudoSftp(client, password) {
if (!SFTPWrapper) {
throw new Error("SFTPWrapper not available, cannot use sudo mode");
throw new Error("SFTP sudo mode is not available on this platform. Please disable sudo mode in host settings.");
}
// Known sftp-server paths to try
@@ -213,7 +213,7 @@ async function connectSudoSftp(client, password) {
];
console.log("[SFTP] Probing sftp-server path for sudo mode...");
let serverPath = null;
// Try to find the path
for (const p of sftpPaths) {
@@ -252,7 +252,7 @@ async function connectSudoSftp(client, password) {
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
@@ -277,7 +277,7 @@ async function connectSudoSftp(client, password) {
settled = true;
stream.stderr?.removeListener('data', onStderr);
stream.removeListener('data', onStdout);
const error = new Error("SFTP sudo handshake timed out");
const error = new Error("SFTP sudo handshake timed out. This may happen if: (1) the password is incorrect, (2) sudo requires a TTY, or (3) the user does not have sudo privileges.");
reject(error);
}, timeoutMs);
@@ -301,7 +301,7 @@ async function connectSudoSftp(client, password) {
outgoing: stream.outgoing
};
sftp = new SFTPWrapper(client, chanInfo, {
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
// debug: (str) => console.log(`[SFTP DEBUG] ${str}`)
});
// Route any remaining channel data directly into the SFTP parser
@@ -323,7 +323,7 @@ async function connectSudoSftp(client, password) {
});
stream.on('end', () => {
try { sftp.push(null); } catch {}
try { sftp.push(null); } catch { }
});
} catch (e) {
console.error("[SFTP] Initialization failed:", e.message);
@@ -365,13 +365,13 @@ async function connectSudoSftp(client, password) {
// 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();
initSftp();
}, 1000);
} else if (stdoutBuffer.length > 256) {
stdoutBuffer = stdoutBuffer.subarray(stdoutBuffer.length - 256);
@@ -380,7 +380,7 @@ async function connectSudoSftp(client, password) {
const onStderr = (data) => {
const chunk = data.toString();
console.log("[SFTP] stderr:", chunk);
// Only log that we received stderr data, not the content (may contain sensitive prompts)
stderrBuffer += chunk;
if (stderrBuffer.includes(prompt)) {
console.log("[SFTP] Sudo requested password, sending...");
@@ -404,7 +404,13 @@ async function connectSudoSftp(client, password) {
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}`);
let errorMsg = `SFTP sudo failed with exit code ${code}.`;
if (code === 1) {
errorMsg += " The password may be incorrect or sudo privileges are denied.";
} else if (code === 127) {
errorMsg += " sftp-server was not found on the remote system.";
}
const error = new Error(errorMsg);
finalize(error);
}
});
@@ -514,60 +520,21 @@ async function openSftp(event, options) {
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`[SFTP] Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`[SFTP] All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`[SFTP] Merged responses, finishing keyboard-interactive`);
finish(responses);
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, event.sender.id, connId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts that need user input`);
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
safeSend(event.sender, "netcatty:keyboard-interactive", {
requestId,
@@ -576,9 +543,11 @@ async function openSftp(event, options) {
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null,
});
};
// Add keyboard-interactive listener BEFORE connecting
client.on("keyboard-interactive", kiHandler);
+10 -48
View File
@@ -522,61 +522,21 @@ async function startSSHSession(event, options) {
return;
}
// Check if all prompts are password prompts that we can auto-answer
const responses = [];
const promptsNeedingUserInput = [];
for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
const promptText = (prompt.prompt || '').toLowerCase().trim();
// Auto-answer password prompts if we have a configured password
if (options.password && (
promptText.includes('password') ||
promptText === 'password:' ||
promptText === 'password'
)) {
console.log(`${logPrefix} Auto-answering password prompt at index ${i}`);
responses[i] = options.password;
} else {
// This prompt needs user input (likely 2FA)
promptsNeedingUserInput.push({ index: i, prompt: prompt });
responses[i] = null; // Placeholder
}
}
// If all prompts were auto-answered, finish immediately
if (promptsNeedingUserInput.length === 0) {
console.log(`${logPrefix} All prompts auto-answered, finishing keyboard-interactive`);
finish(responses);
return;
}
// If some prompts need user input, show the modal
// But only send the prompts that need user input
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
// Store finish callback with context about which responses are already filled
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
// Merge user responses with auto-filled responses
let userResponseIndex = 0;
for (let i = 0; i < prompts.length; i++) {
if (responses[i] === null) {
responses[i] = userResponses[userResponseIndex] || '';
userResponseIndex++;
}
}
console.log(`${logPrefix} Merged responses, finishing keyboard-interactive`);
finish(responses);
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
finish(userResponses);
}, sender.id, sessionId);
// Send only the prompts that need user input
const promptsData = promptsNeedingUserInput.map((item) => ({
prompt: item.prompt.prompt,
echo: item.prompt.echo,
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts that need user input`);
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
@@ -585,9 +545,11 @@ async function startSSHSession(event, options) {
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null, // Pass saved password for optional fill button
});
});
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Add keyboard-interactive after the existing methods
Vendored
+440 -439
View File
@@ -2,462 +2,463 @@ import type { RemoteFile } from "./types";
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
declare global {
// Proxy configuration for SSH connections
interface NetcattyProxyConfig {
type: 'http' | 'socks5';
host: string;
port: number;
username?: string;
password?: string;
}
// Jump host configuration for SSH tunneling
interface NetcattyJumpHost {
hostname: string;
port: number;
username: string;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
label?: string; // Display label for UI
}
// Host key information for verification
// Reserved for future host key verification UI feature
interface _NetcattyHostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
}
interface NetcattySSHOptions {
sessionId?: string;
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
// Optional OpenSSH user certificate
certificate?: string;
publicKey?: string; // OpenSSH public key line
keyId?: string;
keySource?: 'generated' | 'imported';
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
extraArgs?: string[];
startupCommand?: string;
passphrase?: string;
// Environment variables to set in the remote shell
env?: Record<string, string>;
// Proxy configuration
proxy?: NetcattyProxyConfig;
// Jump hosts (bastion chain)
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
// Use sudo for SFTP server
sudo?: boolean;
}
interface SftpStatResult {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
lastModified: number; // timestamp
permissions?: string; // e.g., "rwxr-xr-x"
owner?: string;
group?: string;
}
interface SftpTransferProgress {
transferId: string;
bytesTransferred: number;
totalBytes: number;
speed: number; // bytes per second
}
// Port Forwarding Types
interface PortForwardOptions {
tunnelId: string;
type: 'local' | 'remote' | 'dynamic';
localPort: number;
bindAddress?: string;
remoteHost?: string;
remotePort?: number;
// SSH connection details
hostname: string;
port?: number;
username: string;
password?: string;
privateKey?: string;
}
interface PortForwardResult {
tunnelId: string;
success: boolean;
error?: string;
}
interface PortForwardStatusResult {
tunnelId: string;
status: 'inactive' | 'connecting' | 'active' | 'error';
type?: 'local' | 'remote' | 'dynamic';
error?: string;
}
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
interface NetcattyBridge {
startSSHSession(options: NetcattySSHOptions): Promise<string>;
startTelnetSession?(options: {
sessionId?: string;
hostname: string;
port?: number;
cols?: number;
rows?: number;
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startMoshSession?(options: {
sessionId?: string;
hostname: string;
// Proxy configuration for SSH connections
interface NetcattyProxyConfig {
type: 'http' | 'socks5';
host: string;
port: number;
username?: string;
port?: number;
moshServerPath?: string;
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
startSerialSession?(options: {
password?: string;
}
// Jump host configuration for SSH tunneling
interface NetcattyJumpHost {
hostname: string;
port: number;
username: string;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
label?: string; // Display label for UI
}
// Host key information for verification
// Reserved for future host key verification UI feature
interface _NetcattyHostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
}
interface NetcattySSHOptions {
sessionId?: string;
path: string;
baudRate?: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
}>>;
getDefaultShell?(): Promise<string>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
bits?: number;
comment?: string;
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
execCommand(options: {
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
command: string;
timeout?: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
/** Get current working directory from an active SSH session */
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void
): () => void;
onAuthFailed?(
sessionId: string,
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
): () => void;
// Keyboard-interactive authentication (2FA/MFA)
onKeyboardInteractive?(
cb: (request: {
requestId: string;
sessionId: string;
name: string;
instructions: string;
prompts: Array<{ prompt: string; echo: boolean }>;
hostname: string;
}) => void
): () => void;
respondKeyboardInteractive?(
requestId: string,
responses: string[],
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
// SFTP operations
openSftp(options: NetcattySSHOptions): Promise<string>;
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
readSftp(sftpId: string, path: string): Promise<string>;
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
closeSftp(sftpId: string): Promise<void>;
mkdirSftp(sftpId: string, path: string): Promise<void>;
deleteSftp?(sftpId: string, path: string): Promise<void>;
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
// Write binary with real-time progress callback
writeSftpBinaryWithProgress?(
sftpId: string,
path: string,
content: ArrayBuffer,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
): Promise<{ success: boolean; transferId: string }>;
// Transfer with progress
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
// Streaming transfer with real progress and cancellation
startStreamTransfer?(
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
): Promise<{ transferId: string; totalBytes?: number; error?: string }>;
// Local filesystem operations
listLocalDir?(path: string): Promise<RemoteFile[]>;
readLocalFile?(path: string): Promise<ArrayBuffer>;
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
deleteLocalFile?(path: string): Promise<void>;
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
mkdirLocal?(path: string): Promise<void>;
statLocal?(path: string): Promise<SftpStatResult>;
getHomeDir?(): Promise<string>;
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
setBackgroundColor?(color: string): Promise<boolean>;
setLanguage?(language: string): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)
windowMinimize?(): Promise<void>;
windowMaximize?(): Promise<boolean>;
windowClose?(): Promise<void>;
windowIsMaximized?(): Promise<boolean>;
windowIsFullscreen?(): Promise<boolean>;
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
// Settings window
openSettingsWindow?(): Promise<boolean>;
closeSettingsWindow?(): Promise<void>;
// Optional OpenSSH user certificate
certificate?: string;
publicKey?: string; // OpenSSH public key line
keyId?: string;
keySource?: 'generated' | 'imported';
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
extraArgs?: string[];
startupCommand?: string;
passphrase?: string;
// Environment variables to set in the remote shell
env?: Record<string, string>;
// Proxy configuration
proxy?: NetcattyProxyConfig;
// Jump hosts (bastion chain)
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
// Use sudo for SFTP server
sudo?: boolean;
}
// Cross-window settings sync
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
interface SftpStatResult {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
lastModified: number; // timestamp
permissions?: string; // e.g., "rwxr-xr-x"
owner?: string;
group?: string;
}
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
cloudSyncGetSessionPassword?(): Promise<string | null>;
cloudSyncClearSessionPassword?(): Promise<boolean>;
interface SftpTransferProgress {
transferId: string;
bytesTransferred: number;
totalBytes: number;
speed: number; // bytes per second
}
// Cloud sync network operations (proxied via main process)
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
cloudSyncWebdavUpload?(
config: WebDAVConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
// Port Forwarding Types
interface PortForwardOptions {
tunnelId: string;
type: 'local' | 'remote' | 'dynamic';
localPort: number;
bindAddress?: string;
remoteHost?: string;
remotePort?: number;
// SSH connection details
hostname: string;
port?: number;
username: string;
password?: string;
privateKey?: string;
}
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
cloudSyncS3Upload?(
config: S3Config,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
cloudSyncSmbUpload?(
config: SMBConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
// Port Forwarding
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
// Known Hosts
readKnownHosts?(): Promise<string | null>;
// Open URL in default browser
openExternal?(url: string): Promise<void>;
// App info (name/version/platform) for About screens
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
rendererReady?(): void;
onLanguageChanged?(cb: (language: string) => void): () => void;
// Chain progress listener for jump host connections
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
// OAuth callback server for cloud sync
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
cancelOAuthCallback?(): Promise<void>;
// GitHub Device Flow (cloud sync)
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
deviceCode: string;
userCode: string;
verificationUri: string;
expiresAt: number;
interval: number;
}>;
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
access_token?: string;
token_type?: string;
scope?: string;
interface PortForwardResult {
tunnelId: string;
success: boolean;
error?: string;
error_description?: string;
}>;
}
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
googleExchangeCodeForTokens?(options: {
clientId: string;
clientSecret?: string;
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
googleRefreshAccessToken?(options: {
clientId: string;
clientSecret?: string;
refreshToken: string;
}): Promise<{
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
googleGetUserInfo?(options: { accessToken: string }): Promise<{
id: string;
email: string;
name: string;
picture?: string;
}>;
interface PortForwardStatusResult {
tunnelId: string;
status: 'inactive' | 'connecting' | 'active' | 'error';
type?: 'local' | 'remote' | 'dynamic';
error?: string;
}
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
onedriveExchangeCodeForTokens?(options: {
clientId: string;
code: string;
codeVerifier: string;
redirectUri: string;
scope?: string;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
onedriveRefreshAccessToken?(options: {
clientId: string;
refreshToken: string;
scope?: string;
}): Promise<{
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
id: string;
email: string;
name: string;
avatarDataUrl?: string;
}>;
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
interface NetcattyBridge {
startSSHSession(options: NetcattySSHOptions): Promise<string>;
startTelnetSession?(options: {
sessionId?: string;
hostname: string;
port?: number;
cols?: number;
rows?: number;
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startMoshSession?(options: {
sessionId?: string;
hostname: string;
username?: string;
port?: number;
moshServerPath?: string;
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
env?: Record<string, string>;
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
path: string;
baudRate?: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
path: string;
manufacturer: string;
serialNumber: string;
vendorId: string;
productId: string;
pnpId: string;
}>>;
getDefaultShell?(): Promise<string>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
bits?: number;
comment?: string;
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
execCommand(options: {
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
command: string;
timeout?: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
/** Get current working directory from an active SSH session */
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void
): () => void;
onAuthFailed?(
sessionId: string,
cb: (evt: { sessionId: string; error: string; hostname: string }) => void
): () => void;
// File opener helpers (for "Open With" feature)
selectApplication?(): Promise<{ path: string; name: string } | null>;
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
// File watcher for auto-sync feature
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
// Temp file cleanup
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
getTempDirPath?(): Promise<string>;
openTempDir?(): Promise<{ success: boolean }>;
}
// Keyboard-interactive authentication (2FA/MFA)
onKeyboardInteractive?(
cb: (request: {
requestId: string;
sessionId: string;
name: string;
instructions: string;
prompts: Array<{ prompt: string; echo: boolean }>;
hostname: string;
savedPassword?: string | null;
}) => void
): () => void;
respondKeyboardInteractive?(
requestId: string,
responses: string[],
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
interface Window {
netcatty?: NetcattyBridge;
}
// SFTP operations
openSftp(options: NetcattySSHOptions): Promise<string>;
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
readSftp(sftpId: string, path: string): Promise<string>;
readSftpBinary?(sftpId: string, path: string): Promise<ArrayBuffer>;
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
writeSftpBinary?(sftpId: string, path: string, content: ArrayBuffer): Promise<void>;
closeSftp(sftpId: string): Promise<void>;
mkdirSftp(sftpId: string, path: string): Promise<void>;
deleteSftp?(sftpId: string, path: string): Promise<void>;
renameSftp?(sftpId: string, oldPath: string, newPath: string): Promise<void>;
statSftp?(sftpId: string, path: string): Promise<SftpStatResult>;
chmodSftp?(sftpId: string, path: string, mode: string): Promise<void>;
// Write binary with real-time progress callback
writeSftpBinaryWithProgress?(
sftpId: string,
path: string,
content: ArrayBuffer,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
): Promise<{ success: boolean; transferId: string }>;
// Transfer with progress
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
// Streaming transfer with real progress and cancellation
startStreamTransfer?(
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
): Promise<{ transferId: string; totalBytes?: number; error?: string }>;
// Local filesystem operations
listLocalDir?(path: string): Promise<RemoteFile[]>;
readLocalFile?(path: string): Promise<ArrayBuffer>;
writeLocalFile?(path: string, content: ArrayBuffer): Promise<void>;
deleteLocalFile?(path: string): Promise<void>;
renameLocalFile?(oldPath: string, newPath: string): Promise<void>;
mkdirLocal?(path: string): Promise<void>;
statLocal?(path: string): Promise<SftpStatResult>;
getHomeDir?(): Promise<string>;
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
setBackgroundColor?(color: string): Promise<boolean>;
setLanguage?(language: string): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)
windowMinimize?(): Promise<void>;
windowMaximize?(): Promise<boolean>;
windowClose?(): Promise<void>;
windowIsMaximized?(): Promise<boolean>;
windowIsFullscreen?(): Promise<boolean>;
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
// Settings window
openSettingsWindow?(): Promise<boolean>;
closeSettingsWindow?(): Promise<void>;
// Cross-window settings sync
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
cloudSyncGetSessionPassword?(): Promise<string | null>;
cloudSyncClearSessionPassword?(): Promise<boolean>;
// Cloud sync network operations (proxied via main process)
cloudSyncWebdavInitialize?(config: WebDAVConfig): Promise<{ resourceId: string | null }>;
cloudSyncWebdavUpload?(
config: WebDAVConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncWebdavDownload?(config: WebDAVConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncWebdavDelete?(config: WebDAVConfig): Promise<{ ok: true }>;
cloudSyncS3Initialize?(config: S3Config): Promise<{ resourceId: string | null }>;
cloudSyncS3Upload?(
config: S3Config,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
cloudSyncSmbUpload?(
config: SMBConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
// Port Forwarding
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
// Known Hosts
readKnownHosts?(): Promise<string | null>;
// Open URL in default browser
openExternal?(url: string): Promise<void>;
// App info (name/version/platform) for About screens
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
rendererReady?(): void;
onLanguageChanged?(cb: (language: string) => void): () => void;
// Chain progress listener for jump host connections
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
// OAuth callback server for cloud sync
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
cancelOAuthCallback?(): Promise<void>;
// GitHub Device Flow (cloud sync)
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
deviceCode: string;
userCode: string;
verificationUri: string;
expiresAt: number;
interval: number;
}>;
githubPollDeviceFlowToken?(options: { clientId?: string; deviceCode: string }): Promise<{
access_token?: string;
token_type?: string;
scope?: string;
error?: string;
error_description?: string;
}>;
// Google OAuth (cloud sync) - proxied via main process to avoid CORS
googleExchangeCodeForTokens?(options: {
clientId: string;
clientSecret?: string;
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
googleRefreshAccessToken?(options: {
clientId: string;
clientSecret?: string;
refreshToken: string;
}): Promise<{
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
googleGetUserInfo?(options: { accessToken: string }): Promise<{
id: string;
email: string;
name: string;
picture?: string;
}>;
// Google Drive API (cloud sync) - proxied via main process to avoid CORS/COEP issues
googleDriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
googleDriveCreateSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string }>;
googleDriveUpdateSyncFile?(options: { accessToken: string; fileId: string; syncedFile: unknown }): Promise<{ ok: true }>;
googleDriveDownloadSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ syncedFile: unknown | null }>;
googleDriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
// OneDrive OAuth + Graph (cloud sync) - proxied via main process to avoid CORS
onedriveExchangeCodeForTokens?(options: {
clientId: string;
code: string;
codeVerifier: string;
redirectUri: string;
scope?: string;
}): Promise<{
accessToken: string;
refreshToken?: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
onedriveRefreshAccessToken?(options: {
clientId: string;
refreshToken: string;
scope?: string;
}): Promise<{
accessToken: string;
refreshToken: string;
expiresAt?: number;
tokenType: string;
scope?: string;
}>;
onedriveGetUserInfo?(options: { accessToken: string }): Promise<{
id: string;
email: string;
name: string;
avatarDataUrl?: string;
}>;
onedriveFindSyncFile?(options: { accessToken: string; fileName?: string }): Promise<{ fileId: string | null }>;
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
// File opener helpers (for "Open With" feature)
selectApplication?(): Promise<{ path: string; name: string } | null>;
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
// File watcher for auto-sync feature
startFileWatch?(localPath: string, remotePath: string, sftpId: string): Promise<{ watchId: string }>;
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
listFileWatches?(): Promise<Array<{ watchId: string; localPath: string; remotePath: string; sftpId: string }>>;
registerTempFile?(sftpId: string, localPath: string): Promise<{ success: boolean }>;
onFileWatchSynced?(cb: (payload: { watchId: string; localPath: string; remotePath: string; bytesWritten: number }) => void): () => void;
onFileWatchError?(cb: (payload: { watchId: string; localPath: string; remotePath: string; error: string }) => void): () => void;
// Temp file cleanup
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
getTempDirPath?(): Promise<string>;
openTempDir?(): Promise<{ success: boolean }>;
}
interface Window {
netcatty?: NetcattyBridge;
}
}
+7
View File
@@ -216,6 +216,13 @@ const BASE_TERMINAL_FONTS: TerminalFont[] = [
description: 'Highly customizable monospace font',
category: 'monospace',
},
{
id: 'ioskeley-mono',
name: 'Ioskeley Mono',
family: '"Ioskeley Mono", monospace',
description: 'Iosevka variant mimicking Berkeley Mono style',
category: 'monospace',
},
{
id: 'mononoki',
name: 'Mononoki',
-155
View File
@@ -1,155 +0,0 @@
# 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