Merge pull request #163 from community-scripts/fix/ssh_keys
fix: implement persistent SSH key storage with key generation
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,6 +16,9 @@
|
|||||||
db.sqlite
|
db.sqlite
|
||||||
data/settings.db
|
data/settings.db
|
||||||
|
|
||||||
|
# ssh keys (sensitive)
|
||||||
|
data/ssh-keys/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|||||||
@@ -55,8 +55,15 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
|||||||
<ul className="text-sm text-muted-foreground space-y-2">
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
<li>• <strong>Password:</strong> Use username and password authentication</li>
|
<li>• <strong>Password:</strong> Use username and password authentication</li>
|
||||||
<li>• <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
|
<li>• <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
|
||||||
<li>• <strong>Both:</strong> Try SSH key first, fallback to password if needed</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md">
|
||||||
|
<h5 className="font-medium text-blue-900 dark:text-blue-100 mb-2">SSH Key Features:</h5>
|
||||||
|
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||||
|
<li>• <strong>Generate Key Pair:</strong> Create new SSH keys automatically</li>
|
||||||
|
<li>• <strong>View Public Key:</strong> Copy public key for server setup</li>
|
||||||
|
<li>• <strong>Persistent Storage:</strong> Keys are stored securely on disk</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border border-border rounded-lg">
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
|||||||
147
src/app/_components/PublicKeyModal.tsx
Normal file
147
src/app/_components/PublicKeyModal.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Copy, Check, Server, Globe } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
interface PublicKeyModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
publicKey: string;
|
||||||
|
serverName: string;
|
||||||
|
serverIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
// Try modern clipboard API first
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(publicKey);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} else {
|
||||||
|
// Fallback for older browsers or non-HTTPS
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = publicKey;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Fallback copy failed:', fallbackError);
|
||||||
|
// If all else fails, show the key in an alert
|
||||||
|
alert('Please manually copy this key:\n\n' + publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
// Fallback: show the key in an alert
|
||||||
|
alert('Please manually copy this key:\n\n' + publicKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
|
<Server className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-card-foreground">SSH Public Key</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Add this key to your server's authorized_keys</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Server Info */}
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
<span className="font-medium">{serverName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
<span>{serverIp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-foreground">Instructions:</h3>
|
||||||
|
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||||
|
<li>Copy the public key below</li>
|
||||||
|
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
|
||||||
|
<li>Add the key to authorized_keys: <code className="bg-muted px-1 rounded">echo "<paste-key>" >> ~/.ssh/authorized_keys</code></li>
|
||||||
|
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-foreground">Public Key:</label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={publicKey}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[120px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||||
|
placeholder="Public key will appear here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { useState, useEffect } from 'react';
|
|||||||
import type { CreateServerData } from '../../types/server';
|
import type { CreateServerData } from '../../types/server';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { SSHKeyInput } from './SSHKeyInput';
|
import { SSHKeyInput } from './SSHKeyInput';
|
||||||
|
import { PublicKeyModal } from './PublicKeyModal';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
onSubmit: (data: CreateServerData) => void;
|
onSubmit: (data: CreateServerData) => void;
|
||||||
@@ -30,6 +32,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
||||||
const [sshKeyError, setSshKeyError] = useState<string>('');
|
const [sshKeyError, setSshKeyError] = useState<string>('');
|
||||||
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
|
||||||
|
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
||||||
|
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||||
|
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
|
||||||
|
const [, setIsGeneratedKey] = useState(false);
|
||||||
|
const [, setGeneratedServerId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadColorCodingSetting = async () => {
|
const loadColorCodingSetting = async () => {
|
||||||
@@ -75,25 +82,18 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
// Validate authentication based on auth_type
|
// Validate authentication based on auth_type
|
||||||
const authType = formData.auth_type ?? 'password';
|
const authType = formData.auth_type ?? 'password';
|
||||||
|
|
||||||
if (authType === 'password' || authType === 'both') {
|
if (authType === 'password') {
|
||||||
if (!formData.password?.trim()) {
|
if (!formData.password?.trim()) {
|
||||||
newErrors.password = 'Password is required for password authentication';
|
newErrors.password = 'Password is required for password authentication';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === 'key' || authType === 'both') {
|
if (authType === 'key') {
|
||||||
if (!formData.ssh_key?.trim()) {
|
if (!formData.ssh_key?.trim()) {
|
||||||
newErrors.ssh_key = 'SSH key is required for key authentication';
|
newErrors.ssh_key = 'SSH key is required for key authentication';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if at least one authentication method is provided
|
|
||||||
if (authType === 'both') {
|
|
||||||
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
|
|
||||||
newErrors.password = 'At least one authentication method (password or SSH key) is required';
|
|
||||||
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||||
@@ -127,6 +127,54 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
if (errors[field]) {
|
if (errors[field]) {
|
||||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset generated key state when switching auth types
|
||||||
|
if (field === 'auth_type') {
|
||||||
|
setIsGeneratedKey(false);
|
||||||
|
setGeneratedPublicKey('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateKeyPair = async () => {
|
||||||
|
setIsGeneratingKey(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/servers/generate-keypair', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to generate key pair');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const serverId = data.serverId ?? 0;
|
||||||
|
const keyPath = `data/ssh-keys/server_${serverId}_key`;
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
ssh_key: data.privateKey ?? '',
|
||||||
|
ssh_key_path: keyPath,
|
||||||
|
key_generated: 1
|
||||||
|
}));
|
||||||
|
setGeneratedPublicKey(data.publicKey ?? '');
|
||||||
|
setGeneratedServerId(serverId);
|
||||||
|
setIsGeneratedKey(true);
|
||||||
|
setShowPublicKeyModal(true);
|
||||||
|
setSshKeyError('');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error ?? 'Failed to generate key pair');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating key pair:', error);
|
||||||
|
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingKey(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSSHKeyChange = (value: string) => {
|
const handleSSHKeyChange = (value: string) => {
|
||||||
@@ -137,6 +185,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -221,7 +270,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
>
|
>
|
||||||
<option value="password">Password Only</option>
|
<option value="password">Password Only</option>
|
||||||
<option value="key">SSH Key Only</option>
|
<option value="key">SSH Key Only</option>
|
||||||
<option value="both">Both Password & SSH Key</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -247,10 +295,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password Authentication */}
|
{/* Password Authentication */}
|
||||||
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
|
{formData.auth_type === 'password' && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
Password *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -267,12 +315,29 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SSH Key Authentication */}
|
{/* SSH Key Authentication */}
|
||||||
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
|
{formData.auth_type === 'key' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
<label className="block text-sm font-medium text-muted-foreground">
|
||||||
|
SSH Private Key *
|
||||||
</label>
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateKeyPair}
|
||||||
|
disabled={isGeneratingKey}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Key className="h-4 w-4" />
|
||||||
|
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show manual key input only if no key has been generated */}
|
||||||
|
{!formData.key_generated && (
|
||||||
|
<>
|
||||||
<SSHKeyInput
|
<SSHKeyInput
|
||||||
value={formData.ssh_key ?? ''}
|
value={formData.ssh_key ?? ''}
|
||||||
onChange={handleSSHKeyChange}
|
onChange={handleSSHKeyChange}
|
||||||
@@ -280,6 +345,25 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
/>
|
/>
|
||||||
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
|
||||||
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show generated key status */}
|
||||||
|
{formData.key_generated && (
|
||||||
|
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
SSH key pair generated successfully
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||||
|
The private key has been generated and will be saved with the server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -323,6 +407,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Public Key Modal */}
|
||||||
|
<PublicKeyModal
|
||||||
|
isOpen={showPublicKeyModal}
|
||||||
|
onClose={() => setShowPublicKeyModal(false)}
|
||||||
|
publicKey={generatedPublicKey}
|
||||||
|
serverName={formData.name || 'New Server'}
|
||||||
|
serverIp={formData.ip}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { Server, CreateServerData } from '../../types/server';
|
|||||||
import { ServerForm } from './ServerForm';
|
import { ServerForm } from './ServerForm';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { ConfirmationModal } from './ConfirmationModal';
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
|
import { PublicKeyModal } from './PublicKeyModal';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
|
||||||
interface ServerListProps {
|
interface ServerListProps {
|
||||||
servers: Server[];
|
servers: Server[];
|
||||||
@@ -24,6 +26,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
confirmText: string;
|
confirmText: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
|
||||||
|
const [publicKeyData, setPublicKeyData] = useState<{
|
||||||
|
publicKey: string;
|
||||||
|
serverName: string;
|
||||||
|
serverIp: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleEdit = (server: Server) => {
|
const handleEdit = (server: Server) => {
|
||||||
setEditingId(server.id);
|
setEditingId(server.id);
|
||||||
@@ -40,6 +48,32 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewPublicKey = async (server: Server) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/servers/${server.id}/public-key`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to retrieve public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { success: boolean; publicKey?: string; serverName?: string; serverIp?: string; error?: string };
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setPublicKeyData({
|
||||||
|
publicKey: data.publicKey ?? '',
|
||||||
|
serverName: data.serverName ?? '',
|
||||||
|
serverIp: data.serverIp ?? ''
|
||||||
|
});
|
||||||
|
setShowPublicKeyModal(true);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error ?? 'Failed to retrieve public key');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving public key:', error);
|
||||||
|
// You could show a toast notification here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDelete = (id: number) => {
|
||||||
const server = servers.find(s => s.id === id);
|
const server = servers.find(s => s.id === id);
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
@@ -218,6 +252,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
|
{/* View Public Key button - only show for generated keys */}
|
||||||
|
{server.key_generated === 1 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleViewPublicKey(server)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 sm:flex-none border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
|
||||||
|
>
|
||||||
|
<Key className="w-4 h-4 mr-1" />
|
||||||
|
<span className="hidden sm:inline">View Public Key</span>
|
||||||
|
<span className="sm:hidden">Key</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleEdit(server)}
|
onClick={() => handleEdit(server)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -263,6 +310,20 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
cancelButtonText="Cancel"
|
cancelButtonText="Cancel"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Public Key Modal */}
|
||||||
|
{publicKeyData && (
|
||||||
|
<PublicKeyModal
|
||||||
|
isOpen={showPublicKeyModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowPublicKeyModal(false);
|
||||||
|
setPublicKeyData(null);
|
||||||
|
}}
|
||||||
|
publicKey={publicKeyData.publicKey}
|
||||||
|
serverName={publicKeyData.serverName}
|
||||||
|
serverIp={publicKeyData.serverIp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/app/api/servers/[id]/public-key/route.ts
Normal file
64
src/app/api/servers/[id]/public-key/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '../../../../../server/database';
|
||||||
|
import { getSSHService } from '../../../../../server/ssh-service';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: idParam } = await params;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid server ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const server = db.getServerById(id);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow viewing public key if it was generated by the system
|
||||||
|
if (!(server as any).key_generated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Public key not available for user-provided keys' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(server as any).ssh_key_path) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key path not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshService = getSSHService();
|
||||||
|
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
publicKey,
|
||||||
|
serverName: (server as any).name,
|
||||||
|
serverIp: (server as any).ip
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving public key:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !ip || !user) {
|
if (!name || !ip || !user) {
|
||||||
@@ -73,7 +73,7 @@ export async function PUT(
|
|||||||
// Validate authentication based on auth_type
|
// Validate authentication based on auth_type
|
||||||
const authType = auth_type ?? 'password';
|
const authType = auth_type ?? 'password';
|
||||||
|
|
||||||
if (authType === 'password' || authType === 'both') {
|
if (authType === 'password') {
|
||||||
if (!password?.trim()) {
|
if (!password?.trim()) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Password is required for password authentication' },
|
{ error: 'Password is required for password authentication' },
|
||||||
@@ -82,7 +82,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === 'key' || authType === 'both') {
|
if (authType === 'key') {
|
||||||
if (!ssh_key?.trim()) {
|
if (!ssh_key?.trim()) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'SSH key is required for key authentication' },
|
{ error: 'SSH key is required for key authentication' },
|
||||||
@@ -91,15 +91,6 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if at least one authentication method is provided
|
|
||||||
if (authType === 'both') {
|
|
||||||
if (!password?.trim() && !ssh_key?.trim()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'At least one authentication method (password or SSH key) is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
@@ -121,7 +112,9 @@ export async function PUT(
|
|||||||
ssh_key,
|
ssh_key,
|
||||||
ssh_key_passphrase,
|
ssh_key_passphrase,
|
||||||
ssh_port: ssh_port ?? 22,
|
ssh_port: ssh_port ?? 22,
|
||||||
color
|
color,
|
||||||
|
key_generated: key_generated ?? 0,
|
||||||
|
ssh_key_path
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
32
src/app/api/servers/generate-keypair/route.ts
Normal file
32
src/app/api/servers/generate-keypair/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getSSHService } from '../../../../server/ssh-service';
|
||||||
|
import { getDatabase } from '../../../../server/database';
|
||||||
|
|
||||||
|
export async function POST(_request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sshService = getSSHService();
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get the next available server ID for key file naming
|
||||||
|
const serverId = db.getNextServerId();
|
||||||
|
|
||||||
|
const keyPair = await sshService.generateKeyPair(serverId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
privateKey: keyPair.privateKey,
|
||||||
|
publicKey: keyPair.publicKey,
|
||||||
|
serverId: serverId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating SSH key pair:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to generate SSH key pair'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export async function GET() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !ip || !user) {
|
if (!name || !ip || !user) {
|
||||||
@@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Validate authentication based on auth_type
|
// Validate authentication based on auth_type
|
||||||
const authType = auth_type ?? 'password';
|
const authType = auth_type ?? 'password';
|
||||||
|
|
||||||
if (authType === 'password' || authType === 'both') {
|
if (authType === 'password') {
|
||||||
if (!password?.trim()) {
|
if (!password?.trim()) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Password is required for password authentication' },
|
{ error: 'Password is required for password authentication' },
|
||||||
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === 'key' || authType === 'both') {
|
if (authType === 'key') {
|
||||||
if (!ssh_key?.trim()) {
|
if (!ssh_key?.trim()) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'SSH key is required for key authentication' },
|
{ error: 'SSH key is required for key authentication' },
|
||||||
@@ -59,15 +59,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if at least one authentication method is provided
|
|
||||||
if (authType === 'both') {
|
|
||||||
if (!password?.trim() && !ssh_key?.trim()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'At least one authentication method (password or SSH key) is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.createServer({
|
const result = db.createServer({
|
||||||
@@ -79,7 +70,9 @@ export async function POST(request: NextRequest) {
|
|||||||
ssh_key,
|
ssh_key,
|
||||||
ssh_key_passphrase,
|
ssh_key_passphrase,
|
||||||
ssh_port: ssh_port ?? 22,
|
ssh_port: ssh_port ?? 22,
|
||||||
color
|
color,
|
||||||
|
key_generated: key_generated ?? 0,
|
||||||
|
ssh_key_path
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
class DatabaseService {
|
class DatabaseService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -9,6 +11,12 @@ class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// Ensure data/ssh-keys directory exists
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
if (!existsSync(sshKeysDir)) {
|
||||||
|
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
// Create servers table if it doesn't exist
|
// Create servers table if it doesn't exist
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS servers (
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
@@ -17,10 +25,12 @@ class DatabaseService {
|
|||||||
ip TEXT NOT NULL,
|
ip TEXT NOT NULL,
|
||||||
user TEXT NOT NULL,
|
user TEXT NOT NULL,
|
||||||
password TEXT,
|
password TEXT,
|
||||||
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')),
|
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key')),
|
||||||
ssh_key TEXT,
|
ssh_key TEXT,
|
||||||
ssh_key_passphrase TEXT,
|
ssh_key_passphrase TEXT,
|
||||||
ssh_port INTEGER DEFAULT 22,
|
ssh_port INTEGER DEFAULT 22,
|
||||||
|
ssh_key_path TEXT,
|
||||||
|
key_generated INTEGER DEFAULT 0,
|
||||||
color TEXT,
|
color TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -30,7 +40,7 @@ class DatabaseService {
|
|||||||
// Migration: Add new columns to existing servers table
|
// Migration: Add new columns to existing servers table
|
||||||
try {
|
try {
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both'))
|
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key'))
|
||||||
`);
|
`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
@@ -68,6 +78,22 @@ class DatabaseService {
|
|||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_key_path TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN key_generated INTEGER DEFAULT 0
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
// Update existing servers to have auth_type='password' if not set
|
// Update existing servers to have auth_type='password' if not set
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
|
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
|
||||||
@@ -78,6 +104,16 @@ class DatabaseService {
|
|||||||
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
|
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: Convert 'both' auth_type to 'key'
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET auth_type = 'key' WHERE auth_type = 'both'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update existing servers to have key_generated=0 if not set
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET key_generated = 0 WHERE key_generated IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
// Migration: Add web_ui_ip column to existing installed_scripts table
|
// Migration: Add web_ui_ip column to existing installed_scripts table
|
||||||
try {
|
try {
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
@@ -129,12 +165,21 @@ class DatabaseService {
|
|||||||
* @param {import('../types/server').CreateServerData} serverData
|
* @param {import('../types/server').CreateServerData} serverData
|
||||||
*/
|
*/
|
||||||
createServer(serverData) {
|
createServer(serverData) {
|
||||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
|
|
||||||
|
let ssh_key_path = null;
|
||||||
|
|
||||||
|
// If using SSH key authentication, create persistent key file
|
||||||
|
if (auth_type === 'key' && ssh_key) {
|
||||||
|
const serverId = this.getNextServerId();
|
||||||
|
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||||
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
|
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, ssh_key_path, key_generated, color)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color);
|
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated || 0, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllServers() {
|
getAllServers() {
|
||||||
@@ -155,19 +200,85 @@ class DatabaseService {
|
|||||||
* @param {import('../types/server').CreateServerData} serverData
|
* @param {import('../types/server').CreateServerData} serverData
|
||||||
*/
|
*/
|
||||||
updateServer(id, serverData) {
|
updateServer(id, serverData) {
|
||||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||||
|
|
||||||
|
// Get existing server to check for key changes
|
||||||
|
const existingServer = this.getServerById(id);
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
let ssh_key_path = existingServer?.ssh_key_path;
|
||||||
|
|
||||||
|
// Handle SSH key changes
|
||||||
|
if (auth_type === 'key' && ssh_key) {
|
||||||
|
// Delete old key file if it exists
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
unlinkSync(existingServer.ssh_key_path);
|
||||||
|
// Also delete public key file if it exists
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete old SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new key file
|
||||||
|
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||||
|
} else if (auth_type !== 'key') {
|
||||||
|
// If switching away from key auth, delete key files
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
unlinkSync(existingServer.ssh_key_path);
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssh_key_path = null;
|
||||||
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
UPDATE servers
|
UPDATE servers
|
||||||
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
|
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, ssh_key_path = ?, key_generated = ?, color = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color, id);
|
// @ts-ignore - Database migration adds this column
|
||||||
|
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated !== undefined ? key_generated : (existingServer?.key_generated || 0), color, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} id
|
* @param {number} id
|
||||||
*/
|
*/
|
||||||
deleteServer(id) {
|
deleteServer(id) {
|
||||||
|
// Get server info before deletion to clean up key files
|
||||||
|
const server = this.getServerById(id);
|
||||||
|
|
||||||
|
// Delete SSH key files if they exist
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||||
|
try {
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
unlinkSync(server.ssh_key_path);
|
||||||
|
// @ts-ignore - Database migration adds this column
|
||||||
|
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||||
|
if (existsSync(pubKeyPath)) {
|
||||||
|
unlinkSync(pubKeyPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete SSH key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
|
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
|
||||||
return stmt.run(id);
|
return stmt.run(id);
|
||||||
}
|
}
|
||||||
@@ -316,6 +427,35 @@ class DatabaseService {
|
|||||||
return stmt.run(server_id);
|
return stmt.run(server_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next available server ID for key file naming
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getNextServerId() {
|
||||||
|
const stmt = this.db.prepare('SELECT MAX(id) as maxId FROM servers');
|
||||||
|
const result = stmt.get();
|
||||||
|
// @ts-ignore - SQL query result type
|
||||||
|
return (result?.maxId || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create SSH key file and return the path
|
||||||
|
* @param {number} serverId
|
||||||
|
* @param {string} sshKey
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
createSSHKeyFile(serverId, sshKey) {
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||||
|
|
||||||
|
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||||
|
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||||
|
writeFileSync(keyPath, normalizedKey);
|
||||||
|
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||||
|
|
||||||
|
return keyPath;
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,43 +9,22 @@ import { tmpdir } from 'os';
|
|||||||
* @property {string} user - Username
|
* @property {string} user - Username
|
||||||
* @property {string} [password] - Password (optional)
|
* @property {string} [password] - Password (optional)
|
||||||
* @property {string} name - Server name
|
* @property {string} name - Server name
|
||||||
* @property {string} [auth_type] - Authentication type ('password', 'key', 'both')
|
* @property {string} [auth_type] - Authentication type ('password', 'key')
|
||||||
* @property {string} [ssh_key] - SSH private key content
|
* @property {string} [ssh_key] - SSH private key content
|
||||||
* @property {string} [ssh_key_passphrase] - SSH key passphrase
|
* @property {string} [ssh_key_passphrase] - SSH key passphrase
|
||||||
|
* @property {string} [ssh_key_path] - Path to persistent SSH key file
|
||||||
* @property {number} [ssh_port] - SSH port (default: 22)
|
* @property {number} [ssh_port] - SSH port (default: 22)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class SSHExecutionService {
|
class SSHExecutionService {
|
||||||
/**
|
|
||||||
* Create a temporary SSH key file for authentication
|
|
||||||
* @param {Server} server - Server configuration
|
|
||||||
* @returns {string} Path to temporary key file
|
|
||||||
*/
|
|
||||||
createTempKeyFile(server) {
|
|
||||||
const { ssh_key } = server;
|
|
||||||
if (!ssh_key) {
|
|
||||||
throw new Error('SSH key not provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
|
||||||
const tempKeyPath = join(tempDir, 'private_key');
|
|
||||||
|
|
||||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
|
||||||
const normalizedKey = ssh_key.trimEnd() + '\n';
|
|
||||||
writeFileSync(tempKeyPath, normalizedKey);
|
|
||||||
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
|
||||||
|
|
||||||
return tempKeyPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build SSH command arguments based on authentication type
|
* Build SSH command arguments based on authentication type
|
||||||
* @param {Server} server - Server configuration
|
* @param {Server} server - Server configuration
|
||||||
* @param {string|null} [tempKeyPath=null] - Path to temporary key file (if using key auth)
|
|
||||||
* @returns {{command: string, args: string[]}} Command and arguments for SSH
|
* @returns {{command: string, args: string[]}} Command and arguments for SSH
|
||||||
*/
|
*/
|
||||||
buildSSHCommand(server, tempKeyPath = null) {
|
buildSSHCommand(server) {
|
||||||
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server;
|
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
|
||||||
|
|
||||||
const baseArgs = [
|
const baseArgs = [
|
||||||
'-t',
|
'-t',
|
||||||
@@ -69,11 +46,13 @@ class SSHExecutionService {
|
|||||||
|
|
||||||
if (auth_type === 'key') {
|
if (auth_type === 'key') {
|
||||||
// SSH key authentication
|
// SSH key authentication
|
||||||
if (tempKeyPath) {
|
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||||
baseArgs.push('-i', tempKeyPath);
|
throw new Error('SSH key file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
baseArgs.push('-i', ssh_key_path);
|
||||||
baseArgs.push('-o', 'PasswordAuthentication=no');
|
baseArgs.push('-o', 'PasswordAuthentication=no');
|
||||||
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
||||||
}
|
|
||||||
|
|
||||||
if (ssh_key_passphrase) {
|
if (ssh_key_passphrase) {
|
||||||
return {
|
return {
|
||||||
@@ -86,35 +65,6 @@ class SSHExecutionService {
|
|||||||
args: [...baseArgs, `${user}@${ip}`]
|
args: [...baseArgs, `${user}@${ip}`]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (auth_type === 'both') {
|
|
||||||
// Try SSH key first, then password
|
|
||||||
if (tempKeyPath) {
|
|
||||||
baseArgs.push('-i', tempKeyPath);
|
|
||||||
baseArgs.push('-o', 'PasswordAuthentication=yes');
|
|
||||||
baseArgs.push('-o', 'PubkeyAuthentication=yes');
|
|
||||||
|
|
||||||
if (ssh_key_passphrase) {
|
|
||||||
return {
|
|
||||||
command: 'sshpass',
|
|
||||||
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
command: 'ssh',
|
|
||||||
args: [...baseArgs, `${user}@${ip}`]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to password
|
|
||||||
if (password) {
|
|
||||||
return {
|
|
||||||
command: 'sshpass',
|
|
||||||
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error('Password is required for password authentication');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Password authentication (default)
|
// Password authentication (default)
|
||||||
if (password) {
|
if (password) {
|
||||||
@@ -138,9 +88,6 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<Object>} Process information
|
* @returns {Promise<Object>} Process information
|
||||||
*/
|
*/
|
||||||
async executeScript(server, scriptPath, onData, onError, onExit) {
|
async executeScript(server, scriptPath, onData, onError, onExit) {
|
||||||
/** @type {string|null} */
|
|
||||||
let tempKeyPath = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.transferScriptsFolder(server, onData, onError);
|
await this.transferScriptsFolder(server, onData, onError);
|
||||||
|
|
||||||
@@ -148,13 +95,8 @@ class SSHExecutionService {
|
|||||||
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create temporary key file if using key authentication
|
|
||||||
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
|
||||||
tempKeyPath = this.createTempKeyFile(server);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build SSH command based on authentication type
|
// Build SSH command based on authentication type
|
||||||
const { command, args } = this.buildSSHCommand(server, tempKeyPath);
|
const { command, args } = this.buildSSHCommand(server);
|
||||||
|
|
||||||
// Add the script execution command to the args
|
// Add the script execution command to the args
|
||||||
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
|
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
|
||||||
@@ -193,30 +135,10 @@ class SSHExecutionService {
|
|||||||
process: sshCommand,
|
process: sshCommand,
|
||||||
kill: () => {
|
kill: () => {
|
||||||
sshCommand.kill('SIGTERM');
|
sshCommand.kill('SIGTERM');
|
||||||
// Clean up temporary key file
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up temporary key file on error
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -235,35 +157,24 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async transferScriptsFolder(server, onData, onError) {
|
async transferScriptsFolder(server, onData, onError) {
|
||||||
const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
|
||||||
/** @type {string|null} */
|
|
||||||
let tempKeyPath = null;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
// Create temporary key file if using key authentication
|
|
||||||
if (auth_type === 'key' || auth_type === 'both') {
|
|
||||||
if (ssh_key) {
|
|
||||||
tempKeyPath = this.createTempKeyFile(server);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build rsync command based on authentication type
|
// Build rsync command based on authentication type
|
||||||
let rshCommand;
|
let rshCommand;
|
||||||
if (auth_type === 'key' && tempKeyPath) {
|
if (auth_type === 'key') {
|
||||||
if (ssh_key_passphrase) {
|
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||||
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
throw new Error('SSH key file not found');
|
||||||
} else {
|
|
||||||
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
|
||||||
}
|
}
|
||||||
} else if (auth_type === 'both' && tempKeyPath) {
|
|
||||||
if (ssh_key_passphrase) {
|
if (ssh_key_passphrase) {
|
||||||
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
} else {
|
} else {
|
||||||
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to password authentication
|
// Password authentication
|
||||||
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,17 +203,6 @@ class SSHExecutionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsyncCommand.on('close', (code) => {
|
rsyncCommand.on('close', (code) => {
|
||||||
// Clean up temporary key file
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
@@ -311,30 +211,10 @@ class SSHExecutionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsyncCommand.on('error', (error) => {
|
rsyncCommand.on('error', (error) => {
|
||||||
// Clean up temporary key file on error
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up temporary key file on error
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -350,18 +230,10 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<Object>} Process information
|
* @returns {Promise<Object>} Process information
|
||||||
*/
|
*/
|
||||||
async executeCommand(server, command, onData, onError, onExit) {
|
async executeCommand(server, command, onData, onError, onExit) {
|
||||||
/** @type {string|null} */
|
|
||||||
let tempKeyPath = null;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
// Create temporary key file if using key authentication
|
|
||||||
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
|
||||||
tempKeyPath = this.createTempKeyFile(server);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build SSH command based on authentication type
|
// Build SSH command based on authentication type
|
||||||
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
|
const { command: sshCommandName, args } = this.buildSSHCommand(server);
|
||||||
|
|
||||||
// Add the command to execute to the args
|
// Add the command to execute to the args
|
||||||
args.push(command);
|
args.push(command);
|
||||||
@@ -380,16 +252,6 @@ class SSHExecutionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
sshCommand.onExit((e) => {
|
sshCommand.onExit((e) => {
|
||||||
// Clean up temporary key file
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onExit(e.exitCode);
|
onExit(e.exitCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -397,30 +259,10 @@ class SSHExecutionService {
|
|||||||
process: sshCommand,
|
process: sshCommand,
|
||||||
kill: () => {
|
kill: () => {
|
||||||
sshCommand.kill('SIGTERM');
|
sshCommand.kill('SIGTERM');
|
||||||
// Clean up temporary key file
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up temporary key file on error
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync, readFileSync, existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
@@ -21,9 +21,6 @@ class SSHService {
|
|||||||
let authPromise;
|
let authPromise;
|
||||||
if (auth_type === 'key') {
|
if (auth_type === 'key') {
|
||||||
authPromise = this.testWithSSHKey(server);
|
authPromise = this.testWithSSHKey(server);
|
||||||
} else if (auth_type === 'both') {
|
|
||||||
// Try SSH key first, then password
|
|
||||||
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
|
|
||||||
} else {
|
} else {
|
||||||
// Default to password authentication
|
// Default to password authentication
|
||||||
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
|
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
|
||||||
@@ -540,31 +537,20 @@ expect {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testWithSSHKey(server) {
|
async testWithSSHKey(server) {
|
||||||
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
const { ip, user, ssh_key_path, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||||
|
|
||||||
if (!ssh_key) {
|
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||||
throw new Error('SSH key not provided');
|
throw new Error('SSH key file not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = 10000;
|
const timeout = 10000;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
let tempKeyPath = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create temporary key file
|
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
|
||||||
tempKeyPath = join(tempDir, 'private_key');
|
|
||||||
|
|
||||||
// Write the private key to temporary file
|
|
||||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
|
||||||
const normalizedKey = ssh_key.trimEnd() + '\n';
|
|
||||||
writeFileSync(tempKeyPath, normalizedKey);
|
|
||||||
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
|
||||||
|
|
||||||
// Build SSH command
|
// Build SSH command
|
||||||
const sshArgs = [
|
const sshArgs = [
|
||||||
'-i', tempKeyPath,
|
'-i', ssh_key_path,
|
||||||
'-p', ssh_port.toString(),
|
'-p', ssh_port.toString(),
|
||||||
'-o', 'ConnectTimeout=10',
|
'-o', 'ConnectTimeout=10',
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
@@ -662,22 +648,82 @@ expect {
|
|||||||
resolved = true;
|
resolved = true;
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
// Clean up temporary key file
|
|
||||||
if (tempKeyPath) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tempKeyPath);
|
|
||||||
// Also remove the temp directory
|
|
||||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
|
||||||
rmdirSync(tempDir);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SSH key pair for a server
|
||||||
|
* @param {number} serverId - Server ID for key file naming
|
||||||
|
* @returns {Promise<{privateKey: string, publicKey: string}>}
|
||||||
|
*/
|
||||||
|
async generateKeyPair(serverId) {
|
||||||
|
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||||
|
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const sshKeygen = spawn('ssh-keygen', [
|
||||||
|
'-t', 'ed25519',
|
||||||
|
'-f', keyPath,
|
||||||
|
'-N', '', // No passphrase
|
||||||
|
'-C', 'pve-scripts-local'
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
sshKeygen.stderr.on('data', (data) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshKeygen.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
try {
|
||||||
|
// Read the generated private key
|
||||||
|
const privateKey = readFileSync(keyPath, 'utf8');
|
||||||
|
|
||||||
|
// Read the generated public key
|
||||||
|
const publicKeyPath = keyPath + '.pub';
|
||||||
|
const publicKey = readFileSync(publicKeyPath, 'utf8');
|
||||||
|
|
||||||
|
// Set proper permissions
|
||||||
|
chmodSync(keyPath, 0o600);
|
||||||
|
chmodSync(publicKeyPath, 0o644);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
privateKey,
|
||||||
|
publicKey: publicKey.trim()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to read generated key files: ${error instanceof Error ? error.message : String(error)}`));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error(`ssh-keygen failed: ${errorOutput}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sshKeygen.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to run ssh-keygen: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public key from private key file
|
||||||
|
* @param {string} keyPath - Path to private key file
|
||||||
|
* @returns {string} Public key content
|
||||||
|
*/
|
||||||
|
getPublicKey(keyPath) {
|
||||||
|
const publicKeyPath = keyPath + '.pub';
|
||||||
|
|
||||||
|
if (!existsSync(publicKeyPath)) {
|
||||||
|
throw new Error('Public key file not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(publicKeyPath, 'utf8').trim();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ export interface Server {
|
|||||||
ip: string;
|
ip: string;
|
||||||
user: string;
|
user: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
auth_type?: 'password' | 'key' | 'both';
|
auth_type?: 'password' | 'key';
|
||||||
ssh_key?: string;
|
ssh_key?: string;
|
||||||
ssh_key_passphrase?: string;
|
ssh_key_passphrase?: string;
|
||||||
|
ssh_key_path?: string;
|
||||||
|
key_generated?: number;
|
||||||
ssh_port?: number;
|
ssh_port?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -18,9 +20,11 @@ export interface CreateServerData {
|
|||||||
ip: string;
|
ip: string;
|
||||||
user: string;
|
user: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
auth_type?: 'password' | 'key' | 'both';
|
auth_type?: 'password' | 'key';
|
||||||
ssh_key?: string;
|
ssh_key?: string;
|
||||||
ssh_key_passphrase?: string;
|
ssh_key_passphrase?: string;
|
||||||
|
ssh_key_path?: string;
|
||||||
|
key_generated?: number;
|
||||||
ssh_port?: number;
|
ssh_port?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user