Add SSH key generation and deployment features #234

Merged
ZacharyZcR merged 6 commits from main into dev-1.7.0 2025-09-15 02:29:32 +00:00
4 changed files with 532 additions and 1 deletions
Showing only changes of commit a674073ec8 - Show all commits

View File

@@ -8,7 +8,7 @@ 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 } = ssh2Pkg;
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 } {
@@ -678,6 +678,7 @@ function formatCredentialOutput(credential: any): any {
: [],
authType: credential.authType,
username: credential.username,
publicKey: credential.publicKey,
keyType: credential.keyType,
detectedKeyType: credential.detectedKeyType,
usageCount: credential.usageCount || 0,
@@ -1118,4 +1119,280 @@ router.post("/generate-public-key", authenticateJWT, async (req: Request, res: R
}
});
// 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"
});
}
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

💡 Verification agent

🧩 Analysis chain

Address Gitleaks finding (private-key).

The flagged lines are due to debug logs printing key material. Removing/redacting them resolves the alert.

Run to confirm no key contents are logged:


🏁 Script executed:

#!/bin/bash
rg -nP -C0 "(first 100 chars|Private key length|Public key.*first 100|console\.log\(.+private key|console\.log\(.+public key)" src/backend | sed -n '1,200p'

Length of output: 2283


Remove/redact debug logs that print SSH private/public key material

Gitleaks flagged console logs that expose key contents. Remove or redact any logs that print keys, substrings, or raw key content; log only presence/status or a hashed/truncated indicator.

  • src/backend/database/routes/credentials.ts:42 — console.log('Private key length:', keyPair.private.length)
  • src/backend/database/routes/credentials.ts:942–944 — console.log("Private key length:", privateKey.length); console.log("Private key first 100 chars:", privateKey.substring(0, 100))
  • src/backend/database/routes/credentials.ts:1023–1024, 1056–1063 — SSH2 fallback/public key type/length and "Generated public key first 100 chars" logs
  • src/backend/utils/ssh-key-utils.ts:270, 292, 432, 476–477 — logs printing generated/provided key lengths/content

Replace with non-sensitive logs (e.g., privateKeyProvided: true, generatedPublicKey: true) or redact values (e.g., "[REDACTED]" or a short hash). Re-run scans after changes.

🧰 Tools
🪛 Gitleaks (8.27.2)

[high] 986-1000: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.

(private-key)

_💡 Verification agent_ <details> <summary>🧩 Analysis chain</summary> **Address Gitleaks finding (private-key).** The flagged lines are due to debug logs printing key material. Removing/redacting them resolves the alert. Run to confirm no key contents are logged: --- 🏁 Script executed: ```shell #!/bin/bash rg -nP -C0 "(first 100 chars|Private key length|Public key.*first 100|console\.log\(.+private key|console\.log\(.+public key)" src/backend | sed -n '1,200p' ``` Length of output: 2283 --- </details> **Remove/redact debug logs that print SSH private/public key material** Gitleaks flagged console logs that expose key contents. Remove or redact any logs that print keys, substrings, or raw key content; log only presence/status or a hashed/truncated indicator. - src/backend/database/routes/credentials.ts:42 — console.log('Private key length:', keyPair.private.length) - src/backend/database/routes/credentials.ts:942–944 — console.log("Private key length:", privateKey.length); console.log("Private key first 100 chars:", privateKey.substring(0, 100)) - src/backend/database/routes/credentials.ts:1023–1024, 1056–1063 — SSH2 fallback/public key type/length and "Generated public key first 100 chars" logs - src/backend/utils/ssh-key-utils.ts:270, 292, 432, 476–477 — logs printing generated/provided key lengths/content Replace with non-sensitive logs (e.g., privateKeyProvided: true, generatedPublicKey: true) or redact values (e.g., "[REDACTED]" or a short hash). Re-run scans after changes. <details> <summary>🧰 Tools</summary> <details> <summary>🪛 Gitleaks (8.27.2)</summary> [high] 986-1000: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption. (private-key) </details> </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

⚠️ Potential issue

generate-public-key may return PEM; enforce SSH format or fail.

The UI and PR promise SSH-format output. If ssh2 conversion fails, returning PEM breaks deployment. Prefer returning 400 with guidance rather than a PEM fallback.

