Add SSH key generation and deployment features (#234)

* Fix SSH key upload and credential editing issues

Fixed two major credential management issues:

1. Fix SSH key upload button not responding (Issue #232)
   - Error handling was silently swallowing exceptions
   - Added proper error propagation in axios functions
   - Improved error display to show specific error messages
   - Users now see actual error details instead of generic messages

2. Improve credential editing to show actual content
   - Both "Upload File" and "Paste Key" modes now display existing data
   - Upload mode: shows current key content in read-only preview area
   - Paste mode: shows editable key content in textarea
   - Smart input method switching preserves existing data
   - Enhanced button labels and status indicators

Key changes:
- Fixed handleApiError propagation in main-axios.ts credential functions
- Enhanced CredentialEditor.tsx with key content preview
- Improved error handling with console logging for debugging
- Better UX with clear status indicators and preserved data

These fixes resolve the "Add Credential button does nothing" issue
and provide full visibility of credential content during editing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add comprehensive SSH key management and validation features

- Add support for both private and public key storage
- Implement automatic SSH key type detection for all major formats (RSA, Ed25519, ECDSA, DSA)
- Add real-time key pair validation to verify private/public key correspondence
- Enhance credential editor UI with unified key input interface supporting upload/paste
- Improve file format support including extensionless files (id_rsa, id_ed25519, etc.)
- Add comprehensive fallback detection for OpenSSH format keys
- Implement debounced API calls for better UX during real-time validation
- Update database schema with backward compatibility for existing credentials
- Add API endpoints for key detection and pair validation
- Fix SSH2 module integration issues in TypeScript environment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Optimize credentials interface and add i18n improvements

- Merge upload/paste tabs into unified SSH key input interface
- Remove manual key type selection dropdown (rely on auto-detection)
- Add public key generation from private key functionality
- Complete key pair validation removal to fix errors
- Add missing translation keys for better internationalization
- Improve UX with streamlined credential editing workflow

* Implement direct SSH key generation with ssh2 native API

- Replace complex PEM-to-SSH conversion logic with ssh2's generateKeyPairSync
- Add three key generation buttons: Ed25519, ECDSA P-256, and RSA
- Generate keys directly in SSH format (ssh-ed25519, ecdsa-sha2-nistp256, ssh-rsa)
- Fix ECDSA parameter bug: use bits (256) instead of curve for ssh2 API
- Enhance generate-public-key endpoint with SSH format conversion
- Add comprehensive key type detection and parsing fallbacks
- Add internationalization support for key generation UI
- Simplify codebase from 300+ lines to ~80 lines of clean SSH generation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add passphrase support for SSH key generation

- Add optional passphrase input field in key generation container
- Implement AES-128-CBC encryption for protected private keys
- Auto-fill key password field when passphrase is provided
- Support passphrase protection for all key types (Ed25519, ECDSA, RSA)
- Enhance user experience with automatic form field population

* Implement SSH key deployment feature with credential resolution

- Add SSH key deployment endpoint supporting all authentication types
- Implement automatic credential resolution for credential-based hosts
- Add deployment UI with host selection and progress tracking
- Support password, key, and credential authentication methods
- Include deployment verification and error handling
- Add public key field to credential types and API responses
- Implement secure SSH connection handling with proper timeout

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #234.
This commit is contained in:
ZacharyZcR
2025-09-15 10:29:32 +08:00
committed by GitHub
parent 0f75cd4d16
commit a4feb67c08
12 changed files with 2338 additions and 276 deletions

View File

@@ -137,9 +137,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
authType: text("auth_type").notNull(),
username: text("username").notNull(),
password: text("password"),
key: text("key", { length: 16384 }),
key: text("key", { length: 16384 }), // backward compatibility
privateKey: text("private_key", { length: 16384 }),
publicKey: text("public_key", { length: 4096 }),
keyPassword: text("key_password"),
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
createdAt: text("created_at")

View File

@@ -5,6 +5,56 @@ import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { authLogger } from "../../utils/logger.js";
import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js";
import crypto from "crypto";
import ssh2Pkg from "ssh2";
const { utils: ssh2Utils, Client } = ssh2Pkg;
// Direct SSH key generation with ssh2 - the right way
function generateSSHKeyPair(keyType: string, keySize?: number, passphrase?: string): { success: boolean; privateKey?: string; publicKey?: string; error?: string } {
console.log('Generating SSH key pair with ssh2:', keyType);
try {
// Convert our keyType to ssh2 format
let ssh2Type = keyType;
const options: any = {};
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; // ECDSA P-256 uses 256 bits
}
// Add passphrase protection if provided
if (passphrase && passphrase.trim()) {
options.passphrase = passphrase;
options.cipher = 'aes128-cbc'; // Default cipher for encrypted private keys
}
// Use ssh2's native key generation
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
console.log('SSH key pair generated successfully!');
console.log('Private key length:', keyPair.private.length);
console.log('Public key preview:', keyPair.public.substring(0, 50) + '...');
return {
success: true,
privateKey: keyPair.private,
publicKey: keyPair.public
};
} catch (error) {
console.error('SSH key generation failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'SSH key generation failed'
};
}
}
const router = express.Router();
@@ -109,6 +159,22 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
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(),
@@ -118,9 +184,12 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
authType,
username: username.trim(),
password: plainPassword,
key: plainKey,
key: plainKey, // backward compatibility
privateKey: keyInfo?.privateKey || plainKey,
publicKey: keyInfo?.publicKey || null,
keyPassword: plainKeyPassword,
keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null,
usageCount: 0,
lastUsed: null,
};
@@ -248,7 +317,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
(output as any).password = credential.password;
}
if (credential.key) {
(output as any).key = credential.key;
(output as any).key = credential.key; // backward compatibility
}
if (credential.privateKey) {
(output as any).privateKey = credential.privateKey;
}
if (credential.publicKey) {
(output as any).publicKey = credential.publicKey;
}
if (credential.keyPassword) {
(output as any).keyPassword = credential.keyPassword;
@@ -314,7 +389,26 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
updateFields.password = updateData.password || null;
}
if (updateData.key !== undefined) {
updateFields.key = updateData.key || null;
updateFields.key = updateData.key || null; // backward compatibility
// Parse SSH key if provided
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.privateKey = keyInfo.privateKey;
updateFields.publicKey = keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType;
}
}
if (updateData.keyPassword !== undefined) {
updateFields.keyPassword = updateData.keyPassword || null;
@@ -584,7 +678,9 @@ function formatCredentialOutput(credential: any): any {
: [],
authType: credential.authType,
username: credential.username,
publicKey: credential.publicKey,
keyType: credential.keyType,
detectedKeyType: credential.detectedKeyType,
usageCount: credential.usageCount || 0,
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
@@ -661,4 +757,642 @@ router.put(
},
);
// 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;
console.log("=== Key Detection API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Private key type:", typeof privateKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
try {
console.log("Calling parseSSHKey...");
const keyInfo = parseSSHKey(privateKey, keyPassword);
console.log("parseSSHKey result:", keyInfo);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
hasPublicKey: !!keyInfo.publicKey,
error: keyInfo.error || null
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in detect-key-type endpoint:", 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;
console.log("=== Public Key Detection API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Public key provided:", !!publicKey);
console.log("Public key type:", typeof publicKey);
if (!publicKey || typeof publicKey !== "string") {
console.log("Invalid public key provided");
return res.status(400).json({ error: "Public key is required" });
}
try {
console.log("Calling parsePublicKey...");
const keyInfo = parsePublicKey(publicKey);
console.log("parsePublicKey result:", keyInfo);
const response = {
success: keyInfo.success,
keyType: keyInfo.keyType,
detectedKeyType: keyInfo.keyType,
error: keyInfo.error || null
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in detect-public-key-type endpoint:", 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;
console.log("=== Key Pair Validation API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Public key provided:", !!publicKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
if (!publicKey || typeof publicKey !== "string") {
console.log("Invalid public key provided");
return res.status(400).json({ error: "Public key is required" });
}
try {
console.log("Calling validateKeyPair...");
const validationResult = validateKeyPair(privateKey, publicKey, keyPassword);
console.log("validateKeyPair result:", validationResult);
const response = {
isValid: validationResult.isValid,
privateKeyType: validationResult.privateKeyType,
publicKeyType: validationResult.publicKeyType,
generatedPublicKey: validationResult.generatedPublicKey,
error: validationResult.error || null
};
console.log("Sending response:", response);
res.json(response);
} catch (error) {
console.error("Exception in validate-key-pair endpoint:", 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;
console.log("=== Generate Key Pair API Called ===");
console.log("Key type:", keyType);
console.log("Key size:", keySize);
console.log("Has passphrase:", !!passphrase);
try {
// Generate SSH keys directly with ssh2
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
};
console.log("SSH key pair generated successfully:", keyType);
res.json(response);
} else {
console.error("SSH key generation failed:", result.error);
res.status(500).json({
success: false,
error: result.error || "Failed to generate SSH key pair"
});
}
} catch (error) {
console.error("Exception in generate-key-pair endpoint:", 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;
console.log("=== Generate Public Key API Called ===");
console.log("Request body keys:", Object.keys(req.body));
console.log("Private key provided:", !!privateKey);
console.log("Private key type:", typeof privateKey);
if (!privateKey || typeof privateKey !== "string") {
console.log("Invalid private key provided");
return res.status(400).json({ error: "Private key is required" });
}
try {
console.log("Using Node.js crypto to generate public key from private key...");
console.log("Private key length:", privateKey.length);
console.log("Private key first 100 chars:", privateKey.substring(0, 100));
// First try to create private key object from the input
let privateKeyObj;
let parseAttempts = [];
// Attempt 1: Direct parsing with passphrase
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
passphrase: keyPassword
});
console.log("Successfully parsed with passphrase method");
} catch (error) {
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
}
// Attempt 2: Direct parsing without passphrase
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey(privateKey);
console.log("Successfully parsed without passphrase");
} catch (error) {
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
}
}
// Attempt 3: Try with explicit format specification
if (!privateKeyObj) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: 'pem',
type: 'pkcs8'
});
console.log("Successfully parsed as PKCS#8");
} catch (error) {
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
}
}
// Attempt 4: Try as PKCS#1 RSA
if (!privateKeyObj && privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: 'pem',
type: 'pkcs1'
});
console.log("Successfully parsed as PKCS#1 RSA");
} catch (error) {
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
}
}
// Attempt 5: Try as SEC1 EC
if (!privateKeyObj && privateKey.includes('-----BEGIN EC PRIVATE KEY-----')) {
try {
privateKeyObj = crypto.createPrivateKey({
key: privateKey,
format: 'pem',
type: 'sec1'
});
console.log("Successfully parsed as SEC1 EC");
} catch (error) {
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
}
}
// Final attempt: Try using ssh2 as fallback
if (!privateKeyObj) {
console.log("Attempting fallback to parseSSHKey function...");
try {
const keyInfo = parseSSHKey(privateKey, keyPassword);
console.log("parseSSHKey fallback result:", keyInfo);
if (keyInfo.success && keyInfo.publicKey) {
// Ensure SSH2 fallback also returns proper string
const publicKeyString = String(keyInfo.publicKey);
console.log("SSH2 fallback public key type:", typeof publicKeyString);
console.log("SSH2 fallback public key length:", publicKeyString.length);
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) {
console.error("All parsing attempts failed:", parseAttempts);
return res.status(400).json({
success: false,
error: "Unable to parse private key. Tried multiple formats.",
details: parseAttempts
});
}
// Generate public key from private key
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
const publicKeyPem = publicKeyObj.export({
type: 'spki',
format: 'pem'
});
// Debug: Check what we're actually generating
console.log("Generated public key type:", typeof publicKeyPem);
console.log("Generated public key is Buffer:", Buffer.isBuffer(publicKeyPem));
// Ensure publicKeyPem is a string
const publicKeyString = typeof publicKeyPem === 'string' ? publicKeyPem : publicKeyPem.toString('utf8');
console.log("Public key string length:", publicKeyString.length);
console.log("Generated public key first 100 chars:", publicKeyString.substring(0, 100));
console.log("Public key is string:", typeof publicKeyString === 'string');
console.log("Public key contains PEM header:", publicKeyString.includes('-----BEGIN PUBLIC KEY-----'));
// Detect key type from the private key object
let keyType = 'unknown';
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
if (asymmetricKeyType === 'rsa') {
keyType = 'ssh-rsa';
} else if (asymmetricKeyType === 'ed25519') {
keyType = 'ssh-ed25519';
} else if (asymmetricKeyType === 'ec') {
// For EC keys, we need to check the curve
keyType = 'ecdsa-sha2-nistp256'; // Default assumption for P-256
}
// Use ssh2 to generate SSH format public key
let finalPublicKey = publicKeyString; // PEM fallback
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';
console.log("SSH format public key generated!");
} else {
console.warn("ssh2 parsing failed, using PEM format");
}
} catch (sshError) {
console.warn("ssh2 failed, using PEM format");
}
const response = {
success: true,
publicKey: finalPublicKey,
keyType: keyType,
format: formatType
};
console.log("Final response publicKey type:", typeof response.publicKey);
console.log("Final response publicKey format:", response.format);
console.log("Final response publicKey length:", response.publicKey.length);
console.log("Public key generated successfully using crypto module:", keyType);
res.json(response);
} catch (error) {
console.error("Exception in generate-public-key endpoint:", 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"
});
}
});
// SSH Key Deployment Function
async function deploySSHKeyToHost(
hostConfig: any,
publicKey: string,
credentialData: any
): Promise<{ success: boolean; message?: string; error?: string }> {
return new Promise((resolve) => {
const conn = new Client();
let connectionTimeout: NodeJS.Timeout;
// Connection timeout
connectionTimeout = setTimeout(() => {
conn.destroy();
resolve({ success: false, error: "Connection timeout" });
}, 30000);
conn.on('ready', async () => {
clearTimeout(connectionTimeout);
try {
// Step 1: Create ~/.ssh directory if it doesn't exist
await new Promise<void>((resolveCmd, rejectCmd) => {
conn.exec('mkdir -p ~/.ssh && chmod 700 ~/.ssh', (err, stream) => {
if (err) return rejectCmd(err);
stream.on('close', (code) => {
if (code === 0) {
resolveCmd();
} else {
rejectCmd(new Error(`mkdir command failed with code ${code}`));
}
});
});
});
// Step 2: Check if public key already exists
const keyExists = await new Promise<boolean>((resolveCheck, rejectCheck) => {
const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, (err, stream) => {
if (err) return rejectCheck(err);
stream.on('close', (code) => {
resolveCheck(code === 0); // code 0 means key found
});
});
});
if (keyExists) {
conn.end();
resolve({ success: true, message: "SSH key already deployed" });
return;
}
// Step 3: Add public key to authorized_keys
await new Promise<void>((resolveAdd, rejectAdd) => {
const escapedKey = publicKey.replace(/'/g, "'\\''");
conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => {
if (err) return rejectAdd(err);
stream.on('close', (code) => {
if (code === 0) {
resolveAdd();
} else {
rejectAdd(new Error(`Key deployment failed with code ${code}`));
}
});
});
});
// Step 4: Verify deployment
const verifySuccess = await new Promise<boolean>((resolveVerify, rejectVerify) => {
const keyPattern = publicKey.split(' ')[1];
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys`, (err, stream) => {
if (err) return rejectVerify(err);
stream.on('close', (code) => {
resolveVerify(code === 0);
});
});
});
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);
resolve({ success: false, error: err.message });
});
// Connect to the target host
try {
const connectionConfig: any = {
host: hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
};
if (hostConfig.authType === 'password' && hostConfig.password) {
connectionConfig.password = hostConfig.password;
} else if (hostConfig.authType === 'key' && hostConfig.privateKey) {
connectionConfig.privateKey = hostConfig.privateKey;
if (hostConfig.keyPassword) {
connectionConfig.passphrase = hostConfig.keyPassword;
}
} else {
resolve({ success: false, error: "Invalid authentication configuration" });
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 {
// Get credential details
const credential = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId))
.limit(1);
if (!credential || credential.length === 0) {
return res.status(404).json({
success: false,
error: "Credential not found"
});
}
const credData = credential[0];
// Only support key-based credentials for deployment
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"
});
}
// Get target host details
const targetHost = await db
.select()
.from(sshData)
.where(eq(sshData.id, targetHostId))
.limit(1);
if (!targetHost || targetHost.length === 0) {
return res.status(404).json({
success: false,
error: "Target host not found"
});
}
const hostData = targetHost[0];
// Prepare host configuration for connection
let hostConfig = {
ip: hostData.ip,
port: hostData.port,
username: hostData.username,
authType: hostData.authType,
password: hostData.password,
privateKey: hostData.key,
keyPassword: hostData.keyPassword
};
// If host uses credential authentication, resolve the credential
if (hostData.authType === 'credential' && hostData.credentialId) {
const hostCredential = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, hostData.credentialId))
.limit(1);
if (hostCredential && hostCredential.length > 0) {
const cred = hostCredential[0];
// Update hostConfig with credential data
hostConfig.authType = cred.authType;
hostConfig.username = cred.username; // Use credential's username
if (cred.authType === 'password') {
hostConfig.password = cred.password;
} else if (cred.authType === 'key') {
hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields
hostConfig.keyPassword = cred.keyPassword;
}
} else {
return res.status(400).json({
success: false,
error: "Host credential not found"
});
}
}
// Deploy the SSH key
const deployResult = await deploySSHKeyToHost(
hostConfig,
credData.publicKey,
credData
);
if (deployResult.success) {
// Log successful deployment
authLogger.info(`SSH key deployed successfully`, {
credentialId,
targetHostId,
operation: "deploy_ssh_key"
});
res.json({
success: true,
message: deployResult.message || "SSH key deployed successfully"
});
} else {
authLogger.error(`SSH key deployment failed`, {
credentialId,
targetHostId,
error: deployResult.error,
operation: "deploy_ssh_key"
});
res.status(500).json({
success: false,
error: deployResult.error || "Deployment failed"
});
}
} catch (error) {
authLogger.error("Failed to deploy SSH key", error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : "Failed to deploy SSH key"
});
}
});
export default router;

View File

@@ -191,7 +191,7 @@ wss.on("connection", (ws: WebSocket) => {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key: credential.key,
key: credential.privateKey || credential.key, // prefer new privateKey field
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authType: credential.authType,

View File

@@ -455,7 +455,7 @@ async function connectSSHTunnel(
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password,
sshKey: credential.key,
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,
@@ -501,7 +501,7 @@ async function connectSSHTunnel(
const credential = credentials[0];
resolvedEndpointCredentials = {
password: credential.password,
sshKey: credential.key,
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authMethod: credential.authType,

View File

@@ -0,0 +1,527 @@
// Import SSH2 using ES modules
import ssh2Pkg from 'ssh2';
const ssh2Utils = ssh2Pkg.utils;
// Simple fallback SSH key type detection
function detectKeyTypeFromContent(keyContent: string): string {
const content = keyContent.trim();
// Check for OpenSSH format headers
if (content.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) {
// Look for key type indicators in the content
if (content.includes('ssh-ed25519') || content.includes('AAAAC3NzaC1lZDI1NTE5')) {
return 'ssh-ed25519';
}
if (content.includes('ssh-rsa') || content.includes('AAAAB3NzaC1yc2E')) {
return 'ssh-rsa';
}
if (content.includes('ecdsa-sha2-nistp256')) {
return 'ecdsa-sha2-nistp256';
}
if (content.includes('ecdsa-sha2-nistp384')) {
return 'ecdsa-sha2-nistp384';
}
if (content.includes('ecdsa-sha2-nistp521')) {
return 'ecdsa-sha2-nistp521';
}
// For OpenSSH format, try to detect by analyzing the base64 content structure
try {
const base64Content = content
.replace('-----BEGIN OPENSSH PRIVATE KEY-----', '')
.replace('-----END OPENSSH PRIVATE KEY-----', '')
.replace(/\s/g, '');
// OpenSSH format starts with "openssh-key-v1" followed by key type
const decoded = Buffer.from(base64Content, 'base64').toString('binary');
if (decoded.includes('ssh-rsa')) {
return 'ssh-rsa';
}
if (decoded.includes('ssh-ed25519')) {
return 'ssh-ed25519';
}
if (decoded.includes('ecdsa-sha2-nistp256')) {
return 'ecdsa-sha2-nistp256';
}
if (decoded.includes('ecdsa-sha2-nistp384')) {
return 'ecdsa-sha2-nistp384';
}
if (decoded.includes('ecdsa-sha2-nistp521')) {
return 'ecdsa-sha2-nistp521';
}
// Default to RSA for OpenSSH format if we can't detect specifically
return 'ssh-rsa';
} catch (error) {
console.warn('Failed to decode OpenSSH key content:', error);
// If decoding fails, default to RSA as it's most common for OpenSSH format
return 'ssh-rsa';
}
}
// Check for traditional PEM headers
if (content.includes('-----BEGIN RSA PRIVATE KEY-----')) {
return 'ssh-rsa';
}
if (content.includes('-----BEGIN DSA PRIVATE KEY-----')) {
return 'ssh-dss';
}
if (content.includes('-----BEGIN EC PRIVATE KEY-----')) {
return 'ecdsa-sha2-nistp256'; // Default ECDSA type
}
// Check for PKCS#8 format (modern format)
if (content.includes('-----BEGIN PRIVATE KEY-----')) {
// Try to decode and analyze the DER structure for better detection
try {
const base64Content = content
.replace('-----BEGIN PRIVATE KEY-----', '')
.replace('-----END PRIVATE KEY-----', '')
.replace(/\s/g, '');
const decoded = Buffer.from(base64Content, 'base64');
const decodedString = decoded.toString('binary');
// Check for algorithm identifiers in the DER structure
if (decodedString.includes('1.2.840.113549.1.1.1')) {
// RSA OID
return 'ssh-rsa';
} else if (decodedString.includes('1.2.840.10045.2.1')) {
// EC Private Key OID - this indicates ECDSA
if (decodedString.includes('1.2.840.10045.3.1.7')) {
// prime256v1 curve OID
return 'ecdsa-sha2-nistp256';
}
return 'ecdsa-sha2-nistp256'; // Default to P-256
} else if (decodedString.includes('1.3.101.112')) {
// Ed25519 OID
return 'ssh-ed25519';
}
} catch (error) {
// If decoding fails, fall back to length-based detection
console.warn('Failed to decode private key for type detection:', error);
}
// Fallback: Try to detect key type from the content structure
// This is a fallback for PKCS#8 format keys
if (content.length < 800) {
// Ed25519 keys are typically shorter
return 'ssh-ed25519';
} else if (content.length > 1600) {
// RSA keys are typically longer
return 'ssh-rsa';
} else {
// ECDSA keys are typically medium length
return 'ecdsa-sha2-nistp256';
}
}
return 'unknown';
}
// Detect public key type from public key content
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
const content = publicKeyContent.trim();
// SSH public keys start with the key type
if (content.startsWith('ssh-rsa ')) {
return 'ssh-rsa';
}
if (content.startsWith('ssh-ed25519 ')) {
return 'ssh-ed25519';
}
if (content.startsWith('ecdsa-sha2-nistp256 ')) {
return 'ecdsa-sha2-nistp256';
}
if (content.startsWith('ecdsa-sha2-nistp384 ')) {
return 'ecdsa-sha2-nistp384';
}
if (content.startsWith('ecdsa-sha2-nistp521 ')) {
return 'ecdsa-sha2-nistp521';
}
if (content.startsWith('ssh-dss ')) {
return 'ssh-dss';
}
// Check for PEM format public keys
if (content.includes('-----BEGIN PUBLIC KEY-----')) {
// Try to decode the base64 content to detect key type
try {
const base64Content = content
.replace('-----BEGIN PUBLIC KEY-----', '')
.replace('-----END PUBLIC KEY-----', '')
.replace(/\s/g, '');
const decoded = Buffer.from(base64Content, 'base64');
const decodedString = decoded.toString('binary');
// Check for algorithm identifiers in the DER structure
if (decodedString.includes('1.2.840.113549.1.1.1')) {
// RSA OID
return 'ssh-rsa';
} else if (decodedString.includes('1.2.840.10045.2.1')) {
// EC Public Key OID - this indicates ECDSA
if (decodedString.includes('1.2.840.10045.3.1.7')) {
// prime256v1 curve OID
return 'ecdsa-sha2-nistp256';
}
return 'ecdsa-sha2-nistp256'; // Default to P-256
} else if (decodedString.includes('1.3.101.112')) {
// Ed25519 OID
return 'ssh-ed25519';
}
} catch (error) {
// If decoding fails, fall back to length-based detection
console.warn('Failed to decode public key for type detection:', error);
}
// Fallback: Try to guess based on key length
if (content.length < 400) {
return 'ssh-ed25519';
} else if (content.length > 600) {
return 'ssh-rsa';
} else {
return 'ecdsa-sha2-nistp256';
}
}
if (content.includes('-----BEGIN RSA PUBLIC KEY-----')) {
return 'ssh-rsa';
}
// Check for base64 encoded key data patterns
if (content.includes('AAAAB3NzaC1yc2E')) {
return 'ssh-rsa';
}
if (content.includes('AAAAC3NzaC1lZDI1NTE5')) {
return 'ssh-ed25519';
}
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY')) {
return 'ecdsa-sha2-nistp256';
}
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ')) {
return 'ecdsa-sha2-nistp384';
}
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE')) {
return 'ecdsa-sha2-nistp521';
}
if (content.includes('AAAAB3NzaC1kc3M')) {
return 'ssh-dss';
}
return 'unknown';
}
export interface KeyInfo {
privateKey: string;
publicKey: string;
keyType: string;
success: boolean;
error?: string;
}
export interface PublicKeyInfo {
publicKey: string;
keyType: string;
success: boolean;
error?: string;
}
export interface KeyPairValidationResult {
isValid: boolean;
privateKeyType: string;
publicKeyType: string;
generatedPublicKey?: string;
error?: string;
}
/**
* Parse SSH private key and extract public key and type information
*/
export function parseSSHKey(privateKeyData: string, passphrase?: string): KeyInfo {
console.log('=== SSH Key Parsing Debug ===');
console.log('Key length:', privateKeyData?.length || 'undefined');
console.log('First 100 chars:', privateKeyData?.substring(0, 100) || 'undefined');
console.log('ssh2Utils available:', typeof ssh2Utils);
console.log('parseKey function available:', typeof ssh2Utils?.parseKey);
try {
let keyType = 'unknown';
let publicKey = '';
let useSSH2 = false;
// Try SSH2 first if available
if (ssh2Utils && typeof ssh2Utils.parseKey === 'function') {
try {
console.log('Calling ssh2Utils.parseKey...');
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
console.log('parseKey returned:', typeof parsedKey, parsedKey instanceof Error ? parsedKey.message : 'success');
if (!(parsedKey instanceof Error)) {
// Extract key type
if (parsedKey.type) {
keyType = parsedKey.type;
}
console.log('Extracted key type:', keyType);
// Generate public key in SSH format
try {
console.log('Attempting to generate public key...');
const publicKeyBuffer = parsedKey.getPublicSSH();
console.log('Public key buffer type:', typeof publicKeyBuffer);
console.log('Public key buffer is Buffer:', Buffer.isBuffer(publicKeyBuffer));
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
// We need to convert this to proper SSH public key format
if (Buffer.isBuffer(publicKeyBuffer)) {
// Convert binary SSH data to base64 and create proper SSH key format
const base64Data = publicKeyBuffer.toString('base64');
// Create proper SSH public key format: "keytype base64data"
if (keyType === 'ssh-rsa') {
publicKey = `ssh-rsa ${base64Data}`;
} else if (keyType === 'ssh-ed25519') {
publicKey = `ssh-ed25519 ${base64Data}`;
} else if (keyType.startsWith('ecdsa-')) {
publicKey = `${keyType} ${base64Data}`;
} else {
publicKey = `${keyType} ${base64Data}`;
}
console.log('Generated SSH public key format, length:', publicKey.length);
console.log('Public key starts with:', publicKey.substring(0, 50));
} else {
console.warn('Unexpected public key buffer type');
publicKey = '';
}
} catch (error) {
console.warn('Failed to generate public key:', error);
publicKey = '';
}
useSSH2 = true;
console.log(`SSH key parsed successfully with SSH2: ${keyType}`);
} else {
console.warn('SSH2 parsing failed:', parsedKey.message);
}
} catch (error) {
console.warn('SSH2 parsing exception:', error instanceof Error ? error.message : error);
}
} else {
console.warn('SSH2 parseKey function not available');
}
// Fallback to content-based detection
if (!useSSH2) {
console.log('Using fallback key type detection...');
keyType = detectKeyTypeFromContent(privateKeyData);
console.log(`Fallback detected key type: ${keyType}`);
// For fallback, we can't generate public key but the detection is still useful
publicKey = '';
if (keyType !== 'unknown') {
console.log(`SSH key type detected successfully with fallback: ${keyType}`);
}
}
return {
privateKey: privateKeyData,
publicKey,
keyType,
success: keyType !== 'unknown'
};
} catch (error) {
console.error('Exception during SSH key parsing:', error);
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack');
// Final fallback - try content detection
try {
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
if (fallbackKeyType !== 'unknown') {
console.log(`Final fallback detection successful: ${fallbackKeyType}`);
return {
privateKey: privateKeyData,
publicKey: '',
keyType: fallbackKeyType,
success: true
};
}
} catch (fallbackError) {
console.error('Even fallback detection failed:', fallbackError);
}
return {
privateKey: privateKeyData,
publicKey: '',
keyType: 'unknown',
success: false,
error: error instanceof Error ? error.message : 'Unknown error parsing key'
};
}
}
/**
* Parse SSH public key and extract type information
*/
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
console.log('=== SSH Public Key Parsing Debug ===');
console.log('Public key length:', publicKeyData?.length || 'undefined');
console.log('First 100 chars:', publicKeyData?.substring(0, 100) || 'undefined');
try {
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
console.log(`Public key type detected: ${keyType}`);
return {
publicKey: publicKeyData,
keyType,
success: keyType !== 'unknown'
};
} catch (error) {
console.error('Exception during SSH public key parsing:', error);
return {
publicKey: publicKeyData,
keyType: 'unknown',
success: false,
error: error instanceof Error ? error.message : 'Unknown error parsing public key'
};
}
}
/**
* Detect SSH key type from private key content
*/
export function detectKeyType(privateKeyData: string): string {
try {
const parsedKey = ssh2Utils.parseKey(privateKeyData);
if (parsedKey instanceof Error) {
return 'unknown';
}
return parsedKey.type || 'unknown';
} catch (error) {
return 'unknown';
}
}
/**
* Get friendly key type name
*/
export function getFriendlyKeyTypeName(keyType: string): string {
const keyTypeMap: Record<string, string> = {
'ssh-rsa': 'RSA',
'ssh-ed25519': 'Ed25519',
'ecdsa-sha2-nistp256': 'ECDSA P-256',
'ecdsa-sha2-nistp384': 'ECDSA P-384',
'ecdsa-sha2-nistp521': 'ECDSA P-521',
'ssh-dss': 'DSA',
'rsa-sha2-256': 'RSA-SHA2-256',
'rsa-sha2-512': 'RSA-SHA2-512',
'unknown': 'Unknown'
};
return keyTypeMap[keyType] || keyType;
}
/**
* Validate if a private key and public key form a valid key pair
*/
export function validateKeyPair(privateKeyData: string, publicKeyData: string, passphrase?: string): KeyPairValidationResult {
console.log('=== Key Pair Validation Debug ===');
console.log('Private key length:', privateKeyData?.length || 'undefined');
console.log('Public key length:', publicKeyData?.length || 'undefined');
try {
// First parse the private key and try to generate public key
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
const publicKeyInfo = parsePublicKey(publicKeyData);
console.log('Private key parsing result:', privateKeyInfo.success, privateKeyInfo.keyType);
console.log('Public key parsing result:', publicKeyInfo.success, publicKeyInfo.keyType);
if (!privateKeyInfo.success) {
return {
isValid: false,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: `Invalid private key: ${privateKeyInfo.error}`
};
}
if (!publicKeyInfo.success) {
return {
isValid: false,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: `Invalid public key: ${publicKeyInfo.error}`
};
}
// Check if key types match
if (privateKeyInfo.keyType !== publicKeyInfo.keyType) {
return {
isValid: false,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: `Key type mismatch: private key is ${privateKeyInfo.keyType}, public key is ${publicKeyInfo.keyType}`
};
}
// If we have a generated public key from the private key, compare them
if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
const generatedPublicKey = privateKeyInfo.publicKey.trim();
const providedPublicKey = publicKeyData.trim();
console.log('Generated public key length:', generatedPublicKey.length);
console.log('Provided public key length:', providedPublicKey.length);
// Compare the key data part (excluding comments)
const generatedKeyParts = generatedPublicKey.split(' ');
const providedKeyParts = providedPublicKey.split(' ');
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
// Compare key type and key data (first two parts)
const generatedKeyData = generatedKeyParts[0] + ' ' + generatedKeyParts[1];
const providedKeyData = providedKeyParts[0] + ' ' + providedKeyParts[1];
console.log('Generated key data:', generatedKeyData.substring(0, 50) + '...');
console.log('Provided key data:', providedKeyData.substring(0, 50) + '...');
if (generatedKeyData === providedKeyData) {
return {
isValid: true,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
generatedPublicKey: generatedPublicKey
};
} else {
return {
isValid: false,
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
generatedPublicKey: generatedPublicKey,
error: 'Public key does not match the private key'
};
}
}
}
// If we can't generate public key or compare, just check if types match
return {
isValid: true, // Assume valid if types match and no errors
privateKeyType: privateKeyInfo.keyType,
publicKeyType: publicKeyInfo.keyType,
error: 'Unable to verify key pair match, but key types are compatible'
};
} catch (error) {
console.error('Exception during key pair validation:', error);
return {
isValid: false,
privateKeyType: 'unknown',
publicKeyType: 'unknown',
error: error instanceof Error ? error.message : 'Unknown error during validation'
};
}
}