diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4e664c31..cc32f3d1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -84,7 +84,8 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} - outputs: type=registry,compression=zstd,compression-level=19 + org.opencontainers.image.created=${{ github.run_id }} + outputs: type=registry,compression=gzip,compression-level=9 - name: Cleanup Docker if: always() diff --git a/electron/main.cjs b/electron/main.cjs index 8f623912..97ced567 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -395,6 +395,48 @@ ipcMain.handle("save-server-config", (event, config) => { } }); +ipcMain.handle("get-setting", (event, key) => { + try { + const userDataPath = app.getPath("userData"); + const settingsPath = path.join(userDataPath, "settings.json"); + + if (!fs.existsSync(settingsPath)) { + return null; + } + + const settingsData = fs.readFileSync(settingsPath, "utf8"); + const settings = JSON.parse(settingsData); + return settings[key] !== undefined ? settings[key] : null; + } catch (error) { + console.error("Error reading setting:", error); + return null; + } +}); + +ipcMain.handle("set-setting", (event, key, value) => { + try { + const userDataPath = app.getPath("userData"); + const settingsPath = path.join(userDataPath, "settings.json"); + + if (!fs.existsSync(userDataPath)) { + fs.mkdirSync(userDataPath, { recursive: true }); + } + + let settings = {}; + if (fs.existsSync(settingsPath)) { + const settingsData = fs.readFileSync(settingsPath, "utf8"); + settings = JSON.parse(settingsData); + } + + settings[key] = value; + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + return { success: true }; + } catch (error) { + console.error("Error saving setting:", error); + return { success: false, error: error.message }; + } +}); + ipcMain.handle("test-server-connection", async (event, serverUrl) => { try { const https = require("https"); diff --git a/electron/preload.js b/electron/preload.js index 0fbd3dc4..e8b8655a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -22,6 +22,10 @@ contextBridge.exposeInMainWorld("electronAPI", { isElectron: true, isDev: process.env.NODE_ENV === "development", + // Settings/preferences storage + getSetting: (key) => ipcRenderer.invoke("get-setting", key), + setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 37a09592..01f713ba 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -22,6 +22,7 @@ import { DatabaseMigration } from "../utils/database-migration.js"; import { UserDataExport } from "../utils/user-data-export.js"; import { AutoSSLSetup } from "../utils/auto-ssl-setup.js"; import { eq, and } from "drizzle-orm"; +import { parseUserAgent } from "../utils/user-agent-parser.js"; import { users, sshData, @@ -457,8 +458,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => { code: "PASSWORD_REQUIRED", }); } - - const unlocked = await authManager.authenticateUser(userId, password); + const deviceInfo = parseUserAgent(req); + const unlocked = await authManager.authenticateUser( + userId, + password, + deviceInfo.type, + ); if (!unlocked) { return res.status(401).json({ error: "Invalid password" }); } @@ -905,6 +910,7 @@ app.post( const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; const mainDb = getDb(); + const deviceInfo = parseUserAgent(req); const userRecords = await mainDb .select() @@ -925,12 +931,19 @@ app.post( }); } - const unlocked = await authManager.authenticateUser(userId, password); + const unlocked = await authManager.authenticateUser( + userId, + password, + deviceInfo.type, + ); if (!unlocked) { return res.status(401).json({ error: "Invalid password" }); } } else if (!DataCrypto.getUserDataKey(userId)) { - const oidcUnlocked = await authManager.authenticateOIDCUser(userId); + const oidcUnlocked = await authManager.authenticateOIDCUser( + userId, + deviceInfo.type, + ); if (!oidcUnlocked) { return res.status(403).json({ error: "Failed to unlock user data with SSO credentials", @@ -948,7 +961,10 @@ app.post( let userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey && isOidcUser) { - const oidcUnlocked = await authManager.authenticateOIDCUser(userId); + const oidcUnlocked = await authManager.authenticateOIDCUser( + userId, + deviceInfo.type, + ); if (oidcUnlocked) { userDataKey = DataCrypto.getUserDataKey(userId); } diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 4c5b00bd..43f6e5e9 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -183,7 +183,7 @@ async function initializeCompleteDatabase(): Promise { created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_data ( @@ -214,7 +214,7 @@ async function initializeCompleteDatabase(): Promise { terminal_config TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_recent ( @@ -224,8 +224,8 @@ async function initializeCompleteDatabase(): Promise { name TEXT NOT NULL, path TEXT NOT NULL, last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + 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 file_manager_pinned ( @@ -235,8 +235,8 @@ async function initializeCompleteDatabase(): Promise { name TEXT NOT NULL, path TEXT NOT NULL, pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + 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 file_manager_shortcuts ( @@ -246,8 +246,8 @@ async function initializeCompleteDatabase(): Promise { name TEXT NOT NULL, path TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + 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 dismissed_alerts ( @@ -255,7 +255,7 @@ async function initializeCompleteDatabase(): Promise { user_id TEXT NOT NULL, alert_id TEXT NOT NULL, dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_credentials ( @@ -275,7 +275,7 @@ async function initializeCompleteDatabase(): Promise { last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_credential_usage ( @@ -284,9 +284,9 @@ async function initializeCompleteDatabase(): Promise { host_id INTEGER NOT NULL, user_id TEXT NOT NULL, used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id), - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS snippets ( @@ -297,7 +297,7 @@ async function initializeCompleteDatabase(): Promise { description TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_folders ( @@ -308,7 +308,7 @@ async function initializeCompleteDatabase(): Promise { icon TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS recent_activity ( @@ -318,8 +318,8 @@ async function initializeCompleteDatabase(): Promise { host_id INTEGER NOT NULL, host_name TEXT NOT NULL, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + 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 command_history ( @@ -328,8 +328,8 @@ async function initializeCompleteDatabase(): Promise { host_id INTEGER NOT NULL, command TEXT NOT NULL, executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); `); @@ -452,6 +452,7 @@ const migrateSchema = () => { "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT"); + addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_file_manager", @@ -475,7 +476,7 @@ const migrateSchema = () => { addColumnIfNotExists( "ssh_data", "credential_id", - "INTEGER REFERENCES ssh_credentials(id)", + "INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL", ); addColumnIfNotExists( "ssh_data", diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 05973cae..2bc5ee67 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -34,7 +34,7 @@ export const sessions = sqliteTable("sessions", { id: text("id").primaryKey(), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), jwtToken: text("jwt_token").notNull(), deviceType: text("device_type").notNull(), deviceInfo: text("device_info").notNull(), @@ -51,7 +51,7 @@ export const sshData = sqliteTable("ssh_data", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), name: text("name"), ip: text("ip").notNull(), port: integer("port").notNull(), @@ -71,7 +71,7 @@ export const sshData = sqliteTable("ssh_data", { autostartKey: text("autostart_key", { length: 8192 }), autostartKeyPassword: text("autostart_key_password"), - credentialId: integer("credential_id").references(() => sshCredentials.id), + credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), overrideCredentialUsername: integer("override_credential_username", { mode: "boolean", }), @@ -82,6 +82,7 @@ export const sshData = sqliteTable("ssh_data", { .notNull() .default(true), tunnelConnections: text("tunnel_connections"), + jumpHosts: text("jump_hosts"), enableFileManager: integer("enable_file_manager", { mode: "boolean" }) .notNull() .default(true), @@ -98,12 +99,9 @@ export const sshData = sqliteTable("ssh_data", { export const fileManagerRecent = sqliteTable("file_manager_recent", { id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id") - .notNull() - .references(() => users.id), hostId: integer("host_id") .notNull() - .references(() => sshData.id), + .references(() => sshData.id, { onDelete: "cascade" }), name: text("name").notNull(), path: text("path").notNull(), lastOpened: text("last_opened") @@ -115,10 +113,10 @@ export const fileManagerPinned = sqliteTable("file_manager_pinned", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() - .references(() => sshData.id), + .references(() => sshData.id, { onDelete: "cascade" }), name: text("name").notNull(), path: text("path").notNull(), pinnedAt: text("pinned_at") @@ -130,13 +128,10 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() - .references(() => sshData.id), - name: text("name").notNull(), - path: text("path").notNull(), - createdAt: text("created_at") + .references(() => sshData.id, { onDelete: "cascade" }), .notNull() .default(sql`CURRENT_TIMESTAMP`), }); @@ -145,7 +140,7 @@ export const dismissedAlerts = sqliteTable("dismissed_alerts", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), alertId: text("alert_id").notNull(), dismissedAt: text("dismissed_at") .notNull() @@ -156,7 +151,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), description: text("description"), folder: text("folder"), @@ -184,13 +179,13 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", { id: integer("id").primaryKey({ autoIncrement: true }), credentialId: integer("credential_id") .notNull() - .references(() => sshCredentials.id), + .references(() => sshCredentials.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() - .references(() => sshData.id), + .references(() => sshData.id, { onDelete: "cascade" }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), usedAt: text("used_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -200,7 +195,7 @@ export const snippets = sqliteTable("snippets", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), content: text("content").notNull(), description: text("description"), @@ -216,7 +211,7 @@ export const sshFolders = sqliteTable("ssh_folders", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), color: text("color"), icon: text("icon"), @@ -232,12 +227,11 @@ export const recentActivity = sqliteTable("recent_activity", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), type: text("type").notNull(), hostId: integer("host_id") .notNull() - .references(() => sshData.id), - hostName: text("host_name").notNull(), + .references(() => sshData.id, { onDelete: "cascade" }), timestamp: text("timestamp") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -247,11 +241,10 @@ export const commandHistory = sqliteTable("command_history", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() - .references(() => sshData.id), - command: text("command").notNull(), + .references(() => sshData.id, { onDelete: "cascade" }), executedAt: text("executed_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 7a4dd158..808d4d40 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -235,6 +235,7 @@ router.post( enableFileManager, defaultPath, tunnelConnections, + jumpHosts, statsConfig, terminalConfig, forceKeyboardInteractive, @@ -271,6 +272,7 @@ router.post( tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, + jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, enableFileManager: enableFileManager ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, @@ -329,6 +331,9 @@ router.post( tunnelConnections: createdHost.tunnelConnections ? JSON.parse(createdHost.tunnelConnections as string) : [], + jumpHosts: createdHost.jumpHosts + ? JSON.parse(createdHost.jumpHosts as string) + : [], enableFileManager: !!createdHost.enableFileManager, statsConfig: createdHost.statsConfig ? JSON.parse(createdHost.statsConfig as string) @@ -370,6 +375,7 @@ router.post( router.put( "/db/host/:id", authenticateJWT, + requireDataAccess, upload.single("key"), async (req: Request, res: Response) => { const hostId = req.params.id; @@ -425,6 +431,7 @@ router.put( enableFileManager, defaultPath, tunnelConnections, + jumpHosts, statsConfig, terminalConfig, forceKeyboardInteractive, @@ -462,6 +469,7 @@ router.put( tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, + jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, enableFileManager: enableFileManager ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, @@ -538,6 +546,9 @@ router.put( tunnelConnections: updatedHost.tunnelConnections ? JSON.parse(updatedHost.tunnelConnections as string) : [], + jumpHosts: updatedHost.jumpHosts + ? JSON.parse(updatedHost.jumpHosts as string) + : [], enableFileManager: !!updatedHost.enableFileManager, statsConfig: updatedHost.statsConfig ? JSON.parse(updatedHost.statsConfig as string) @@ -577,67 +588,74 @@ router.put( // Route: Get SSH data for the authenticated user (requires JWT) // GET /ssh/host -router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - if (!isNonEmptyString(userId)) { - sshLogger.warn("Invalid userId for SSH data fetch", { - operation: "host_fetch", - userId, - }); - return res.status(400).json({ error: "Invalid userId" }); - } - try { - const data = await SimpleDBOps.select( - db.select().from(sshData).where(eq(sshData.userId, userId)), - "ssh_data", - userId, - ); +router.get( + "/db/host", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + if (!isNonEmptyString(userId)) { + sshLogger.warn("Invalid userId for SSH data fetch", { + operation: "host_fetch", + userId, + }); + return res.status(400).json({ error: "Invalid userId" }); + } + try { + const data = await SimpleDBOps.select( + db.select().from(sshData).where(eq(sshData.userId, userId)), + "ssh_data", + userId, + ); - const result = await Promise.all( - data.map(async (row: Record) => { - const baseHost = { - ...row, - tags: - typeof row.tags === "string" - ? row.tags - ? row.tags.split(",").filter(Boolean) - : [] + const result = await Promise.all( + data.map(async (row: Record) => { + const baseHost = { + ...row, + tags: + typeof row.tags === "string" + ? row.tags + ? row.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!row.pin, + enableTerminal: !!row.enableTerminal, + enableTunnel: !!row.enableTunnel, + tunnelConnections: row.tunnelConnections + ? JSON.parse(row.tunnelConnections as string) : [], - pin: !!row.pin, - enableTerminal: !!row.enableTerminal, - enableTunnel: !!row.enableTunnel, - tunnelConnections: row.tunnelConnections - ? JSON.parse(row.tunnelConnections as string) - : [], - enableFileManager: !!row.enableFileManager, - statsConfig: row.statsConfig - ? JSON.parse(row.statsConfig as string) - : undefined, - terminalConfig: row.terminalConfig - ? JSON.parse(row.terminalConfig as string) - : undefined, - forceKeyboardInteractive: row.forceKeyboardInteractive === "true", - }; + jumpHosts: row.jumpHosts ? JSON.parse(row.jumpHosts as string) : [], + enableFileManager: !!row.enableFileManager, + statsConfig: row.statsConfig + ? JSON.parse(row.statsConfig as string) + : undefined, + terminalConfig: row.terminalConfig + ? JSON.parse(row.terminalConfig as string) + : undefined, + forceKeyboardInteractive: row.forceKeyboardInteractive === "true", + }; - return (await resolveHostCredentials(baseHost)) || baseHost; - }), - ); + return (await resolveHostCredentials(baseHost)) || baseHost; + }), + ); - res.json(result); - } catch (err) { - sshLogger.error("Failed to fetch SSH hosts from database", err, { - operation: "host_fetch", - userId, - }); - res.status(500).json({ error: "Failed to fetch SSH data" }); - } -}); + res.json(result); + } catch (err) { + sshLogger.error("Failed to fetch SSH hosts from database", err, { + operation: "host_fetch", + userId, + }); + res.status(500).json({ error: "Failed to fetch SSH data" }); + } + }, +); // Route: Get SSH host by ID (requires JWT) // GET /ssh/host/:id router.get( "/db/host/:id", authenticateJWT, + requireDataAccess, async (req: Request, res: Response) => { const hostId = req.params.id; const userId = (req as AuthenticatedRequest).userId; @@ -813,42 +831,6 @@ router.delete( const numericHostId = Number(hostId); - await db - .delete(fileManagerRecent) - .where( - and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, numericHostId), - ), - ); - - await db - .delete(fileManagerPinned) - .where( - and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, numericHostId), - ), - ); - - await db - .delete(fileManagerShortcuts) - .where( - and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, numericHostId), - ), - ); - - await db - .delete(sshCredentialUsage) - .where( - and( - eq(sshCredentialUsage.userId, userId), - eq(sshCredentialUsage.hostId, numericHostId), - ), - ); - await db .delete(sshData) .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId))); diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c16cbf96..c81aa64d 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -747,6 +747,7 @@ router.get("/oidc/callback", async (req, res) => { }); } + const deviceInfo = parseUserAgent(req); let user = await db .select() .from(users) @@ -780,7 +781,11 @@ router.get("/oidc/callback", async (req, res) => { }); try { - await authManager.registerOIDCUser(id); + const sessionDurationMs = + deviceInfo.type === "desktop" || deviceInfo.type === "mobile" + ? 30 * 24 * 60 * 60 * 1000 // 30 days + : 7 * 24 * 60 * 60 * 1000; // 7 days + await authManager.registerOIDCUser(id, sessionDurationMs); } catch (encryptionError) { await db.delete(users).where(eq(users.id, id)); authLogger.error( @@ -819,7 +824,7 @@ router.get("/oidc/callback", async (req, res) => { const userRecord = user[0]; try { - await authManager.authenticateOIDCUser(userRecord.id); + await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch (setupError) { authLogger.error("Failed to setup OIDC user encryption", setupError, { operation: "oidc_user_encryption_setup_failed", @@ -827,7 +832,6 @@ router.get("/oidc/callback", async (req, res) => { }); } - const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, @@ -941,7 +945,10 @@ router.post("/login", async (req, res) => { operation: "user_login", username, ip: clientIp, - remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), + remainingAttempts: loginRateLimiter.getRemainingAttempts( + clientIp, + username, + ), }); return res.status(401).json({ error: "Invalid username or password" }); } @@ -967,7 +974,10 @@ router.post("/login", async (req, res) => { username, userId: userRecord.id, ip: clientIp, - remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), + remainingAttempts: loginRateLimiter.getRemainingAttempts( + clientIp, + username, + ), }); return res.status(401).json({ error: "Invalid username or password" }); } @@ -985,9 +995,11 @@ router.post("/login", async (req, res) => { databaseLogger.debug("Operation failed, continuing", { error }); } + const deviceInfo = parseUserAgent(req); const dataUnlocked = await authManager.authenticateUser( userRecord.id, password, + deviceInfo.type, ); if (!dataUnlocked) { return res.status(401).json({ error: "Incorrect password" }); @@ -1005,7 +1017,6 @@ router.post("/login", async (req, res) => { }); } - const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 6a051784..84258fc9 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -89,6 +89,173 @@ app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); const authManager = AuthManager.getInstance(); app.use(authManager.createAuthMiddleware()); +async function resolveJumpHost( + hostId: number, + userId: string, +): Promise { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return null; + } + + const host = hosts[0]; + + if (host.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + password: credential.password, + key: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authType: credential.auth_type || credential.authType, + }; + } + } + + return host; + } catch (error) { + fileLogger.error("Failed to resolve jump host", error, { + operation: "resolve_jump_host", + hostId, + userId, + }); + return null; + } +} + +async function createJumpHostChain( + jumpHosts: Array<{ hostId: number }>, + userId: string, +): Promise { + if (!jumpHosts || jumpHosts.length === 0) { + return null; + } + + let currentClient: SSHClient | null = null; + const clients: SSHClient[] = []; + + try { + for (let i = 0; i < jumpHosts.length; i++) { + const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + + if (!jumpHostConfig) { + fileLogger.error(`Jump host ${i + 1} not found`, undefined, { + operation: "jump_host_chain", + hostId: jumpHosts[i].hostId, + }); + clients.forEach((c) => c.end()); + return null; + } + + const jumpClient = new SSHClient(); + clients.push(jumpClient); + + const connected = await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + jumpClient.on("ready", () => { + clearTimeout(timeout); + resolve(true); + }); + + jumpClient.on("error", (err) => { + clearTimeout(timeout); + fileLogger.error(`Jump host ${i + 1} connection failed`, err, { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + }); + resolve(false); + }); + + const connectConfig: any = { + host: jumpHostConfig.ip, + port: jumpHostConfig.port || 22, + username: jumpHostConfig.username, + tryKeyboard: true, + readyTimeout: 30000, + }; + + if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { + connectConfig.password = jumpHostConfig.password; + } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { + const cleanKey = jumpHostConfig.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + if (jumpHostConfig.keyPassword) { + connectConfig.passphrase = jumpHostConfig.keyPassword; + } + } + + if (currentClient) { + currentClient.forwardOut( + "127.0.0.1", + 0, + jumpHostConfig.ip, + jumpHostConfig.port || 22, + (err, stream) => { + if (err) { + clearTimeout(timeout); + resolve(false); + return; + } + connectConfig.sock = stream; + jumpClient.connect(connectConfig); + }, + ); + } else { + jumpClient.connect(connectConfig); + } + }); + + if (!connected) { + clients.forEach((c) => c.end()); + return null; + } + + currentClient = jumpClient; + } + + return currentClient; + } catch (error) { + fileLogger.error("Failed to create jump host chain", error, { + operation: "jump_host_chain", + }); + clients.forEach((c) => c.end()); + return null; + } +} + interface SSHSession { client: SSHClient; isConnected: boolean; @@ -176,6 +343,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { credentialId, userProvidedPassword, forceKeyboardInteractive, + jumpHosts, } = req.body; const userId = (req as AuthenticatedRequest).userId; @@ -627,7 +795,54 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }, ); - client.connect(config); + if (jumpHosts && jumpHosts.length > 0 && userId) { + try { + const jumpClient = await createJumpHostChain(jumpHosts, userId); + + if (!jumpClient) { + fileLogger.error("Failed to establish jump host chain", { + operation: "file_jump_chain", + sessionId, + hostId, + }); + return res + .status(500) + .json({ error: "Failed to connect through jump hosts" }); + } + + jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { + if (err) { + fileLogger.error("Failed to forward through jump host", err, { + operation: "file_jump_forward", + sessionId, + hostId, + ip, + port, + }); + jumpClient.end(); + return res + .status(500) + .json({ + error: "Failed to forward through jump host: " + err.message, + }); + } + + config.sock = stream; + client.connect(config); + }); + } catch (error) { + fileLogger.error("Jump host error", error, { + operation: "file_jump_host", + sessionId, + hostId, + }); + return res + .status(500) + .json({ error: "Failed to connect through jump hosts" }); + } + } else { + client.connect(config); + } }); app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index a0a666f5..1b93b123 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -19,6 +19,173 @@ import { collectProcessesMetrics } from "./widgets/processes-collector.js"; import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; +async function resolveJumpHost( + hostId: number, + userId: string, +): Promise { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return null; + } + + const host = hosts[0]; + + if (host.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + password: credential.password, + key: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authType: credential.auth_type || credential.authType, + }; + } + } + + return host; + } catch (error) { + statsLogger.error("Failed to resolve jump host", error, { + operation: "resolve_jump_host", + hostId, + userId, + }); + return null; + } +} + +async function createJumpHostChain( + jumpHosts: Array<{ hostId: number }>, + userId: string, +): Promise { + if (!jumpHosts || jumpHosts.length === 0) { + return null; + } + + let currentClient: Client | null = null; + const clients: Client[] = []; + + try { + for (let i = 0; i < jumpHosts.length; i++) { + const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + + if (!jumpHostConfig) { + statsLogger.error(`Jump host ${i + 1} not found`, undefined, { + operation: "jump_host_chain", + hostId: jumpHosts[i].hostId, + }); + clients.forEach((c) => c.end()); + return null; + } + + const jumpClient = new Client(); + clients.push(jumpClient); + + const connected = await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + jumpClient.on("ready", () => { + clearTimeout(timeout); + resolve(true); + }); + + jumpClient.on("error", (err) => { + clearTimeout(timeout); + statsLogger.error(`Jump host ${i + 1} connection failed`, err, { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + }); + resolve(false); + }); + + const connectConfig: any = { + host: jumpHostConfig.ip, + port: jumpHostConfig.port || 22, + username: jumpHostConfig.username, + tryKeyboard: true, + readyTimeout: 30000, + }; + + if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { + connectConfig.password = jumpHostConfig.password; + } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { + const cleanKey = jumpHostConfig.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + if (jumpHostConfig.keyPassword) { + connectConfig.passphrase = jumpHostConfig.keyPassword; + } + } + + if (currentClient) { + currentClient.forwardOut( + "127.0.0.1", + 0, + jumpHostConfig.ip, + jumpHostConfig.port || 22, + (err, stream) => { + if (err) { + clearTimeout(timeout); + resolve(false); + return; + } + connectConfig.sock = stream; + jumpClient.connect(connectConfig); + }, + ); + } else { + jumpClient.connect(connectConfig); + } + }); + + if (!connected) { + clients.forEach((c) => c.end()); + return null; + } + + currentClient = jumpClient; + } + + return currentClient; + } catch (error) { + statsLogger.error("Failed to create jump host chain", error, { + operation: "jump_host_chain", + }); + clients.forEach((c) => c.end()); + return null; + } +} + interface PooledConnection { client: Client; lastUsed: number; @@ -87,7 +254,7 @@ class SSHConnectionPool { private async createConnection( host: SSHHostWithCredentials, ): Promise { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { const client = new Client(); const timeout = setTimeout(() => { client.end(); @@ -137,7 +304,44 @@ class SSHConnectionPool { ); try { - client.connect(buildSshConfig(host)); + const config = buildSshConfig(host); + + if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) { + const jumpClient = await createJumpHostChain( + host.jumpHosts, + host.userId, + ); + + if (!jumpClient) { + clearTimeout(timeout); + reject(new Error("Failed to establish jump host chain")); + return; + } + + jumpClient.forwardOut( + "127.0.0.1", + 0, + host.ip, + host.port, + (err, stream) => { + if (err) { + clearTimeout(timeout); + jumpClient.end(); + reject( + new Error( + "Failed to forward through jump host: " + err.message, + ), + ); + return; + } + + config.sock = stream; + client.connect(config); + }, + ); + } else { + client.connect(config); + } } catch (err) { clearTimeout(timeout); reject(err); @@ -396,6 +600,7 @@ interface SSHHostWithCredentials { enableFileManager: boolean; defaultPath: string; tunnelConnections: unknown[]; + jumpHosts?: Array<{ hostId: number }>; statsConfig?: string; createdAt: string; updatedAt: string; diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 3e9930e2..b297ab53 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -31,6 +31,7 @@ interface ConnectToHostData { credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; + jumpHosts?: Array<{ hostId: number }>; }; initialPath?: string; executeCommand?: string; @@ -57,6 +58,173 @@ const userCrypto = UserCrypto.getInstance(); const userConnections = new Map>(); +async function resolveJumpHost( + hostId: number, + userId: string, +): Promise { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return null; + } + + const host = hosts[0]; + + if (host.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + password: credential.password, + key: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authType: credential.auth_type || credential.authType, + }; + } + } + + return host; + } catch (error) { + sshLogger.error("Failed to resolve jump host", error, { + operation: "resolve_jump_host", + hostId, + userId, + }); + return null; + } +} + +async function createJumpHostChain( + jumpHosts: Array<{ hostId: number }>, + userId: string, +): Promise { + if (!jumpHosts || jumpHosts.length === 0) { + return null; + } + + let currentClient: Client | null = null; + const clients: Client[] = []; + + try { + for (let i = 0; i < jumpHosts.length; i++) { + const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + + if (!jumpHostConfig) { + sshLogger.error(`Jump host ${i + 1} not found`, undefined, { + operation: "jump_host_chain", + hostId: jumpHosts[i].hostId, + }); + clients.forEach((c) => c.end()); + return null; + } + + const jumpClient = new Client(); + clients.push(jumpClient); + + const connected = await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + jumpClient.on("ready", () => { + clearTimeout(timeout); + resolve(true); + }); + + jumpClient.on("error", (err) => { + clearTimeout(timeout); + sshLogger.error(`Jump host ${i + 1} connection failed`, err, { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + }); + resolve(false); + }); + + const connectConfig: any = { + host: jumpHostConfig.ip, + port: jumpHostConfig.port || 22, + username: jumpHostConfig.username, + tryKeyboard: true, + readyTimeout: 30000, + }; + + if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { + connectConfig.password = jumpHostConfig.password; + } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { + const cleanKey = jumpHostConfig.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + if (jumpHostConfig.keyPassword) { + connectConfig.passphrase = jumpHostConfig.keyPassword; + } + } + + if (currentClient) { + currentClient.forwardOut( + "127.0.0.1", + 0, + jumpHostConfig.ip, + jumpHostConfig.port || 22, + (err, stream) => { + if (err) { + clearTimeout(timeout); + resolve(false); + return; + } + connectConfig.sock = stream; + jumpClient.connect(connectConfig); + }, + ); + } else { + jumpClient.connect(connectConfig); + } + }); + + if (!connected) { + clients.forEach((c) => c.end()); + return null; + } + + currentClient = jumpClient; + } + + return currentClient; + } catch (error) { + sshLogger.error("Failed to create jump host chain", error, { + operation: "jump_host_chain", + }); + clients.forEach((c) => c.end()); + return null; + } +} + const wss = new WebSocketServer({ port: 30002, verifyClient: async (info) => { @@ -990,7 +1158,68 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - sshConn.connect(connectConfig); + if ( + hostConfig.jumpHosts && + hostConfig.jumpHosts.length > 0 && + hostConfig.userId + ) { + try { + const jumpClient = await createJumpHostChain( + hostConfig.jumpHosts, + hostConfig.userId, + ); + + if (!jumpClient) { + sshLogger.error("Failed to establish jump host chain"); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to connect through jump hosts", + }), + ); + cleanupSSH(connectionTimeout); + return; + } + + jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { + if (err) { + sshLogger.error("Failed to forward through jump host", err, { + operation: "ssh_jump_forward", + hostId: id, + ip, + port, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to forward through jump host: " + err.message, + }), + ); + jumpClient.end(); + cleanupSSH(connectionTimeout); + return; + } + + connectConfig.sock = stream; + sshConn.connect(connectConfig); + }); + } catch (error) { + sshLogger.error("Jump host error", error, { + operation: "ssh_jump_host", + hostId: id, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to connect through jump hosts", + }), + ); + cleanupSSH(connectionTimeout); + return; + } + } else { + sshConn.connect(connectConfig); + } } function handleResize(data: ResizeData) { diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 4c936110..1b0dc2e6 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -85,12 +85,26 @@ class AuthManager { await this.userCrypto.setupUserEncryption(userId, password); } - async registerOIDCUser(userId: string): Promise { - await this.userCrypto.setupOIDCUserEncryption(userId); + async registerOIDCUser( + userId: string, + sessionDurationMs: number, + ): Promise { + await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs); } - async authenticateOIDCUser(userId: string): Promise { - const authenticated = await this.userCrypto.authenticateOIDCUser(userId); + async authenticateOIDCUser( + userId: string, + deviceType?: DeviceType, + ): Promise { + const sessionDurationMs = + deviceType === "desktop" || deviceType === "mobile" + ? 30 * 24 * 60 * 60 * 1000 // 30 days + : 7 * 24 * 60 * 60 * 1000; // 7 days + + const authenticated = await this.userCrypto.authenticateOIDCUser( + userId, + sessionDurationMs, + ); if (authenticated) { await this.performLazyEncryptionMigration(userId); @@ -99,10 +113,20 @@ class AuthManager { return authenticated; } - async authenticateUser(userId: string, password: string): Promise { + async authenticateUser( + userId: string, + password: string, + deviceType?: DeviceType, + ): Promise { + const sessionDurationMs = + deviceType === "desktop" || deviceType === "mobile" + ? 30 * 24 * 60 * 60 * 1000 // 30 days + : 7 * 24 * 60 * 60 * 1000; // 7 days + const authenticated = await this.userCrypto.authenticateUser( userId, password, + sessionDurationMs, ); if (authenticated) { diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index 607066b6..74442274 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -21,7 +21,6 @@ interface EncryptedDEK { interface UserSession { dataKey: Buffer; - lastActivity: number; expiresAt: number; } @@ -33,8 +32,6 @@ class UserCrypto { private static readonly PBKDF2_ITERATIONS = 100000; private static readonly KEK_LENGTH = 32; private static readonly DEK_LENGTH = 32; - private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000; - private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000; private constructor() { setInterval( @@ -69,7 +66,10 @@ class UserCrypto { DEK.fill(0); } - async setupOIDCUserEncryption(userId: string): Promise { + async setupOIDCUserEncryption( + userId: string, + sessionDurationMs: number, + ): Promise { const existingEncryptedDEK = await this.getEncryptedDEK(userId); let DEK: Buffer; @@ -104,14 +104,17 @@ class UserCrypto { const now = Date.now(); this.userSessions.set(userId, { dataKey: Buffer.from(DEK), - lastActivity: now, - expiresAt: now + UserCrypto.SESSION_DURATION, + expiresAt: now + sessionDurationMs, }); DEK.fill(0); } - async authenticateUser(userId: string, password: string): Promise { + async authenticateUser( + userId: string, + password: string, + sessionDurationMs: number, + ): Promise { try { const kekSalt = await this.getKEKSalt(userId); if (!kekSalt) return false; @@ -144,8 +147,7 @@ class UserCrypto { this.userSessions.set(userId, { dataKey: Buffer.from(DEK), - lastActivity: now, - expiresAt: now + UserCrypto.SESSION_DURATION, + expiresAt: now + sessionDurationMs, }); DEK.fill(0); @@ -161,13 +163,16 @@ class UserCrypto { } } - async authenticateOIDCUser(userId: string): Promise { + async authenticateOIDCUser( + userId: string, + sessionDurationMs: number, + ): Promise { try { const kekSalt = await this.getKEKSalt(userId); const encryptedDEK = await this.getEncryptedDEK(userId); if (!kekSalt || !encryptedDEK) { - await this.setupOIDCUserEncryption(userId); + await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } @@ -176,7 +181,7 @@ class UserCrypto { systemKey.fill(0); if (!DEK || DEK.length === 0) { - await this.setupOIDCUserEncryption(userId); + await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } @@ -189,15 +194,14 @@ class UserCrypto { this.userSessions.set(userId, { dataKey: Buffer.from(DEK), - lastActivity: now, - expiresAt: now + UserCrypto.SESSION_DURATION, + expiresAt: now + sessionDurationMs, }); DEK.fill(0); return true; } catch { - await this.setupOIDCUserEncryption(userId); + await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } } @@ -219,16 +223,6 @@ class UserCrypto { return null; } - if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) { - this.userSessions.delete(userId); - session.dataKey.fill(0); - if (this.sessionExpiredCallback) { - this.sessionExpiredCallback(userId); - } - return null; - } - - session.lastActivity = now; return session.dataKey; } @@ -359,10 +353,7 @@ class UserCrypto { const expiredUsers: string[] = []; for (const [userId, session] of this.userSessions.entries()) { - if ( - now > session.expiresAt || - now - session.lastActivity > UserCrypto.MAX_INACTIVITY - ) { + if (now > session.expiresAt) { session.dataKey.fill(0); expiredUsers.push(userId); } diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index c1d6639b..ce3a1028 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -295,6 +295,7 @@ "submit": "Senden", "change": "Ändern", "save": "Speichern", + "saving": "Speichern...", "delete": "Löschen", "edit": "Bearbeiten", "add": "Hinzufügen", @@ -1192,7 +1193,7 @@ "from": "von" }, "auth": { - "tagline": "SSH TERMINAL MANAGER", + "tagline": "SSH SERVER MANAGER", "description": "Sichere, leistungsstarke und intuitive SSH-Verbindungsverwaltung", "welcomeBack": "Willkommen zurück bei TERMIX", "createAccount": "Erstellen Sie Ihr TERMIX-Konto", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e8c5bd21..08559665 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -344,6 +344,7 @@ "cancel": "Cancel", "change": "Change", "save": "Save", + "saving": "Saving...", "delete": "Delete", "edit": "Edit", "add": "Add", @@ -1343,7 +1344,7 @@ "from": "from" }, "auth": { - "tagline": "SSH TERMINAL MANAGER", + "tagline": "SSH SERVER MANAGER", "description": "Secure, powerful, and intuitive SSH connection management", "welcomeBack": "Welcome back to TERMIX", "createAccount": "Create your TERMIX account", diff --git a/src/types/index.ts b/src/types/index.ts index 913cf5fc..06ac52c1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,10 @@ import type { Request } from "express"; // SSH HOST TYPES // ============================================================================ +export interface JumpHost { + hostId: number; +} + export interface SSHHost { id: number; name: string; @@ -33,12 +37,17 @@ export interface SSHHost { enableFileManager: boolean; defaultPath: string; tunnelConnections: TunnelConnection[]; + jumpHosts?: JumpHost[]; statsConfig?: string; terminalConfig?: TerminalConfig; createdAt: string; updatedAt: string; } +export interface JumpHostData { + hostId: number; +} + export interface SSHHostData { name?: string; ip: string; @@ -60,6 +69,7 @@ export interface SSHHostData { defaultPath?: string; forceKeyboardInteractive?: boolean; tunnelConnections?: TunnelConnection[]; + jumpHosts?: JumpHostData[]; statsConfig?: string | Record; terminalConfig?: TerminalConfig; } diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 1aba3b30..c2ae66d7 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -8,6 +8,7 @@ import { useTabs, } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; +import { CommandHistoryProvider } from "@/ui/desktop/contexts/CommandHistoryContext.tsx"; import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; import { Toaster } from "@/components/ui/sonner.tsx"; @@ -37,11 +38,16 @@ function AppContent() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code === "ShiftLeft") { + if (event.repeat) { + return; + } const now = Date.now(); if (now - lastShiftPressTime.current < 300) { setIsCommandPaletteOpen((isOpen) => !isOpen); + lastShiftPressTime.current = 0; // Reset on double press + } else { + lastShiftPressTime.current = now; } - lastShiftPressTime.current = now; } if (event.key === "Escape") { setIsCommandPaletteOpen(false); @@ -314,7 +320,7 @@ function AppContent() { "subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards", }} > - SSH TERMINAL MANAGER + SSH SERVER MANAGER @@ -421,7 +427,9 @@ function AppContent() { function DesktopApp() { return ( - + + + ); } diff --git a/src/ui/desktop/admin/AdminSettings.tsx b/src/ui/desktop/admin/AdminSettings.tsx index 56d3ef47..d0181e2a 100644 --- a/src/ui/desktop/admin/AdminSettings.tsx +++ b/src/ui/desktop/admin/AdminSettings.tsx @@ -1072,109 +1072,113 @@ export function AdminSettings({ ) : (
- - - - Device - User - Created - Last Active - Expires - - {t("admin.actions")} - - - - - {sessions.map((session) => { - const DeviceIcon = - session.deviceType === "desktop" - ? Monitor - : session.deviceType === "mobile" - ? Smartphone - : Globe; +
+
+ + + Device + User + Created + Last Active + Expires + + {t("admin.actions")} + + + + + {sessions.map((session) => { + const DeviceIcon = + session.deviceType === "desktop" + ? Monitor + : session.deviceType === "mobile" + ? Smartphone + : Globe; - const createdDate = new Date(session.createdAt); - const lastActiveDate = new Date(session.lastActiveAt); - const expiresDate = new Date(session.expiresAt); + const createdDate = new Date(session.createdAt); + const lastActiveDate = new Date( + session.lastActiveAt, + ); + const expiresDate = new Date(session.expiresAt); - const formatDate = (date: Date) => - date.toLocaleDateString() + - " " + - date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); + const formatDate = (date: Date) => + date.toLocaleDateString() + + " " + + date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); - return ( - - -
- -
- - {session.deviceInfo} - - {session.isRevoked && ( - - Revoked + return ( + + +
+ +
+ + {session.deviceInfo} - )} + {session.isRevoked && ( + + Revoked + + )} +
-
- - - {session.username || session.userId} - - - {formatDate(createdDate)} - - - {formatDate(lastActiveDate)} - - - {formatDate(expiresDate)} - - -
- - {session.username && ( + + + {session.username || session.userId} + + + {formatDate(createdDate)} + + + {formatDate(lastActiveDate)} + + + {formatDate(expiresDate)} + + +
- )} -
-
- - ); - })} - -
+ {session.username && ( + + )} +
+ + + ); + })} + + + )} @@ -1185,8 +1189,8 @@ export function AdminSettings({

{t("admin.adminManagement")}

-
-

{t("admin.makeUserAdmin")}

+
+

{t("admin.makeUserAdmin")}