* feat: add host list sort

* feat: fixed rdp truncating (taskbar invisible unless resizing window) and improved split screen system

* feat: revamp conneciton persistance system to save to backend with a new connections panel to restore old connections and view current ones. Also added new user profile toggle to reopen all tabs (saves and loads from backend). Added user profile toggle for host tray click vs hover.

* feat: added WOL button, added proper use of BASE_PATH, toggles/buttons in admin/user profile now are always visible regardless of sidebar width, duplicating hosts not adding jumphost/socks5, keepalive internal not mulitplying into seconds causing a keepalive error, and finally guacamole giving 1_0_0 and 1_1_0 errors

* feat: add filter host button, improve alert system UI, save sidebar width to localstorage, and fix host toolbar row overflow (add host going off screen on small sidebar width)

* feat: add pin rail toggle, fix command pallete toggle not working, fixed command pallete toggling when typing in a field, made file manager not uppercase, host manager custom ports not loading, guacd hosts made on >=2.2.1, fixed host tags toggling, added reorder snippet sfeature, made snippet folder clllapse and require confimration toggle work, removed file manager color toggle, and fixed macos not displaying GUI until switching to another app and coming back, and jump host servers failing.

* feat: use blacksmith caching for docker compile and improve keepalive system for ssh to all match the same implementation and use the data from the host config instead of a predefined value

* feat: reset host manager state if the form is left and remove file manager color logic from the removed toggle

* feat: update electron version check to use new ui

