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:
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
527
src/backend/utils/ssh-key-utils.ts
Normal file
527
src/backend/utils/ssh-key-utils.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,27 @@
|
||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||
"failedToRenameFolder": "Failed to rename folder",
|
||||
"movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||
"failedToMoveToFolder": "Failed to move credential to folder"
|
||||
"failedToMoveToFolder": "Failed to move credential to folder",
|
||||
"sshPublicKey": "SSH Public Key",
|
||||
"publicKeyNote": "Public key is optional but recommended for key validation",
|
||||
"publicKeyUploaded": "Public Key Uploaded",
|
||||
"uploadPublicKey": "Upload Public Key",
|
||||
"uploadPrivateKeyFile": "Upload Private Key File",
|
||||
"uploadPublicKeyFile": "Upload Public Key File",
|
||||
"privateKeyRequiredForGeneration": "Private key is required to generate public key",
|
||||
"failedToGeneratePublicKey": "Failed to generate public key",
|
||||
"generatePublicKey": "Generate from Private Key",
|
||||
"publicKeyGeneratedSuccessfully": "Public key generated successfully",
|
||||
"detectedKeyType": "Detected key type",
|
||||
"detectingKeyType": "detecting...",
|
||||
"optional": "Optional",
|
||||
"generateKeyPair": "Generate New Key Pair",
|
||||
"generateEd25519": "Generate Ed25519",
|
||||
"generateECDSA": "Generate ECDSA",
|
||||
"generateRSA": "Generate RSA",
|
||||
"keyPairGeneratedSuccessfully": "{{keyType}} key pair generated successfully",
|
||||
"failedToGenerateKeyPair": "Failed to generate key pair",
|
||||
"generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form."
|
||||
},
|
||||
"sshTools": {
|
||||
"title": "SSH Tools",
|
||||
@@ -878,6 +898,7 @@
|
||||
"password": "password",
|
||||
"keyPassword": "key password",
|
||||
"pastePrivateKey": "Paste your private key here...",
|
||||
"pastePublicKey": "Paste your public key here...",
|
||||
"credentialName": "My SSH Server",
|
||||
"description": "SSH credential description",
|
||||
"searchCredentials": "Search credentials by name, username, or tags...",
|
||||
|
||||
@@ -129,7 +129,27 @@
|
||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||
"failedToRenameFolder": "重命名文件夹失败",
|
||||
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||
"failedToMoveToFolder": "移动凭据到文件夹失败"
|
||||
"failedToMoveToFolder": "移动凭据到文件夹失败",
|
||||
"sshPublicKey": "SSH公钥",
|
||||
"publicKeyNote": "公钥是可选的,但建议提供以验证密钥对",
|
||||
"publicKeyUploaded": "公钥已上传",
|
||||
"uploadPublicKey": "上传公钥",
|
||||
"uploadPrivateKeyFile": "上传私钥文件",
|
||||
"uploadPublicKeyFile": "上传公钥文件",
|
||||
"privateKeyRequiredForGeneration": "生成公钥需要先输入私钥",
|
||||
"failedToGeneratePublicKey": "生成公钥失败",
|
||||
"generatePublicKey": "从私钥生成",
|
||||
"publicKeyGeneratedSuccessfully": "公钥生成成功",
|
||||
"detectedKeyType": "检测到的密钥类型",
|
||||
"detectingKeyType": "检测中...",
|
||||
"optional": "可选",
|
||||
"generateKeyPair": "生成新的密钥对",
|
||||
"generateEd25519": "生成 Ed25519",
|
||||
"generateECDSA": "生成 ECDSA",
|
||||
"generateRSA": "生成 RSA",
|
||||
"keyPairGeneratedSuccessfully": "{{keyType}} 密钥对生成成功",
|
||||
"failedToGenerateKeyPair": "生成密钥对失败",
|
||||
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。"
|
||||
},
|
||||
"sshTools": {
|
||||
"title": "SSH 工具",
|
||||
@@ -874,6 +894,7 @@
|
||||
"searchCredentials": "按名称、用户名或标签搜索凭据...",
|
||||
"keyPassword": "密钥密码",
|
||||
"pastePrivateKey": "在此粘贴您的私钥...",
|
||||
"pastePublicKey": "在此粘贴您的公钥...",
|
||||
"sshConfig": "端点 SSH 配置",
|
||||
"homePath": "/home",
|
||||
"clientId": "您的客户端 ID",
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface Credential {
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
publicKey?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
@@ -87,6 +88,7 @@ export interface CredentialData {
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
publicKey?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
updateCredential,
|
||||
getCredentials,
|
||||
getCredentialDetails,
|
||||
detectKeyType,
|
||||
detectPublicKeyType,
|
||||
generatePublicKeyFromPrivate,
|
||||
generateKeyPair,
|
||||
} from "@/ui/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
@@ -42,9 +46,14 @@ export function CredentialEditor({
|
||||
useState<Credential | null>(null);
|
||||
|
||||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
||||
"upload",
|
||||
);
|
||||
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
|
||||
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
|
||||
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<string | null>(null);
|
||||
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false);
|
||||
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -101,6 +110,7 @@ export function CredentialEditor({
|
||||
username: z.string().min(1),
|
||||
password: z.string().optional(),
|
||||
key: z.any().optional().nullable(),
|
||||
publicKey: z.string().optional(),
|
||||
keyPassword: z.string().optional(),
|
||||
keyType: z
|
||||
.enum([
|
||||
@@ -149,6 +159,7 @@ export function CredentialEditor({
|
||||
username: "",
|
||||
password: "",
|
||||
key: null,
|
||||
publicKey: "",
|
||||
keyPassword: "",
|
||||
keyType: "auto",
|
||||
},
|
||||
@@ -169,6 +180,7 @@ export function CredentialEditor({
|
||||
username: fullCredentialDetails.username || "",
|
||||
password: "",
|
||||
key: null,
|
||||
publicKey: "",
|
||||
keyPassword: "",
|
||||
keyType: "auto" as const,
|
||||
};
|
||||
@@ -176,7 +188,8 @@ export function CredentialEditor({
|
||||
if (defaultAuthType === "password") {
|
||||
formData.password = fullCredentialDetails.password || "";
|
||||
} else if (defaultAuthType === "key") {
|
||||
formData.key = "existing_key";
|
||||
formData.key = fullCredentialDetails.key || "";
|
||||
formData.publicKey = fullCredentialDetails.publicKey || "";
|
||||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||
formData.keyType =
|
||||
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
||||
@@ -196,6 +209,7 @@ export function CredentialEditor({
|
||||
username: "",
|
||||
password: "",
|
||||
key: null,
|
||||
publicKey: "",
|
||||
keyPassword: "",
|
||||
keyType: "auto",
|
||||
});
|
||||
@@ -203,6 +217,104 @@ export function CredentialEditor({
|
||||
}
|
||||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (keyDetectionTimeoutRef.current) {
|
||||
clearTimeout(keyDetectionTimeoutRef.current);
|
||||
}
|
||||
if (publicKeyDetectionTimeoutRef.current) {
|
||||
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Detect key type function
|
||||
const handleKeyTypeDetection = async (keyValue: string, keyPassword?: string) => {
|
||||
if (!keyValue || keyValue.trim() === '') {
|
||||
setDetectedKeyType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyDetectionLoading(true);
|
||||
try {
|
||||
const result = await detectKeyType(keyValue, keyPassword);
|
||||
if (result.success) {
|
||||
setDetectedKeyType(result.keyType);
|
||||
} else {
|
||||
setDetectedKeyType('invalid');
|
||||
console.warn('Key detection failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
setDetectedKeyType('error');
|
||||
console.error('Key type detection error:', error);
|
||||
} finally {
|
||||
setKeyDetectionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced key type detection
|
||||
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
|
||||
if (keyDetectionTimeoutRef.current) {
|
||||
clearTimeout(keyDetectionTimeoutRef.current);
|
||||
}
|
||||
keyDetectionTimeoutRef.current = setTimeout(() => {
|
||||
handleKeyTypeDetection(keyValue, keyPassword);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Detect public key type function
|
||||
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
|
||||
if (!publicKeyValue || publicKeyValue.trim() === '') {
|
||||
setDetectedPublicKeyType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicKeyDetectionLoading(true);
|
||||
try {
|
||||
const result = await detectPublicKeyType(publicKeyValue);
|
||||
if (result.success) {
|
||||
setDetectedPublicKeyType(result.keyType);
|
||||
} else {
|
||||
setDetectedPublicKeyType('invalid');
|
||||
console.warn('Public key detection failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
setDetectedPublicKeyType('error');
|
||||
console.error('Public key type detection error:', error);
|
||||
} finally {
|
||||
setPublicKeyDetectionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced public key type detection
|
||||
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
|
||||
if (publicKeyDetectionTimeoutRef.current) {
|
||||
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
||||
}
|
||||
publicKeyDetectionTimeoutRef.current = setTimeout(() => {
|
||||
handlePublicKeyTypeDetection(publicKeyValue);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
|
||||
const getFriendlyKeyTypeName = (keyType: string): string => {
|
||||
const keyTypeMap: Record<string, string> = {
|
||||
'ssh-rsa': 'RSA (SSH)',
|
||||
'ssh-ed25519': 'Ed25519 (SSH)',
|
||||
'ecdsa-sha2-nistp256': 'ECDSA P-256 (SSH)',
|
||||
'ecdsa-sha2-nistp384': 'ECDSA P-384 (SSH)',
|
||||
'ecdsa-sha2-nistp521': 'ECDSA P-521 (SSH)',
|
||||
'ssh-dss': 'DSA (SSH)',
|
||||
'rsa-sha2-256': 'RSA-SHA2-256',
|
||||
'rsa-sha2-512': 'RSA-SHA2-512',
|
||||
'invalid': 'Invalid Key',
|
||||
'error': 'Detection Error',
|
||||
'unknown': 'Unknown'
|
||||
};
|
||||
return keyTypeMap[keyType] || keyType;
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
if (!data.name || data.name.trim() === "") {
|
||||
@@ -221,20 +333,15 @@ export function CredentialEditor({
|
||||
|
||||
submitData.password = null;
|
||||
submitData.key = null;
|
||||
submitData.publicKey = null;
|
||||
submitData.keyPassword = null;
|
||||
submitData.keyType = null;
|
||||
|
||||
if (data.authType === "password") {
|
||||
submitData.password = data.password;
|
||||
} else if (data.authType === "key") {
|
||||
if (data.key instanceof File) {
|
||||
const keyContent = await data.key.text();
|
||||
submitData.key = keyContent;
|
||||
} else if (data.key === "existing_key") {
|
||||
delete submitData.key;
|
||||
} else {
|
||||
submitData.key = data.key;
|
||||
}
|
||||
submitData.key = data.key;
|
||||
submitData.publicKey = data.publicKey;
|
||||
submitData.keyPassword = data.keyPassword;
|
||||
submitData.keyType = data.keyType;
|
||||
}
|
||||
@@ -259,11 +366,17 @@ export function CredentialEditor({
|
||||
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToSaveCredential"));
|
||||
console.error("Credential save error:", error);
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error(t("credentials.failedToSaveCredential"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [keyGenerationPassphrase, setKeyGenerationPassphrase] = useState("");
|
||||
|
||||
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -305,38 +418,6 @@ export function CredentialEditor({
|
||||
};
|
||||
}, [folderDropdownOpen]);
|
||||
|
||||
const keyTypeOptions = [
|
||||
{ value: "auto", label: t("hosts.autoDetect") },
|
||||
{ value: "ssh-rsa", label: t("hosts.rsa") },
|
||||
{ value: "ssh-ed25519", label: t("hosts.ed25519") },
|
||||
{ value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") },
|
||||
{ value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") },
|
||||
{ value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") },
|
||||
{ value: "ssh-dss", label: t("hosts.dsa") },
|
||||
{ value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") },
|
||||
{ value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") },
|
||||
];
|
||||
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
keyTypeDropdownOpen &&
|
||||
keyTypeDropdownRef.current &&
|
||||
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
||||
keyTypeButtonRef.current &&
|
||||
!keyTypeButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, [keyTypeDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -589,74 +670,305 @@ export function CredentialEditor({
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="key">
|
||||
<Tabs
|
||||
value={keyInputMethod}
|
||||
onValueChange={(value) => {
|
||||
setKeyInputMethod(value as "upload" | "paste");
|
||||
if (value === "upload") {
|
||||
form.setValue("key", null);
|
||||
} else {
|
||||
form.setValue("key", "");
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger value="upload">
|
||||
{t("hosts.uploadFile")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="paste">
|
||||
{t("hosts.pasteKey")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upload" className="mt-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative inline-block">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
<div className="mt-4">
|
||||
{/* Generate Key Pair Buttons */}
|
||||
<div className="mb-4 p-4 bg-muted/20 border border-muted rounded-md">
|
||||
<FormLabel className="mb-3 font-bold block">
|
||||
{t("credentials.generateKeyPair")}
|
||||
</FormLabel>
|
||||
|
||||
{/* Key Generation Passphrase Input */}
|
||||
<div className="mb-3">
|
||||
<FormLabel className="text-sm mb-2 block">
|
||||
{t("credentials.keyPassword")} ({t("credentials.optional")})
|
||||
</FormLabel>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
value={keyGenerationPassphrase}
|
||||
onChange={(e) => setKeyGenerationPassphrase(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("credentials.keyPassphraseOptional")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await generateKeyPair('ssh-ed25519', undefined, keyGenerationPassphrase);
|
||||
|
||||
if (result.success) {
|
||||
form.setValue("key", result.privateKey);
|
||||
form.setValue("publicKey", result.publicKey);
|
||||
// Auto-fill the key password field if passphrase was used
|
||||
if (keyGenerationPassphrase) {
|
||||
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||
}
|
||||
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||
debouncedPublicKeyDetection(result.publicKey);
|
||||
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "Ed25519" }));
|
||||
} else {
|
||||
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate Ed25519 key pair:', error);
|
||||
toast.error(t("credentials.failedToGenerateKeyPair"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("credentials.generateEd25519")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await generateKeyPair('ecdsa-sha2-nistp256', undefined, keyGenerationPassphrase);
|
||||
|
||||
if (result.success) {
|
||||
form.setValue("key", result.privateKey);
|
||||
form.setValue("publicKey", result.publicKey);
|
||||
// Auto-fill the key password field if passphrase was used
|
||||
if (keyGenerationPassphrase) {
|
||||
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||
}
|
||||
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||
debouncedPublicKeyDetection(result.publicKey);
|
||||
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "ECDSA" }));
|
||||
} else {
|
||||
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate ECDSA key pair:', error);
|
||||
toast.error(t("credentials.failedToGenerateKeyPair"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("credentials.generateECDSA")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await generateKeyPair('ssh-rsa', 2048, keyGenerationPassphrase);
|
||||
|
||||
if (result.success) {
|
||||
form.setValue("key", result.privateKey);
|
||||
form.setValue("publicKey", result.publicKey);
|
||||
// Auto-fill the key password field if passphrase was used
|
||||
if (keyGenerationPassphrase) {
|
||||
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||
}
|
||||
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||
debouncedPublicKeyDetection(result.publicKey);
|
||||
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "RSA" }));
|
||||
} else {
|
||||
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate RSA key pair:', error);
|
||||
toast.error(t("credentials.failedToGenerateKeyPair"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("credentials.generateRSA")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{t("credentials.generateKeyPairNote")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 items-start">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4 flex flex-col">
|
||||
<FormLabel className="mb-2 min-h-[20px]">
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</FormLabel>
|
||||
<div className="mb-2">
|
||||
<div className="relative inline-block w-full">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept="*,.pem,.key,.txt,.ppk"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const fileContent = await file.text();
|
||||
field.onChange(fileContent);
|
||||
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
|
||||
} catch (error) {
|
||||
console.error('Failed to read uploaded file:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{t("credentials.uploadPrivateKeyFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<textarea
|
||||
placeholder={t(
|
||||
"placeholders.pastePrivateKey",
|
||||
)}
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={
|
||||
typeof field.value === "string"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
field.onChange(file || null);
|
||||
field.onChange(e.target.value);
|
||||
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</FormControl>
|
||||
{detectedKeyType && (
|
||||
<div className="text-sm mt-2">
|
||||
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
|
||||
<span className={`font-medium ${
|
||||
detectedKeyType === 'invalid' || detectedKeyType === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-green-600'
|
||||
}`}>
|
||||
{getFriendlyKeyTypeName(detectedKeyType)}
|
||||
</span>
|
||||
{keyDetectionLoading && (
|
||||
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="publicKey"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4 flex flex-col">
|
||||
<FormLabel className="mb-2 min-h-[20px]">
|
||||
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
|
||||
</FormLabel>
|
||||
<div className="mb-2 flex gap-2">
|
||||
<div className="relative inline-block flex-1">
|
||||
<input
|
||||
id="public-key-upload"
|
||||
type="file"
|
||||
accept="*,.pub,.txt"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const fileContent = await file.text();
|
||||
field.onChange(fileContent);
|
||||
debouncedPublicKeyDetection(fileContent);
|
||||
} catch (error) {
|
||||
console.error('Failed to read uploaded public key file:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span className="truncate">
|
||||
{t("credentials.uploadPublicKeyFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="justify-start text-left"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={
|
||||
field.value?.name ||
|
||||
t("credentials.upload")
|
||||
className="flex-shrink-0"
|
||||
onClick={async () => {
|
||||
const privateKey = form.watch("key");
|
||||
if (!privateKey || typeof privateKey !== "string" || !privateKey.trim()) {
|
||||
toast.error(t("credentials.privateKeyRequiredForGeneration"));
|
||||
return;
|
||||
}
|
||||
>
|
||||
{field.value === "existing_key"
|
||||
? t("hosts.existingKey")
|
||||
: field.value
|
||||
? editingCredential
|
||||
? t("credentials.updateKey")
|
||||
: field.value.name
|
||||
: t("credentials.upload")}
|
||||
</span>
|
||||
|
||||
try {
|
||||
const keyPassword = form.watch("keyPassword");
|
||||
const result = await generatePublicKeyFromPrivate(privateKey, keyPassword);
|
||||
|
||||
if (result.success && result.publicKey) {
|
||||
// Set the generated public key
|
||||
field.onChange(result.publicKey);
|
||||
// Trigger public key detection
|
||||
debouncedPublicKeyDetection(result.publicKey);
|
||||
|
||||
toast.success(t("credentials.publicKeyGeneratedSuccessfully"));
|
||||
} else {
|
||||
toast.error(result.error || t("credentials.failedToGeneratePublicKey"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate public key:', error);
|
||||
toast.error(t("credentials.failedToGeneratePublicKey"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("credentials.generatePublicKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||
<FormControl>
|
||||
<textarea
|
||||
placeholder={t(
|
||||
"placeholders.pastePublicKey",
|
||||
)}
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
debouncedPublicKeyDetection(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("credentials.publicKeyNote")}
|
||||
</div>
|
||||
{detectedPublicKeyType && field.value && (
|
||||
<div className="text-sm mt-2">
|
||||
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
|
||||
<span className={`font-medium ${
|
||||
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-green-600'
|
||||
}`}>
|
||||
{getFriendlyKeyTypeName(detectedPublicKeyType)}
|
||||
</span>
|
||||
{publicKeyDetectionLoading && (
|
||||
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-8 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
@@ -674,161 +986,8 @@ export function CredentialEditor({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>
|
||||
{t("credentials.keyType")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={keyTypeButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
|
||||
onClick={() =>
|
||||
setKeyTypeDropdownOpen((open) => !open)
|
||||
}
|
||||
>
|
||||
{keyTypeOptions.find(
|
||||
(opt) => opt.value === field.value,
|
||||
)?.label || t("credentials.keyTypeRSA")}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="paste" className="mt-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
placeholder={t(
|
||||
"placeholders.pastePrivateKey",
|
||||
)}
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={
|
||||
typeof field.value === "string"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>
|
||||
{t("credentials.keyPassword")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>
|
||||
{t("credentials.keyType")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={keyTypeButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
|
||||
onClick={() =>
|
||||
setKeyTypeDropdownOpen((open) => !open)
|
||||
}
|
||||
>
|
||||
{keyTypeOptions.find(
|
||||
(opt) => opt.value === field.value,
|
||||
)?.label || t("credentials.keyTypeRSA")}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
@@ -9,6 +9,21 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -29,12 +44,17 @@ import {
|
||||
Pencil,
|
||||
X,
|
||||
Check,
|
||||
Upload,
|
||||
Server,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getCredentials,
|
||||
deleteCredential,
|
||||
updateCredential,
|
||||
renameCredentialFolder,
|
||||
deployCredentialToHost,
|
||||
getSSHHosts,
|
||||
} from "@/ui/main-axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -65,12 +85,27 @@ export function CredentialsManager({
|
||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState("");
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const [showDeployDialog, setShowDeployDialog] = useState(false);
|
||||
const [deployingCredential, setDeployingCredential] = useState<Credential | null>(null);
|
||||
const [availableHosts, setAvailableHosts] = useState<any[]>([]);
|
||||
const [selectedHostId, setSelectedHostId] = useState<string>("");
|
||||
const [deployLoading, setDeployLoading] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
fetchHosts();
|
||||
}, []);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
setAvailableHosts(hosts);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hosts:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -90,6 +125,49 @@ export function CredentialsManager({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = (credential: Credential) => {
|
||||
if (credential.authType !== 'key') {
|
||||
toast.error("Only SSH key-based credentials can be deployed");
|
||||
return;
|
||||
}
|
||||
if (!credential.publicKey) {
|
||||
toast.error("Public key is required for deployment");
|
||||
return;
|
||||
}
|
||||
setDeployingCredential(credential);
|
||||
setSelectedHostId("");
|
||||
setShowDeployDialog(true);
|
||||
};
|
||||
|
||||
const performDeploy = async () => {
|
||||
if (!deployingCredential || !selectedHostId) {
|
||||
toast.error("Please select a target host");
|
||||
return;
|
||||
}
|
||||
|
||||
setDeployLoading(true);
|
||||
try {
|
||||
const result = await deployCredentialToHost(
|
||||
deployingCredential.id,
|
||||
parseInt(selectedHostId)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message || "SSH key deployed successfully");
|
||||
setShowDeployDialog(false);
|
||||
setDeployingCredential(null);
|
||||
setSelectedHostId("");
|
||||
} else {
|
||||
toast.error(result.error || "Deployment failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Deployment error:', error);
|
||||
toast.error("Failed to deploy SSH key");
|
||||
} finally {
|
||||
setDeployLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||
confirmWithToast(
|
||||
t("credentials.confirmDeleteCredential", { name: credentialName }),
|
||||
@@ -577,6 +655,26 @@ export function CredentialsManager({
|
||||
<p>Edit credential</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{credential.authType === 'key' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeploy(credential);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-500/10"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Deploy SSH key to host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -687,6 +785,145 @@ export function CredentialsManager({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
|
||||
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetHeader className="space-y-6 pb-8">
|
||||
<SheetTitle className="flex items-center space-x-4">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<Upload className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xl font-semibold">Deploy SSH Key</div>
|
||||
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
Deploy public key to target server
|
||||
</div>
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Credential Information Card */}
|
||||
{deployingCredential && (
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3 flex items-center">
|
||||
<Key className="h-4 w-4 mr-2 text-zinc-500" />
|
||||
Source Credential
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">Name</div>
|
||||
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{deployingCredential.name || deployingCredential.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">Username</div>
|
||||
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{deployingCredential.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||
<Key className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">Key Type</div>
|
||||
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{deployingCredential.keyType || 'SSH Key'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Host Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 flex items-center">
|
||||
<Server className="h-4 w-4 mr-2 text-zinc-500" />
|
||||
Target Host
|
||||
</label>
|
||||
<Select value={selectedHostId} onValueChange={setSelectedHostId}>
|
||||
<SelectTrigger className="h-12 border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<SelectValue placeholder="Choose a host to deploy to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableHosts.map((host) => (
|
||||
<SelectItem key={host.id} value={host.id.toString()}>
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||
<Server className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{host.name || host.ip}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{host.username}@{host.ip}:{host.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Information Note */}
|
||||
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-1">Deployment Process</p>
|
||||
<p className="text-blue-700 dark:text-blue-300">
|
||||
This will safely add the public key to the target host's ~/.ssh/authorized_keys file
|
||||
without overwriting existing keys. The operation is reversible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="mt-8 flex space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeployDialog(false)}
|
||||
disabled={deployLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={performDeploy}
|
||||
disabled={!selectedHostId || deployLoading}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{deployLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
|
||||
Deploying...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Deploy SSH Key
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1506,7 +1506,7 @@ export async function getCredentials(): Promise<any> {
|
||||
const response = await authApi.get("/credentials");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch credentials");
|
||||
throw handleApiError(error, "fetch credentials");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1515,7 +1515,7 @@ export async function getCredentialDetails(credentialId: number): Promise<any> {
|
||||
const response = await authApi.get(`/credentials/${credentialId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch credential details");
|
||||
throw handleApiError(error, "fetch credential details");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1524,7 +1524,7 @@ export async function createCredential(credentialData: any): Promise<any> {
|
||||
const response = await authApi.post("/credentials", credentialData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create credential");
|
||||
throw handleApiError(error, "create credential");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1539,7 +1539,7 @@ export async function updateCredential(
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update credential");
|
||||
throw handleApiError(error, "update credential");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1548,7 +1548,7 @@ export async function deleteCredential(credentialId: number): Promise<any> {
|
||||
const response = await authApi.delete(`/credentials/${credentialId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete credential");
|
||||
throw handleApiError(error, "delete credential");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1594,7 +1594,7 @@ export async function applyCredentialToHost(
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "apply credential to host");
|
||||
throw handleApiError(error, "apply credential to host");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1604,7 +1604,7 @@ export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
||||
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove credential from host");
|
||||
throw handleApiError(error, "remove credential from host");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1620,7 +1620,7 @@ export async function migrateHostToCredential(
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "migrate host to credential");
|
||||
throw handleApiError(error, "migrate host to credential");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1663,6 +1663,98 @@ export async function renameCredentialFolder(
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename credential folder");
|
||||
throw handleApiError(error, "rename credential folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectKeyType(
|
||||
privateKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/detect-key-type", {
|
||||
privateKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect key type");
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectPublicKeyType(
|
||||
publicKey: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/detect-public-key-type", {
|
||||
publicKey,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect public key type");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateKeyPair(
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/validate-key-pair", {
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate key pair");
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePublicKeyFromPrivate(
|
||||
privateKey: string,
|
||||
keyPassword?: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/generate-public-key", {
|
||||
privateKey,
|
||||
keyPassword,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "generate public key from private key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateKeyPair(
|
||||
keyType: 'ssh-ed25519' | 'ssh-rsa' | 'ecdsa-sha2-nistp256',
|
||||
keySize?: number,
|
||||
passphrase?: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post("/credentials/generate-key-pair", {
|
||||
keyType,
|
||||
keySize,
|
||||
passphrase,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "generate SSH key pair");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deployCredentialToHost(
|
||||
credentialId: number,
|
||||
targetHostId: number,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/credentials/${credentialId}/deploy-to-host`,
|
||||
{ targetHostId }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "deploy credential to host");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user