- let finalPublicKey = publicKeyString; // PEM fallback
- let formatType = 'pem';
+ let finalPublicKey = '';
+ let formatType = 'ssh';
...
- } catch (sshError) {
-   console.warn("ssh2 failed, using PEM format");
- }
+ } catch (sshError) {
+   return res.status(400).json({
+     success: false,
+     error: "Unable to derive SSH-format public key from the provided private key"
+   });
+ }

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Gitleaks (8.27.2)

[high] 986-1000: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.

(private-key)

🤖 Prompt for AI Agents
In src/backend/database/routes/credentials.ts around lines 926 to 1120, the
endpoint currently falls back to returning a PEM public key if ssh2 conversion
fails; change this so the endpoint only returns an SSH-formatted public key and
returns a 400 error (with helpful guidance and parseAttempts details) when ssh2
conversion fails instead of returning PEM. Concretely: remove/stop using the PEM
fallback as the finalPublicKey; after attempting ssh2Utils.parseKey, if it fails
or returns an Error respond with res.status(400).json({ success:false, error:
"Unable to produce SSH-format public key; provide an OpenSSH private key or
correct passphrase", details: parseAttempts }) (include any parseAttempts
collected), and only return success when ssh2 produced the SSH public key; keep
existing logging but adjust messages to reflect the hard failure path.
_⚠️ Potential issue_ **generate-public-key may return PEM; enforce SSH format or fail.** The UI and PR promise SSH-format output. If ssh2 conversion fails, returning PEM breaks deployment. Prefer returning 400 with guidance rather than a PEM fallback. ```diff - let finalPublicKey = publicKeyString; // PEM fallback - let formatType = 'pem'; + let finalPublicKey = ''; + let formatType = 'ssh'; ... - } catch (sshError) { - console.warn("ssh2 failed, using PEM format"); - } + } catch (sshError) { + return res.status(400).json({ + success: false, + error: "Unable to derive SSH-format public key from the provided private key" + }); + } ``` > Committable suggestion skipped: line range outside the PR's diff. <details> <summary>🧰 Tools</summary> <details> <summary>🪛 Gitleaks (8.27.2)</summary> [high] 986-1000: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption. (private-key) </details> </details> <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/database/routes/credentials.ts around lines 926 to 1120, the endpoint currently falls back to returning a PEM public key if ssh2 conversion fails; change this so the endpoint only returns an SSH-formatted public key and returns a 400 error (with helpful guidance and parseAttempts details) when ssh2 conversion fails instead of returning PEM. Concretely: remove/stop using the PEM fallback as the finalPublicKey; after attempting ssh2Utils.parseKey, if it fails or returns an Error respond with res.status(400).json({ success:false, error: "Unable to produce SSH-format public key; provide an OpenSSH private key or correct passphrase", details: parseAttempts }) (include any parseAttempts collected), and only return success when ssh2 produced the SSH public key; keep existing logging but adjust messages to reflect the hard failure path. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
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;
}
coderabbitai[bot] commented 2025-09-14 23:13:14 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

⚠️ Potential issue

Command injection risk and fragile grep in deployment.

  • echo with single-quote escaping is insufficient if publicKey contains newlines; prefer SFTP or at minimum printf + grep -F.
  • Use fixed-string grep (-Fq) on key data, not regex.

Apply:

