v1.10.0 #471

Merged
LukeGus merged 106 commits from dev-1.10.0 into main 2026-01-01 04:20:12 +00:00
33 changed files with 197 additions and 59673 deletions
Showing only changes of commit 7ed8ac625a - Show all commits
+54 -57
View File
@@ -472,6 +472,14 @@ app.post("/docker/ssh/connect", async (req, res) => {
cleanupSession(sessionId); cleanupSession(sessionId);
} }
// Clean up any stale pending TOTP sessions
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
} catch {}
delete pendingTOTPSessions[sessionId];
}
let resolvedCredentials: any = { let resolvedCredentials: any = {
password: host.password, password: host.password,
sshKey: host.key, sshKey: host.key,
@@ -552,9 +560,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
host: host.ip, host: host.ip,
port: host.port || 22, port: host.port || 22,
username: host.username, username: host.username,
tryKeyboard: tryKeyboard: true,
resolvedCredentials.authType === "none" ||
forceKeyboardInteractive === true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 60000, readyTimeout: 60000,
@@ -564,7 +570,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (resolvedCredentials.authType === "none") { if (resolvedCredentials.authType === "none") {
} else if (resolvedCredentials.authType === "password") { } else if (resolvedCredentials.authType === "password") {
if (!forceKeyboardInteractive && resolvedCredentials.password) { if (resolvedCredentials.password) {
config.password = resolvedCredentials.password; config.password = resolvedCredentials.password;
} }
} else if ( } else if (
@@ -689,37 +695,29 @@ app.post("/docker/ssh/connect", async (req, res) => {
); );
if (totpPromptIndex !== -1) { if (totpPromptIndex !== -1) {
if (pendingTOTPSessions[sessionId]) {
const existingSession = pendingTOTPSessions[sessionId];
if (existingSession.totpAttempts >= 3) {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
client.end();
res.status(401).json({
error: "Maximum TOTP attempts reached",
code: "TOTP_MAX_ATTEMPTS",
});
}
return;
}
existingSession.totpAttempts++;
if (!responseSent) {
responseSent = true;
res.json({
requires_totp: true,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
attempts_remaining: 3 - existingSession.totpAttempts,
});
}
return;
}
if (responseSent) { if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return; return;
} }
responseSent = true; responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
keyboardInteractiveResponded = true; keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {
@@ -768,36 +766,35 @@ app.post("/docker/ssh/connect", async (req, res) => {
resolvedCredentials.authType !== "none"; resolvedCredentials.authType !== "none";
if (!hasStoredPassword && passwordPromptIndex !== -1) { if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (pendingTOTPSessions[sessionId]) { if (responseSent) {
const existingSession = pendingTOTPSessions[sessionId]; const responses = prompts.map((p) => {
if (existingSession.totpAttempts >= 3) { if (
if (!responseSent) { /password/i.test(p.prompt) &&
responseSent = true; resolvedCredentials.password
delete pendingTOTPSessions[sessionId]; ) {
client.end(); return resolvedCredentials.password;
res.status(401).json({
error: "Maximum password attempts reached",
code: "PASSWORD_MAX_ATTEMPTS",
});
} }
return; return "";
} });
existingSession.totpAttempts++; finish(responses);
if (!responseSent) { return;
responseSent = true; }
res.json({ responseSent = true;
requires_totp: true,
sessionId, if (pendingTOTPSessions[sessionId]) {
prompt: prompts[passwordPromptIndex].prompt, const responses = prompts.map((p) => {
isPassword: true, if (
attempts_remaining: 3 - existingSession.totpAttempts, /password/i.test(p.prompt) &&
}); resolvedCredentials.password
} ) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return; return;
} }
if (responseSent) return;
responseSent = true;
keyboardInteractiveResponded = true; keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {
+49 -61
View File
@@ -390,6 +390,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
if (sshSessions[sessionId]?.isConnected) { if (sshSessions[sessionId]?.isConnected) {
cleanupSession(sessionId); cleanupSession(sessionId);
} }
// Clean up any stale pending TOTP sessions
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
} catch {}
delete pendingTOTPSessions[sessionId];
}
const client = new SSHClient(); const client = new SSHClient();
let resolvedCredentials = { password, sshKey, keyPassword, authType }; let resolvedCredentials = { password, sshKey, keyPassword, authType };
@@ -450,9 +459,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
host: ip, host: ip,
port, port,
username, username,
tryKeyboard: tryKeyboard: true,
resolvedCredentials.authType === "none" ||
forceKeyboardInteractive === true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 60000, readyTimeout: 60000,
@@ -555,9 +562,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
.json({ error: "Password required for password authentication" }); .json({ error: "Password required for password authentication" });
} }
if (!forceKeyboardInteractive) { config.password = resolvedCredentials.password;
config.password = resolvedCredentials.password;
}
} else if (resolvedCredentials.authType === "none") { } else if (resolvedCredentials.authType === "none") {
} else { } else {
fileLogger.warn( fileLogger.warn(
@@ -684,37 +689,29 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
); );
if (totpPromptIndex !== -1) { if (totpPromptIndex !== -1) {
if (pendingTOTPSessions[sessionId]) {
const existingSession = pendingTOTPSessions[sessionId];
if (existingSession.totpAttempts >= 3) {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
client.end();
res.status(401).json({
error: "Maximum TOTP attempts reached",
code: "TOTP_MAX_ATTEMPTS",
});
}
return;
}
existingSession.totpAttempts++;
if (!responseSent) {
responseSent = true;
res.json({
requires_totp: true,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
attempts_remaining: 3 - existingSession.totpAttempts,
});
}
return;
}
if (responseSent) { if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return; return;
} }
responseSent = true; responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
keyboardInteractiveResponded = true; keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {
@@ -765,38 +762,29 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
} }
if (!hasStoredPassword && passwordPromptIndex !== -1) { if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (pendingTOTPSessions[sessionId]) {
const existingSession = pendingTOTPSessions[sessionId];
if (existingSession.totpAttempts >= 3) {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
client.end();
res.status(401).json({
error: "Maximum password attempts reached",
code: "PASSWORD_MAX_ATTEMPTS",
});
}
return;
}
existingSession.totpAttempts++;
if (!responseSent) {
responseSent = true;
res.json({
requires_totp: true,
sessionId,
prompt: prompts[passwordPromptIndex].prompt,
isPassword: true,
attempts_remaining: 3 - existingSession.totpAttempts,
});
}
return;
}
if (responseSent) { if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return; return;
} }
responseSent = true; responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
keyboardInteractiveResponded = true; keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = { pendingTOTPSessions[sessionId] = {
+41 -38
View File
@@ -456,6 +456,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
const totpCode = totpData.code; const totpCode = totpData.code;
totpAttempts++; totpAttempts++;
keyboardInteractiveFinish([totpCode]); keyboardInteractiveFinish([totpCode]);
keyboardInteractiveFinish = null;
totpPromptSent = false;
} else { } else {
sshLogger.warn("TOTP response received but no callback available", { sshLogger.warn("TOTP response received but no callback available", {
operation: "totp_response_error", operation: "totp_response_error",
@@ -482,6 +484,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
const password = passwordData.code; const password = passwordData.code;
keyboardInteractiveFinish([password]); keyboardInteractiveFinish([password]);
keyboardInteractiveFinish = null;
} else { } else {
sshLogger.warn( sshLogger.warn(
"Password response received but no callback available", "Password response received but no callback available",
@@ -988,29 +991,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (totpPromptIndex !== -1) { if (totpPromptIndex !== -1) {
if (totpPromptSent) { if (totpPromptSent) {
if (totpAttempts >= 3) { sshLogger.warn("TOTP prompt asked again - ignoring duplicate", {
sshLogger.error("TOTP maximum attempts reached", { operation: "ssh_keyboard_interactive_totp_duplicate",
operation: "ssh_keyboard_interactive_totp_max_attempts", hostId: id,
hostId: id, prompts: promptTexts,
attempts: totpAttempts, });
});
ws.send(
JSON.stringify({
type: "error",
message: "Maximum TOTP attempts reached",
code: "TOTP_MAX_ATTEMPTS",
}),
);
cleanupSSH();
return;
}
ws.send(
JSON.stringify({
type: "totp_retry",
attempts_remaining: 3 - totpAttempts,
prompt: prompts[totpPromptIndex].prompt,
}),
);
return; return;
} }
totpPromptSent = true; totpPromptSent = true;
@@ -1032,19 +1017,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
finish(responses); finish(responses);
}; };
// Set timeout for TOTP response
totpTimeout = setTimeout(() => { totpTimeout = setTimeout(() => {
if (keyboardInteractiveFinish) { if (keyboardInteractiveFinish) {
keyboardInteractiveFinish = null; keyboardInteractiveFinish = null;
totpPromptSent = false; totpPromptSent = false;
totpAttempts = 0; sshLogger.warn("TOTP prompt timeout", {
operation: "totp_timeout",
hostId: id,
});
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "error", type: "error",
message: "TOTP verification timeout", message: "TOTP verification timeout. Please reconnect.",
code: "TOTP_TIMEOUT",
}), }),
); );
cleanupSSH(); cleanupSSH(connectionTimeout);
} }
}, 180000); }, 180000);
@@ -1082,6 +1070,25 @@ wss.on("connection", async (ws: WebSocket, req) => {
finish(responses); finish(responses);
}; };
// Set timeout for password response
totpTimeout = setTimeout(() => {
if (keyboardInteractiveFinish) {
keyboardInteractiveFinish = null;
keyboardInteractiveResponded = false;
sshLogger.warn("Password prompt timeout", {
operation: "password_timeout",
hostId: id,
});
ws.send(
JSON.stringify({
type: "error",
message: "Password verification timeout. Please reconnect.",
}),
);
cleanupSSH(connectionTimeout);
}
}, 180000);
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "password_required", type: "password_required",
@@ -1107,9 +1114,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
host: ip, host: ip,
port, port,
username, username,
tryKeyboard: tryKeyboard: true,
resolvedCredentials.authType === "none" ||
hostConfig.forceKeyboardInteractive === true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 30000, readyTimeout: 30000,
@@ -1191,9 +1196,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
if (!hostConfig.forceKeyboardInteractive) { connectConfig.password = resolvedCredentials.password;
connectConfig.password = resolvedCredentials.password;
}
} else if ( } else if (
resolvedCredentials.authType === "key" && resolvedCredentials.authType === "key" &&
resolvedCredentials.key resolvedCredentials.key
@@ -1385,6 +1388,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
}
if (sshStream) { if (sshStream) {
try { try {
sshStream.end(); sshStream.end();
@@ -1409,11 +1417,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn = null; sshConn = null;
} }
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
}
totpPromptSent = false; totpPromptSent = false;
totpAttempts = 0; totpAttempts = 0;
isKeyboardInteractive = false; isKeyboardInteractive = false;
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2380
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -218,6 +218,7 @@
"run": "Run", "run": "Run",
"empty": "No snippets yet", "empty": "No snippets yet",
"emptyHint": "Create a snippet to save commonly used commands", "emptyHint": "Create a snippet to save commonly used commands",
"searchSnippets": "Search snippets...",
"name": "Name", "name": "Name",
"description": "Description", "description": "Description",
"content": "Command", "content": "Command",
@@ -283,7 +284,7 @@
"deleteSuccess": "Command deleted from history", "deleteSuccess": "Command deleted from history",
"deleteFailed": "Failed to delete command.", "deleteFailed": "Failed to delete command.",
"deleteTooltip": "Delete command", "deleteTooltip": "Delete command",
"tabHint": "Use Tab in Terminal to autocomplete from command history", "tabHint": "Use Tab in Terminal to autocomplete from command history if enabled in User Profile",
"authRequiredRefresh": "Authentication required. Please refresh the page.", "authRequiredRefresh": "Authentication required. Please refresh the page.",
"dataAccessLockedReauth": "Data access locked. Please re-authenticate.", "dataAccessLockedReauth": "Data access locked. Please re-authenticate.",
"loading": "Loading command history...", "loading": "Loading command history...",
@@ -1417,7 +1418,7 @@
"connectToServer": "Connect to a Server", "connectToServer": "Connect to a Server",
"selectServerToEdit": "Select a server from the sidebar to start editing files", "selectServerToEdit": "Select a server from the sidebar to start editing files",
"fileOperations": "File Operations", "fileOperations": "File Operations",
"confirmDeleteMessage": "Are you sure you want to delete <strong>{{name}}</strong>?", "confirmDeleteMessage": "Are you sure you want to delete {{name}}?",
"confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?", "confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?",
"confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?", "confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?",
"confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.", "confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.",
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2374
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2378
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
-2381
View File
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ interface DockerManagerProps {
isVisible?: boolean; isVisible?: boolean;
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
embedded?: boolean; embedded?: boolean;
onClose?: () => void;
} }
export function DockerManager({ export function DockerManager({
@@ -40,6 +41,7 @@ export function DockerManager({
isVisible = true, isVisible = true,
isTopbarOpen = true, isTopbarOpen = true,
embedded = false, embedded = false,
onClose,
}: DockerManagerProps): React.ReactElement { }: DockerManagerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
@@ -177,6 +179,7 @@ export function DockerManager({
); );
setIsConnecting(false); setIsConnecting(false);
setIsValidating(false); setIsValidating(false);
onClose?.();
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }
@@ -347,6 +350,7 @@ export function DockerManager({
toast.error(error instanceof Error ? error.message : "Failed to connect"); toast.error(error instanceof Error ? error.message : "Failed to connect");
setIsConnecting(false); setIsConnecting(false);
setIsValidating(false); setIsValidating(false);
onClose?.();
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }
@@ -355,6 +359,7 @@ export function DockerManager({
const handleAuthCancel = () => { const handleAuthCancel = () => {
setShowAuthDialog(false); setShowAuthDialog(false);
setIsConnecting(false); setIsConnecting(false);
onClose?.();
}; };
const topMarginPx = isTopbarOpen ? 74 : 16; const topMarginPx = isTopbarOpen ? 74 : 16;
@@ -675,6 +675,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
if (reconnectAttempts.current > 0) { if (reconnectAttempts.current > 0) {
attemptReconnection(); attemptReconnection();
} else {
shouldNotReconnectRef.current = true;
if (onClose) {
onClose();
}
} }
} }
}, 35000); }, 35000);
+39 -1
View File
@@ -202,6 +202,7 @@ export function SSHToolsSidebar({
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false"; localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
return shouldCollapse ? new Set() : new Set(); return shouldCollapse ? new Set() : new Set();
}); });
const [snippetSearchQuery, setSnippetSearchQuery] = useState("");
const [showFolderDialog, setShowFolderDialog] = useState(false); const [showFolderDialog, setShowFolderDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState<SnippetFolder | null>( const [editingFolder, setEditingFolder] = useState<SnippetFolder | null>(
null, null,
@@ -772,7 +773,22 @@ export function SSHToolsSidebar({
} }
}); });
snippets.forEach((snippet) => { const filteredSnippets = snippetSearchQuery
? snippets.filter(
(snippet) =>
snippet.name
.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()) ||
snippet.content
.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()) ||
snippet.description
?.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()),
)
: snippets;
filteredSnippets.forEach((snippet) => {
const folderName = snippet.folder || ""; const folderName = snippet.folder || "";
if (!grouped.has(folderName)) { if (!grouped.has(folderName)) {
grouped.set(folderName, []); grouped.set(folderName, []);
@@ -1280,6 +1296,28 @@ export function SSHToolsSidebar({
</> </>
)} )}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("snippets.searchSnippets")}
value={snippetSearchQuery}
onChange={(e) => {
setSnippetSearchQuery(e.target.value);
}}
className="pl-10 pr-10"
/>
{snippetSearchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => setSnippetSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleCreate} onClick={handleCreate}
+1
View File
@@ -368,6 +368,7 @@ export function AppView({
isVisible={effectiveVisible} isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
embedded embedded
onClose={() => removeTab(t.id)}
/> />
) : ( ) : (
<FileManager <FileManager