* feat: add draggable server stats dashboard with customizable widgets * fix: widget deletion and layout persistence issues * fix: improve widget deletion UX and add debug logs for persistence * fix: resolve widget deletion and layout persistence issues - Add drag handles to widget title bars for precise drag control - Prevent delete button from triggering drag via event stopPropagation - Include statsConfig field in all GET/PUT API responses - Remove debug console logs from production code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: complete statsConfig field support across all API routes - Add statsConfig to POST /db/host (create) route - Add statsConfig to all GET routes for consistent API responses - Remove incorrect statsConfig schema from HostManagerEditor - statsConfig is now only managed by Server page layout editor 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add statsConfig to metrics API response - Add statsConfig field to SSHHostWithCredentials interface - Include statsConfig in resolveHostCredentials baseHost object - Ensures /metrics/:id API returns complete host configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: include statsConfig in SSH host create/update requests The statsConfig field was being dropped by createSSHHost and updateSSHHost functions in main-axios.ts, preventing layout customization from persisting. Fixed by adding statsConfig to the submitData object in both functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: refactor server stats widgets into modular structure Created dedicated widgets directory with individual components: - CpuWidget, MemoryWidget, DiskWidget as separate components - Widget registry for centralized widget configuration - AddWidgetDialog for user-friendly widget selection - Updated Server.tsx to use modular widget system Benefits: - Better code organization and maintainability - Easier to add new widget types in the future - Centralized widget metadata and configuration - User can now add widgets via dialog interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: exit edit mode after saving layout * feat: add customizable widget sizes with chart visualizations Add three-tier size system (small/medium/large) for server stats widgets. Integrate recharts library for visualizing trends in large widgets with line charts (CPU), area charts (Memory), and radial bar charts (Disk). Fix layout overflow issues with proper flexbox patterns. * refactor: simplify server stats widget system Replaced complex drag-and-drop grid layout with simple checkbox-based configuration and static responsive grid display. - Removed react-grid-layout dependency and 6 related packages - Simplified StatsConfig from complex Widget objects to simple array - Added Statistics tab in HostManagerEditor for checkbox selection - Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout - Simplified widget components by removing edit mode and size selection - Deleted unused AddWidgetDialog and registry files - Fixed statsConfig serialization in backend routes Net result: -787 lines of code, cleaner architecture. * feat: add system, uptime, network and processes widgets Add four new server statistics widgets: - SystemWidget: displays hostname, OS, and kernel information - UptimeWidget: shows server total uptime with formatted display - NetworkWidget: lists network interfaces with IP and status - ProcessesWidget: displays top processes by CPU usage Backend changes: - Extended SSH metrics collection to gather network, uptime, process, and system data - Added commands to parse /proc/uptime, ip addr, ps aux output Frontend changes: - Created 4 new widget components with consistent styling - Updated widget type definitions and HostManagerEditor - Unified all widget heights to 280px for consistent layout - Added translations for all new widgets (EN/ZH) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve widget styling and UX consistency Enhance all server stats widgets with improved styling and user experience: Widget improvements: - Fix hardcoded titles, now use i18n translations for all widgets - Improve data formatting with consistent translation keys - Enhance empty state displays with better visual hierarchy - Add smooth hover transitions and visual feedback - Standardize spacing and layout patterns across widgets Specific optimizations: - CPU: Use translated load average display - Memory: Translate "Free" label - Disk: Translate "Available" label - System: Improve icon colors and spacing consistency - Network: Better empty state, enhanced card styling - Processes: Improved card borders and spacing Visual polish: - Unified icon sizing and opacity for empty states - Consistent border radius (rounded-lg) - Better hover states with subtle transitions - Enhanced font weights for improved readability * fix: replace explicit any types with proper TypeScript types - Replace 'any' with 'unknown' in catch blocks and add type assertions - Create explicit interfaces for complex objects (HostConfig, TabData, TerminalHandle) - Fix window/document object type extensions - Update Electron API type definitions - Improve type safety in database routes and utilities - Add proper types to Terminal components (Desktop & Mobile) - Fix navigation component types (TopNavbar, LeftSidebar, AppView) Reduces TypeScript lint errors from 394 to 358 (-36 errors) Fixes 45 @typescript-eslint/no-explicit-any violations * fix: replace explicit any types with proper TypeScript types - Create explicit interfaces for Request extensions (AuthenticatedRequest, RequestWithHeaders) - Add type definitions for WebSocket messages and SSH connection data - Use generic types in DataCrypto methods instead of any return types - Define proper interfaces for file manager data structures - Replace catch block any types with unknown and proper type assertions - Add HostConfig and TabData interfaces for Server component Fixes 32 @typescript-eslint/no-explicit-any violations across 5 files * fix: resolve 6 TypeScript compilation errors Fixed field name mismatches and generic type issues: - database.ts: Changed camelCase to snake_case for key_password, private_key, public_key fields - simple-db-ops.ts: Added explicit generic type parameters to DataCrypto method calls Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in backend utils Fixed @typescript-eslint/no-unused-vars errors in: - starter.ts: removed unused error variables (2 fixes) - auto-ssl-setup.ts: removed unused error variable (1 fix) - ssh-key-utils.ts: removed unused error variables (3 fixes) - user-crypto.ts: removed unused error variables (5 fixes) - data-crypto.ts: removed unused plaintextFields and error variables (2 fixes) - simple-db-ops.ts: removed unused parameters _userId and _tableName (2 fixes) Total: 15 unused variable errors fixed Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variable in terminal.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused userPayload variable (line 123) - Removed unused cols and rows from destructuring (line 348) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in server-stats.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused _reject parameter in Promise (line 64) - Removed shadowed now variable in pollStatusesOnce (line 1130) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in tunnel.ts Removed 5 unused variables: - Removed unused data parameter from stdout event handler - Removed hasSourcePassword, hasSourceKey, hasEndpointPassword, hasEndpointKey variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in main-axios.ts Removed 8 unused variables: - Removed unused type imports (Credential, CredentialData, HostInfo, ApiResponse) - Removed unused apiPort variable - Removed unused error variables in 3 catch blocks * fix: remove unused variables in terminal.ts and starter.ts Removed 2 unused variables: - Removed unused JWTPayload type import from terminal.ts - Removed unused _promise parameter from starter.ts * fix: remove unused variables in sidebar.tsx Removed 9 unused variables: - Removed 5 unused Sheet component imports - Removed unused SIDEBAR_WIDTH_MOBILE constant - Removed 3 unused variables from useSidebar destructuring * fix: remove 13 unused variables in frontend files - version-check-modal.tsx: removed 4 unused imports and functions - main.tsx: removed unused isMobile state - AdminSettings.tsx: removed 8 unused imports and error variables * fix: remove 28 unused variables across frontend components Cleaned up unused imports, state variables, and function parameters: - CredentialsManager.tsx: removed 8 unused variables (Sheet/Select imports) - FileManager.tsx: removed 10 unused variables (icons, state, functions) - Terminal.tsx (Desktop): removed 5 unused variables (state, handlers) - Terminal.tsx (Mobile): removed 5 unused variables (imports, state) Reduced lint errors from 271 to 236 (35 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in File Manager and config files Cleaned up more unused imports, parameters, and variables: - FileManagerGrid.tsx: removed 4 unused variables (params, function) - FileManagerContextMenu.tsx: removed Share import - FileManagerSidebar.tsx: removed onLoadDirectory parameter - DraggableWindow.tsx: removed Square import - FileWindow.tsx: removed updateWindow variable - ServerConfig.tsx: removed 2 unused error parameters Reduced lint errors from 236 to 222 (14 errors fixed total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 7 unused variables in widgets and Homepage components Cleaned up unused imports, parameters, and variables: - DiskWidget.tsx: removed metricsHistory parameter - FileManagerContextMenu.tsx: removed ExternalLink import - Homepage.tsx: removed useTranslation import - HomepageAlertManager.tsx: removed loading variable - HomepageAuth.tsx: removed setCookie import (Desktop & Mobile) - HompageUpdateLog.tsx: removed err parameter Reduced lint errors from 222 to 216 (6 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 8 unused variables in File Manager and Host Manager components Cleaned up unused imports, state variables, and function parameters: - DiffViewer.tsx: removed unused error parameter in catch block - FileViewer.tsx: removed ReactPlayer import, unused originalContent state, node parameters from markdown code components, audio variable - HostManager.tsx: removed onSelectView and updatedHost parameters - TunnelViewer.tsx: removed TunnelConnection import Reduced lint errors from 271 to 208 (63 errors fixed total) * fix: remove 7 unused variables in UI hooks and components Cleaned up unused parameters and functions: - status/index.tsx: removed unused className parameter from StatusIndicator - useDragToDesktop.ts: removed unused sshHost parameter and from dependency arrays (4 occurrences) - useDragToSystemDesktop.ts: removed unused sshHost parameter and getLastSaveDirectory function (29 lines removed) Continued reducing frontend lint errors * fix: remove 2 unused variables in hooks and TabContext - useDragToDesktop.ts: removed unused onSuccess in dragFolderToDesktop - TabContext.tsx: removed unused useTranslation import and t variable Continued reducing frontend lint errors * fix: remove 2 unused variables in Homepage component - Removed unused isAdmin state variable (changed to setter only) - Removed unused jwt variable by inlining getCookie check Continued reducing frontend lint errors * fix: remove 3 unused variables in Mobile navigation components - Host.tsx: removed unused Server icon import - LeftSidebar.tsx: removed unused setHostsLoading setter and err parameter Continued reducing frontend lint errors * fix: remove 9 unused variables across multiple files Fixed unused variables in: - database-file-encryption.ts: removed currentFingerprint (backend) - FileManagerContextMenu.tsx: removed ExternalLink import, hasDirectories - frontend-logger.ts: removed 5 unused shortUrl variables Continued reducing lint errors * fix: remove 18 unused variables across 4 files - HostManagerViewer.tsx: remove 9 unused error variables and parameters - HostManagerEditor.tsx: remove WidgetType import, hosts/loading states, error variable - CredentialViewer.tsx: remove 3 unused error variables - Server.tsx: remove 2 unused error variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 9 unused variables across 4 files - SnippetsSidebar.tsx: remove 3 unused err variables in catch blocks - TunnelViewer.tsx: remove 2 unused parameters from callback - DesktopApp.tsx: remove getCookie import and unused state variables - HomepageAlertManager.tsx: remove 2 unused err variables in catch blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables and imports across 4 navigation files - Homepage.tsx: remove unused username state variable - AppView.tsx: remove 3 unused Lucide icon imports - TopNavbar.tsx: remove 4 unused Accordion component imports - LeftSidebar.tsx: remove 2 unused variables (err, jwt) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 5 unused variables across 4 user/credentials files - PasswordReset.tsx: remove unused result variable - UserProfile.tsx: remove unused Key import and err variable - version-check-modal.tsx: remove unused setVersionDismissed setter - CredentialsManager.tsx: remove unused e parameter from handleDragLeave 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 2 unused variables in FileViewer and TerminalWindow - FileViewer.tsx: remove unused node parameter from code component - TerminalWindow.tsx: remove unused handleMinimize function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in HomepageAuth.tsx Removed unused variables: - getCookie import - dbError prop - visibility state and toggleVisibility - error state variable - result variable in handleInitiatePasswordReset - token URL parameter - err parameters in catch blocks - retryDatabaseConnection function - Multiple setError(null) calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 9 unused variables across multiple files Files fixed: - DesktopApp.tsx: Removed _nextView parameter - TerminalWindow.tsx: Removed minimizeWindow - Mobile Host.tsx: Removed Server import - Mobile LeftSidebar.tsx: Removed setHostsLoading, err in catch - Desktop LeftSidebar.tsx: Removed getCookie, setCookie, onSelectView, getView, setHostsLoading 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in Mobile files Files fixed: - MobileApp.tsx: Removed getCookie, removeTab, isAdmin, id, err parameters - Mobile/HomepageAuth.tsx: Removed getCookie, error state, result, token, err parameters All @typescript-eslint/no-unused-vars errors in frontend now resolved! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused t variable in TabContext Removed useTranslation import and unused t variable in Mobile TabContext.tsx All @typescript-eslint/no-unused-vars errors now resolved! Total fixed: 154 unused variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve TypeScript and ESLint errors across the codebase - Fixed @typescript-eslint/no-unused-vars errors (31 instances) - Fixed @typescript-eslint/no-explicit-any errors in backend (~22 instances) - Fixed @typescript-eslint/no-explicit-any errors in frontend (~60 instances) - Fixed prefer-const errors (5 instances) - Fixed no-empty-object-type and rules-of-hooks errors - Added proper type assertions for database operations - Improved type safety in authentication and encryption modules - Enhanced type definitions for API routes and SSH operations All TypeScript compilation errors resolved. Application builds and runs successfully. * fix: disable react-refresh/only-export-components rule for component files Disable the react-refresh/only-export-components ESLint rule in files that export both components and related utilities (hooks, types, constants). This is a pragmatic solution to maintain code organization without splitting files unnecessarily. * style: fix prettier formatting issues Fix code style issues in translation file and TOTP dialog component to pass CI prettier check. * chore: fix rollup optional dependencies installation in CI Add step to force reinstall rollup after npm ci to fix the known npm bug with optional dependencies on Linux x64 platform. * chore: fix lightningcss optional dependencies in CI Add lightningcss to the force reinstall step to fix npm optional dependencies bug for both rollup and lightningcss on Linux x64. * chore: fix npm optional dependencies bug in CI Remove package-lock.json and node_modules before install to properly handle optional dependencies for rollup, lightningcss, and tailwindcss native bindings on Linux x64 platform as recommended by npm. * Update src/types/index.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Set terminal environment variables for SSH Added environment variables for terminal configuration. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1629 lines
46 KiB
TypeScript
1629 lines
46 KiB
TypeScript
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import express from "express";
|
|
import { db } from "../db/index.js";
|
|
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
|
import { eq, and, desc, sql } from "drizzle-orm";
|
|
import type { Request, Response } from "express";
|
|
import { authLogger } from "../../utils/logger.js";
|
|
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import {
|
|
parseSSHKey,
|
|
parsePublicKey,
|
|
validateKeyPair,
|
|
} from "../../utils/ssh-key-utils.js";
|
|
import crypto from "crypto";
|
|
import ssh2Pkg from "ssh2";
|
|
const { utils: ssh2Utils, Client } = ssh2Pkg;
|
|
|
|
function generateSSHKeyPair(
|
|
keyType: string,
|
|
keySize?: number,
|
|
passphrase?: string,
|
|
): {
|
|
success: boolean;
|
|
privateKey?: string;
|
|
publicKey?: string;
|
|
error?: string;
|
|
} {
|
|
try {
|
|
let ssh2Type = keyType;
|
|
const options: {
|
|
bits?: number;
|
|
passphrase?: string;
|
|
cipher?: string;
|
|
} = {};
|
|
|
|
if (keyType === "ssh-rsa") {
|
|
ssh2Type = "rsa";
|
|
options.bits = keySize || 2048;
|
|
} else if (keyType === "ssh-ed25519") {
|
|
ssh2Type = "ed25519";
|
|
} else if (keyType === "ecdsa-sha2-nistp256") {
|
|
ssh2Type = "ecdsa";
|
|
options.bits = 256;
|
|
}
|
|
|
|
if (passphrase && passphrase.trim()) {
|
|
options.passphrase = passphrase;
|
|
options.cipher = "aes128-cbc";
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
|
|
|
return {
|
|
success: true,
|
|
privateKey: keyPair.private,
|
|
publicKey: keyPair.public,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : "SSH key generation failed",
|
|
};
|
|
}
|
|
}
|
|
|
|
const router = express.Router();
|
|
|
|
function isNonEmptyString(val: unknown): val is string {
|
|
return typeof val === "string" && val.trim().length > 0;
|
|
}
|
|
|
|
const authManager = AuthManager.getInstance();
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const requireDataAccess = authManager.createDataAccessMiddleware();
|
|
|
|
// Create a new credential
|
|
// POST /credentials
|
|
router.post(
|
|
"/",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const {
|
|
name,
|
|
description,
|
|
folder,
|
|
tags,
|
|
authType,
|
|
username,
|
|
password,
|
|
key,
|
|
keyPassword,
|
|
keyType,
|
|
} = req.body;
|
|
|
|
if (
|
|
!isNonEmptyString(userId) ||
|
|
!isNonEmptyString(name) ||
|
|
!isNonEmptyString(username)
|
|
) {
|
|
authLogger.warn("Invalid credential creation data validation failed", {
|
|
operation: "credential_create",
|
|
userId,
|
|
hasName: !!name,
|
|
hasUsername: !!username,
|
|
});
|
|
return res.status(400).json({ error: "Name and username are required" });
|
|
}
|
|
|
|
if (!["password", "key"].includes(authType)) {
|
|
authLogger.warn("Invalid auth type provided", {
|
|
operation: "credential_create",
|
|
userId,
|
|
name,
|
|
authType,
|
|
});
|
|
return res
|
|
.status(400)
|
|
.json({ error: 'Auth type must be "password" or "key"' });
|
|
}
|
|
|
|
try {
|
|
if (authType === "password" && !password) {
|
|
authLogger.warn("Password required for password authentication", {
|
|
operation: "credential_create",
|
|
userId,
|
|
name,
|
|
authType,
|
|
});
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Password is required for password authentication" });
|
|
}
|
|
if (authType === "key" && !key) {
|
|
authLogger.warn("SSH key required for key authentication", {
|
|
operation: "credential_create",
|
|
userId,
|
|
name,
|
|
authType,
|
|
});
|
|
return res
|
|
.status(400)
|
|
.json({ error: "SSH key is required for key authentication" });
|
|
}
|
|
const plainPassword =
|
|
authType === "password" && password ? password : null;
|
|
const plainKey = authType === "key" && key ? key : null;
|
|
const plainKeyPassword =
|
|
authType === "key" && keyPassword ? keyPassword : null;
|
|
|
|
let keyInfo = null;
|
|
if (authType === "key" && plainKey) {
|
|
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
|
|
if (!keyInfo.success) {
|
|
authLogger.warn("SSH key parsing failed", {
|
|
operation: "credential_create",
|
|
userId,
|
|
name,
|
|
error: keyInfo.error,
|
|
});
|
|
return res.status(400).json({
|
|
error: `Invalid SSH key: ${keyInfo.error}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const credentialData = {
|
|
userId,
|
|
name: name.trim(),
|
|
description: description?.trim() || null,
|
|
folder: folder?.trim() || null,
|
|
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
|
authType,
|
|
username: username.trim(),
|
|
password: plainPassword,
|
|
key: plainKey,
|
|
private_key: keyInfo?.privateKey || plainKey,
|
|
public_key: keyInfo?.publicKey || null,
|
|
key_password: plainKeyPassword,
|
|
keyType: keyType || null,
|
|
detectedKeyType: keyInfo?.keyType || null,
|
|
usageCount: 0,
|
|
lastUsed: null,
|
|
};
|
|
|
|
const created = (await SimpleDBOps.insert(
|
|
sshCredentials,
|
|
"ssh_credentials",
|
|
credentialData,
|
|
userId,
|
|
)) as typeof credentialData & { id: number };
|
|
|
|
authLogger.success(
|
|
`SSH credential created: ${name} (${authType}) by user ${userId}`,
|
|
{
|
|
operation: "credential_create_success",
|
|
userId,
|
|
credentialId: created.id,
|
|
name,
|
|
authType,
|
|
username,
|
|
},
|
|
);
|
|
|
|
res.status(201).json(formatCredentialOutput(created));
|
|
} catch (err) {
|
|
authLogger.error("Failed to create credential in database", err, {
|
|
operation: "credential_create",
|
|
userId,
|
|
name,
|
|
authType,
|
|
username,
|
|
});
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error ? err.message : "Failed to create credential",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get all credentials for the authenticated user
|
|
// GET /credentials
|
|
router.get(
|
|
"/",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
if (!isNonEmptyString(userId)) {
|
|
authLogger.warn("Invalid userId for credential fetch");
|
|
return res.status(400).json({ error: "Invalid userId" });
|
|
}
|
|
|
|
try {
|
|
const credentials = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.userId, userId))
|
|
.orderBy(desc(sshCredentials.updatedAt)),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
|
|
} catch (err) {
|
|
authLogger.error("Failed to fetch credentials", err);
|
|
res.status(500).json({ error: "Failed to fetch credentials" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get all unique credential folders for the authenticated user
|
|
// GET /credentials/folders
|
|
router.get(
|
|
"/folders",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
|
|
if (!isNonEmptyString(userId)) {
|
|
authLogger.warn("Invalid userId for credential folder fetch");
|
|
return res.status(400).json({ error: "Invalid userId" });
|
|
}
|
|
|
|
try {
|
|
const result = await db
|
|
.select({ folder: sshCredentials.folder })
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.userId, userId));
|
|
|
|
const folderCounts: Record<string, number> = {};
|
|
result.forEach((r) => {
|
|
if (r.folder && r.folder.trim() !== "") {
|
|
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
|
|
}
|
|
});
|
|
|
|
const folders = Object.keys(folderCounts).filter(
|
|
(folder) => folderCounts[folder] > 0,
|
|
);
|
|
res.json(folders);
|
|
} catch (err) {
|
|
authLogger.error("Failed to fetch credential folders", err);
|
|
res.status(500).json({ error: "Failed to fetch credential folders" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get a specific credential by ID (with plain text secrets)
|
|
// GET /credentials/:id
|
|
router.get(
|
|
"/:id",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { id } = req.params;
|
|
|
|
if (!isNonEmptyString(userId) || !id) {
|
|
authLogger.warn("Invalid request for credential fetch");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const credentials = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, parseInt(id)),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
if (credentials.length === 0) {
|
|
return res.status(404).json({ error: "Credential not found" });
|
|
}
|
|
|
|
const credential = credentials[0];
|
|
const output = formatCredentialOutput(credential);
|
|
|
|
if (credential.password) {
|
|
output.password = credential.password;
|
|
}
|
|
if (credential.key) {
|
|
output.key = credential.key;
|
|
}
|
|
if (credential.private_key) {
|
|
output.privateKey = credential.private_key;
|
|
}
|
|
if (credential.public_key) {
|
|
output.publicKey = credential.public_key;
|
|
}
|
|
if (credential.key_password) {
|
|
output.keyPassword = credential.key_password;
|
|
}
|
|
|
|
res.json(output);
|
|
} catch (err) {
|
|
authLogger.error("Failed to fetch credential", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error ? err.message : "Failed to fetch credential",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Update a credential
|
|
// PUT /credentials/:id
|
|
router.put(
|
|
"/:id",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { id } = req.params;
|
|
const updateData = req.body;
|
|
|
|
if (!isNonEmptyString(userId) || !id) {
|
|
authLogger.warn("Invalid request for credential update");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const existing = await db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, parseInt(id)),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
);
|
|
|
|
if (existing.length === 0) {
|
|
return res.status(404).json({ error: "Credential not found" });
|
|
}
|
|
|
|
const updateFields: Record<string, string | null | undefined> = {};
|
|
|
|
if (updateData.name !== undefined)
|
|
updateFields.name = updateData.name.trim();
|
|
if (updateData.description !== undefined)
|
|
updateFields.description = updateData.description?.trim() || null;
|
|
if (updateData.folder !== undefined)
|
|
updateFields.folder = updateData.folder?.trim() || null;
|
|
if (updateData.tags !== undefined) {
|
|
updateFields.tags = Array.isArray(updateData.tags)
|
|
? updateData.tags.join(",")
|
|
: updateData.tags || "";
|
|
}
|
|
if (updateData.username !== undefined)
|
|
updateFields.username = updateData.username.trim();
|
|
if (updateData.authType !== undefined)
|
|
updateFields.authType = updateData.authType;
|
|
if (updateData.keyType !== undefined)
|
|
updateFields.keyType = updateData.keyType;
|
|
|
|
if (updateData.password !== undefined) {
|
|
updateFields.password = updateData.password || null;
|
|
}
|
|
if (updateData.key !== undefined) {
|
|
updateFields.key = updateData.key || null;
|
|
|
|
if (updateData.key && existing[0].authType === "key") {
|
|
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
|
|
if (!keyInfo.success) {
|
|
authLogger.warn("SSH key parsing failed during update", {
|
|
operation: "credential_update",
|
|
userId,
|
|
credentialId: parseInt(id),
|
|
error: keyInfo.error,
|
|
});
|
|
return res.status(400).json({
|
|
error: `Invalid SSH key: ${keyInfo.error}`,
|
|
});
|
|
}
|
|
updateFields.private_key = keyInfo.privateKey;
|
|
updateFields.public_key = keyInfo.publicKey;
|
|
updateFields.detectedKeyType = keyInfo.keyType;
|
|
}
|
|
}
|
|
if (updateData.keyPassword !== undefined) {
|
|
updateFields.key_password = updateData.keyPassword || null;
|
|
}
|
|
|
|
if (Object.keys(updateFields).length === 0) {
|
|
const existing = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.id, parseInt(id))),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
return res.json(formatCredentialOutput(existing[0]));
|
|
}
|
|
|
|
await SimpleDBOps.update(
|
|
sshCredentials,
|
|
"ssh_credentials",
|
|
and(
|
|
eq(sshCredentials.id, parseInt(id)),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
updateFields,
|
|
userId,
|
|
);
|
|
|
|
const updated = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.id, parseInt(id))),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
const credential = updated[0];
|
|
authLogger.success(
|
|
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
|
|
{
|
|
operation: "credential_update_success",
|
|
userId,
|
|
credentialId: parseInt(id),
|
|
name: credential.name,
|
|
authType: credential.authType,
|
|
username: credential.username,
|
|
},
|
|
);
|
|
|
|
res.json(formatCredentialOutput(updated[0]));
|
|
} catch (err) {
|
|
authLogger.error("Failed to update credential", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error ? err.message : "Failed to update credential",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Delete a credential
|
|
// DELETE /credentials/:id
|
|
router.delete(
|
|
"/:id",
|
|
authenticateJWT,
|
|
requireDataAccess,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { id } = req.params;
|
|
|
|
if (!isNonEmptyString(userId) || !id) {
|
|
authLogger.warn("Invalid request for credential deletion");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const credentialToDelete = await db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, parseInt(id)),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
);
|
|
|
|
if (credentialToDelete.length === 0) {
|
|
return res.status(404).json({ error: "Credential not found" });
|
|
}
|
|
|
|
const hostsUsingCredential = await db
|
|
.select()
|
|
.from(sshData)
|
|
.where(
|
|
and(
|
|
eq(sshData.credentialId, parseInt(id)),
|
|
eq(sshData.userId, userId),
|
|
),
|
|
);
|
|
|
|
if (hostsUsingCredential.length > 0) {
|
|
await db
|
|
.update(sshData)
|
|
.set({
|
|
credentialId: null,
|
|
password: null,
|
|
key: null,
|
|
key_password: null,
|
|
authType: "password",
|
|
})
|
|
.where(
|
|
and(
|
|
eq(sshData.credentialId, parseInt(id)),
|
|
eq(sshData.userId, userId),
|
|
),
|
|
);
|
|
}
|
|
|
|
await db
|
|
.delete(sshCredentialUsage)
|
|
.where(
|
|
and(
|
|
eq(sshCredentialUsage.credentialId, parseInt(id)),
|
|
eq(sshCredentialUsage.userId, userId),
|
|
),
|
|
);
|
|
|
|
await db
|
|
.delete(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, parseInt(id)),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
);
|
|
|
|
const credential = credentialToDelete[0];
|
|
authLogger.success(
|
|
`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`,
|
|
{
|
|
operation: "credential_delete_success",
|
|
userId,
|
|
credentialId: parseInt(id),
|
|
name: credential.name,
|
|
authType: credential.authType,
|
|
username: credential.username,
|
|
},
|
|
);
|
|
|
|
res.json({ message: "Credential deleted successfully" });
|
|
} catch (err) {
|
|
authLogger.error("Failed to delete credential", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error ? err.message : "Failed to delete credential",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Apply a credential to an SSH host (for quick application)
|
|
// POST /credentials/:id/apply-to-host/:hostId
|
|
router.post(
|
|
"/:id/apply-to-host/:hostId",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { id: credentialId, hostId } = req.params;
|
|
|
|
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
|
authLogger.warn("Invalid request for credential application");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const credentials = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, parseInt(credentialId)),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
if (credentials.length === 0) {
|
|
return res.status(404).json({ error: "Credential not found" });
|
|
}
|
|
|
|
const credential = credentials[0];
|
|
|
|
await db
|
|
.update(sshData)
|
|
.set({
|
|
credentialId: parseInt(credentialId),
|
|
username: credential.username as string,
|
|
authType: (credential.auth_type || credential.authType) as string,
|
|
password: null,
|
|
key: null,
|
|
key_password: null,
|
|
keyType: null,
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
.where(
|
|
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
|
);
|
|
|
|
await db.insert(sshCredentialUsage).values({
|
|
credentialId: parseInt(credentialId),
|
|
hostId: parseInt(hostId),
|
|
userId,
|
|
});
|
|
|
|
await db
|
|
.update(sshCredentials)
|
|
.set({
|
|
usageCount: sql`${sshCredentials.usageCount}
|
|
+ 1`,
|
|
lastUsed: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
.where(eq(sshCredentials.id, parseInt(credentialId)));
|
|
res.json({ message: "Credential applied to host successfully" });
|
|
} catch (err) {
|
|
authLogger.error("Failed to apply credential to host", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to apply credential to host",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Get hosts using a specific credential
|
|
// GET /credentials/:id/hosts
|
|
router.get(
|
|
"/:id/hosts",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { id: credentialId } = req.params;
|
|
|
|
if (!isNonEmptyString(userId) || !credentialId) {
|
|
authLogger.warn("Invalid request for credential hosts fetch");
|
|
return res.status(400).json({ error: "Invalid request" });
|
|
}
|
|
|
|
try {
|
|
const hosts = await db
|
|
.select()
|
|
.from(sshData)
|
|
.where(
|
|
and(
|
|
eq(sshData.credentialId, parseInt(credentialId)),
|
|
eq(sshData.userId, userId),
|
|
),
|
|
);
|
|
|
|
res.json(hosts.map((host) => formatSSHHostOutput(host)));
|
|
} catch (err) {
|
|
authLogger.error("Failed to fetch hosts using credential", err);
|
|
res.status(500).json({
|
|
error:
|
|
err instanceof Error
|
|
? err.message
|
|
: "Failed to fetch hosts using credential",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
function formatCredentialOutput(
|
|
credential: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
return {
|
|
id: credential.id,
|
|
name: credential.name,
|
|
description: credential.description,
|
|
folder: credential.folder,
|
|
tags:
|
|
typeof credential.tags === "string"
|
|
? credential.tags
|
|
? credential.tags.split(",").filter(Boolean)
|
|
: []
|
|
: [],
|
|
authType: credential.authType || credential.auth_type,
|
|
username: credential.username,
|
|
publicKey: credential.public_key || credential.publicKey,
|
|
keyType: credential.key_type || credential.keyType,
|
|
detectedKeyType: credential.detected_key_type || credential.detectedKeyType,
|
|
usageCount: credential.usage_count || credential.usageCount || 0,
|
|
lastUsed: credential.last_used || credential.lastUsed,
|
|
createdAt: credential.created_at || credential.createdAt,
|
|
updatedAt: credential.updated_at || credential.updatedAt,
|
|
};
|
|
}
|
|
|
|
function formatSSHHostOutput(
|
|
host: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
return {
|
|
id: host.id,
|
|
userId: host.userId,
|
|
name: host.name,
|
|
ip: host.ip,
|
|
port: host.port,
|
|
username: host.username,
|
|
folder: host.folder,
|
|
tags:
|
|
typeof host.tags === "string"
|
|
? host.tags
|
|
? host.tags.split(",").filter(Boolean)
|
|
: []
|
|
: [],
|
|
pin: !!host.pin,
|
|
authType: host.authType,
|
|
enableTerminal: !!host.enableTerminal,
|
|
enableTunnel: !!host.enableTunnel,
|
|
tunnelConnections: host.tunnelConnections
|
|
? JSON.parse(host.tunnelConnections as string)
|
|
: [],
|
|
enableFileManager: !!host.enableFileManager,
|
|
defaultPath: host.defaultPath,
|
|
createdAt: host.createdAt,
|
|
updatedAt: host.updatedAt,
|
|
};
|
|
}
|
|
|
|
// Rename a credential folder
|
|
// PUT /credentials/folders/rename
|
|
router.put(
|
|
"/folders/rename",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { oldName, newName } = req.body;
|
|
|
|
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Both oldName and newName are required" });
|
|
}
|
|
|
|
if (oldName === newName) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Old name and new name cannot be the same" });
|
|
}
|
|
|
|
try {
|
|
await db
|
|
.update(sshCredentials)
|
|
.set({ folder: newName })
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.userId, userId),
|
|
eq(sshCredentials.folder, oldName),
|
|
),
|
|
);
|
|
|
|
res.json({ success: true, message: "Folder renamed successfully" });
|
|
} catch (error) {
|
|
authLogger.error("Error renaming credential folder:", error);
|
|
res.status(500).json({ error: "Failed to rename folder" });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Detect SSH key type endpoint
|
|
// POST /credentials/detect-key-type
|
|
router.post(
|
|
"/detect-key-type",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const { privateKey, keyPassword } = req.body;
|
|
|
|
if (!privateKey || typeof privateKey !== "string") {
|
|
return res.status(400).json({ error: "Private key is required" });
|
|
}
|
|
|
|
try {
|
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
|
|
|
const response = {
|
|
success: keyInfo.success,
|
|
keyType: keyInfo.keyType,
|
|
detectedKeyType: keyInfo.keyType,
|
|
hasPublicKey: !!keyInfo.publicKey,
|
|
error: keyInfo.error || null,
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
authLogger.error("Failed to detect key type", error);
|
|
res.status(500).json({
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to detect key type",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Detect SSH public key type endpoint
|
|
// POST /credentials/detect-public-key-type
|
|
router.post(
|
|
"/detect-public-key-type",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const { publicKey } = req.body;
|
|
|
|
if (!publicKey || typeof publicKey !== "string") {
|
|
return res.status(400).json({ error: "Public key is required" });
|
|
}
|
|
|
|
try {
|
|
const keyInfo = parsePublicKey(publicKey);
|
|
|
|
const response = {
|
|
success: keyInfo.success,
|
|
keyType: keyInfo.keyType,
|
|
detectedKeyType: keyInfo.keyType,
|
|
error: keyInfo.error || null,
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
authLogger.error("Failed to detect public key type", error);
|
|
res.status(500).json({
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to detect public key type",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Validate SSH key pair endpoint
|
|
// POST /credentials/validate-key-pair
|
|
router.post(
|
|
"/validate-key-pair",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const { privateKey, publicKey, keyPassword } = req.body;
|
|
|
|
if (!privateKey || typeof privateKey !== "string") {
|
|
return res.status(400).json({ error: "Private key is required" });
|
|
}
|
|
|
|
if (!publicKey || typeof publicKey !== "string") {
|
|
return res.status(400).json({ error: "Public key is required" });
|
|
}
|
|
|
|
try {
|
|
const validationResult = validateKeyPair(
|
|
privateKey,
|
|
publicKey,
|
|
keyPassword,
|
|
);
|
|
|
|
const response = {
|
|
isValid: validationResult.isValid,
|
|
privateKeyType: validationResult.privateKeyType,
|
|
publicKeyType: validationResult.publicKeyType,
|
|
generatedPublicKey: validationResult.generatedPublicKey,
|
|
error: validationResult.error || null,
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
authLogger.error("Failed to validate key pair", error);
|
|
res.status(500).json({
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to validate key pair",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Generate new SSH key pair endpoint
|
|
// POST /credentials/generate-key-pair
|
|
router.post(
|
|
"/generate-key-pair",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
|
|
|
try {
|
|
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
|
|
|
if (result.success && result.privateKey && result.publicKey) {
|
|
const response = {
|
|
success: true,
|
|
privateKey: result.privateKey,
|
|
publicKey: result.publicKey,
|
|
keyType: keyType,
|
|
format: "ssh",
|
|
algorithm: keyType,
|
|
keySize: keyType === "ssh-rsa" ? keySize : undefined,
|
|
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
|
|
};
|
|
|
|
res.json(response);
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: result.error || "Failed to generate SSH key pair",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
authLogger.error("Failed to generate key pair", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to generate key pair",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
// Generate public key from private key endpoint
|
|
// POST /credentials/generate-public-key
|
|
router.post(
|
|
"/generate-public-key",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const { privateKey, keyPassword } = req.body;
|
|
|
|
if (!privateKey || typeof privateKey !== "string") {
|
|
return res.status(400).json({ error: "Private key is required" });
|
|
}
|
|
|
|
try {
|
|
let privateKeyObj;
|
|
const parseAttempts = [];
|
|
|
|
try {
|
|
privateKeyObj = crypto.createPrivateKey({
|
|
key: privateKey,
|
|
passphrase: keyPassword,
|
|
});
|
|
} catch (error) {
|
|
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
|
}
|
|
|
|
if (!privateKeyObj) {
|
|
try {
|
|
privateKeyObj = crypto.createPrivateKey(privateKey);
|
|
} catch (error) {
|
|
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (!privateKeyObj) {
|
|
try {
|
|
privateKeyObj = crypto.createPrivateKey({
|
|
key: privateKey,
|
|
format: "pem",
|
|
type: "pkcs8",
|
|
});
|
|
} catch (error) {
|
|
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
!privateKeyObj &&
|
|
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
|
|
) {
|
|
try {
|
|
privateKeyObj = crypto.createPrivateKey({
|
|
key: privateKey,
|
|
format: "pem",
|
|
type: "pkcs1",
|
|
});
|
|
} catch (error) {
|
|
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
!privateKeyObj &&
|
|
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
|
|
) {
|
|
try {
|
|
privateKeyObj = crypto.createPrivateKey({
|
|
key: privateKey,
|
|
format: "pem",
|
|
type: "sec1",
|
|
});
|
|
} catch (error) {
|
|
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (!privateKeyObj) {
|
|
try {
|
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
|
|
|
if (keyInfo.success && keyInfo.publicKey) {
|
|
const publicKeyString = String(keyInfo.publicKey);
|
|
return res.json({
|
|
success: true,
|
|
publicKey: publicKeyString,
|
|
keyType: keyInfo.keyType,
|
|
});
|
|
} else {
|
|
parseAttempts.push(
|
|
`SSH2 fallback: ${keyInfo.error || "No public key generated"}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
if (!privateKeyObj) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Unable to parse private key. Tried multiple formats.",
|
|
details: parseAttempts,
|
|
});
|
|
}
|
|
|
|
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
|
|
const publicKeyPem = publicKeyObj.export({
|
|
type: "spki",
|
|
format: "pem",
|
|
});
|
|
|
|
const publicKeyString =
|
|
typeof publicKeyPem === "string"
|
|
? publicKeyPem
|
|
: publicKeyPem.toString("utf8");
|
|
|
|
let keyType = "unknown";
|
|
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
|
|
|
if (asymmetricKeyType === "rsa") {
|
|
keyType = "ssh-rsa";
|
|
} else if (asymmetricKeyType === "ed25519") {
|
|
keyType = "ssh-ed25519";
|
|
} else if (asymmetricKeyType === "ec") {
|
|
keyType = "ecdsa-sha2-nistp256";
|
|
}
|
|
|
|
let finalPublicKey = publicKeyString;
|
|
let formatType = "pem";
|
|
|
|
try {
|
|
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
|
|
if (!(ssh2PrivateKey instanceof Error)) {
|
|
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
|
|
const base64Data = publicKeyBuffer.toString("base64");
|
|
finalPublicKey = `${keyType} ${base64Data}`;
|
|
formatType = "ssh";
|
|
}
|
|
} catch {
|
|
// Ignore validation errors
|
|
}
|
|
|
|
const response = {
|
|
success: true,
|
|
publicKey: finalPublicKey,
|
|
keyType: keyType,
|
|
format: formatType,
|
|
};
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
authLogger.error("Failed to generate public key", error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to generate public key",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
async function deploySSHKeyToHost(
|
|
hostConfig: Record<string, unknown>,
|
|
publicKey: string,
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
_credentialData: Record<string, unknown>,
|
|
): Promise<{ success: boolean; message?: string; error?: string }> {
|
|
return new Promise((resolve) => {
|
|
const conn = new Client();
|
|
|
|
const connectionTimeout = setTimeout(() => {
|
|
conn.destroy();
|
|
resolve({ success: false, error: "Connection timeout" });
|
|
}, 120000);
|
|
|
|
conn.on("ready", async () => {
|
|
clearTimeout(connectionTimeout);
|
|
|
|
try {
|
|
await new Promise<void>((resolveCmd, rejectCmd) => {
|
|
const cmdTimeout = setTimeout(() => {
|
|
rejectCmd(new Error("mkdir command timeout"));
|
|
}, 10000);
|
|
|
|
conn.exec(
|
|
"test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh",
|
|
(err, stream) => {
|
|
if (err) {
|
|
clearTimeout(cmdTimeout);
|
|
return rejectCmd(err);
|
|
}
|
|
|
|
stream.on("close", (code) => {
|
|
clearTimeout(cmdTimeout);
|
|
if (code === 0) {
|
|
resolveCmd();
|
|
} else {
|
|
rejectCmd(
|
|
new Error(`mkdir command failed with code ${code}`),
|
|
);
|
|
}
|
|
});
|
|
|
|
stream.on("data", () => {
|
|
// Ignore output
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
const keyExists = await new Promise<boolean>(
|
|
(resolveCheck, rejectCheck) => {
|
|
const checkTimeout = setTimeout(() => {
|
|
rejectCheck(new Error("Key check timeout"));
|
|
}, 5000);
|
|
|
|
let actualPublicKey = publicKey;
|
|
try {
|
|
const parsed = JSON.parse(publicKey);
|
|
if (parsed.data) {
|
|
actualPublicKey = parsed.data;
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
|
|
const keyParts = actualPublicKey.trim().split(" ");
|
|
if (keyParts.length < 2) {
|
|
clearTimeout(checkTimeout);
|
|
return rejectCheck(
|
|
new Error(
|
|
"Invalid public key format - must contain at least 2 parts",
|
|
),
|
|
);
|
|
}
|
|
|
|
const keyPattern = keyParts[1];
|
|
|
|
conn.exec(
|
|
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
|
|
(err, stream) => {
|
|
if (err) {
|
|
clearTimeout(checkTimeout);
|
|
return rejectCheck(err);
|
|
}
|
|
|
|
let output = "";
|
|
stream.on("data", (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
stream.on("close", () => {
|
|
clearTimeout(checkTimeout);
|
|
const exists = output.trim() === "0";
|
|
resolveCheck(exists);
|
|
});
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (keyExists) {
|
|
conn.end();
|
|
resolve({ success: true, message: "SSH key already deployed" });
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolveAdd, rejectAdd) => {
|
|
const addTimeout = setTimeout(() => {
|
|
rejectAdd(new Error("Key add timeout"));
|
|
}, 30000);
|
|
|
|
let actualPublicKey = publicKey;
|
|
try {
|
|
const parsed = JSON.parse(publicKey);
|
|
if (parsed.data) {
|
|
actualPublicKey = parsed.data;
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
|
|
const escapedKey = actualPublicKey
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/'/g, "'\\''");
|
|
|
|
conn.exec(
|
|
`printf '%s\\n' '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
|
(err, stream) => {
|
|
if (err) {
|
|
clearTimeout(addTimeout);
|
|
return rejectAdd(err);
|
|
}
|
|
|
|
stream.on("close", (code) => {
|
|
clearTimeout(addTimeout);
|
|
if (code === 0) {
|
|
resolveAdd();
|
|
} else {
|
|
rejectAdd(
|
|
new Error(`Key deployment failed with code ${code}`),
|
|
);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
const verifySuccess = await new Promise<boolean>(
|
|
(resolveVerify, rejectVerify) => {
|
|
const verifyTimeout = setTimeout(() => {
|
|
rejectVerify(new Error("Key verification timeout"));
|
|
}, 5000);
|
|
|
|
let actualPublicKey = publicKey;
|
|
try {
|
|
const parsed = JSON.parse(publicKey);
|
|
if (parsed.data) {
|
|
actualPublicKey = parsed.data;
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
|
|
const keyParts = actualPublicKey.trim().split(" ");
|
|
if (keyParts.length < 2) {
|
|
clearTimeout(verifyTimeout);
|
|
return rejectVerify(
|
|
new Error(
|
|
"Invalid public key format - must contain at least 2 parts",
|
|
),
|
|
);
|
|
}
|
|
|
|
const keyPattern = keyParts[1];
|
|
conn.exec(
|
|
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
|
|
(err, stream) => {
|
|
if (err) {
|
|
clearTimeout(verifyTimeout);
|
|
return rejectVerify(err);
|
|
}
|
|
|
|
let output = "";
|
|
stream.on("data", (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
stream.on("close", () => {
|
|
clearTimeout(verifyTimeout);
|
|
const verified = output.trim() === "0";
|
|
resolveVerify(verified);
|
|
});
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
conn.end();
|
|
|
|
if (verifySuccess) {
|
|
resolve({ success: true, message: "SSH key deployed successfully" });
|
|
} else {
|
|
resolve({
|
|
success: false,
|
|
error: "Key deployment verification failed",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
conn.end();
|
|
resolve({
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Deployment failed",
|
|
});
|
|
}
|
|
});
|
|
|
|
conn.on("error", (err) => {
|
|
clearTimeout(connectionTimeout);
|
|
let errorMessage = err.message;
|
|
|
|
if (
|
|
err.message.includes("All configured authentication methods failed")
|
|
) {
|
|
errorMessage =
|
|
"Authentication failed. Please check your credentials and ensure the SSH service is running.";
|
|
} else if (
|
|
err.message.includes("ENOTFOUND") ||
|
|
err.message.includes("ENOENT")
|
|
) {
|
|
errorMessage = "Could not resolve hostname or connect to server.";
|
|
} else if (err.message.includes("ECONNREFUSED")) {
|
|
errorMessage =
|
|
"Connection refused. The server may not be running or the port may be incorrect.";
|
|
} else if (err.message.includes("ETIMEDOUT")) {
|
|
errorMessage =
|
|
"Connection timed out. Check your network connection and server availability.";
|
|
} else if (
|
|
err.message.includes("authentication failed") ||
|
|
err.message.includes("Permission denied")
|
|
) {
|
|
errorMessage =
|
|
"Authentication failed. Please check your username and password/key.";
|
|
}
|
|
|
|
resolve({ success: false, error: errorMessage });
|
|
});
|
|
|
|
try {
|
|
const connectionConfig: Record<string, unknown> = {
|
|
host: hostConfig.ip,
|
|
port: hostConfig.port || 22,
|
|
username: hostConfig.username,
|
|
readyTimeout: 60000,
|
|
keepaliveInterval: 30000,
|
|
keepaliveCountMax: 3,
|
|
tcpKeepAlive: true,
|
|
tcpKeepAliveInitialDelay: 30000,
|
|
algorithms: {
|
|
kex: [
|
|
"diffie-hellman-group14-sha256",
|
|
"diffie-hellman-group14-sha1",
|
|
"diffie-hellman-group1-sha1",
|
|
"diffie-hellman-group-exchange-sha256",
|
|
"diffie-hellman-group-exchange-sha1",
|
|
"ecdh-sha2-nistp256",
|
|
"ecdh-sha2-nistp384",
|
|
"ecdh-sha2-nistp521",
|
|
],
|
|
cipher: [
|
|
"aes128-ctr",
|
|
"aes192-ctr",
|
|
"aes256-ctr",
|
|
"aes128-gcm@openssh.com",
|
|
"aes256-gcm@openssh.com",
|
|
"aes128-cbc",
|
|
"aes192-cbc",
|
|
"aes256-cbc",
|
|
"3des-cbc",
|
|
],
|
|
hmac: [
|
|
"hmac-sha2-256-etm@openssh.com",
|
|
"hmac-sha2-512-etm@openssh.com",
|
|
"hmac-sha2-256",
|
|
"hmac-sha2-512",
|
|
"hmac-sha1",
|
|
"hmac-md5",
|
|
],
|
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
|
},
|
|
};
|
|
|
|
if (hostConfig.authType === "password" && hostConfig.password) {
|
|
connectionConfig.password = hostConfig.password;
|
|
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
|
|
try {
|
|
const privateKey = hostConfig.privateKey as string;
|
|
if (
|
|
!privateKey.includes("-----BEGIN") ||
|
|
!privateKey.includes("-----END")
|
|
) {
|
|
throw new Error("Invalid private key format");
|
|
}
|
|
|
|
const cleanKey = privateKey
|
|
.trim()
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\r/g, "\n");
|
|
|
|
connectionConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
|
|
|
if (hostConfig.keyPassword) {
|
|
connectionConfig.passphrase = hostConfig.keyPassword;
|
|
}
|
|
} catch (keyError) {
|
|
clearTimeout(connectionTimeout);
|
|
resolve({
|
|
success: false,
|
|
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
|
});
|
|
return;
|
|
}
|
|
} else {
|
|
clearTimeout(connectionTimeout);
|
|
resolve({
|
|
success: false,
|
|
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
conn.connect(connectionConfig);
|
|
} catch (error) {
|
|
clearTimeout(connectionTimeout);
|
|
resolve({
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Connection failed",
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Deploy SSH Key to Host endpoint
|
|
// POST /credentials/:id/deploy-to-host
|
|
router.post(
|
|
"/:id/deploy-to-host",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
const credentialId = parseInt(req.params.id);
|
|
const { targetHostId } = req.body;
|
|
|
|
if (!credentialId || !targetHostId) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Credential ID and target host ID are required",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
if (!userId) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: "Authentication required",
|
|
});
|
|
}
|
|
|
|
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
|
const credential = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.id, credentialId))
|
|
.limit(1),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
if (!credential || credential.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Credential not found",
|
|
});
|
|
}
|
|
|
|
const credData = credential[0];
|
|
|
|
if (credData.authType !== "key") {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Only SSH key-based credentials can be deployed",
|
|
});
|
|
}
|
|
|
|
if (!credData.publicKey) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Public key is required for deployment",
|
|
});
|
|
}
|
|
const targetHost = await SimpleDBOps.select(
|
|
db.select().from(sshData).where(eq(sshData.id, targetHostId)).limit(1),
|
|
"ssh_data",
|
|
userId,
|
|
);
|
|
|
|
if (!targetHost || targetHost.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: "Target host not found",
|
|
});
|
|
}
|
|
|
|
const hostData = targetHost[0];
|
|
|
|
const hostConfig = {
|
|
ip: hostData.ip,
|
|
port: hostData.port,
|
|
username: hostData.username,
|
|
authType: hostData.authType,
|
|
password: hostData.password,
|
|
privateKey: hostData.key,
|
|
keyPassword: hostData.keyPassword,
|
|
};
|
|
|
|
if (hostData.authType === "credential" && hostData.credentialId) {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
if (!userId) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Authentication required for credential resolution",
|
|
});
|
|
}
|
|
|
|
try {
|
|
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
|
const hostCredential = await SimpleDBOps.select(
|
|
db
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(eq(sshCredentials.id, hostData.credentialId as number))
|
|
.limit(1),
|
|
"ssh_credentials",
|
|
userId,
|
|
);
|
|
|
|
if (hostCredential && hostCredential.length > 0) {
|
|
const cred = hostCredential[0];
|
|
|
|
hostConfig.authType = cred.auth_type || cred.authType;
|
|
hostConfig.username = cred.username;
|
|
|
|
if ((cred.auth_type || cred.authType) === "password") {
|
|
hostConfig.password = cred.password;
|
|
} else if ((cred.auth_type || cred.authType) === "key") {
|
|
hostConfig.privateKey =
|
|
cred.private_key || cred.privateKey || cred.key;
|
|
hostConfig.keyPassword = cred.key_password || cred.keyPassword;
|
|
}
|
|
} else {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Host credential not found",
|
|
});
|
|
}
|
|
} catch {
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: "Failed to resolve host credentials",
|
|
});
|
|
}
|
|
}
|
|
|
|
const deployResult = await deploySSHKeyToHost(
|
|
hostConfig,
|
|
credData.publicKey as string,
|
|
credData,
|
|
);
|
|
|
|
if (deployResult.success) {
|
|
res.json({
|
|
success: true,
|
|
message: deployResult.message || "SSH key deployed successfully",
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: deployResult.error || "Deployment failed",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to deploy SSH key",
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|