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

View File

@@ -472,6 +472,14 @@ app.post("/docker/ssh/connect", async (req, res) => {
cleanupSession(sessionId);
}
// Clean up any stale pending TOTP sessions
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
} catch {}
delete pendingTOTPSessions[sessionId];
}
let resolvedCredentials: any = {
password: host.password,
sshKey: host.key,
@@ -552,9 +560,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
host: host.ip,
port: host.port || 22,
username: host.username,
tryKeyboard:
resolvedCredentials.authType === "none" ||
forceKeyboardInteractive === true,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
@@ -564,7 +570,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (resolvedCredentials.authType === "none") {
} else if (resolvedCredentials.authType === "password") {
if (!forceKeyboardInteractive && resolvedCredentials.password) {
if (resolvedCredentials.password) {
config.password = resolvedCredentials.password;
}
} else if (
@@ -689,37 +695,29 @@ app.post("/docker/ssh/connect", async (req, res) => {
);
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) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
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;
pendingTOTPSessions[sessionId] = {
@@ -768,36 +766,35 @@ app.post("/docker/ssh/connect", async (req, res) => {
resolvedCredentials.authType !== "none";
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",
});
if (responseSent) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
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 "";
});
finish(responses);
return;
}
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;
}
if (responseSent) return;
responseSent = true;
keyboardInteractiveResponded = true;
pendingTOTPSessions[sessionId] = {

View File

@@ -390,6 +390,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
if (sshSessions[sessionId]?.isConnected) {
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();
let resolvedCredentials = { password, sshKey, keyPassword, authType };
@@ -450,9 +459,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
host: ip,
port,
username,
tryKeyboard:
resolvedCredentials.authType === "none" ||
forceKeyboardInteractive === true,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
@@ -555,9 +562,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
.json({ error: "Password required for password authentication" });
}
if (!forceKeyboardInteractive) {
config.password = resolvedCredentials.password;
}
config.password = resolvedCredentials.password;
} else if (resolvedCredentials.authType === "none") {
} else {
fileLogger.warn(
@@ -684,37 +689,29 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
);
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) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
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;
pendingTOTPSessions[sessionId] = {
@@ -765,38 +762,29 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}
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) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
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;
pendingTOTPSessions[sessionId] = {

View File

@@ -456,6 +456,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
const totpCode = totpData.code;
totpAttempts++;
keyboardInteractiveFinish([totpCode]);
keyboardInteractiveFinish = null;
totpPromptSent = false;
} else {
sshLogger.warn("TOTP response received but no callback available", {
operation: "totp_response_error",
@@ -482,6 +484,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
const password = passwordData.code;
keyboardInteractiveFinish([password]);
keyboardInteractiveFinish = null;
} else {
sshLogger.warn(
"Password response received but no callback available",
@@ -988,29 +991,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (totpPromptIndex !== -1) {
if (totpPromptSent) {
if (totpAttempts >= 3) {
sshLogger.error("TOTP maximum attempts reached", {
operation: "ssh_keyboard_interactive_totp_max_attempts",
hostId: id,
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,
}),
);
sshLogger.warn("TOTP prompt asked again - ignoring duplicate", {
operation: "ssh_keyboard_interactive_totp_duplicate",
hostId: id,
prompts: promptTexts,
});
return;
}
totpPromptSent = true;
@@ -1032,19 +1017,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
finish(responses);
};
// Set timeout for TOTP response
totpTimeout = setTimeout(() => {
if (keyboardInteractiveFinish) {
keyboardInteractiveFinish = null;
totpPromptSent = false;
totpAttempts = 0;
sshLogger.warn("TOTP prompt timeout", {
operation: "totp_timeout",
hostId: id,
});
ws.send(
JSON.stringify({
type: "error",
message: "TOTP verification timeout",
code: "TOTP_TIMEOUT",
message: "TOTP verification timeout. Please reconnect.",
}),
);
cleanupSSH();
cleanupSSH(connectionTimeout);
}
}, 180000);
@@ -1082,6 +1070,25 @@ wss.on("connection", async (ws: WebSocket, req) => {
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(
JSON.stringify({
type: "password_required",
@@ -1107,9 +1114,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
host: ip,
port,
username,
tryKeyboard:
resolvedCredentials.authType === "none" ||
hostConfig.forceKeyboardInteractive === true,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 30000,
@@ -1191,9 +1196,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
if (!hostConfig.forceKeyboardInteractive) {
connectConfig.password = resolvedCredentials.password;
}
connectConfig.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.key
@@ -1385,6 +1388,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
clearTimeout(timeoutId);
}
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
}
if (sshStream) {
try {
sshStream.end();
@@ -1409,11 +1417,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn = null;
}
if (totpTimeout) {
clearTimeout(totpTimeout);
totpTimeout = null;
}
totpPromptSent = false;
totpAttempts = 0;
isKeyboardInteractive = false;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -218,6 +218,7 @@
"run": "Run",
"empty": "No snippets yet",
"emptyHint": "Create a snippet to save commonly used commands",
"searchSnippets": "Search snippets...",
"name": "Name",
"description": "Description",
"content": "Command",
@@ -283,7 +284,7 @@
"deleteSuccess": "Command deleted from history",
"deleteFailed": "Failed to 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.",
"dataAccessLockedReauth": "Data access locked. Please re-authenticate.",
"loading": "Loading command history...",
@@ -1417,7 +1418,7 @@
"connectToServer": "Connect to a Server",
"selectServerToEdit": "Select a server from the sidebar to start editing files",
"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}}\"?",
"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.",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ interface DockerManagerProps {
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
onClose?: () => void;
}
export function DockerManager({
@@ -40,6 +41,7 @@ export function DockerManager({
isVisible = true,
isTopbarOpen = true,
embedded = false,
onClose,
}: DockerManagerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
@@ -177,6 +179,7 @@ export function DockerManager({
);
setIsConnecting(false);
setIsValidating(false);
onClose?.();
} finally {
setIsConnecting(false);
}
@@ -347,6 +350,7 @@ export function DockerManager({
toast.error(error instanceof Error ? error.message : "Failed to connect");
setIsConnecting(false);
setIsValidating(false);
onClose?.();
} finally {
setIsConnecting(false);
}
@@ -355,6 +359,7 @@ export function DockerManager({
const handleAuthCancel = () => {
setShowAuthDialog(false);
setIsConnecting(false);
onClose?.();
};
const topMarginPx = isTopbarOpen ? 74 : 16;

View File

@@ -675,6 +675,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (reconnectAttempts.current > 0) {
attemptReconnection();
} else {
shouldNotReconnectRef.current = true;
if (onClose) {
onClose();
}
}
}
}, 35000);

View File

@@ -202,6 +202,7 @@ export function SSHToolsSidebar({
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
return shouldCollapse ? new Set() : new Set();
});
const [snippetSearchQuery, setSnippetSearchQuery] = useState("");
const [showFolderDialog, setShowFolderDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState<SnippetFolder | 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 || "";
if (!grouped.has(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">
<Button
onClick={handleCreate}

View File

@@ -368,6 +368,7 @@ export function AppView({
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
onClose={() => removeTab(t.id)}
/>
) : (
<FileManager