- const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm
- conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, ...
+ const keyPattern = publicKey.split(' ')[1] || '';
+ conn.exec(`grep -Fq "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, ...

- const escapedKey = publicKey.replace(/'/g, "'\\''");
- conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, ...
+ const sanitized = publicKey.replace(/\r?\n/g, '').trim();
+ const escaped = sanitized.replace(/'/g, "'\\''");
+ conn.exec(`printf '%s\\n' '${escaped}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, ...

Recommended: replace shell appends with SFTP append to ~/.ssh/authorized_keys to eliminate shell injection entirely.

Also applies to: 1191-1200, 1176-1189

🤖 Prompt for AI Agents
In src/backend/database/routes/credentials.ts around lines 1123-1201 (and
similarly at 1176-1189 and 1191-1200), the current deploySSHKeyToHost uses shell
echo with ad-hoc single-quote escaping and plain grep which is vulnerable to
command-injection and breaks on keys containing newlines; replace the
shell-based append and grep with SFTP-based file operations and fixed-string
grep: open ~/.ssh/authorized_keys via the SSH client's sftp() API (create ~/.ssh
with proper mode first), read and check for the key using a fixed-string search
(or use grep -Fq if you must exec), and if missing append the publicKey via an
SFTP write/append stream and set file mode to 600—this eliminates shell
interpolation and handles newlines safely while ensuring grep uses -Fq for
fixed-string matching.
_🛠️ Refactor suggestion_ _⚠️ Potential issue_ **Command injection risk and fragile grep in deployment.** - echo with single-quote escaping is insufficient if publicKey contains newlines; prefer SFTP or at minimum printf + grep -F. - Use fixed-string grep (-Fq) on key data, not regex. Apply: ```diff - const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm - conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, ... + const keyPattern = publicKey.split(' ')[1] || ''; + conn.exec(`grep -Fq "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, ... - const escapedKey = publicKey.replace(/'/g, "'\\''"); - conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, ... + const sanitized = publicKey.replace(/\r?\n/g, '').trim(); + const escaped = sanitized.replace(/'/g, "'\\''"); + conn.exec(`printf '%s\\n' '${escaped}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, ... ``` Recommended: replace shell appends with SFTP append to ~/.ssh/authorized_keys to eliminate shell injection entirely. Also applies to: 1191-1200, 1176-1189 <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/backend/database/routes/credentials.ts around lines 1123-1201 (and similarly at 1176-1189 and 1191-1200), the current deploySSHKeyToHost uses shell echo with ad-hoc single-quote escaping and plain grep which is vulnerable to command-injection and breaks on keys containing newlines; replace the shell-based append and grep with SFTP-based file operations and fixed-string grep: open ~/.ssh/authorized_keys via the SSH client's sftp() API (create ~/.ssh with proper mode first), read and check for the key using a fixed-string search (or use grep -Fq if you must exec), and if missing append the publicKey via an SFTP write/append stream and set file mode to 600—this eliminates shell interpolation and handles newlines safely while ensuring grep uses -Fq for fixed-string matching. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
} 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

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

@@ -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) {
coderabbitai[bot] commented 2025-09-14 23:13:15 +00:00 (Migrated from github.com)
Review

🛠️ Refactor suggestion

Type safety for hosts; avoid any[].

Use SSHHost[] for availableHosts and number for selectedHostId where possible. This reduces parseInt churn and runtime errors.

- const [availableHosts, setAvailableHosts] = useState<any[]>([]);
- const [selectedHostId, setSelectedHostId] = useState<string>("");
+ const [availableHosts, setAvailableHosts] = useState<SSHHost[]>([]);
+ const [selectedHostId, setSelectedHostId] = useState<string>("");

And when calling:

- parseInt(selectedHostId)
+ Number(selectedHostId)

Also applies to: 100-107

🤖 Prompt for AI Agents
In src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx around lines 88 to 93
(also applies to lines 100-107), the state uses any[] and string for
host-related values causing extra parseInt usage and weak typing; change
availableHosts to SSHHost[] and selectedHostId to number (initialize
selectedHostId as 0 or -1 as sentinel), update setAvailableHosts usages to
push/fetch SSHHost objects, remove parseInt calls by ensuring host IDs are
numeric where provided, and update any event handlers, dropdown/select value
props, comparisons, and API call params to accept and use number IDs instead of
strings so type-safety is preserved across the component.
_🛠️ Refactor suggestion_ **Type safety for hosts; avoid any[].** Use SSHHost[] for availableHosts and number for selectedHostId where possible. This reduces parseInt churn and runtime errors. ```diff - const [availableHosts, setAvailableHosts] = useState<any[]>([]); - const [selectedHostId, setSelectedHostId] = useState<string>(""); + const [availableHosts, setAvailableHosts] = useState<SSHHost[]>([]); + const [selectedHostId, setSelectedHostId] = useState<string>(""); ``` And when calling: ```diff - parseInt(selectedHostId) + Number(selectedHostId) ``` Also applies to: 100-107 <details> <summary>🤖 Prompt for AI Agents</summary> ``` In src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx around lines 88 to 93 (also applies to lines 100-107), the state uses any[] and string for host-related values causing extra parseInt usage and weak typing; change availableHosts to SSHHost[] and selectedHostId to number (initialize selectedHostId as 0 or -1 as sentinel), update setAvailableHosts usages to push/fetch SSHHost objects, remove parseInt calls by ensuring host IDs are numeric where provided, and update any event handlers, dropdown/select value props, comparisons, and API call params to accept and use number IDs instead of strings so type-safety is preserved across the component. ``` </details> <!-- fingerprinting:phantom:medusa:chinchilla --> <!-- This is an auto-generated comment by CodeRabbit -->
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

@@ -1743,3 +1743,18 @@ export async function generateKeyPair(
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");
}
}