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>
This commit is contained in:
@@ -8,7 +8,7 @@ import { authLogger } from "../../utils/logger.js";
|
|||||||
import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js";
|
import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import ssh2Pkg from "ssh2";
|
import ssh2Pkg from "ssh2";
|
||||||
const { utils: ssh2Utils } = ssh2Pkg;
|
const { utils: ssh2Utils, Client } = ssh2Pkg;
|
||||||
|
|
||||||
// Direct SSH key generation with ssh2 - the right way
|
// 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 } {
|
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,
|
authType: credential.authType,
|
||||||
username: credential.username,
|
username: credential.username,
|
||||||
|
publicKey: credential.publicKey,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
detectedKeyType: credential.detectedKeyType,
|
detectedKeyType: credential.detectedKeyType,
|
||||||
usageCount: credential.usageCount || 0,
|
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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface Credential {
|
|||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
publicKey?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
@@ -87,6 +88,7 @@ export interface CredentialData {
|
|||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
publicKey?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} 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 {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -29,12 +44,17 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
|
Upload,
|
||||||
|
Server,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getCredentials,
|
getCredentials,
|
||||||
deleteCredential,
|
deleteCredential,
|
||||||
updateCredential,
|
updateCredential,
|
||||||
renameCredentialFolder,
|
renameCredentialFolder,
|
||||||
|
deployCredentialToHost,
|
||||||
|
getSSHHosts,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -65,12 +85,27 @@ export function CredentialsManager({
|
|||||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||||
const [editingFolderName, setEditingFolderName] = useState("");
|
const [editingFolderName, setEditingFolderName] = useState("");
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
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);
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCredentials();
|
fetchCredentials();
|
||||||
|
fetchHosts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchHosts = async () => {
|
||||||
|
try {
|
||||||
|
const hosts = await getSSHHosts();
|
||||||
|
setAvailableHosts(hosts);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch hosts:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchCredentials = async () => {
|
const fetchCredentials = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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) => {
|
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
t("credentials.confirmDeleteCredential", { name: credentialName }),
|
t("credentials.confirmDeleteCredential", { name: credentialName }),
|
||||||
@@ -577,6 +655,26 @@ export function CredentialsManager({
|
|||||||
<p>Edit credential</p>
|
<p>Edit credential</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1743,3 +1743,18 @@ export async function generateKeyPair(
|
|||||||
throw handleApiError(error, "generate SSH key pair");
|
throw handleApiError(error, "generate SSH key pair");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deployCredentialToHost(
|
||||||
|
credentialId: number,
|
||||||
|
targetHostId: number,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post(
|
||||||
|
`/credentials/${credentialId}/deploy-to-host`,
|
||||||
|
{ targetHostId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "deploy credential to host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user