* feat: improve duplication system to proplery map all fields
This commit is contained in:
Luke Gustafson
2026-05-28 22:05:25 -04:00
committed by LukeGus
parent b5ab1479ce
commit 7370e8f3df
50 changed files with 3337 additions and 577 deletions
+4
View File
@@ -13,6 +13,8 @@ import terminalRoutes from "./routes/terminal.js";
import guacamoleRoutes from "../guacamole/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import fs from "fs";
import path from "path";
@@ -1762,6 +1764,8 @@ app.use("/terminal", terminalRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences", userPreferencesRoutes);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
+33
View File
@@ -450,8 +450,41 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_open_tabs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
tab_type TEXT NOT NULL,
host_id INTEGER,
label TEXT NOT NULL,
tab_order INTEGER NOT NULL DEFAULT 0,
backend_session_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY,
reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
try {
sqlite.prepare("DELETE FROM user_open_tabs").run();
databaseLogger.info("Open tabs cleared on startup", {
operation: "db_init_open_tabs_cleanup",
});
} catch (e) {
databaseLogger.warn("Could not clear open tabs on startup", {
operation: "db_init_open_tabs_cleanup_failed",
error: e,
});
}
try {
const result = sqlite
.prepare("DELETE FROM sessions WHERE expires_at <= ?")
+30
View File
@@ -632,3 +632,33 @@ export const apiKeys = sqliteTable("api_keys", {
lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
});
export const userOpenTabs = sqliteTable("user_open_tabs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tabType: text("tab_type").notNull(),
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
label: text("label").notNull(),
tabOrder: integer("tab_order").notNull().default(0),
backendSessionId: text("backend_session_id"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const userPreferences = sqliteTable("user_preferences", {
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
reopenTabsOnLogin: integer("reopen_tabs_on_login", { mode: "boolean" })
.notNull()
.default(false),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
+302
View File
@@ -0,0 +1,302 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { userOpenTabs } from "../db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../ssh/terminal-session-manager.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /open-tabs:
* get:
* summary: Get all open tabs for the current user
* tags:
* - Open Tabs
* responses:
* 200:
* description: List of open tabs ordered by tab_order.
*/
const TAB_TTL_MS = 30 * 60 * 1000;
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const cutoff = new Date(Date.now() - TAB_TTL_MS).toISOString();
const tabs = db
.select()
.from(userOpenTabs)
.where(and(eq(userOpenTabs.userId, userId), sql`${userOpenTabs.updatedAt} > ${cutoff}`))
.orderBy(userOpenTabs.tabOrder)
.all();
return res.json(tabs);
} catch (e) {
databaseLogger.error("Failed to get open tabs", e, { operation: "get_open_tabs", userId });
return res.status(500).json({ error: "Failed to get open tabs" });
}
});
/**
* @openapi
* /open-tabs:
* post:
* summary: Upsert a single open tab for the current user
* tags:
* - Open Tabs
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [id, tabType, label, tabOrder]
* properties:
* id:
* type: string
* tabType:
* type: string
* hostId:
* type: integer
* nullable: true
* label:
* type: string
* tabOrder:
* type: integer
* backendSessionId:
* type: string
* nullable: true
* responses:
* 200:
* description: Tab upserted successfully.
*/
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id, tabType, hostId, label, tabOrder, backendSessionId } = req.body as {
id: string;
tabType: string;
hostId?: number | null;
label: string;
tabOrder: number;
backendSessionId?: string | null;
};
if (!id || !tabType || !label) {
return res.status(400).json({ error: "id, tabType, and label are required" });
}
try {
const now = new Date().toISOString();
const existing = db.select().from(userOpenTabs).where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))).all();
if (existing.length > 0) {
// Preserve existing backendSessionId when not explicitly provided
const sessionId = backendSessionId !== undefined ? backendSessionId : existing[0].backendSessionId;
db.update(userOpenTabs)
.set({ tabType, hostId: hostId ?? null, label, tabOrder, backendSessionId: sessionId ?? null, updatedAt: now })
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
} else {
db.insert(userOpenTabs).values({ id, userId, tabType, hostId: hostId ?? null, label, tabOrder, backendSessionId: backendSessionId ?? null, updatedAt: now }).run();
}
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to upsert open tab", e, { operation: "upsert_open_tab", userId, id });
return res.status(500).json({ error: "Failed to upsert open tab" });
}
});
/**
* @openapi
* /open-tabs:
* put:
* summary: Bulk replace all open tabs for the current user
* tags:
* - Open Tabs
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tabs:
* type: array
* responses:
* 200:
* description: Tabs updated successfully.
*/
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { tabs } = req.body as {
tabs: Array<{
id: string;
tabType: string;
hostId?: number | null;
label: string;
tabOrder: number;
backendSessionId?: string | null;
}>;
};
if (!Array.isArray(tabs)) {
return res.status(400).json({ error: "tabs must be an array" });
}
try {
db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)).run();
if (tabs.length > 0) {
const now = new Date().toISOString();
db.insert(userOpenTabs).values(
tabs.map((t) => ({
id: t.id,
userId,
tabType: t.tabType,
hostId: t.hostId ?? null,
label: t.label,
tabOrder: t.tabOrder,
backendSessionId: t.backendSessionId ?? null,
updatedAt: now,
})),
).run();
}
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to sync open tabs", e, { operation: "sync_open_tabs", userId });
return res.status(500).json({ error: "Failed to sync open tabs" });
}
});
/**
* @openapi
* /open-tabs/{id}:
* patch:
* summary: Update a single open tab
* tags:
* - Open Tabs
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Tab updated successfully.
* 404:
* description: Tab not found.
*/
router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = String(req.params.id);
const updates = req.body as Partial<{
label: string;
tabOrder: number;
backendSessionId: string | null;
}>;
try {
const result = db
.update(userOpenTabs)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
if (result.changes === 0) {
return res.status(404).json({ error: "Tab not found" });
}
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to update open tab", e, { operation: "update_open_tab", userId, id });
return res.status(500).json({ error: "Failed to update open tab" });
}
});
/**
* @openapi
* /open-tabs/{id}:
* delete:
* summary: Delete a single open tab
* tags:
* - Open Tabs
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Tab deleted successfully.
*/
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = String(req.params.id);
try {
db.delete(userOpenTabs)
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.run();
return res.json({ success: true });
} catch (e) {
databaseLogger.error("Failed to delete open tab", e, { operation: "delete_open_tab", userId, id });
return res.status(500).json({ error: "Failed to delete open tab" });
}
});
/**
* @openapi
* /open-tabs/active-sessions:
* get:
* summary: Get all active backend sessions for the current user
* description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic.
* tags:
* - Open Tabs
* responses:
* 200:
* description: List of active sessions.
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* sessionId:
* type: string
* hostId:
* type: integer
* hostName:
* type: string
* tabInstanceId:
* type: string
* isConnected:
* type: boolean
* createdAt:
* type: number
*/
router.get("/active-sessions", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const sessions = sessionManager.getUserSessions(userId);
return res.json(
sessions.map((s) => ({
sessionId: s.id,
hostId: s.hostId,
hostName: s.hostName,
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
isConnected: s.isConnected,
createdAt: s.createdAt,
})),
);
} catch (e) {
databaseLogger.error("Failed to get active sessions", e, { operation: "get_active_sessions", userId });
return res.status(500).json({ error: "Failed to get active sessions" });
}
});
export default router;
@@ -0,0 +1,106 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { userPreferences } from "../db/schema.js";
import { eq } from "drizzle-orm";
import type { Request, Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /user-preferences:
* get:
* summary: Get preferences for the current user
* tags:
* - User Preferences
* responses:
* 200:
* description: User preferences.
* content:
* application/json:
* schema:
* type: object
* properties:
* reopenTabsOnLogin:
* type: boolean
*/
router.get("/", authenticateJWT, (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, userId))
.all();
if (rows.length === 0) {
return res.json({ reopenTabsOnLogin: false });
}
return res.json({ reopenTabsOnLogin: rows[0].reopenTabsOnLogin });
} catch (e) {
databaseLogger.error("Failed to get user preferences", e, { operation: "get_user_preferences", userId });
return res.status(500).json({ error: "Failed to get user preferences" });
}
});
/**
* @openapi
* /user-preferences:
* put:
* summary: Update preferences for the current user
* tags:
* - User Preferences
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* reopenTabsOnLogin:
* type: boolean
* responses:
* 200:
* description: Preferences updated successfully.
*/
router.put("/", authenticateJWT, (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { reopenTabsOnLogin } = req.body as { reopenTabsOnLogin?: boolean };
if (typeof reopenTabsOnLogin !== "boolean") {
return res.status(400).json({ error: "reopenTabsOnLogin must be a boolean" });
}
try {
const existing = db
.select()
.from(userPreferences)
.where(eq(userPreferences.userId, userId))
.all();
if (existing.length === 0) {
db.insert(userPreferences).values({
userId,
reopenTabsOnLogin,
updatedAt: new Date().toISOString(),
}).run();
} else {
db.update(userPreferences)
.set({ reopenTabsOnLogin, updatedAt: new Date().toISOString() })
.where(eq(userPreferences.userId, userId))
.run();
}
return res.json({ success: true, reopenTabsOnLogin });
} catch (e) {
databaseLogger.error("Failed to update user preferences", e, { operation: "update_user_preferences", userId });
return res.status(500).json({ error: "Failed to update user preferences" });
}
});
export default router;
+4
View File
@@ -31,6 +31,8 @@ import {
dashboardPreferences,
opksshTokens,
apiKeys,
userOpenTabs,
userPreferences,
} from "../db/schema.js";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
@@ -284,6 +286,8 @@ async function deleteUserAndRelatedData(userId: string): Promise<void> {
.delete(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId));
await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId));
await db.delete(userPreferences).where(eq(userPreferences.userId, userId));
db.$client
.prepare("DELETE FROM settings WHERE key LIKE ?")
+10 -3
View File
@@ -226,10 +226,17 @@ router.post(
});
}
// Old hosts only had connectionType set; enableRdp/enableVnc/enableTelnet defaulted to false.
// Apply the same migration fallback used in host.ts GET routes.
const ct = host.connectionType as string;
const rdpRaw = !!host.enableRdp;
const vncRaw = !!host.enableVnc;
const telRaw = !!host.enableTelnet;
const isMigratedNonSsh = !rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
const protocolEnabledMap: Record<string, boolean> = {
rdp: !!host.enableRdp,
vnc: !!host.enableVnc,
telnet: !!host.enableTelnet,
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
telnet: isMigratedNonSsh ? ct === "telnet" : telRaw,
};
if (!protocolEnabledMap[connectionType]) {
return res.status(400).json({
+59 -1
View File
@@ -1,4 +1,5 @@
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../utils/auth-manager.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
@@ -155,12 +156,50 @@ async function createJumpHostChain(
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
tryKeyboard: true,
tryKeyboard: resolvedCredentials.authType !== "none",
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (
@@ -182,6 +221,25 @@ async function createJumpHostChain(
}
}
client.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
await new Promise<void>((resolve, reject) => {
currentClient!.forwardOut(
+75 -4
View File
@@ -3,6 +3,7 @@ import { createCorsMiddleware } from "../utils/cors-config.js";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { getDb } from "../database/db/index.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
@@ -312,9 +313,47 @@ async function createJumpHostChain(
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: true,
readyTimeout: 30000,
tryKeyboard: jumpHostConfig.authType !== "none",
readyTimeout: 60000,
hostVerifier: jumpHostVerifier,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
@@ -330,6 +369,25 @@ async function createJumpHostChain(
}
}
jumpClient.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
return jumpHostConfig.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
currentClient.forwardOut(
"127.0.0.1",
@@ -627,6 +685,13 @@ app.post("/docker/ssh/connect", async (req, res) => {
host.jumpHosts = [];
}
}
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
try {
host.terminalConfig = JSON.parse(host.terminalConfig as string);
} catch {
host.terminalConfig = undefined;
}
}
if (!host.enableDocker) {
sshLogger.warn("Docker not enabled for host", {
@@ -757,8 +822,14 @@ app.post("/docker/ssh/connect", async (req, res) => {
port: host.port || 22,
username: host.username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
keepaliveInterval:
typeof host.terminalConfig?.keepaliveInterval === "number"
? host.terminalConfig.keepaliveInterval * 1000
: 60000,
keepaliveCountMax:
typeof host.terminalConfig?.keepaliveCountMax === "number"
? host.terminalConfig.keepaliveCountMax
: 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
+72 -5
View File
@@ -327,9 +327,47 @@ async function createJumpHostChain(
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: true,
readyTimeout: 30000,
tryKeyboard: jumpHostConfig.authType !== "none",
readyTimeout: 60000,
hostVerifier: jumpHostVerifier,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
@@ -345,6 +383,25 @@ async function createJumpHostChain(
}
}
jumpClient.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
return jumpHostConfig.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
currentClient.forwardOut(
"127.0.0.1",
@@ -917,6 +974,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
authType,
sudoPassword: undefined as string | undefined,
};
let hostKeepaliveInterval: number | undefined;
let hostKeepaliveCountMax: number | undefined;
if (hostId && userId && !password && !sshKey) {
try {
const { resolveHostById } = await import("./host-resolver.js");
@@ -929,6 +988,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
};
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
connectionLogs.push(
createConnectionLog(
"info",
@@ -957,6 +1018,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
};
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
connectionLogs.push(
createConnectionLog(
"info",
@@ -980,11 +1043,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
port,
username,
tryKeyboard: true,
keepaliveInterval: 10000,
keepaliveCountMax: 5,
keepaliveInterval:
typeof hostKeepaliveInterval === "number"
? hostKeepaliveInterval * 1000
: 60000,
keepaliveCountMax:
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 5000,
tcpKeepAliveInitialDelay: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
hostId,
ip,
+61 -4
View File
@@ -251,9 +251,47 @@ async function createJumpHostChain(
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: true,
readyTimeout: 30000,
tryKeyboard: jumpHostConfig.authType !== "none",
readyTimeout: 60000,
hostVerifier: jumpHostVerifier,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
@@ -269,6 +307,25 @@ async function createJumpHostChain(
}
}
jumpClient.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
return jumpHostConfig.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
currentClient.forwardOut(
"127.0.0.1",
@@ -1493,8 +1550,8 @@ async function buildSshConfig(
port: host.port,
username: host.username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
keepaliveInterval: 60000,
keepaliveCountMax: 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
+18 -4
View File
@@ -79,13 +79,27 @@ class TerminalSessionManager {
const tabSessions = userSessions.filter(
(s) => s.tabInstanceId === tabInstanceId,
);
if (tabSessions.length > 0) {
for (const existing of tabSessions) {
const isLiveSession =
existing.isConnected &&
existing.sshStream != null &&
!existing.sshStream.destroyed;
if (isLiveSession) {
// Don't destroy a live session (even if detached) — the caller should attach instead
sshLogger.warn("Tab instance has live session, skipping duplicate create", {
operation: "session_tab_duplicate_skip",
existingSessionId: existing.id,
tabInstanceId,
hasAttachedWs: existing.attachedWs !== null,
});
return existing.id;
}
sshLogger.warn("Tab instance already has session, destroying old", {
operation: "session_tab_duplicate_cleanup",
existingSessionId: tabSessions[0].id,
existingSessionId: existing.id,
tabInstanceId,
});
this.destroySession(tabSessions[0].id);
this.destroySession(existing.id);
}
}
@@ -179,7 +193,7 @@ class TerminalSessionManager {
const isDetached =
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
const isOriginalTab = session.tabInstanceId === tabInstanceId;
const isOriginalTab = (session.attachedTabInstanceId ?? session.tabInstanceId) === tabInstanceId;
if (
!isDetached &&
+116 -4
View File
@@ -373,8 +373,46 @@ async function createJumpHostChain(
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: jumpHostConfig.authType !== "none",
readyTimeout: 30000,
readyTimeout: 60000,
hostVerifier: jumpHostVerifier,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
@@ -390,6 +428,25 @@ async function createJumpHostChain(
}
}
jumpClient.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
return jumpHostConfig.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
currentClient.forwardOut(
"127.0.0.1",
@@ -1380,6 +1437,61 @@ wss.on("connection", async (ws: WebSocket, req) => {
tabInstanceId,
);
// If createSession returned an existing live session (duplicate tabInstanceId),
// close the newly-established SSH connection and attach this WS to the live session instead.
const existingSession = sessionManager.getSession(currentSessionId);
if (
existingSession &&
existingSession.sshStream &&
!existingSession.sshStream.destroyed &&
existingSession.sshConn !== sshConn
) {
sshLogger.info(
"Reusing existing live session after duplicate connectToHost, closing new SSH conn",
{
operation: "terminal_reuse_existing_session",
sessionId: currentSessionId,
tabInstanceId,
userId,
},
);
try {
sshConn?.end();
} catch {
/* ignore */
}
sshConn = null;
sshStream = existingSession.sshStream;
sshConn = existingSession.sshConn;
isConnecting = false;
isConnected = true;
sessionManager.attachWs(currentSessionId, userId, ws, tabInstanceId);
const buffered = sessionManager.getBuffer(existingSession);
if (buffered) {
ws.send(JSON.stringify({ type: "data", data: buffered }));
}
ws.send(
JSON.stringify({
type: "sessionCreated",
sessionId: currentSessionId,
}),
);
ws.send(
JSON.stringify({
type: "sessionAttached",
sessionId: currentSessionId,
}),
);
ws.send(
JSON.stringify({ type: "connected", message: "Session reattached" }),
);
cleanupAuthState(connectionTimeout);
return;
}
sshLogger.info("Terminal session created after SSH ready", {
operation: "terminal_session_created",
sessionId: currentSessionId,
@@ -2170,10 +2282,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
tryKeyboard: resolvedCredentials.authType !== "none",
keepaliveInterval:
typeof hostKeepaliveInterval === "number"
? hostKeepaliveInterval
: 30000,
? hostKeepaliveInterval * 1000
: 60000,
keepaliveCountMax:
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
readyTimeout: 120000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
+29 -9
View File
@@ -943,8 +943,8 @@ async function connectEndpointThroughSource(
sock: endpointSock,
username: tunnelConfig.endpointUsername,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 60000,
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
@@ -1150,6 +1150,14 @@ async function resolveC2STunnelSource(
socks5Username: resolvedHost.socks5Username,
socks5Password: resolvedHost.socks5Password,
socks5ProxyChain: resolvedHost.socks5ProxyChain,
keepaliveInterval:
typeof resolvedHost.terminalConfig?.keepaliveInterval === "number"
? resolvedHost.terminalConfig.keepaliveInterval * 1000
: 60000,
keepaliveCountMax:
typeof resolvedHost.terminalConfig?.keepaliveCountMax === "number"
? resolvedHost.terminalConfig.keepaliveCountMax
: 5,
};
}
@@ -1162,8 +1170,8 @@ async function connectC2SSourceClient(
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 60000,
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
@@ -1609,6 +1617,18 @@ async function connectSSHTunnel(
keyType: resolvedHost.keyType,
authMethod: resolvedHost.authType,
};
if (tunnelConfig.keepaliveInterval === undefined) {
tunnelConfig.keepaliveInterval =
typeof resolvedHost.terminalConfig?.keepaliveInterval === "number"
? resolvedHost.terminalConfig.keepaliveInterval * 1000
: 60000;
}
if (tunnelConfig.keepaliveCountMax === undefined) {
tunnelConfig.keepaliveCountMax =
typeof resolvedHost.terminalConfig?.keepaliveCountMax === "number"
? resolvedHost.terminalConfig.keepaliveCountMax
: 5;
}
}
} catch (error) {
tunnelLogger.warn("Failed to resolve source host credentials", {
@@ -1936,8 +1956,8 @@ async function connectSSHTunnel(
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 60000,
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
@@ -2192,11 +2212,11 @@ async function killRemoteTunnelByMarker(
tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 60000,
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 15000,
tcpKeepAliveInitialDelay: 30000,
algorithms: {
kex: [
"diffie-hellman-group14-sha256",