Add SSH key generation and deployment features (#234)

* Fix SSH key upload and credential editing issues

Fixed two major credential management issues:

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

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

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

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

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

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

* Add comprehensive SSH key management and validation features

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

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

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

* Optimize credentials interface and add i18n improvements

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

* Implement direct SSH key generation with ssh2 native API

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

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

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

* Add passphrase support for SSH key generation

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

* Implement SSH key deployment feature with credential resolution

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

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

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

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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...",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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");
}
}