feat: Improve dashboard API, improve tab system, various other fixes
This commit is contained in:
@@ -107,6 +107,9 @@ interface PendingTOTPSession {
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
@@ -459,30 +462,28 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
promptsCount: prompts.length,
|
||||
});
|
||||
|
||||
const totpPrompt = prompts.find((p) =>
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPrompt) {
|
||||
if (totpPromptIndex !== -1) {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
|
||||
if (pendingTOTPSessions[sessionId]) {
|
||||
fileLogger.warn(
|
||||
"TOTP session already exists, cleaning up old client",
|
||||
"TOTP session already exists, ignoring duplicate keyboard-interactive",
|
||||
{
|
||||
operation: "file_keyboard_interactive",
|
||||
hostId,
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
try {
|
||||
pendingTOTPSessions[sessionId].client.end();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
// Don't respond to duplicate keyboard-interactive events
|
||||
// The first one is still being processed
|
||||
return;
|
||||
}
|
||||
|
||||
pendingTOTPSessions[sessionId] = {
|
||||
@@ -496,19 +497,23 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
port,
|
||||
username,
|
||||
userId,
|
||||
prompts,
|
||||
totpPromptIndex,
|
||||
resolvedPassword: resolvedCredentials.password,
|
||||
};
|
||||
|
||||
fileLogger.info("Created TOTP session", {
|
||||
operation: "file_keyboard_interactive_totp",
|
||||
hostId,
|
||||
sessionId,
|
||||
prompt: totpPrompt.prompt,
|
||||
prompt: prompts[totpPromptIndex].prompt,
|
||||
promptsCount: prompts.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt: totpPrompt.prompt,
|
||||
prompt: prompts[totpPromptIndex].prompt,
|
||||
});
|
||||
} else {
|
||||
if (resolvedCredentials.password) {
|
||||
@@ -580,15 +585,40 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
sessionId,
|
||||
userId,
|
||||
codeLength: totpCode.length,
|
||||
promptsCount: session.prompts?.length || 0,
|
||||
});
|
||||
|
||||
session.finish([totpCode]);
|
||||
// Build responses for ALL prompts, just like in terminal.ts
|
||||
const responses = (session.prompts || []).map((p, index) => {
|
||||
if (index === session.totpPromptIndex) {
|
||||
return totpCode;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && session.resolvedPassword) {
|
||||
return session.resolvedPassword;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
fileLogger.info("Full keyboard-interactive response for file manager", {
|
||||
operation: "file_totp_full_response",
|
||||
sessionId,
|
||||
userId,
|
||||
totalPrompts: session.prompts?.length || 0,
|
||||
responsesProvided: responses.filter((r) => r !== "").length,
|
||||
});
|
||||
|
||||
let responseSent = false;
|
||||
let responseTimeout: NodeJS.Timeout;
|
||||
|
||||
session.client.on("ready", () => {
|
||||
// Remove old event listeners from /connect endpoint to avoid conflicts
|
||||
session.client.removeAllListeners("ready");
|
||||
session.client.removeAllListeners("error");
|
||||
|
||||
// CRITICAL: Attach event listeners BEFORE calling finish() to avoid race condition
|
||||
session.client.once("ready", () => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
clearTimeout(responseTimeout);
|
||||
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
|
||||
@@ -666,9 +696,10 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
session.client.on("error", (err) => {
|
||||
session.client.once("error", (err) => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
clearTimeout(responseTimeout);
|
||||
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
|
||||
@@ -682,13 +713,21 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
responseTimeout = setTimeout(() => {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
fileLogger.warn("TOTP verification timeout", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
res.status(408).json({ error: "TOTP verification timeout" });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// Now that event listeners are attached, submit the TOTP response
|
||||
session.finish(responses);
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
|
||||
|
||||
@@ -479,151 +479,155 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn.on("ready", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
sshConn!.shell(
|
||||
{
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
term: "xterm-256color",
|
||||
} as PseudoTtyOptions,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Shell error", err, {
|
||||
operation: "ssh_shell",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Shell error: " + err.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshStream = stream;
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
try {
|
||||
const utf8String = data.toString("utf-8");
|
||||
ws.send(JSON.stringify({ type: "data", data: utf8String }));
|
||||
} catch (error) {
|
||||
sshLogger.error("Error encoding terminal data", error, {
|
||||
operation: "terminal_data_encoding",
|
||||
// Small delay to let connection stabilize after keyboard-interactive auth
|
||||
// This helps prevent "No response from server" errors with TOTP
|
||||
setTimeout(() => {
|
||||
sshConn!.shell(
|
||||
{
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
term: "xterm-256color",
|
||||
} as PseudoTtyOptions,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Shell error", err, {
|
||||
operation: "ssh_shell",
|
||||
hostId: id,
|
||||
dataLength: data.length,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "data",
|
||||
data: data.toString("latin1"),
|
||||
type: "error",
|
||||
message: "Shell error: " + err.message,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
}),
|
||||
);
|
||||
});
|
||||
sshStream = stream;
|
||||
|
||||
stream.on("error", (err: Error) => {
|
||||
sshLogger.error("SSH stream error", err, {
|
||||
operation: "ssh_stream",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH stream error: " + err.message,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
setTimeout(() => {
|
||||
const command = `${executeCommand}\n`;
|
||||
stream.write(command);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||
);
|
||||
|
||||
// Log activity to homepage API
|
||||
if (id && hostConfig.userId) {
|
||||
(async () => {
|
||||
stream.on("data", (data: Buffer) => {
|
||||
try {
|
||||
// Fetch host name from database
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.id, id),
|
||||
eq(sshData.userId, hostConfig.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
hostConfig.userId!,
|
||||
const utf8String = data.toString("utf-8");
|
||||
ws.send(JSON.stringify({ type: "data", data: utf8String }));
|
||||
} catch (error) {
|
||||
sshLogger.error("Error encoding terminal data", error, {
|
||||
operation: "terminal_data_encoding",
|
||||
hostId: id,
|
||||
dataLength: data.length,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "data",
|
||||
data: data.toString("latin1"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${username}@${ip}:${port}`;
|
||||
stream.on("close", () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "terminal",
|
||||
stream.on("error", (err: Error) => {
|
||||
sshLogger.error("SSH stream error", err, {
|
||||
operation: "ssh_stream",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH stream error: " + err.message,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
setTimeout(() => {
|
||||
const command = `${executeCommand}\n`;
|
||||
stream.write(command);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||
);
|
||||
|
||||
// Log activity to homepage API
|
||||
if (id && hostConfig.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
// Fetch host name from database
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.id, id),
|
||||
eq(sshData.userId, hostConfig.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
hostConfig.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "terminal",
|
||||
hostId: id,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
sshLogger.info("Terminal activity logged", {
|
||||
operation: "activity_log",
|
||||
userId: hostConfig.userId,
|
||||
hostId: id,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
sshLogger.info("Terminal activity logged", {
|
||||
operation: "activity_log",
|
||||
userId: hostConfig.userId,
|
||||
hostId: id,
|
||||
hostName,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to log terminal activity", {
|
||||
operation: "activity_log_error",
|
||||
userId: hostConfig.userId,
|
||||
hostId: id,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to log terminal activity", {
|
||||
operation: "activity_log_error",
|
||||
userId: hostConfig.userId,
|
||||
hostId: id,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
}, 100); // Small delay to stabilize connection after keyboard-interactive auth
|
||||
});
|
||||
|
||||
sshConn.on("error", (err: Error) => {
|
||||
@@ -716,19 +720,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyboardInteractiveFinish = (totpResponses: string[]) => {
|
||||
const totpCode = (totpResponses[0] || "").trim();
|
||||
|
||||
sshLogger.info("TOTP response being sent to SSH server", {
|
||||
operation: "totp_verification",
|
||||
hostId: id,
|
||||
responseLength: totpCode.length,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[SSH TOTP Response] Host ${id}: TOTP code: "${totpCode}" (length: ${totpCode.length})`,
|
||||
);
|
||||
console.log(`[SSH TOTP Response] Calling finish() with array:`, [
|
||||
totpCode,
|
||||
]);
|
||||
|
||||
// Respond to ALL prompts, not just TOTP
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === totpPromptIndex) {
|
||||
@@ -740,9 +731,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return "";
|
||||
});
|
||||
|
||||
sshLogger.info("Full keyboard-interactive response", {
|
||||
operation: "totp_full_response",
|
||||
sshLogger.info("TOTP response being sent to SSH server", {
|
||||
operation: "totp_verification",
|
||||
hostId: id,
|
||||
totpCodeLength: totpCode.length,
|
||||
totalPrompts: prompts.length,
|
||||
responsesProvided: responses.filter((r) => r !== "").length,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user