feat: general bug fixes and readme updates
This commit is contained in:
@@ -145,6 +145,7 @@ channel, however, response times may be longer.
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
|
<img src="./repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
|
||||||
|
<img src="./repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -152,7 +153,7 @@ channel, however, response times may be longer.
|
|||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
</p>
|
</p>
|
||||||
Videos and images may be out of date.
|
Some videos and images may be out of date or may not perfectly showcase features.
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 407 KiB After Width: | Height: | Size: 355 KiB |
BIN
repo-images/Image 8.png
Normal file
BIN
repo-images/Image 8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
@@ -269,12 +269,6 @@ router.post(
|
|||||||
overrideCredentialUsername,
|
overrideCredentialUsername,
|
||||||
} = hostData;
|
} = hostData;
|
||||||
|
|
||||||
console.log("POST /db/ssh - Received SOCKS5 data:", {
|
|
||||||
useSocks5,
|
|
||||||
socks5Host,
|
|
||||||
socks5ProxyChain,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isNonEmptyString(userId) ||
|
!isNonEmptyString(userId) ||
|
||||||
!isNonEmptyString(ip) ||
|
!isNonEmptyString(ip) ||
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
|
import axios from "axios";
|
||||||
import { Client as SSHClient } from "ssh2";
|
import { Client as SSHClient } from "ssh2";
|
||||||
import type { ClientChannel } from "ssh2";
|
import type { ClientChannel } from "ssh2";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
@@ -9,6 +10,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import { logger } from "../utils/logger.js";
|
import { logger } from "../utils/logger.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
|
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||||
import type { AuthenticatedRequest, SSHHost } from "../../types/index.js";
|
import type { AuthenticatedRequest, SSHHost } from "../../types/index.js";
|
||||||
|
|
||||||
const dockerLogger = logger;
|
const dockerLogger = logger;
|
||||||
@@ -22,10 +24,41 @@ interface SSHSession {
|
|||||||
hostId?: number;
|
hostId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingTOTPSession {
|
||||||
|
client: SSHClient;
|
||||||
|
finish: (responses: string[]) => void;
|
||||||
|
config: any;
|
||||||
|
createdAt: number;
|
||||||
|
sessionId: string;
|
||||||
|
hostId?: number;
|
||||||
|
ip?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
userId?: string;
|
||||||
|
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||||
|
totpPromptIndex?: number;
|
||||||
|
resolvedPassword?: string;
|
||||||
|
totpAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
const sshSessions: Record<string, SSHSession> = {};
|
const sshSessions: Record<string, SSHSession> = {};
|
||||||
|
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||||
|
|
||||||
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
|
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
|
||||||
|
const session = pendingTOTPSessions[sessionId];
|
||||||
|
if (now - session.createdAt > 180000) {
|
||||||
|
try {
|
||||||
|
session.client.end();
|
||||||
|
} catch {}
|
||||||
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
function cleanupSession(sessionId: string) {
|
function cleanupSession(sessionId: string) {
|
||||||
const session = sshSessions[sessionId];
|
const session = sshSessions[sessionId];
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -336,7 +369,20 @@ app.use(authManager.createAuthMiddleware());
|
|||||||
|
|
||||||
// POST /docker/ssh/connect - Establish SSH session
|
// POST /docker/ssh/connect - Establish SSH session
|
||||||
app.post("/docker/ssh/connect", async (req, res) => {
|
app.post("/docker/ssh/connect", async (req, res) => {
|
||||||
const { sessionId, hostId } = req.body;
|
const {
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
userProvidedPassword,
|
||||||
|
userProvidedSshKey,
|
||||||
|
userProvidedKeyPassword,
|
||||||
|
forceKeyboardInteractive,
|
||||||
|
useSocks5,
|
||||||
|
socks5Host,
|
||||||
|
socks5Port,
|
||||||
|
socks5Username,
|
||||||
|
socks5Password,
|
||||||
|
socks5ProxyChain,
|
||||||
|
} = req.body;
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -433,6 +479,17 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
authType: host.authType,
|
authType: host.authType,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (userProvidedPassword) {
|
||||||
|
resolvedCredentials.password = userProvidedPassword;
|
||||||
|
}
|
||||||
|
if (userProvidedSshKey) {
|
||||||
|
resolvedCredentials.sshKey = userProvidedSshKey;
|
||||||
|
resolvedCredentials.authType = "key";
|
||||||
|
}
|
||||||
|
if (userProvidedKeyPassword) {
|
||||||
|
resolvedCredentials.keyPassword = userProvidedKeyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
const ownerId = host.userId;
|
const ownerId = host.userId;
|
||||||
|
|
||||||
@@ -495,7 +552,9 @@ 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: true,
|
tryKeyboard:
|
||||||
|
resolvedCredentials.authType === "none" ||
|
||||||
|
forceKeyboardInteractive === true,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 60000,
|
readyTimeout: 60000,
|
||||||
@@ -503,15 +562,30 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (resolvedCredentials.authType === "none") {
|
||||||
resolvedCredentials.authType === "password" &&
|
} else if (resolvedCredentials.authType === "password") {
|
||||||
resolvedCredentials.password
|
if (!forceKeyboardInteractive && resolvedCredentials.password) {
|
||||||
) {
|
|
||||||
config.password = resolvedCredentials.password;
|
config.password = resolvedCredentials.password;
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
resolvedCredentials.authType === "key" &&
|
resolvedCredentials.authType === "key" &&
|
||||||
resolvedCredentials.sshKey
|
resolvedCredentials.sshKey
|
||||||
) {
|
) {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!resolvedCredentials.sshKey.includes("-----BEGIN") ||
|
||||||
|
!resolvedCredentials.sshKey.includes("-----END")
|
||||||
|
) {
|
||||||
|
dockerLogger.error("Invalid SSH key format", {
|
||||||
|
operation: "docker_connect",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid private key format",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cleanKey = resolvedCredentials.sshKey
|
const cleanKey = resolvedCredentials.sshKey
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\r\n/g, "\n")
|
.replace(/\r\n/g, "\n")
|
||||||
@@ -520,9 +594,32 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
if (resolvedCredentials.keyPassword) {
|
if (resolvedCredentials.keyPassword) {
|
||||||
config.passphrase = resolvedCredentials.keyPassword;
|
config.passphrase = resolvedCredentials.keyPassword;
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
dockerLogger.error("SSH key processing error", error, {
|
||||||
|
operation: "docker_connect",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "SSH key format error: Invalid private key format",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (resolvedCredentials.authType === "key") {
|
||||||
|
dockerLogger.error(
|
||||||
|
"SSH key authentication requested but no key provided",
|
||||||
|
{
|
||||||
|
operation: "docker_connect",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "SSH key authentication requested but no key provided",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let responseSent = false;
|
let responseSent = false;
|
||||||
|
let keyboardInteractiveResponded = false;
|
||||||
|
|
||||||
client.on("ready", () => {
|
client.on("ready", () => {
|
||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
@@ -552,10 +649,21 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolvedCredentials.authType === "none" &&
|
||||||
|
(err.message.includes("authentication") ||
|
||||||
|
err.message.includes("All configured authentication methods failed"))
|
||||||
|
) {
|
||||||
|
res.json({
|
||||||
|
status: "auth_required",
|
||||||
|
reason: "no_keyboard",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: err.message || "SSH connection failed",
|
message: err.message || "SSH connection failed",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("close", () => {
|
client.on("close", () => {
|
||||||
@@ -565,7 +673,214 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (host.jumpHosts && host.jumpHosts.length > 0) {
|
client.on(
|
||||||
|
"keyboard-interactive",
|
||||||
|
(
|
||||||
|
name: string,
|
||||||
|
instructions: string,
|
||||||
|
instructionsLang: string,
|
||||||
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||||
|
finish: (responses: string[]) => void,
|
||||||
|
) => {
|
||||||
|
const totpPromptIndex = prompts.findIndex((p) =>
|
||||||
|
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||||
|
p.prompt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responseSent = true;
|
||||||
|
keyboardInteractiveResponded = true;
|
||||||
|
|
||||||
|
pendingTOTPSessions[sessionId] = {
|
||||||
|
client,
|
||||||
|
finish,
|
||||||
|
config,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
ip: host.ip,
|
||||||
|
port: host.port || 22,
|
||||||
|
username: host.username,
|
||||||
|
userId,
|
||||||
|
prompts,
|
||||||
|
totpPromptIndex,
|
||||||
|
resolvedPassword: resolvedCredentials.password,
|
||||||
|
totpAttempts: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
requires_totp: true,
|
||||||
|
sessionId,
|
||||||
|
prompt: prompts[totpPromptIndex].prompt,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||||
|
/password/i.test(p.prompt),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolvedCredentials.authType === "none" &&
|
||||||
|
passwordPromptIndex !== -1
|
||||||
|
) {
|
||||||
|
if (responseSent) return;
|
||||||
|
responseSent = true;
|
||||||
|
client.end();
|
||||||
|
res.json({
|
||||||
|
status: "auth_required",
|
||||||
|
reason: "no_keyboard",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasStoredPassword =
|
||||||
|
resolvedCredentials.password &&
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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) return;
|
||||||
|
responseSent = true;
|
||||||
|
keyboardInteractiveResponded = true;
|
||||||
|
|
||||||
|
pendingTOTPSessions[sessionId] = {
|
||||||
|
client,
|
||||||
|
finish,
|
||||||
|
config,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
ip: host.ip,
|
||||||
|
port: host.port || 22,
|
||||||
|
username: host.username,
|
||||||
|
userId,
|
||||||
|
prompts,
|
||||||
|
totpPromptIndex: passwordPromptIndex,
|
||||||
|
resolvedPassword: resolvedCredentials.password,
|
||||||
|
totpAttempts: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
requires_totp: true,
|
||||||
|
sessionId,
|
||||||
|
prompt: prompts[passwordPromptIndex].prompt,
|
||||||
|
isPassword: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = prompts.map((p) => {
|
||||||
|
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||||
|
return resolvedCredentials.password;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
finish(responses);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
useSocks5 &&
|
||||||
|
(socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const socks5Socket = await createSocks5Connection(
|
||||||
|
host.ip,
|
||||||
|
host.port || 22,
|
||||||
|
{
|
||||||
|
useSocks5,
|
||||||
|
socks5Host,
|
||||||
|
socks5Port,
|
||||||
|
socks5Username,
|
||||||
|
socks5Password,
|
||||||
|
socks5ProxyChain: socks5ProxyChain as any,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (socks5Socket) {
|
||||||
|
config.sock = socks5Socket;
|
||||||
|
client.connect(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (socks5Error) {
|
||||||
|
dockerLogger.error("SOCKS5 connection failed", socks5Error, {
|
||||||
|
operation: "docker_socks5_connect",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
proxyHost: socks5Host,
|
||||||
|
proxyPort: socks5Port || 1080,
|
||||||
|
});
|
||||||
|
if (!responseSent) {
|
||||||
|
responseSent = true;
|
||||||
|
return res.status(500).json({
|
||||||
|
error:
|
||||||
|
"SOCKS5 proxy connection failed: " +
|
||||||
|
(socks5Error instanceof Error
|
||||||
|
? socks5Error.message
|
||||||
|
: "Unknown error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (host.jumpHosts && host.jumpHosts.length > 0) {
|
||||||
const jumpClient = await createJumpHostChain(
|
const jumpClient = await createJumpHostChain(
|
||||||
host.jumpHosts as Array<{ hostId: number }>,
|
host.jumpHosts as Array<{ hostId: number }>,
|
||||||
userId,
|
userId,
|
||||||
@@ -633,6 +948,169 @@ app.post("/docker/ssh/disconnect", async (req, res) => {
|
|||||||
res.json({ success: true, message: "SSH session disconnected" });
|
res.json({ success: true, message: "SSH session disconnected" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /docker/ssh/connect-totp - Verify TOTP and complete connection
|
||||||
|
app.post("/docker/ssh/connect-totp", async (req, res) => {
|
||||||
|
const { sessionId, totpCode } = req.body;
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
dockerLogger.error("TOTP verification rejected: no authenticated user", {
|
||||||
|
operation: "docker_totp_auth",
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId || !totpCode) {
|
||||||
|
return res.status(400).json({ error: "Session ID and TOTP code required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = pendingTOTPSessions[sessionId];
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
dockerLogger.warn("TOTP session not found or expired", {
|
||||||
|
operation: "docker_totp_verify",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
availableSessions: Object.keys(pendingTOTPSessions),
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({ error: "TOTP session expired. Please reconnect." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - session.createdAt > 180000) {
|
||||||
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
try {
|
||||||
|
session.client.end();
|
||||||
|
} catch {}
|
||||||
|
dockerLogger.warn("TOTP session timeout before code submission", {
|
||||||
|
operation: "docker_totp_verify",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
age: Date.now() - session.createdAt,
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
.status(408)
|
||||||
|
.json({ error: "TOTP session timeout. Please reconnect." });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "";
|
||||||
|
});
|
||||||
|
|
||||||
|
let responseSent = false;
|
||||||
|
let responseTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
session.client.once("ready", () => {
|
||||||
|
if (responseSent) return;
|
||||||
|
responseSent = true;
|
||||||
|
clearTimeout(responseTimeout);
|
||||||
|
|
||||||
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
sshSessions[sessionId] = {
|
||||||
|
client: session.client,
|
||||||
|
isConnected: true,
|
||||||
|
lastActive: Date.now(),
|
||||||
|
activeOperations: 0,
|
||||||
|
hostId: session.hostId,
|
||||||
|
};
|
||||||
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: "success",
|
||||||
|
message: "TOTP verified, SSH connection established",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.hostId && session.userId) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const hosts = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshData.id, session.hostId!),
|
||||||
|
eq(sshData.userId, session.userId!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ssh_data",
|
||||||
|
session.userId!,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hostName =
|
||||||
|
hosts.length > 0 && hosts[0].name
|
||||||
|
? hosts[0].name
|
||||||
|
: `${session.username}@${session.ip}:${session.port}`;
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
"http://localhost:30006/activity/log",
|
||||||
|
{
|
||||||
|
type: "docker",
|
||||||
|
hostId: session.hostId,
|
||||||
|
hostName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
dockerLogger.warn("Failed to log Docker activity (TOTP)", {
|
||||||
|
operation: "activity_log_error",
|
||||||
|
userId: session.userId,
|
||||||
|
hostId: session.hostId,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
session.client.once("error", (err) => {
|
||||||
|
if (responseSent) return;
|
||||||
|
responseSent = true;
|
||||||
|
clearTimeout(responseTimeout);
|
||||||
|
|
||||||
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
|
||||||
|
dockerLogger.error("TOTP verification failed", {
|
||||||
|
operation: "docker_totp_verify",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
|
||||||
|
});
|
||||||
|
|
||||||
|
responseTimeout = setTimeout(() => {
|
||||||
|
if (!responseSent) {
|
||||||
|
responseSent = true;
|
||||||
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
dockerLogger.warn("TOTP verification timeout", {
|
||||||
|
operation: "docker_totp_verify",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(408).json({ error: "TOTP verification timeout" });
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
session.finish(responses);
|
||||||
|
});
|
||||||
|
|
||||||
// POST /docker/ssh/keepalive - Keep session alive
|
// POST /docker/ssh/keepalive - Keep session alive
|
||||||
app.post("/docker/ssh/keepalive", async (req, res) => {
|
app.post("/docker/ssh/keepalive", async (req, res) => {
|
||||||
const { sessionId } = req.body;
|
const { sessionId } = req.body;
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ interface PendingTOTPSession {
|
|||||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||||
totpPromptIndex?: number;
|
totpPromptIndex?: number;
|
||||||
resolvedPassword?: string;
|
resolvedPassword?: string;
|
||||||
|
totpAttempts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sshSessions: Record<string, SSHSession> = {};
|
const sshSessions: Record<string, SSHSession> = {};
|
||||||
@@ -449,7 +450,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
host: ip,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
tryKeyboard: true,
|
tryKeyboard:
|
||||||
|
resolvedCredentials.authType === "none" ||
|
||||||
|
forceKeyboardInteractive === true,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 60000,
|
readyTimeout: 60000,
|
||||||
@@ -681,29 +684,37 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (totpPromptIndex !== -1) {
|
if (totpPromptIndex !== -1) {
|
||||||
if (responseSent) {
|
if (pendingTOTPSessions[sessionId]) {
|
||||||
const responses = prompts.map((p) => {
|
const existingSession = pendingTOTPSessions[sessionId];
|
||||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
if (existingSession.totpAttempts >= 3) {
|
||||||
return resolvedCredentials.password;
|
if (!responseSent) {
|
||||||
}
|
responseSent = true;
|
||||||
return "";
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
client.end();
|
||||||
|
res.status(401).json({
|
||||||
|
error: "Maximum TOTP attempts reached",
|
||||||
|
code: "TOTP_MAX_ATTEMPTS",
|
||||||
});
|
});
|
||||||
finish(responses);
|
}
|
||||||
|
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) {
|
||||||
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] = {
|
||||||
@@ -720,6 +731,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
prompts,
|
prompts,
|
||||||
totpPromptIndex,
|
totpPromptIndex,
|
||||||
resolvedPassword: resolvedCredentials.password,
|
resolvedPassword: resolvedCredentials.password,
|
||||||
|
totpAttempts: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -753,29 +765,38 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||||
if (responseSent) {
|
if (pendingTOTPSessions[sessionId]) {
|
||||||
const responses = prompts.map((p) => {
|
const existingSession = pendingTOTPSessions[sessionId];
|
||||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
if (existingSession.totpAttempts >= 3) {
|
||||||
return resolvedCredentials.password;
|
if (!responseSent) {
|
||||||
}
|
responseSent = true;
|
||||||
return "";
|
delete pendingTOTPSessions[sessionId];
|
||||||
|
client.end();
|
||||||
|
res.status(401).json({
|
||||||
|
error: "Maximum password attempts reached",
|
||||||
|
code: "PASSWORD_MAX_ATTEMPTS",
|
||||||
});
|
});
|
||||||
finish(responses);
|
}
|
||||||
|
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) {
|
||||||
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] = {
|
||||||
@@ -792,6 +813,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
prompts,
|
prompts,
|
||||||
totpPromptIndex: passwordPromptIndex,
|
totpPromptIndex: passwordPromptIndex,
|
||||||
resolvedPassword: resolvedCredentials.password,
|
resolvedPassword: resolvedCredentials.password,
|
||||||
|
totpAttempts: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -331,6 +331,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
let sshStream: ClientChannel | null = null;
|
let sshStream: ClientChannel | null = null;
|
||||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||||
let totpPromptSent = false;
|
let totpPromptSent = false;
|
||||||
|
let totpAttempts = 0;
|
||||||
|
let totpTimeout: NodeJS.Timeout | null = null;
|
||||||
let isKeyboardInteractive = false;
|
let isKeyboardInteractive = false;
|
||||||
let keyboardInteractiveResponded = false;
|
let keyboardInteractiveResponded = false;
|
||||||
let isConnecting = false;
|
let isConnecting = false;
|
||||||
@@ -447,9 +449,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
case "totp_response": {
|
case "totp_response": {
|
||||||
const totpData = data as TOTPResponseData;
|
const totpData = data as TOTPResponseData;
|
||||||
if (keyboardInteractiveFinish && totpData?.code) {
|
if (keyboardInteractiveFinish && totpData?.code) {
|
||||||
|
if (totpTimeout) {
|
||||||
|
clearTimeout(totpTimeout);
|
||||||
|
totpTimeout = null;
|
||||||
|
}
|
||||||
const totpCode = totpData.code;
|
const totpCode = totpData.code;
|
||||||
|
totpAttempts++;
|
||||||
keyboardInteractiveFinish([totpCode]);
|
keyboardInteractiveFinish([totpCode]);
|
||||||
keyboardInteractiveFinish = null;
|
|
||||||
} 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",
|
||||||
@@ -470,9 +476,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
case "password_response": {
|
case "password_response": {
|
||||||
const passwordData = data as TOTPResponseData;
|
const passwordData = data as TOTPResponseData;
|
||||||
if (keyboardInteractiveFinish && passwordData?.code) {
|
if (keyboardInteractiveFinish && passwordData?.code) {
|
||||||
|
if (totpTimeout) {
|
||||||
|
clearTimeout(totpTimeout);
|
||||||
|
totpTimeout = null;
|
||||||
|
}
|
||||||
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",
|
||||||
@@ -609,6 +618,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
isConnecting,
|
isConnecting,
|
||||||
isConnected,
|
isConnected,
|
||||||
});
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Connection already in progress",
|
||||||
|
code: "DUPLICATE_CONNECTION",
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,11 +988,29 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
if (totpPromptIndex !== -1) {
|
if (totpPromptIndex !== -1) {
|
||||||
if (totpPromptSent) {
|
if (totpPromptSent) {
|
||||||
sshLogger.warn("TOTP prompt asked again - ignoring duplicate", {
|
if (totpAttempts >= 3) {
|
||||||
operation: "ssh_keyboard_interactive_totp_duplicate",
|
sshLogger.error("TOTP maximum attempts reached", {
|
||||||
|
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;
|
||||||
@@ -997,6 +1031,23 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
finish(responses);
|
finish(responses);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
totpTimeout = setTimeout(() => {
|
||||||
|
if (keyboardInteractiveFinish) {
|
||||||
|
keyboardInteractiveFinish = null;
|
||||||
|
totpPromptSent = false;
|
||||||
|
totpAttempts = 0;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "TOTP verification timeout",
|
||||||
|
code: "TOTP_TIMEOUT",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cleanupSSH();
|
||||||
|
}
|
||||||
|
}, 180000);
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "totp_required",
|
type: "totp_required",
|
||||||
@@ -1056,7 +1107,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
host: ip,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
tryKeyboard: true,
|
tryKeyboard:
|
||||||
|
resolvedCredentials.authType === "none" ||
|
||||||
|
hostConfig.forceKeyboardInteractive === true,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
readyTimeout: 30000,
|
readyTimeout: 30000,
|
||||||
@@ -1356,16 +1409,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
sshConn = null;
|
sshConn = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totpTimeout) {
|
||||||
|
clearTimeout(totpTimeout);
|
||||||
|
totpTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
totpPromptSent = false;
|
totpPromptSent = false;
|
||||||
|
totpAttempts = 0;
|
||||||
isKeyboardInteractive = false;
|
isKeyboardInteractive = false;
|
||||||
keyboardInteractiveResponded = false;
|
keyboardInteractiveResponded = false;
|
||||||
keyboardInteractiveFinish = null;
|
keyboardInteractiveFinish = null;
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
isConnected = false;
|
isConnected = false;
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
isCleaningUp = false;
|
isCleaningUp = false;
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: PTY-level keepalive (writing \x00 to the stream) was removed.
|
// Note: PTY-level keepalive (writing \x00 to the stream) was removed.
|
||||||
|
|||||||
@@ -349,11 +349,11 @@ function resetRetryState(tunnelName: string): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDisconnect(
|
async function handleDisconnect(
|
||||||
tunnelName: string,
|
tunnelName: string,
|
||||||
tunnelConfig: TunnelConfig | null,
|
tunnelConfig: TunnelConfig | null,
|
||||||
shouldRetry = true,
|
shouldRetry = true,
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
if (tunnelVerifications.has(tunnelName)) {
|
||||||
try {
|
try {
|
||||||
const verification = tunnelVerifications.get(tunnelName);
|
const verification = tunnelVerifications.get(tunnelName);
|
||||||
@@ -363,7 +363,11 @@ function handleDisconnect(
|
|||||||
tunnelVerifications.delete(tunnelName);
|
tunnelVerifications.delete(tunnelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupTunnelResources(tunnelName);
|
while (cleanupInProgress.has(tunnelName)) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupTunnelResources(tunnelName);
|
||||||
|
|
||||||
if (manualDisconnects.has(tunnelName)) {
|
if (manualDisconnects.has(tunnelName)) {
|
||||||
resetRetryState(tunnelName);
|
resetRetryState(tunnelName);
|
||||||
|
|||||||
@@ -70,12 +70,7 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.debug("Failed to collect recent login stats", {
|
|
||||||
operation: "recent_login_stats_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const failedOut = await execCommand(
|
const failedOut = await execCommand(
|
||||||
@@ -131,12 +126,7 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.debug("Failed to collect failed login stats", {
|
|
||||||
operation: "failed_login_stats_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recentLogins: recentLogins.slice(0, 10),
|
recentLogins: recentLogins.slice(0, 10),
|
||||||
|
|||||||
@@ -68,12 +68,7 @@ export async function collectNetworkMetrics(client: Client): Promise<{
|
|||||||
txBytes: null,
|
txBytes: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.debug("Failed to collect network interface stats", {
|
|
||||||
operation: "network_stats_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { interfaces };
|
return { interfaces };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,7 @@ export async function collectProcessesMetrics(client: Client): Promise<{
|
|||||||
|
|
||||||
const runningCount2 = Number(runningCount.stdout.trim());
|
const runningCount2 = Number(runningCount.stdout.trim());
|
||||||
runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null;
|
runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null;
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.debug("Failed to collect process stats", {
|
|
||||||
operation: "process_stats_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: totalProcesses,
|
total: totalProcesses,
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ export async function collectSystemMetrics(client: Client): Promise<{
|
|||||||
kernel = kernelOut.stdout.trim() || null;
|
kernel = kernelOut.stdout.trim() || null;
|
||||||
os = osOut.stdout.trim() || null;
|
os = osOut.stdout.trim() || null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statsLogger.debug("Failed to collect system info", {
|
// No error log
|
||||||
operation: "system_info_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,12 +21,7 @@ export async function collectUptimeMetrics(client: Client): Promise<{
|
|||||||
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
statsLogger.debug("Failed to collect uptime", {
|
|
||||||
operation: "uptime_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seconds: uptimeSeconds,
|
seconds: uptimeSeconds,
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ export function useConfirmation() {
|
|||||||
variantOrConfirmLabel === "destructive";
|
variantOrConfirmLabel === "destructive";
|
||||||
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
|
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
|
||||||
|
|
||||||
if (typeof opts === "string" && callback) {
|
if (typeof opts === "string") {
|
||||||
toast(opts, {
|
toast(opts, {
|
||||||
action: {
|
action: {
|
||||||
label: confirmLabel,
|
label: confirmLabel,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
callback();
|
if (callback) callback();
|
||||||
resolve(true);
|
resolve(true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -63,7 +63,7 @@ export function useConfirmation() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
} else if (typeof opts === "object" && callback) {
|
} else if (typeof opts === "object") {
|
||||||
const actualConfirmLabel = opts.confirmText || confirmLabel;
|
const actualConfirmLabel = opts.confirmText || confirmLabel;
|
||||||
const actualCancelLabel = opts.cancelText || cancelLabel;
|
const actualCancelLabel = opts.cancelText || cancelLabel;
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export function useConfirmation() {
|
|||||||
action: {
|
action: {
|
||||||
label: actualConfirmLabel,
|
label: actualConfirmLabel,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
callback();
|
if (callback) callback();
|
||||||
resolve(true);
|
resolve(true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -473,6 +473,7 @@
|
|||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
|
"docker": "Docker",
|
||||||
"tunnels": "Tunnels",
|
"tunnels": "Tunnels",
|
||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
"serverStats": "Server Stats",
|
"serverStats": "Server Stats",
|
||||||
@@ -2402,6 +2403,8 @@
|
|||||||
"disconnectedFromContainer": "Disconnected from container",
|
"disconnectedFromContainer": "Disconnected from container",
|
||||||
"containerMustBeRunning": "Container must be running to access console",
|
"containerMustBeRunning": "Container must be running to access console",
|
||||||
"authenticationRequired": "Authentication required",
|
"authenticationRequired": "Authentication required",
|
||||||
|
"verificationCodePrompt": "Enter verification code",
|
||||||
|
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||||
"connectedTo": "Connected to {{containerName}}",
|
"connectedTo": "Connected to {{containerName}}",
|
||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"consoleError": "Console error",
|
"consoleError": "Console error",
|
||||||
|
|||||||
@@ -552,6 +552,7 @@ export interface AlertManagerProps {
|
|||||||
|
|
||||||
export interface SSHTunnelObjectProps {
|
export interface SSHTunnelObjectProps {
|
||||||
host: SSHHost;
|
host: SSHHost;
|
||||||
|
tunnelIndex?: number;
|
||||||
tunnelStatuses: Record<string, TunnelStatus>;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
tunnelActions: Record<string, boolean>;
|
tunnelActions: Record<string, boolean>;
|
||||||
onTunnelAction: (
|
onTunnelAction: (
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ function AppContent() {
|
|||||||
style={{
|
style={{
|
||||||
background: "var(--bg-elevated)",
|
background: "var(--bg-elevated)",
|
||||||
backgroundImage: `repeating-linear-gradient(
|
backgroundImage: `repeating-linear-gradient(
|
||||||
225deg,
|
45deg,
|
||||||
transparent,
|
transparent,
|
||||||
transparent 35px,
|
transparent 35px,
|
||||||
${lineColor} 35px,
|
${lineColor} 35px,
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ export function RolesTab(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Roles Section */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
@@ -149,6 +148,15 @@ export function RolesTab(): React.ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-xs"
|
||||||
|
onClick={() => window.open("https://docs.termix.site/rbac", "_blank")}
|
||||||
|
>
|
||||||
|
{t("common.documentation")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ import {
|
|||||||
listDockerContainers,
|
listDockerContainers,
|
||||||
validateDockerAvailability,
|
validateDockerAvailability,
|
||||||
keepaliveDockerSession,
|
keepaliveDockerSession,
|
||||||
|
verifyDockerTOTP,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||||
import { ContainerList } from "./components/ContainerList.tsx";
|
import { ContainerList } from "./components/ContainerList.tsx";
|
||||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||||
|
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||||
|
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||||
|
|
||||||
interface DockerManagerProps {
|
interface DockerManagerProps {
|
||||||
hostConfig?: SSHHost;
|
hostConfig?: SSHHost;
|
||||||
@@ -53,6 +56,13 @@ export function DockerManager({
|
|||||||
const [isValidating, setIsValidating] = React.useState(false);
|
const [isValidating, setIsValidating] = React.useState(false);
|
||||||
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
|
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
|
||||||
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
|
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
|
||||||
|
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||||
|
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||||
|
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||||
|
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
|
||||||
|
const [authReason, setAuthReason] = React.useState<
|
||||||
|
"no_keyboard" | "auth_failed" | "timeout"
|
||||||
|
>("no_keyboard");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||||
@@ -103,17 +113,52 @@ export function DockerManager({
|
|||||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
}, [hostConfig?.id]);
|
}, [hostConfig?.id]);
|
||||||
|
|
||||||
|
const initializingRef = React.useRef(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const initSession = async () => {
|
const initSession = async () => {
|
||||||
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
|
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initializingRef.current) return;
|
||||||
|
initializingRef.current = true;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
initializingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsConnecting(true);
|
setIsConnecting(true);
|
||||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await connectDockerSession(sid, currentHostConfig.id);
|
const result = await connectDockerSession(sid, currentHostConfig.id, {
|
||||||
|
useSocks5: currentHostConfig.useSocks5,
|
||||||
|
socks5Host: currentHostConfig.socks5Host,
|
||||||
|
socks5Port: currentHostConfig.socks5Port,
|
||||||
|
socks5Username: currentHostConfig.socks5Username,
|
||||||
|
socks5Password: currentHostConfig.socks5Password,
|
||||||
|
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.requires_totp) {
|
||||||
|
setTotpRequired(true);
|
||||||
|
setTotpSessionId(sid);
|
||||||
|
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
|
||||||
|
setIsConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.status === "auth_required") {
|
||||||
|
setShowAuthDialog(true);
|
||||||
|
setAuthReason(
|
||||||
|
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
|
||||||
|
);
|
||||||
|
setIsConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSessionId(sid);
|
setSessionId(sid);
|
||||||
|
|
||||||
setIsValidating(true);
|
setIsValidating(true);
|
||||||
@@ -140,6 +185,7 @@ export function DockerManager({
|
|||||||
initSession();
|
initSession();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
initializingRef.current = false;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
disconnectDockerSession(sessionId).catch(() => {
|
disconnectDockerSession(sessionId).catch(() => {
|
||||||
// Silently handle disconnect errors
|
// Silently handle disconnect errors
|
||||||
@@ -208,6 +254,109 @@ export function DockerManager({
|
|||||||
setSelectedContainer(null);
|
setSelectedContainer(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTotpSubmit = async (code: string) => {
|
||||||
|
if (!totpSessionId || !code) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsConnecting(true);
|
||||||
|
const result = await verifyDockerTOTP(totpSessionId, code);
|
||||||
|
|
||||||
|
if (result?.status === "success") {
|
||||||
|
setTotpRequired(false);
|
||||||
|
setTotpPrompt("");
|
||||||
|
setSessionId(totpSessionId);
|
||||||
|
setTotpSessionId(null);
|
||||||
|
|
||||||
|
setIsValidating(true);
|
||||||
|
const validation = await validateDockerAvailability(totpSessionId);
|
||||||
|
setDockerValidation(validation);
|
||||||
|
setIsValidating(false);
|
||||||
|
|
||||||
|
if (!validation.available) {
|
||||||
|
toast.error(
|
||||||
|
validation.error || "Docker is not available on this host",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("TOTP verification failed:", error);
|
||||||
|
toast.error(t("docker.totpVerificationFailed"));
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTotpCancel = () => {
|
||||||
|
setTotpRequired(false);
|
||||||
|
setTotpSessionId(null);
|
||||||
|
setTotpPrompt("");
|
||||||
|
setIsConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthSubmit = async (credentials: {
|
||||||
|
password?: string;
|
||||||
|
sshKey?: string;
|
||||||
|
keyPassword?: string;
|
||||||
|
}) => {
|
||||||
|
if (!currentHostConfig?.id) return;
|
||||||
|
|
||||||
|
setShowAuthDialog(false);
|
||||||
|
setIsConnecting(true);
|
||||||
|
|
||||||
|
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await connectDockerSession(sid, currentHostConfig.id, {
|
||||||
|
userProvidedPassword: credentials.password,
|
||||||
|
userProvidedSshKey: credentials.sshKey,
|
||||||
|
userProvidedKeyPassword: credentials.keyPassword,
|
||||||
|
useSocks5: currentHostConfig.useSocks5,
|
||||||
|
socks5Host: currentHostConfig.socks5Host,
|
||||||
|
socks5Port: currentHostConfig.socks5Port,
|
||||||
|
socks5Username: currentHostConfig.socks5Username,
|
||||||
|
socks5Password: currentHostConfig.socks5Password,
|
||||||
|
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.requires_totp) {
|
||||||
|
setTotpRequired(true);
|
||||||
|
setTotpSessionId(sid);
|
||||||
|
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
|
||||||
|
setIsConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.status === "auth_required") {
|
||||||
|
setShowAuthDialog(true);
|
||||||
|
setAuthReason("auth_failed");
|
||||||
|
setIsConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionId(sid);
|
||||||
|
|
||||||
|
setIsValidating(true);
|
||||||
|
const validation = await validateDockerAvailability(sid);
|
||||||
|
setDockerValidation(validation);
|
||||||
|
setIsValidating(false);
|
||||||
|
|
||||||
|
if (!validation.available) {
|
||||||
|
toast.error(validation.error || "Docker is not available on this host");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to connect");
|
||||||
|
setIsConnecting(false);
|
||||||
|
setIsValidating(false);
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthCancel = () => {
|
||||||
|
setShowAuthDialog(false);
|
||||||
|
setIsConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
@@ -384,6 +533,26 @@ export function DockerManager({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TOTPDialog
|
||||||
|
isOpen={totpRequired}
|
||||||
|
prompt={totpPrompt}
|
||||||
|
onSubmit={handleTotpSubmit}
|
||||||
|
onCancel={handleTotpCancel}
|
||||||
|
/>
|
||||||
|
{currentHostConfig && (
|
||||||
|
<SSHAuthDialog
|
||||||
|
isOpen={showAuthDialog}
|
||||||
|
reason={authReason}
|
||||||
|
onSubmit={handleAuthSubmit}
|
||||||
|
onCancel={handleAuthCancel}
|
||||||
|
hostInfo={{
|
||||||
|
ip: currentHostConfig.ip,
|
||||||
|
port: currentHostConfig.port,
|
||||||
|
username: currentHostConfig.username,
|
||||||
|
name: currentHostConfig.name,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,6 +469,27 @@ export function ServerStats({
|
|||||||
{t("nav.fileManager")}
|
{t("nav.fileManager")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentHostConfig?.enableDocker && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => {
|
||||||
|
const titleBase =
|
||||||
|
currentHostConfig?.name &&
|
||||||
|
currentHostConfig.name.trim() !== ""
|
||||||
|
? currentHostConfig.name.trim()
|
||||||
|
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||||
|
addTab({
|
||||||
|
type: "docker",
|
||||||
|
title: titleBase,
|
||||||
|
hostConfig: currentHostConfig,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("nav.docker")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="p-0.25 w-full" />
|
<Separator className="p-0.25 w-full" />
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const shouldNotReconnectRef = useRef(false);
|
const shouldNotReconnectRef = useRef(false);
|
||||||
const isReconnectingRef = useRef(false);
|
const isReconnectingRef = useRef(false);
|
||||||
const isConnectingRef = useRef(false);
|
const isConnectingRef = useRef(false);
|
||||||
|
const connectionAttemptIdRef = useRef(0);
|
||||||
|
const totpAttemptsRef = useRef(0);
|
||||||
|
const totpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const activityLoggedRef = useRef(false);
|
const activityLoggedRef = useRef(false);
|
||||||
const keyHandlerAttachedRef = useRef(false);
|
const keyHandlerAttachedRef = useRef(false);
|
||||||
@@ -372,6 +375,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
function handleTotpSubmit(code: string) {
|
function handleTotpSubmit(code: string) {
|
||||||
if (webSocketRef.current && code) {
|
if (webSocketRef.current && code) {
|
||||||
|
if (totpTimeoutRef.current) {
|
||||||
|
clearTimeout(totpTimeoutRef.current);
|
||||||
|
totpTimeoutRef.current = null;
|
||||||
|
}
|
||||||
webSocketRef.current.send(
|
webSocketRef.current.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: isPasswordPrompt ? "password_response" : "totp_response",
|
type: isPasswordPrompt ? "password_response" : "totp_response",
|
||||||
@@ -385,6 +392,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTotpCancel() {
|
function handleTotpCancel() {
|
||||||
|
if (totpTimeoutRef.current) {
|
||||||
|
clearTimeout(totpTimeoutRef.current);
|
||||||
|
totpTimeoutRef.current = null;
|
||||||
|
}
|
||||||
setTotpRequired(false);
|
setTotpRequired(false);
|
||||||
setTotpPrompt("");
|
setTotpPrompt("");
|
||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
@@ -461,6 +472,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
clearTimeout(connectionTimeoutRef.current);
|
clearTimeout(connectionTimeoutRef.current);
|
||||||
connectionTimeoutRef.current = null;
|
connectionTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (totpTimeoutRef.current) {
|
||||||
|
clearTimeout(totpTimeoutRef.current);
|
||||||
|
totpTimeoutRef.current = null;
|
||||||
|
}
|
||||||
webSocketRef.current?.close();
|
webSocketRef.current?.close();
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
@@ -530,6 +545,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
2000 * Math.pow(2, reconnectAttempts.current - 1),
|
||||||
|
8000,
|
||||||
|
);
|
||||||
|
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
if (
|
if (
|
||||||
isUnmountingRef.current ||
|
isUnmountingRef.current ||
|
||||||
@@ -561,7 +581,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
isReconnectingRef.current = false;
|
isReconnectingRef.current = false;
|
||||||
}, 2000 * reconnectAttempts.current);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectToHost(cols: number, rows: number) {
|
function connectToHost(cols: number, rows: number) {
|
||||||
@@ -570,6 +590,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
isConnectingRef.current = true;
|
isConnectingRef.current = true;
|
||||||
|
connectionAttemptIdRef.current++;
|
||||||
|
|
||||||
|
if (!isReconnectingRef.current) {
|
||||||
|
reconnectAttempts.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
const isDev =
|
const isDev =
|
||||||
!isElectron() &&
|
!isElectron() &&
|
||||||
@@ -652,7 +677,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
attemptReconnection();
|
attemptReconnection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 35000);
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -852,6 +877,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} else if (msg.type === "totp_required") {
|
} else if (msg.type === "totp_required") {
|
||||||
|
totpAttemptsRef.current = 0;
|
||||||
setTotpRequired(true);
|
setTotpRequired(true);
|
||||||
setTotpPrompt(msg.prompt || t("terminal.totpCodeLabel"));
|
setTotpPrompt(msg.prompt || t("terminal.totpCodeLabel"));
|
||||||
setIsPasswordPrompt(false);
|
setIsPasswordPrompt(false);
|
||||||
@@ -859,7 +885,25 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
clearTimeout(connectionTimeoutRef.current);
|
clearTimeout(connectionTimeoutRef.current);
|
||||||
connectionTimeoutRef.current = null;
|
connectionTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (totpTimeoutRef.current) {
|
||||||
|
clearTimeout(totpTimeoutRef.current);
|
||||||
|
}
|
||||||
|
totpTimeoutRef.current = setTimeout(() => {
|
||||||
|
setTotpRequired(false);
|
||||||
|
toast.error(t("terminal.totpTimeout"));
|
||||||
|
if (webSocketRef.current) {
|
||||||
|
webSocketRef.current.close();
|
||||||
|
}
|
||||||
|
}, 180000);
|
||||||
|
} else if (msg.type === "totp_retry") {
|
||||||
|
totpAttemptsRef.current++;
|
||||||
|
const attemptsRemaining =
|
||||||
|
msg.attempts_remaining || 3 - totpAttemptsRef.current;
|
||||||
|
toast.error(
|
||||||
|
`Invalid code. ${attemptsRemaining} ${attemptsRemaining === 1 ? "attempt" : "attempts"} remaining.`,
|
||||||
|
);
|
||||||
} else if (msg.type === "password_required") {
|
} else if (msg.type === "password_required") {
|
||||||
|
totpAttemptsRef.current = 0;
|
||||||
setTotpRequired(true);
|
setTotpRequired(true);
|
||||||
setTotpPrompt(msg.prompt || t("common.password"));
|
setTotpPrompt(msg.prompt || t("common.password"));
|
||||||
setIsPasswordPrompt(true);
|
setIsPasswordPrompt(true);
|
||||||
@@ -867,6 +911,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
clearTimeout(connectionTimeoutRef.current);
|
clearTimeout(connectionTimeoutRef.current);
|
||||||
connectionTimeoutRef.current = null;
|
connectionTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (totpTimeoutRef.current) {
|
||||||
|
clearTimeout(totpTimeoutRef.current);
|
||||||
|
}
|
||||||
|
totpTimeoutRef.current = setTimeout(() => {
|
||||||
|
setTotpRequired(false);
|
||||||
|
toast.error(t("terminal.passwordTimeout"));
|
||||||
|
if (webSocketRef.current) {
|
||||||
|
webSocketRef.current.close();
|
||||||
|
}
|
||||||
|
}, 180000);
|
||||||
} else if (msg.type === "keyboard_interactive_available") {
|
} else if (msg.type === "keyboard_interactive_available") {
|
||||||
setKeyboardInteractiveDetected(true);
|
setKeyboardInteractiveDetected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
@@ -888,13 +942,24 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentAttemptId = connectionAttemptIdRef.current;
|
||||||
|
|
||||||
ws.addEventListener("close", (event) => {
|
ws.addEventListener("close", (event) => {
|
||||||
|
if (currentAttemptId !== connectionAttemptIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
terminal.clear();
|
terminal.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (totpTimeoutRef.current) {
|
||||||
|
clearTimeout(totpTimeoutRef.current);
|
||||||
|
totpTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.code === 1008) {
|
if (event.code === 1008) {
|
||||||
console.error("WebSocket authentication failed:", event.reason);
|
console.error("WebSocket authentication failed:", event.reason);
|
||||||
setConnectionError("Authentication failed - please re-login");
|
setConnectionError("Authentication failed - please re-login");
|
||||||
@@ -914,7 +979,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
if (
|
if (
|
||||||
!wasDisconnectedBySSH.current &&
|
!wasDisconnectedBySSH.current &&
|
||||||
!isUnmountingRef.current &&
|
!isUnmountingRef.current &&
|
||||||
!shouldNotReconnectRef.current
|
!shouldNotReconnectRef.current &&
|
||||||
|
!isConnectingRef.current
|
||||||
) {
|
) {
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
attemptReconnection();
|
attemptReconnection();
|
||||||
@@ -922,6 +988,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("error", () => {
|
ws.addEventListener("error", () => {
|
||||||
|
if (currentAttemptId !== connectionAttemptIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
setConnectionError(t("terminal.websocketError"));
|
setConnectionError(t("terminal.websocketError"));
|
||||||
@@ -929,7 +999,17 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
terminal.clear();
|
terminal.clear();
|
||||||
}
|
}
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
|
|
||||||
|
if (totpTimeoutRef.current) {
|
||||||
|
clearTimeout(totpTimeoutRef.current);
|
||||||
|
totpTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isUnmountingRef.current &&
|
||||||
|
!shouldNotReconnectRef.current &&
|
||||||
|
!isConnectingRef.current
|
||||||
|
) {
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
attemptReconnection();
|
attemptReconnection();
|
||||||
}
|
}
|
||||||
@@ -1245,6 +1325,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
clearTimeout(reconnectTimeoutRef.current);
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
if (connectionTimeoutRef.current)
|
if (connectionTimeoutRef.current)
|
||||||
clearTimeout(connectionTimeoutRef.current);
|
clearTimeout(connectionTimeoutRef.current);
|
||||||
|
if (totpTimeoutRef.current) clearTimeout(totpTimeoutRef.current);
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current);
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null;
|
pingIntervalRef.current = null;
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTunnelStatuses();
|
fetchTunnelStatuses();
|
||||||
const interval = setInterval(fetchTunnelStatuses, 5000);
|
const interval = setInterval(fetchTunnelStatuses, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchTunnelStatuses]);
|
}, [fetchTunnelStatuses]);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
|
|
||||||
export function TunnelObject({
|
export function TunnelObject({
|
||||||
host,
|
host,
|
||||||
|
tunnelIndex,
|
||||||
tunnelStatuses,
|
tunnelStatuses,
|
||||||
tunnelActions,
|
tunnelActions,
|
||||||
onTunnelAction,
|
onTunnelAction,
|
||||||
@@ -32,9 +33,9 @@ export function TunnelObject({
|
|||||||
}: SSHTunnelObjectProps): React.ReactElement {
|
}: SSHTunnelObjectProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
const getTunnelStatus = (idx: number): TunnelStatus | undefined => {
|
||||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
const tunnel = host.tunnelConnections[idx];
|
||||||
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||||
return tunnelStatuses[tunnelName];
|
return tunnelStatuses[tunnelName];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,10 +117,14 @@ export function TunnelObject({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
{(tunnelIndex !== undefined
|
||||||
const status = getTunnelStatus(tunnelIndex);
|
? [tunnelIndex]
|
||||||
|
: host.tunnelConnections.map((_, idx) => idx)
|
||||||
|
).map((idx) => {
|
||||||
|
const tunnel = host.tunnelConnections[idx];
|
||||||
|
const status = getTunnelStatus(idx);
|
||||||
const statusDisplay = getTunnelStatusDisplay(status);
|
const statusDisplay = getTunnelStatusDisplay(status);
|
||||||
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||||
const isActionLoading = tunnelActions[tunnelName];
|
const isActionLoading = tunnelActions[tunnelName];
|
||||||
const statusValue =
|
const statusValue =
|
||||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||||
@@ -131,7 +136,7 @@ export function TunnelObject({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tunnelIndex}
|
key={idx}
|
||||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -162,11 +167,7 @@ export function TunnelObject({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onTunnelAction(
|
onTunnelAction("disconnect", host, idx)
|
||||||
"disconnect",
|
|
||||||
host,
|
|
||||||
tunnelIndex,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||||
>
|
>
|
||||||
@@ -179,7 +180,7 @@ export function TunnelObject({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onTunnelAction("cancel", host, tunnelIndex)
|
onTunnelAction("cancel", host, idx)
|
||||||
}
|
}
|
||||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||||
>
|
>
|
||||||
@@ -191,7 +192,7 @@ export function TunnelObject({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onTunnelAction("connect", host, tunnelIndex)
|
onTunnelAction("connect", host, idx)
|
||||||
}
|
}
|
||||||
disabled={isConnecting || isDisconnecting}
|
disabled={isConnecting || isDisconnecting}
|
||||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||||
@@ -344,15 +345,20 @@ export function TunnelObject({
|
|||||||
{!compact && (
|
{!compact && (
|
||||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||||
<Network className="h-4 w-4" />
|
<Network className="h-4 w-4" />
|
||||||
{t("tunnels.tunnelConnections")} ({host.tunnelConnections.length})
|
{t("tunnels.tunnelConnections")} (
|
||||||
|
{tunnelIndex !== undefined ? 1 : host.tunnelConnections.length})
|
||||||
</h4>
|
</h4>
|
||||||
)}
|
)}
|
||||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
{(tunnelIndex !== undefined
|
||||||
const status = getTunnelStatus(tunnelIndex);
|
? [tunnelIndex]
|
||||||
|
: host.tunnelConnections.map((_, idx) => idx)
|
||||||
|
).map((idx) => {
|
||||||
|
const tunnel = host.tunnelConnections[idx];
|
||||||
|
const status = getTunnelStatus(idx);
|
||||||
const statusDisplay = getTunnelStatusDisplay(status);
|
const statusDisplay = getTunnelStatusDisplay(status);
|
||||||
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||||
const isActionLoading = tunnelActions[tunnelName];
|
const isActionLoading = tunnelActions[tunnelName];
|
||||||
const statusValue =
|
const statusValue =
|
||||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||||
@@ -364,7 +370,7 @@ export function TunnelObject({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tunnelIndex}
|
key={idx}
|
||||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -395,11 +401,7 @@ export function TunnelObject({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onTunnelAction(
|
onTunnelAction("disconnect", host, idx)
|
||||||
"disconnect",
|
|
||||||
host,
|
|
||||||
tunnelIndex,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||||
>
|
>
|
||||||
@@ -412,7 +414,7 @@ export function TunnelObject({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onTunnelAction("cancel", host, tunnelIndex)
|
onTunnelAction("cancel", host, idx)
|
||||||
}
|
}
|
||||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||||
>
|
>
|
||||||
@@ -424,7 +426,7 @@ export function TunnelObject({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onTunnelAction("connect", host, tunnelIndex)
|
onTunnelAction("connect", host, idx)
|
||||||
}
|
}
|
||||||
disabled={isConnecting || isDisconnecting}
|
disabled={isConnecting || isDisconnecting}
|
||||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||||
|
|||||||
@@ -47,16 +47,12 @@ export function TunnelViewer({
|
|||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||||
{activeHost.tunnelConnections.map((t, idx) => (
|
{activeHost.tunnelConnections.map((t, idx) => (
|
||||||
<TunnelObject
|
<TunnelObject
|
||||||
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
key={`tunnel-${activeHost.id}-${idx}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||||
host={{
|
host={activeHost}
|
||||||
...activeHost,
|
tunnelIndex={idx}
|
||||||
tunnelConnections: [activeHost.tunnelConnections[idx]],
|
|
||||||
}}
|
|
||||||
tunnelStatuses={tunnelStatuses}
|
tunnelStatuses={tunnelStatuses}
|
||||||
tunnelActions={tunnelActions}
|
tunnelActions={tunnelActions}
|
||||||
onTunnelAction={(action) =>
|
onTunnelAction={onTunnelAction}
|
||||||
onTunnelAction(action, activeHost, idx)
|
|
||||||
}
|
|
||||||
compact
|
compact
|
||||||
bare
|
bare
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -279,6 +279,17 @@ export function HostSharingTab({
|
|||||||
{t("rbac.shareHost")}
|
{t("rbac.shareHost")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
window.open("https://docs.termix.site/rbac", "_blank")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("common.documentation")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={shareType}
|
value={shareType}
|
||||||
onValueChange={(v) => setShareType(v as "user" | "role")}
|
onValueChange={(v) => setShareType(v as "user" | "role")}
|
||||||
|
|||||||
@@ -30,14 +30,13 @@ export function TOTPDialog({
|
|||||||
className="absolute inset-0 bg-canvas rounded-md"
|
className="absolute inset-0 bg-canvas rounded-md"
|
||||||
style={{ backgroundColor: backgroundColor || undefined }}
|
style={{ backgroundColor: backgroundColor || undefined }}
|
||||||
/>
|
/>
|
||||||
<div className="bg-canvas border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<Shield className="w-5 h-5 text-primary" />
|
<Shield className="w-5 h-5 text-primary" />
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">
|
||||||
{t("terminal.totpRequired")}
|
{t("terminal.totpRequired")}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm mb-4">{prompt}</p>
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -3337,15 +3337,57 @@ export async function revokeHostAccess(
|
|||||||
export async function connectDockerSession(
|
export async function connectDockerSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
): Promise<{ success: boolean; message: string }> {
|
config?: {
|
||||||
|
userProvidedPassword?: string;
|
||||||
|
userProvidedSshKey?: string;
|
||||||
|
userProvidedKeyPassword?: string;
|
||||||
|
forceKeyboardInteractive?: boolean;
|
||||||
|
useSocks5?: boolean;
|
||||||
|
socks5Host?: string;
|
||||||
|
socks5Port?: number;
|
||||||
|
socks5Username?: string;
|
||||||
|
socks5Password?: string;
|
||||||
|
socks5ProxyChain?: unknown;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
requires_totp?: boolean;
|
||||||
|
prompt?: string;
|
||||||
|
isPassword?: boolean;
|
||||||
|
status?: string;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await dockerApi.post("/ssh/connect", {
|
const response = await dockerApi.post("/ssh/connect", {
|
||||||
sessionId,
|
sessionId,
|
||||||
hostId,
|
hostId,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.data?.status === "auth_required") {
|
||||||
|
return error.response.data;
|
||||||
|
}
|
||||||
|
if (error.response?.data?.requires_totp) {
|
||||||
|
return error.response.data;
|
||||||
|
}
|
||||||
|
throw handleApiError(error, "connect to Docker SSH session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyDockerTOTP(
|
||||||
|
sessionId: string,
|
||||||
|
totpCode: string,
|
||||||
|
): Promise<{ status: string; message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await dockerApi.post("/ssh/connect-totp", {
|
||||||
|
sessionId,
|
||||||
|
totpCode,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "connect to Docker SSH session");
|
throw handleApiError(error, "verify Docker TOTP");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user