Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664fe90c10 | ||
|
|
2215d52b09 | ||
|
|
c9059a4f29 | ||
|
|
4445bf578c | ||
|
|
f719350507 |
25
App.tsx
25
App.tsx
@@ -14,6 +14,7 @@ import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
@@ -1082,6 +1083,30 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
})();
|
||||
}, [openSettingsWindow, t]);
|
||||
|
||||
const hasShownCredentialProtectionWarningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShownCredentialProtectionWarningRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const available = await getCredentialProtectionAvailability();
|
||||
if (cancelled || available !== false) return;
|
||||
hasShownCredentialProtectionWarningRef.current = true;
|
||||
|
||||
toast.warning(t('credentials.protectionUnavailable.message'), {
|
||||
title: t('credentials.protectionUnavailable.title'),
|
||||
actionLabel: t('credentials.protectionUnavailable.action'),
|
||||
duration: 10000,
|
||||
onClick: handleOpenSettings,
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
|
||||
@@ -57,6 +57,9 @@ const en: Messages = {
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
'credentials.protectionUnavailable.action': 'Open Settings',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Settings',
|
||||
@@ -80,6 +83,14 @@ const en: Messages = {
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
'settings.system.credentials.title': 'Credential Protection',
|
||||
'settings.system.credentials.status': 'Status',
|
||||
'settings.system.credentials.checking': 'Checking...',
|
||||
'settings.system.credentials.available': 'Available (OS keychain ready)',
|
||||
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
|
||||
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
@@ -324,6 +335,7 @@ const en: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
'time.minutesAgo': '{minutes}m ago',
|
||||
@@ -781,6 +793,10 @@ const en: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
@@ -916,6 +932,10 @@ const en: Messages = {
|
||||
'terminal.serverStats.memBuffers': 'Buffers',
|
||||
'terminal.serverStats.memCached': 'Cache',
|
||||
'terminal.serverStats.memFree': 'Free',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap Used',
|
||||
'terminal.serverStats.swapFree': 'Swap Free',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
|
||||
'terminal.serverStats.disk': 'Disk Usage (Root)',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
@@ -952,6 +972,10 @@ const en: Messages = {
|
||||
'terminal.auth.selectKey': 'Select Key',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
|
||||
@@ -42,6 +42,9 @@ const zhCN: Messages = {
|
||||
'placeholder.workspaceName': '工作区名称',
|
||||
'placeholder.sessionName': '会话名称',
|
||||
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
|
||||
'credentials.protectionUnavailable.title': '凭据保护不可用',
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
'credentials.protectionUnavailable.action': '打开设置',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': '设置',
|
||||
@@ -65,6 +68,14 @@ const zhCN: Messages = {
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
'settings.system.credentials.title': '凭据保护',
|
||||
'settings.system.credentials.status': '状态',
|
||||
'settings.system.credentials.checking': '检查中...',
|
||||
'settings.system.credentials.available': '可用(系统钥匙串正常)',
|
||||
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
|
||||
'settings.system.credentials.unknown': '未知(当前环境不支持)',
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
@@ -191,6 +202,7 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
'time.minutesAgo': '{minutes} 分钟前',
|
||||
@@ -496,6 +508,10 @@ const zhCN: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
@@ -602,6 +618,10 @@ const zhCN: Messages = {
|
||||
'terminal.serverStats.memBuffers': '缓冲区',
|
||||
'terminal.serverStats.memCached': '缓存',
|
||||
'terminal.serverStats.memFree': '空闲',
|
||||
'terminal.serverStats.swap': '交换空间',
|
||||
'terminal.serverStats.swapUsed': '已用交换',
|
||||
'terminal.serverStats.swapFree': '空闲交换',
|
||||
'terminal.serverStats.swapTotal': '总计',
|
||||
'terminal.serverStats.topProcesses': '内存占用前十进程',
|
||||
'terminal.serverStats.disk': '磁盘使用(根分区)',
|
||||
'terminal.serverStats.diskDetails': '已挂载磁盘',
|
||||
@@ -638,6 +658,10 @@ const zhCN: Messages = {
|
||||
'terminal.auth.selectKey': '选择密钥',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
|
||||
'terminal.connectionErrorTitle': '连接错误',
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
|
||||
@@ -12,6 +12,9 @@ import { useCloudSync } from './useCloudSync';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
|
||||
@@ -109,6 +112,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length > 0) {
|
||||
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
|
||||
for (const result of results.values()) {
|
||||
|
||||
@@ -286,7 +286,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
@@ -319,7 +319,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -482,7 +485,7 @@ const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => window.open(verificationUri, '_blank')}
|
||||
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
|
||||
className="w-full gap-2 mb-4"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
@@ -751,6 +754,17 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Clear local data dialog
|
||||
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
|
||||
|
||||
const ensureSyncablePayload = useCallback(
|
||||
(payload: SyncPayload): boolean => {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length === 0) return true;
|
||||
|
||||
toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle'));
|
||||
return false;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Handle conflict detection
|
||||
useEffect(() => {
|
||||
if (sync.currentConflict) {
|
||||
@@ -958,6 +972,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const handleSync = async (provider: CloudProvider) => {
|
||||
try {
|
||||
const payload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) return;
|
||||
const result = await sync.syncToProvider(provider, payload);
|
||||
|
||||
if (result.success) {
|
||||
@@ -982,6 +997,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} else if (resolution === 'USE_LOCAL') {
|
||||
// Re-sync with local data
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
await sync.syncNow(localPayload);
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
@@ -1556,6 +1572,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let payloadForReencrypt: SyncPayload | null = null;
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) {
|
||||
setChangeKeyError(t('sync.credentialsUnavailable'));
|
||||
return;
|
||||
}
|
||||
payloadForReencrypt = payload;
|
||||
}
|
||||
|
||||
setIsChangingKey(true);
|
||||
try {
|
||||
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
|
||||
@@ -1564,9 +1590,8 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = onBuildPayload();
|
||||
await sync.syncNow(payload);
|
||||
if (payloadForReencrypt) {
|
||||
await sync.syncNow(payloadForReencrypt);
|
||||
}
|
||||
|
||||
toast.success(t('cloudSync.changeKey.updatedToast'));
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Plus,
|
||||
Settings2,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
@@ -1230,6 +1231,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.legacyAlgorithms.desc")}
|
||||
</p>
|
||||
{form.legacyAlgorithms && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
{t("hostDetails.legacyAlgorithms.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -42,6 +42,7 @@ interface SFTPModalProps {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -526,7 +527,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Find the files to pass to confirm dialog
|
||||
if (fileNames.length === 0) return;
|
||||
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
|
||||
|
||||
|
||||
// Delete files
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -416,6 +416,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setProgressLogs,
|
||||
setProgressValue,
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onOsDetected,
|
||||
@@ -1299,6 +1300,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Swap bar */}
|
||||
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
|
||||
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
|
||||
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
|
||||
<div
|
||||
className="h-full bg-rose-500"
|
||||
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-rose-500" />
|
||||
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
|
||||
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Top 10 processes */}
|
||||
{serverStats.topProcesses.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
@@ -1618,6 +1647,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sftpSudo: host.sftpSudo,
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
@@ -61,6 +62,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
|
||||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
|
||||
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -81,6 +84,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
loadTempDirInfo();
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const loadCredentialProtectionStatus = useCallback(async () => {
|
||||
setIsCheckingCredentials(true);
|
||||
try {
|
||||
const available = await getCredentialProtectionAvailability();
|
||||
setCredentialsAvailable(available);
|
||||
} finally {
|
||||
setIsCheckingCredentials(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCredentialProtectionStatus();
|
||||
}, [loadCredentialProtectionStatus]);
|
||||
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
@@ -201,6 +218,59 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Credential Protection Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.credentials.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.credentials.status")}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium mt-1",
|
||||
credentialsAvailable === true && "text-emerald-600 dark:text-emerald-400",
|
||||
credentialsAvailable === false && "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
{isCheckingCredentials
|
||||
? t("settings.system.credentials.checking")
|
||||
: credentialsAvailable === true
|
||||
? t("settings.system.credentials.available")
|
||||
: credentialsAvailable === false
|
||||
? t("settings.system.credentials.unavailable")
|
||||
: t("settings.system.credentials.unknown")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadCredentialProtectionStatus}
|
||||
disabled={isCheckingCredentials}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isCheckingCredentials ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{credentialsAvailable === false && (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
{t("settings.system.credentials.unavailableHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.credentials.portabilityHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -20,6 +20,7 @@ interface UseSftpModalSessionParams {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
isLocalSession: boolean;
|
||||
@@ -39,6 +40,7 @@ interface UseSftpModalSessionParams {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
}) => Promise<string>;
|
||||
closeSftp: (sftpId: string) => Promise<void>;
|
||||
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
@@ -112,6 +114,7 @@ export const useSftpModalSession = ({
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
legacyAlgorithms: credentials.legacyAlgorithms,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
return sftpId;
|
||||
@@ -131,6 +134,7 @@ export const useSftpModalSession = ({
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
credentials.legacyAlgorithms,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface ServerStats {
|
||||
memFree: number | null; // Free memory in MB
|
||||
memBuffers: number | null; // Buffers in MB
|
||||
memCached: number | null; // Cached in MB
|
||||
swapTotal: number | null; // Total swap in MB
|
||||
swapUsed: number | null; // Used swap in MB
|
||||
topProcesses: ProcessInfo[]; // Top 10 processes by memory
|
||||
diskPercent: number | null; // Disk usage percentage for root partition
|
||||
diskUsed: number | null; // Disk used in GB
|
||||
@@ -66,6 +68,8 @@ export function useServerStats({
|
||||
memFree: null,
|
||||
memBuffers: null,
|
||||
memCached: null,
|
||||
swapTotal: null,
|
||||
swapUsed: null,
|
||||
topProcesses: [],
|
||||
diskPercent: null,
|
||||
diskUsed: null,
|
||||
@@ -109,6 +113,8 @@ export function useServerStats({
|
||||
memFree: result.stats.memFree,
|
||||
memBuffers: result.stats.memBuffers,
|
||||
memCached: result.stats.memCached,
|
||||
swapTotal: result.stats.swapTotal ?? null,
|
||||
swapUsed: result.stats.swapUsed ?? null,
|
||||
topProcesses: result.stats.topProcesses || [],
|
||||
diskPercent: result.stats.diskPercent,
|
||||
diskUsed: result.stats.diskUsed,
|
||||
@@ -155,6 +161,8 @@ export function useServerStats({
|
||||
memFree: null,
|
||||
memBuffers: null,
|
||||
memCached: null,
|
||||
swapTotal: null,
|
||||
swapUsed: null,
|
||||
topProcesses: [],
|
||||
diskPercent: null,
|
||||
diskUsed: null,
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
|
||||
import {
|
||||
isEncryptedCredentialPlaceholder,
|
||||
sanitizeCredentialValue,
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
type TerminalBackendApi = {
|
||||
@@ -85,6 +89,7 @@ export type TerminalSessionStartersContext = {
|
||||
setProgressLogs: Dispatch<SetStateAction<string[]>>;
|
||||
setProgressValue: Dispatch<SetStateAction<number>>;
|
||||
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
|
||||
t?: (key: string) => string;
|
||||
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
@@ -194,6 +199,12 @@ const runDistroDetection = async (
|
||||
};
|
||||
|
||||
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
|
||||
const tr = (key: string, fallback: string): string => {
|
||||
const translated = ctx.t?.(key);
|
||||
if (!translated || translated === key) return fallback;
|
||||
return translated;
|
||||
};
|
||||
|
||||
const startSSH = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
@@ -227,9 +238,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
});
|
||||
|
||||
const effectiveUsername = resolvedAuth.username || "root";
|
||||
const effectivePassword = resolvedAuth.password;
|
||||
const key = resolvedAuth.key;
|
||||
const effectivePassphrase = resolvedAuth.passphrase;
|
||||
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||||
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||||
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||||
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
|
||||
let usedKey: SSHKey | undefined;
|
||||
let usedPassword: string | undefined;
|
||||
|
||||
@@ -244,16 +257,19 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
);
|
||||
};
|
||||
|
||||
const rawProxyPassword = ctx.host.proxyConfig?.password;
|
||||
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
|
||||
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,
|
||||
password: sanitizeCredentialValue(rawProxyPassword),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const jumpHostsWithUnavailableCredentials: string[] = [];
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
@@ -261,14 +277,30 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
identities: ctx.identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const rawJumpPassword = jumpAuth.password;
|
||||
const rawJumpPrivateKey = jumpKey?.privateKey;
|
||||
const rawJumpPassphrase = jumpAuth.passphrase || jumpKey?.passphrase;
|
||||
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
|
||||
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
|
||||
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
|
||||
|
||||
const hasEncryptedJumpCredential =
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
|
||||
|
||||
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
|
||||
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
password: jumpPassword,
|
||||
privateKey: jumpPrivateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
passphrase: jumpPassphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
@@ -276,6 +308,38 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
});
|
||||
|
||||
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
|
||||
const message = tr(
|
||||
"terminal.auth.proxyCredentialsUnavailable",
|
||||
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
|
||||
);
|
||||
ctx.setNeedsAuth(false);
|
||||
ctx.setAuthRetryMessage(null);
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (jumpHostsWithUnavailableCredentials.length > 0) {
|
||||
const jumpList = jumpHostsWithUnavailableCredentials.slice(0, 2).join(", ");
|
||||
const suffix =
|
||||
jumpHostsWithUnavailableCredentials.length > 2
|
||||
? ` +${jumpHostsWithUnavailableCredentials.length - 2}`
|
||||
: "";
|
||||
const base = tr(
|
||||
"terminal.auth.jumpCredentialsUnavailable",
|
||||
"A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.",
|
||||
);
|
||||
const message = `${base} (${jumpList}${suffix})`;
|
||||
ctx.setNeedsAuth(false);
|
||||
ctx.setAuthRetryMessage(null);
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalHops = jumpHosts.length + 1;
|
||||
let unsubscribeChainProgress: (() => void) | undefined;
|
||||
|
||||
@@ -334,8 +398,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
publicKey: attempt.key?.publicKey,
|
||||
keyId: attempt.key?.id,
|
||||
keySource: attempt.key?.source,
|
||||
passphrase: attempt.key ? (effectivePassphrase || attempt.key.passphrase) : undefined,
|
||||
passphrase: attempt.key
|
||||
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||||
: undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
@@ -349,9 +416,46 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
let id: string;
|
||||
// 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 hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== 'password';
|
||||
const hasPassword = !!effectivePassword;
|
||||
|
||||
const needsCredentialReentry =
|
||||
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
|
||||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
|
||||
|
||||
if (needsCredentialReentry) {
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
ctx.setError(null);
|
||||
ctx.setNeedsAuth(true);
|
||||
ctx.setAuthRetryMessage(
|
||||
tr(
|
||||
"terminal.auth.credentialsUnavailable",
|
||||
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||||
),
|
||||
);
|
||||
ctx.setAuthPassword("");
|
||||
ctx.setProgressLogs((prev) => [
|
||||
...prev,
|
||||
tr(
|
||||
"terminal.auth.credentialsUnavailable",
|
||||
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||||
),
|
||||
]);
|
||||
ctx.setStatus("connecting");
|
||||
ctx.setChainProgress(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasKeyMaterial && authMethod !== "password" && hasEncryptedPrimaryKey && hasPassword) {
|
||||
ctx.setProgressLogs((prev) => [
|
||||
...prev,
|
||||
tr(
|
||||
"terminal.auth.keyUnavailableFallbackPassword",
|
||||
"Saved SSH key is unavailable on this device. Falling back to password authentication.",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
if (hasKeyMaterial) {
|
||||
try {
|
||||
|
||||
@@ -316,7 +316,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (ctx.terminalBackend.openExternalAvailable()) {
|
||||
void ctx.terminalBackend.openExternal(uri);
|
||||
} else {
|
||||
window.open(uri, "_blank");
|
||||
const safeUri = String(uri || "");
|
||||
if (/^https?:\/\//i.test(safeUri)) {
|
||||
window.open(safeUri, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
logger.warn("[XTerm] Refusing to open non-http(s) link:", safeUri);
|
||||
}
|
||||
}
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
92
domain/credentials.ts
Normal file
92
domain/credentials.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { SyncPayload } from "./sync";
|
||||
|
||||
export const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
|
||||
|
||||
/**
|
||||
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.
|
||||
*/
|
||||
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
|
||||
|
||||
/**
|
||||
* Chromium/Electron safeStorage ciphertext carries known platform headers:
|
||||
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
|
||||
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
|
||||
*
|
||||
* We validate the base64 payload starts with one of these header signatures
|
||||
* instead of relying only on prefix+length heuristics. This greatly reduces
|
||||
* false positives for plaintext credentials that happen to start with "enc:v1:".
|
||||
*
|
||||
* References:
|
||||
* - components/os_crypt/sync/os_crypt_mac.mm (kObfuscationPrefixV10 = "v10")
|
||||
* - components/os_crypt/sync/os_crypt_linux.cc (kObfuscationPrefixV10/V11)
|
||||
* - components/os_crypt/sync/os_crypt_win.cc (DPAPI legacy path)
|
||||
*/
|
||||
const SAFE_STORAGE_BASE64_HEADER_PREFIXES = [
|
||||
"djEw", // "v10"
|
||||
"djEx", // "v11"
|
||||
"AQAAAA", // 0x01 0x00 0x00 0x00 (DPAPI blob header)
|
||||
] as const;
|
||||
|
||||
export const isEncryptedCredentialPlaceholder = (
|
||||
value: string | undefined | null,
|
||||
): value is string => {
|
||||
if (typeof value !== "string" || !value.startsWith(CREDENTIAL_ENCRYPTION_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
const payload = value.slice(CREDENTIAL_ENCRYPTION_PREFIX.length);
|
||||
if (!payload || !BASE64_RE.test(payload)) return false;
|
||||
|
||||
return SAFE_STORAGE_BASE64_HEADER_PREFIXES.some((prefix) => payload.startsWith(prefix));
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip enc:v1: placeholders from a single credential value.
|
||||
* Used at the terminal connection boundary to avoid sending encrypted
|
||||
* placeholders as actual passwords to SSH/Telnet servers.
|
||||
*/
|
||||
export const sanitizeCredentialValue = (
|
||||
value: string | undefined,
|
||||
): string | undefined => {
|
||||
if (isEncryptedCredentialPlaceholder(value)) return undefined;
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan a sync payload for any fields that still carry device-bound
|
||||
* enc:v1: ciphertext. Returns the dotted paths of offending fields.
|
||||
* Used as a pre-upload guard to prevent pushing un-decryptable data.
|
||||
*/
|
||||
export const findSyncPayloadEncryptedCredentialPaths = (
|
||||
payload: SyncPayload,
|
||||
): string[] => {
|
||||
const issues: string[] = [];
|
||||
|
||||
payload.hosts.forEach((host, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(host.password)) {
|
||||
issues.push(`hosts[${index}].password`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(host.telnetPassword)) {
|
||||
issues.push(`hosts[${index}].telnetPassword`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
|
||||
issues.push(`hosts[${index}].proxyConfig.password`);
|
||||
}
|
||||
});
|
||||
|
||||
payload.keys.forEach((key, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(key.privateKey)) {
|
||||
issues.push(`keys[${index}].privateKey`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
|
||||
issues.push(`keys[${index}].passphrase`);
|
||||
}
|
||||
});
|
||||
|
||||
payload.identities?.forEach((identity, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(identity.password)) {
|
||||
issues.push(`identities[${index}].password`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
};
|
||||
@@ -99,6 +99,8 @@ export interface Host {
|
||||
// Host-level keyword highlighting (overrides/extends global settings)
|
||||
keywordHighlightRules?: KeywordHighlightRule[];
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -194,7 +196,7 @@ export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: strin
|
||||
// Convert keyboard event to a key string
|
||||
export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
|
||||
if (isMac) {
|
||||
if (e.metaKey) parts.push('⌘');
|
||||
if (e.ctrlKey) parts.push('⌃');
|
||||
@@ -206,7 +208,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
if (e.metaKey) parts.push('Win');
|
||||
}
|
||||
|
||||
|
||||
// Get the key name
|
||||
let keyName = e.key;
|
||||
// Normalize special keys
|
||||
@@ -221,12 +223,12 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
else if (keyName === 'Enter') keyName = '↵';
|
||||
else if (keyName === 'Tab') keyName = '⇥';
|
||||
else if (keyName.length === 1) keyName = keyName.toUpperCase();
|
||||
|
||||
|
||||
// Don't include modifier keys themselves
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) {
|
||||
return parts.join(' + ');
|
||||
}
|
||||
|
||||
|
||||
parts.push(keyName);
|
||||
return parts.join(' + ');
|
||||
};
|
||||
@@ -234,7 +236,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
// Check if a keyboard event matches a key binding string
|
||||
export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boolean): boolean => {
|
||||
if (!keyStr || keyStr === 'Disabled') return false;
|
||||
|
||||
|
||||
// Handle range patterns like "[1...9]"
|
||||
if (keyStr.includes('[1...9]')) {
|
||||
const basePattern = keyStr.replace('[1...9]', '');
|
||||
@@ -244,7 +246,7 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
const testStr = basePattern + key;
|
||||
return matchesKeyBinding(e, testStr.trim(), isMac);
|
||||
}
|
||||
|
||||
|
||||
// Handle arrow key patterns like "arrows"
|
||||
if (keyStr.includes('arrows')) {
|
||||
const basePattern = keyStr.replace('arrows', '');
|
||||
@@ -252,18 +254,18 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
// Check if it's an arrow key
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) return false;
|
||||
// Map arrow key to symbol for matching
|
||||
const arrowSymbol = key === 'ArrowUp' ? '↑'
|
||||
const arrowSymbol = key === 'ArrowUp' ? '↑'
|
||||
: key === 'ArrowDown' ? '↓'
|
||||
: key === 'ArrowLeft' ? '←'
|
||||
: '→';
|
||||
: key === 'ArrowLeft' ? '←'
|
||||
: '→';
|
||||
// Check modifiers match the base pattern
|
||||
const testStr = basePattern + arrowSymbol;
|
||||
return matchesKeyBinding(e, testStr.trim(), isMac);
|
||||
}
|
||||
|
||||
|
||||
const parsed = parseKeyCombo(keyStr);
|
||||
if (!parsed) return false;
|
||||
|
||||
|
||||
const { modifiers, key } = parsed;
|
||||
|
||||
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
|
||||
@@ -271,14 +273,14 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check modifiers
|
||||
if (isMac) {
|
||||
const needMeta = modifiers.includes('⌘');
|
||||
const needCtrl = modifiers.includes('⌃');
|
||||
const needAlt = modifiers.includes('⌥');
|
||||
const needShift = modifiers.includes('Shift');
|
||||
|
||||
|
||||
if (e.metaKey !== needMeta) return false;
|
||||
if (e.ctrlKey !== needCtrl) return false;
|
||||
if (e.altKey !== needAlt) return false;
|
||||
@@ -288,13 +290,13 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
const needAlt = modifiers.includes('Alt');
|
||||
const needShift = modifiers.includes('Shift');
|
||||
const needMeta = modifiers.includes('Win');
|
||||
|
||||
|
||||
if (e.ctrlKey !== needCtrl) return false;
|
||||
if (e.altKey !== needAlt) return false;
|
||||
if (e.shiftKey !== needShift) return false;
|
||||
if (e.metaKey !== needMeta) return false;
|
||||
}
|
||||
|
||||
|
||||
const normalizeKey = (rawKey: string): string => {
|
||||
let normalizedKey = rawKey;
|
||||
if (normalizedKey === ' ') normalizedKey = 'Space';
|
||||
@@ -524,17 +526,17 @@ export interface RemoteFile {
|
||||
|
||||
export type WorkspaceNode =
|
||||
| {
|
||||
id: string;
|
||||
type: 'pane';
|
||||
sessionId: string;
|
||||
}
|
||||
id: string;
|
||||
type: 'pane';
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'split';
|
||||
direction: 'horizontal' | 'vertical';
|
||||
children: WorkspaceNode[];
|
||||
sizes?: number[]; // relative sizes for children
|
||||
};
|
||||
id: string;
|
||||
type: 'split';
|
||||
direction: 'horizontal' | 'vertical';
|
||||
children: WorkspaceNode[];
|
||||
sizes?: number[]; // relative sizes for children
|
||||
};
|
||||
|
||||
export type WorkspaceViewMode = 'split' | 'focus';
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
@@ -254,6 +254,44 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration for SFTP connections.
|
||||
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
|
||||
*/
|
||||
function buildSftpAlgorithms(legacyEnabled) {
|
||||
const algorithms = {
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
};
|
||||
|
||||
if (legacyEnabled) {
|
||||
algorithms.kex.push(
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
);
|
||||
algorithms.cipher.push(
|
||||
'aes128-cbc', 'aes256-cbc', '3des-cbc',
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
@@ -307,22 +345,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
@@ -726,6 +749,7 @@ async function openSftp(event, options) {
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Use the tunneled socket if we have one
|
||||
|
||||
@@ -173,6 +173,45 @@ const log = (msg, data) => {
|
||||
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration.
|
||||
* When legacyEnabled is true, legacy algorithms are appended to each list
|
||||
* (lower priority than modern ones) for compatibility with older network equipment.
|
||||
*/
|
||||
function buildAlgorithms(legacyEnabled) {
|
||||
const algorithms = {
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
};
|
||||
|
||||
if (legacyEnabled) {
|
||||
algorithms.kex.push(
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
);
|
||||
algorithms.cipher.push(
|
||||
'aes128-cbc', 'aes256-cbc', '3des-cbc',
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
// Session storage - shared reference passed from main
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
@@ -277,22 +316,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, password, and default key fallback
|
||||
@@ -465,22 +489,7 @@ async function startSSHSession(event, options) {
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Authentication for final target
|
||||
@@ -1470,8 +1479,8 @@ async function getServerStats(event, payload) {
|
||||
`cpuraw=$(awk '/^cpu / {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d %d", total, $5}' /proc/stat 2>/dev/null || echo "")`,
|
||||
// Get raw per-core CPU values from /proc/stat: "total:idle,total:idle,..."
|
||||
`percoreraw=$(awk '/^cpu[0-9]/ {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d:%d,", total, $5}' /proc/stat 2>/dev/null | sed 's/,$//' || echo "")`,
|
||||
// Get memory details from /proc/meminfo (total, free, buffers, cached in KB)
|
||||
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} END{printf "%d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024}' /proc/meminfo 2>/dev/null || echo "")`,
|
||||
// Get memory details from /proc/meminfo (total, free, buffers, cached, swapTotal, swapFree in KB)
|
||||
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} END{printf "%d %d %d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024, st/1024, sf/1024}' /proc/meminfo 2>/dev/null || echo "")`,
|
||||
// Get top 10 processes by memory - with BusyBox fallback
|
||||
// GNU ps: ps -eo pid,%mem,comm --sort=-%mem
|
||||
// BusyBox fallback: ps -o pid,vsz,comm and sort manually (BusyBox ps doesn't have %mem, use vsz instead)
|
||||
@@ -1525,6 +1534,8 @@ async function getServerStats(event, payload) {
|
||||
let memBuffers = null;
|
||||
let memCached = null;
|
||||
let memUsed = null;
|
||||
let swapTotal = null;
|
||||
let swapUsed = null;
|
||||
let topProcesses = []; // Array of { pid, memPercent, command }
|
||||
let disks = []; // Array of { mountPoint, used, total, percent }
|
||||
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
|
||||
@@ -1571,6 +1582,16 @@ async function getServerStats(event, payload) {
|
||||
memUsed = memTotal - memFree - memBuffers - memCached;
|
||||
if (memUsed < 0) memUsed = 0;
|
||||
}
|
||||
// Parse swap info (fields 5 and 6)
|
||||
if (memParts.length >= 6) {
|
||||
const st = parseInt(memParts[4], 10);
|
||||
const sf = parseInt(memParts[5], 10);
|
||||
if (!isNaN(st)) swapTotal = st;
|
||||
if (!isNaN(sf)) {
|
||||
swapUsed = (swapTotal !== null) ? swapTotal - sf : null;
|
||||
if (swapUsed !== null && swapUsed < 0) swapUsed = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (part.startsWith('PROCS:')) {
|
||||
const procsStr = part.substring(6).trim();
|
||||
@@ -1743,6 +1764,8 @@ async function getServerStats(event, payload) {
|
||||
memFree, // Free memory in MB
|
||||
memBuffers, // Buffers in MB
|
||||
memCached, // Cached in MB
|
||||
swapTotal, // Swap total in MB
|
||||
swapUsed, // Swap used in MB
|
||||
topProcesses, // Top 10 processes by memory
|
||||
diskPercent, // Disk usage percentage for root partition (backward compat)
|
||||
diskUsed, // Disk used in GB for root partition (backward compat)
|
||||
|
||||
@@ -35,6 +35,7 @@ const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
|
||||
const OAUTH_DEFAULT_WIDTH = 600;
|
||||
const OAUTH_DEFAULT_HEIGHT = 700;
|
||||
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
|
||||
const OAUTH_LOOPBACK_PORT = 45678; // must match electron/bridges/oauthBridge.cjs
|
||||
const WINDOW_STATE_FILE = "window-state.json";
|
||||
const DEFAULT_WINDOW_WIDTH = 1400;
|
||||
const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
@@ -368,6 +369,86 @@ function parseWindowOpenFeatures(features) {
|
||||
};
|
||||
}
|
||||
|
||||
function createExternalOnlyWindowOpenHandler(shell) {
|
||||
return (details) => {
|
||||
const targetUrl = details?.url;
|
||||
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
|
||||
try {
|
||||
void shell?.openExternal?.(targetUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return { action: "deny" };
|
||||
};
|
||||
}
|
||||
|
||||
function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
|
||||
const allowedPopupHosts = new Set([
|
||||
// OAuth (PKCE loopback)
|
||||
"accounts.google.com",
|
||||
"login.microsoftonline.com",
|
||||
"login.live.com",
|
||||
]);
|
||||
|
||||
const isAllowedInAppPopupUrl = (rawUrl) => {
|
||||
try {
|
||||
const u = new URL(String(rawUrl));
|
||||
if (u.protocol === "https:") {
|
||||
return allowedPopupHosts.has(u.hostname);
|
||||
}
|
||||
if (u.protocol === "http:") {
|
||||
// Allow ONLY the loopback OAuth callback page.
|
||||
const isLoopback =
|
||||
u.hostname === "127.0.0.1" || u.hostname === "localhost";
|
||||
return isLoopback && u.port === String(OAUTH_LOOPBACK_PORT) && u.pathname === "/oauth/callback";
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (details) => {
|
||||
const targetUrl = details?.url;
|
||||
if (!targetUrl || typeof targetUrl !== "string" || !/^https?:/i.test(targetUrl)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
// Default: open in system browser to reduce remote-content attack surface.
|
||||
if (!isAllowedInAppPopupUrl(targetUrl)) {
|
||||
try {
|
||||
void shell?.openExternal?.(targetUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
const size = parseWindowOpenFeatures(details?.features);
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
width: size.width || OAUTH_DEFAULT_WIDTH,
|
||||
height: size.height || OAUTH_DEFAULT_HEIGHT,
|
||||
minWidth: 420,
|
||||
minHeight: 560,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
autoHideMenuBar: true,
|
||||
menuBarVisible: false,
|
||||
title: "Netcatty Authorization",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
||||
sandbox: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function attachOAuthLoadingOverlay(win) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
|
||||
@@ -543,7 +624,7 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
* Create the main application window
|
||||
*/
|
||||
async function createWindow(electronModule, options) {
|
||||
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
|
||||
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
|
||||
|
||||
// Store app reference for window state persistence
|
||||
@@ -611,6 +692,35 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
mainWindow = win;
|
||||
|
||||
// Prevent top-level navigation away from the app origin. If a remote origin ever
|
||||
// loads in a privileged window (with preload), it can become an RCE vector.
|
||||
const allowedOrigins = new Set(["app://netcatty"]);
|
||||
if (isDev && devServerUrl) {
|
||||
try {
|
||||
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
|
||||
} catch {
|
||||
// ignore invalid dev server URL
|
||||
}
|
||||
}
|
||||
const isAllowedTopLevelUrl = (targetUrl) => {
|
||||
try {
|
||||
return allowedOrigins.has(new URL(String(targetUrl)).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const blockUntrustedNavigation = (event, targetUrl) => {
|
||||
if (isAllowedTopLevelUrl(targetUrl)) return;
|
||||
try {
|
||||
event.preventDefault();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
debugLog("Blocked navigation to untrusted origin", { targetUrl });
|
||||
};
|
||||
win.webContents.on("will-navigate", blockUntrustedNavigation);
|
||||
win.webContents.on("will-redirect", blockUntrustedNavigation);
|
||||
|
||||
// Restore maximized state if it was saved
|
||||
if (savedState?.isMaximized && !savedState?.isFullScreen) {
|
||||
win.once("ready-to-show", () => {
|
||||
@@ -732,36 +842,18 @@ async function createWindow(electronModule, options) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Never allow chained popups from remote content windows.
|
||||
try {
|
||||
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
attachOAuthLoadingOverlay(childWindow);
|
||||
});
|
||||
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
const url = details?.url;
|
||||
if (!url || !/^https?:/i.test(url)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
const size = parseWindowOpenFeatures(details?.features);
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
width: size.width || OAUTH_DEFAULT_WIDTH,
|
||||
height: size.height || OAUTH_DEFAULT_HEIGHT,
|
||||
minWidth: 420,
|
||||
minHeight: 560,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
autoHideMenuBar: true,
|
||||
menuBarVisible: false,
|
||||
title: "Netcatty Authorization",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
win.webContents.setWindowOpenHandler(
|
||||
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
|
||||
);
|
||||
|
||||
// Register window control handlers
|
||||
registerWindowHandlers(electronModule.ipcMain, nativeTheme);
|
||||
@@ -788,7 +880,7 @@ async function createWindow(electronModule, options) {
|
||||
* Create or focus the settings window
|
||||
*/
|
||||
async function openSettingsWindow(electronModule, options) {
|
||||
const { BrowserWindow } = electronModule;
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
// If settings window already exists, just focus it
|
||||
@@ -830,6 +922,52 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
settingsWindow = win;
|
||||
|
||||
// Open external links in system browser by default, and allow only known OAuth hosts in-app.
|
||||
try {
|
||||
win.webContents?.setWindowOpenHandler?.(
|
||||
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Never allow chained popups from remote content windows spawned from settings.
|
||||
win.webContents?.on?.("did-create-window", (childWindow) => {
|
||||
try {
|
||||
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Same navigation hardening as the main window (settings has preload access too).
|
||||
const allowedOrigins = new Set(["app://netcatty"]);
|
||||
if (isDev && devServerUrl) {
|
||||
try {
|
||||
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
|
||||
} catch {
|
||||
// ignore invalid dev server URL
|
||||
}
|
||||
}
|
||||
const isAllowedTopLevelUrl = (targetUrl) => {
|
||||
try {
|
||||
return allowedOrigins.has(new URL(String(targetUrl)).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const blockUntrustedNavigation = (event, targetUrl) => {
|
||||
if (isAllowedTopLevelUrl(targetUrl)) return;
|
||||
try {
|
||||
event.preventDefault();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
debugLog("Blocked navigation to untrusted origin (settings)", { targetUrl });
|
||||
};
|
||||
win.webContents.on("will-navigate", blockUntrustedNavigation);
|
||||
win.webContents.on("will-redirect", blockUntrustedNavigation);
|
||||
|
||||
if (isMac) {
|
||||
try {
|
||||
win.setWindowButtonVisibility(true);
|
||||
|
||||
@@ -289,7 +289,8 @@ const ensureKeyDir = async () => {
|
||||
const writeKeyToDisk = async (keyId, privateKey) => {
|
||||
if (!privateKey) return null;
|
||||
await ensureKeyDir();
|
||||
const filename = `${keyId || "temp"}.pem`;
|
||||
const safeId = String(keyId || "temp").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 120);
|
||||
const filename = `${safeId}.pem`;
|
||||
const target = path.join(keyRoot, filename);
|
||||
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
|
||||
try {
|
||||
@@ -644,9 +645,9 @@ const registerBridges = (win) => {
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
// Only allow deleting files in Netcatty temp directory for security
|
||||
const netcattyTempDir = tempDirBridge.getTempDir();
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(netcattyTempDir)) {
|
||||
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
|
||||
const resolvedPath = path.resolve(String(filePath || ""));
|
||||
if (!isPathInside(netcattyTempDir, resolvedPath)) {
|
||||
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
|
||||
return { success: false };
|
||||
}
|
||||
@@ -755,8 +756,24 @@ if (!gotLock) {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Re-create window on macOS dock click
|
||||
// Re-create or focus window on macOS dock click
|
||||
app.on("activate", () => {
|
||||
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
|
||||
// should bring it back. Fallback to creating a new window if none exists.
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
if (mainWin.isMinimized?.()) mainWin.restore();
|
||||
mainWin.show?.();
|
||||
mainWin.focus?.();
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (focusMainWindow()) return;
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
|
||||
@@ -854,4 +854,50 @@ const api = {
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
const existing = (typeof window !== "undefined" && window.netcatty) ? window.netcatty : {};
|
||||
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
|
||||
|
||||
function getAllowedRendererOrigins() {
|
||||
const origins = new Set(["app://netcatty"]);
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (typeof devServerUrl === "string" && devServerUrl.length > 0) {
|
||||
try {
|
||||
const u = new URL(devServerUrl);
|
||||
origins.add(u.origin);
|
||||
// Vite often binds to 0.0.0.0, but Chromium navigates via localhost.
|
||||
if (
|
||||
u.hostname === "0.0.0.0" ||
|
||||
u.hostname === "127.0.0.1" ||
|
||||
u.hostname === "::1" ||
|
||||
u.hostname === "[::1]" ||
|
||||
u.hostname === "::" ||
|
||||
u.hostname === "[::]"
|
||||
) {
|
||||
u.hostname = "localhost";
|
||||
origins.add(u.origin);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid dev URL
|
||||
}
|
||||
}
|
||||
return origins;
|
||||
}
|
||||
|
||||
function isTrustedRendererLocation(allowedOrigins) {
|
||||
try {
|
||||
const origin = window?.location?.origin;
|
||||
return typeof origin === "string" && allowedOrigins.has(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const allowedOrigins = getAllowedRendererOrigins();
|
||||
if (isTrustedRendererLocation(allowedOrigins)) {
|
||||
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
|
||||
} else {
|
||||
// If a window navigates to an untrusted origin, do NOT expose the bridge.
|
||||
try {
|
||||
console.warn("[Preload] Refusing to expose netcatty bridge to untrusted origin:", window?.location?.origin);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
4
global.d.ts
vendored
4
global.d.ts
vendored
@@ -72,6 +72,8 @@ declare global {
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
// Enable legacy SSH algorithms for older network equipment
|
||||
legacyAlgorithms?: boolean;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
}
|
||||
@@ -200,6 +202,8 @@ declare global {
|
||||
memFree: number | null; // Free memory in MB
|
||||
memBuffers: number | null; // Buffers in MB
|
||||
memCached: number | null; // Cached in MB
|
||||
swapTotal: number | null; // Total swap in MB
|
||||
swapUsed: number | null; // Used swap in MB
|
||||
topProcesses: Array<{ // Top 10 processes by memory
|
||||
pid: string;
|
||||
memPercent: number;
|
||||
|
||||
12
infrastructure/services/credentialProtection.ts
Normal file
12
infrastructure/services/credentialProtection.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { netcattyBridge } from "./netcattyBridge";
|
||||
|
||||
export const getCredentialProtectionAvailability = async (): Promise<boolean | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.credentialsAvailable) return null;
|
||||
|
||||
try {
|
||||
return await bridge.credentialsAvailable();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -6378,14 +6378,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0"
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0",
|
||||
"axios": "1.13.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user