feat: Add SSH key authentication and custom port support (#97)
* feat: Add SSH key authentication and custom port support - Add SSH key authentication support with three modes: password, key, or both - Add custom SSH port support (defaults to 22) - Create SSHKeyInput component with file upload and paste modes - Update database schema with auth_type, ssh_key, ssh_key_passphrase, and ssh_port columns - Update TypeScript interfaces to support new authentication fields - Update SSH services to handle key authentication and custom ports - Update ServerForm with authentication type selection and SSH port field - Update API routes with validation for new fields - Add proper cleanup for temporary SSH key files - Support for encrypted SSH keys with passphrase protection - Maintain backward compatibility with existing password-only servers * fix: Resolve TypeScript build errors and improve type safety - Replace || operators with ?? (nullish coalescing) for better type safety - Add proper null checks for password fields in SSH services - Fix JSDoc type annotations for better TypeScript inference - Update error object types to use Record<keyof CreateServerData, string> - Ensure all SSH authentication methods handle optional fields correctly
This commit is contained in:
committed by
GitHub
parent
e8be9e7214
commit
ff1ab35b46
191
src/app/_components/SSHKeyInput.tsx
Normal file
191
src/app/_components/SSHKeyInput.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
interface SSHKeyInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHKeyInputProps) {
|
||||||
|
const [inputMode, setInputMode] = useState<'upload' | 'paste'>('upload');
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const validateSSHKey = (keyContent: string): boolean => {
|
||||||
|
const trimmed = keyContent.trim();
|
||||||
|
return (
|
||||||
|
trimmed.includes('BEGIN') &&
|
||||||
|
trimmed.includes('PRIVATE KEY') &&
|
||||||
|
trimmed.includes('END') &&
|
||||||
|
trimmed.includes('PRIVATE KEY')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (file: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
if (validateSSHKey(content)) {
|
||||||
|
onChange(content);
|
||||||
|
onError?.('');
|
||||||
|
} else {
|
||||||
|
onError?.('Invalid SSH key format. Please ensure the file contains a valid private key.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
onError?.('Failed to read the file. Please try again.');
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const file = event.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const content = event.target.value;
|
||||||
|
onChange(content);
|
||||||
|
|
||||||
|
if (content.trim() && !validateSSHKey(content)) {
|
||||||
|
onError?.('Invalid SSH key format. Please ensure the content is a valid private key.');
|
||||||
|
} else {
|
||||||
|
onError?.('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKeyFingerprint = (keyContent: string): string => {
|
||||||
|
// This is a simplified fingerprint - in a real implementation,
|
||||||
|
// you might want to use a library to generate proper SSH key fingerprints
|
||||||
|
if (!keyContent.trim()) return '';
|
||||||
|
|
||||||
|
const lines = keyContent.trim().split('\n');
|
||||||
|
const keyLine = lines.find(line =>
|
||||||
|
line.includes('BEGIN') && line.includes('PRIVATE KEY')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keyLine) {
|
||||||
|
const keyType = keyLine.includes('RSA') ? 'RSA' :
|
||||||
|
keyLine.includes('ED25519') ? 'ED25519' :
|
||||||
|
keyLine.includes('ECDSA') ? 'ECDSA' : 'Unknown';
|
||||||
|
return `${keyType} key (${keyContent.length} characters)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown key type';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={inputMode === 'upload' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInputMode('upload')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={inputMode === 'paste' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInputMode('paste')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Paste Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Upload Mode */}
|
||||||
|
{inputMode === 'upload' && (
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||||
|
isDragOver
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-lg">📁</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Drag and drop your SSH private key here, or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paste Mode */}
|
||||||
|
{inputMode === 'paste' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">
|
||||||
|
Paste your SSH private key:
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={handlePasteChange}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABFwAAAAdzc2gtcn... -----END OPENSSH PRIVATE KEY-----"
|
||||||
|
className="w-full h-32 px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring font-mono text-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key Information */}
|
||||||
|
{value && (
|
||||||
|
<div className="p-3 bg-muted rounded-md">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">Key detected:</span> {getKeyFingerprint(value)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
⚠️ Keep your private keys secure. This key will be stored in the database.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } 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';
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
onSubmit: (data: CreateServerData) => void;
|
onSubmit: (data: CreateServerData) => void;
|
||||||
@@ -18,13 +19,18 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
ip: '',
|
ip: '',
|
||||||
user: '',
|
user: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
auth_type: 'password',
|
||||||
|
ssh_key: '',
|
||||||
|
ssh_key_passphrase: '',
|
||||||
|
ssh_port: 22,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Partial<CreateServerData>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
|
||||||
|
const [sshKeyError, setSshKeyError] = useState<string>('');
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Partial<CreateServerData> = {};
|
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
newErrors.name = 'Server name is required';
|
newErrors.name = 'Server name is required';
|
||||||
@@ -44,12 +50,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
newErrors.user = 'Username is required';
|
newErrors.user = 'Username is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password.trim()) {
|
// Validate SSH port
|
||||||
newErrors.password = 'Password is required';
|
if (formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535)) {
|
||||||
|
newErrors.ssh_port = 'SSH port must be between 1 and 65535';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = formData.auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password' || authType === 'both') {
|
||||||
|
if (!formData.password?.trim()) {
|
||||||
|
newErrors.password = 'Password is required for password authentication';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key' || authType === 'both') {
|
||||||
|
if (!formData.ssh_key?.trim()) {
|
||||||
|
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;
|
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -57,13 +87,22 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
if (validateForm()) {
|
if (validateForm()) {
|
||||||
onSubmit(formData);
|
onSubmit(formData);
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
setFormData({ name: '', ip: '', user: '', password: '' });
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
ip: '',
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
auth_type: 'password',
|
||||||
|
ssh_key: '',
|
||||||
|
ssh_key_passphrase: '',
|
||||||
|
ssh_port: 22
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field: keyof CreateServerData) => (
|
const handleChange = (field: keyof CreateServerData) => (
|
||||||
e: React.ChangeEvent<HTMLInputElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
) => {
|
) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
// Clear error when user starts typing
|
// Clear error when user starts typing
|
||||||
@@ -72,8 +111,15 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSSHKeyChange = (value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, ssh_key: value }));
|
||||||
|
if (errors.ssh_key) {
|
||||||
|
setErrors(prev => ({ ...prev, ssh_key: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
@@ -126,14 +172,52 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="ssh_port"
|
||||||
|
value={formData.ssh_port ?? 22}
|
||||||
|
onChange={handleChange('ssh_port')}
|
||||||
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
|
errors.ssh_port ? 'border-destructive' : 'border-border'
|
||||||
|
}`}
|
||||||
|
placeholder="22"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
/>
|
||||||
|
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
Authentication Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="auth_type"
|
||||||
|
value={formData.auth_type ?? 'password'}
|
||||||
|
onChange={handleChange('auth_type')}
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||||
|
>
|
||||||
|
<option value="password">Password Only</option>
|
||||||
|
<option value="key">SSH Key Only</option>
|
||||||
|
<option value="both">Both Password & SSH Key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Authentication */}
|
||||||
|
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
|
||||||
<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 *
|
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
value={formData.password}
|
value={formData.password ?? ''}
|
||||||
onChange={handleChange('password')}
|
onChange={handleChange('password')}
|
||||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||||
errors.password ? 'border-destructive' : 'border-border'
|
errors.password ? 'border-destructive' : 'border-border'
|
||||||
@@ -142,7 +226,42 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
/>
|
/>
|
||||||
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* SSH Key Authentication */}
|
||||||
|
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||||
|
</label>
|
||||||
|
<SSHKeyInput
|
||||||
|
value={formData.ssh_key ?? ''}
|
||||||
|
onChange={handleSSHKeyChange}
|
||||||
|
onError={setSshKeyError}
|
||||||
|
/>
|
||||||
|
{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>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
|
SSH Key Passphrase (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="ssh_key_passphrase"
|
||||||
|
value={formData.ssh_key_passphrase ?? ''}
|
||||||
|
onChange={handleChange('ssh_key_passphrase')}
|
||||||
|
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||||
|
placeholder="Enter passphrase for encrypted key"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Only required if your SSH key is encrypted with a passphrase
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||||
{isEditing && onCancel && (
|
{isEditing && onCancel && (
|
||||||
|
|||||||
@@ -52,16 +52,55 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, ip, user, password }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port }: CreateServerData = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !ip || !user || !password) {
|
if (!name || !ip || !user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing required fields' },
|
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate SSH port
|
||||||
|
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH port must be between 1 and 65535' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password' || authType === 'both') {
|
||||||
|
if (!password?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password is required for password authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key' || authType === 'both') {
|
||||||
|
if (!ssh_key?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key is required for key authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
// Check if server exists
|
// Check if server exists
|
||||||
@@ -73,7 +112,16 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.updateServer(id, { name, ip, user, password });
|
const result = db.updateServer(id, {
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: authType,
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,18 +20,66 @@ 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 }: CreateServerData = body;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port }: CreateServerData = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!name || !ip || !user || !password) {
|
if (!name || !ip || !user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing required fields' },
|
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate SSH port
|
||||||
|
if (ssh_port !== undefined && (ssh_port < 1 || ssh_port > 65535)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH port must be between 1 and 65535' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate authentication based on auth_type
|
||||||
|
const authType = auth_type ?? 'password';
|
||||||
|
|
||||||
|
if (authType === 'password' || authType === 'both') {
|
||||||
|
if (!password?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Password is required for password authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authType === 'key' || authType === 'both') {
|
||||||
|
if (!ssh_key?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SSH key is required for key authentication' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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({ name, ip, user, password });
|
const result = db.createServer({
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
auth_type: authType,
|
||||||
|
ssh_key,
|
||||||
|
ssh_key_passphrase,
|
||||||
|
ssh_port: ssh_port ?? 22
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,12 +16,59 @@ class DatabaseService {
|
|||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
ip TEXT NOT NULL,
|
ip TEXT NOT NULL,
|
||||||
user TEXT NOT NULL,
|
user TEXT NOT NULL,
|
||||||
password TEXT NOT NULL,
|
password TEXT,
|
||||||
|
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both')),
|
||||||
|
ssh_key TEXT,
|
||||||
|
ssh_key_passphrase TEXT,
|
||||||
|
ssh_port INTEGER DEFAULT 22,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: Add new columns to existing servers table
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key', 'both'))
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_key TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.db.exec(`
|
||||||
|
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing servers to have auth_type='password' if not set
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update existing servers to have ssh_port=22 if not set
|
||||||
|
this.db.exec(`
|
||||||
|
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
// Create installed_scripts table if it doesn't exist
|
// Create installed_scripts table if it doesn't exist
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
CREATE TABLE IF NOT EXISTS installed_scripts (
|
||||||
@@ -53,12 +100,12 @@ class DatabaseService {
|
|||||||
* @param {import('../types/server').CreateServerData} serverData
|
* @param {import('../types/server').CreateServerData} serverData
|
||||||
*/
|
*/
|
||||||
createServer(serverData) {
|
createServer(serverData) {
|
||||||
const { name, ip, user, password } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port } = serverData;
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
INSERT INTO servers (name, ip, user, password)
|
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
return stmt.run(name, ip, user, password);
|
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllServers() {
|
getAllServers() {
|
||||||
@@ -79,13 +126,13 @@ 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 } = serverData;
|
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port } = serverData;
|
||||||
const stmt = this.db.prepare(`
|
const stmt = this.db.prepare(`
|
||||||
UPDATE servers
|
UPDATE servers
|
||||||
SET name = ?, ip = ?, user = ?, password = ?
|
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
return stmt.run(name, ip, user, password, id);
|
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,16 +1,131 @@
|
|||||||
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 { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Server
|
* @typedef {Object} Server
|
||||||
* @property {string} ip - Server IP address
|
* @property {string} ip - Server IP address
|
||||||
* @property {string} user - Username
|
* @property {string} user - Username
|
||||||
* @property {string} password - Password
|
* @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} [ssh_key] - SSH private key content
|
||||||
|
* @property {string} [ssh_key_passphrase] - SSH key passphrase
|
||||||
|
* @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');
|
||||||
|
|
||||||
|
writeFileSync(tempKeyPath, ssh_key);
|
||||||
|
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||||
|
|
||||||
|
return tempKeyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SSH command arguments based on authentication type
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
buildSSHCommand(server, tempKeyPath = null) {
|
||||||
|
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
const baseArgs = [
|
||||||
|
'-t',
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
'-o', 'LogLevel=ERROR',
|
||||||
|
'-o', 'RequestTTY=yes',
|
||||||
|
'-o', 'SetEnv=TERM=xterm-256color',
|
||||||
|
'-o', 'SetEnv=COLUMNS=120',
|
||||||
|
'-o', 'SetEnv=LINES=30',
|
||||||
|
'-o', 'SetEnv=COLORTERM=truecolor',
|
||||||
|
'-o', 'SetEnv=FORCE_COLOR=1',
|
||||||
|
'-o', 'SetEnv=NO_COLOR=0',
|
||||||
|
'-o', 'SetEnv=CLICOLOR=1',
|
||||||
|
'-o', 'SetEnv=CLICOLOR_FORCE=1'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (auth_type === 'key') {
|
||||||
|
// SSH key authentication
|
||||||
|
if (tempKeyPath) {
|
||||||
|
baseArgs.push('-i', tempKeyPath);
|
||||||
|
baseArgs.push('-o', 'PasswordAuthentication=no');
|
||||||
|
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 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 {
|
||||||
|
// Password authentication (default)
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a script on a remote server via SSH
|
* Execute a script on a remote server via SSH
|
||||||
* @param {Server} server - Server configuration
|
* @param {Server} server - Server configuration
|
||||||
@@ -21,7 +136,8 @@ 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) {
|
||||||
const { ip, user, password } = server;
|
/** @type {string|null} */
|
||||||
|
let tempKeyPath = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.transferScriptsFolder(server, onData, onError);
|
await this.transferScriptsFolder(server, onData, onError);
|
||||||
@@ -29,46 +145,37 @@ class SSHExecutionService {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
||||||
|
|
||||||
// Use ptySpawn for proper terminal emulation and color support
|
try {
|
||||||
const sshCommand = ptySpawn('sshpass', [
|
// Create temporary key file if using key authentication
|
||||||
'-p', password,
|
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||||
'ssh',
|
tempKeyPath = this.createTempKeyFile(server);
|
||||||
'-t',
|
|
||||||
'-o', 'ConnectTimeout=10',
|
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
|
||||||
'-o', 'LogLevel=ERROR',
|
|
||||||
'-o', 'PasswordAuthentication=yes',
|
|
||||||
'-o', 'PubkeyAuthentication=no',
|
|
||||||
'-o', 'RequestTTY=yes',
|
|
||||||
'-o', 'SetEnv=TERM=xterm-256color',
|
|
||||||
'-o', 'SetEnv=COLUMNS=120',
|
|
||||||
'-o', 'SetEnv=LINES=30',
|
|
||||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
|
||||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
|
||||||
'-o', 'SetEnv=NO_COLOR=0',
|
|
||||||
'-o', 'SetEnv=CLICOLOR=1',
|
|
||||||
'-o', 'SetEnv=CLICOLOR_FORCE=1',
|
|
||||||
`${user}@${ip}`,
|
|
||||||
`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}`
|
|
||||||
], {
|
|
||||||
name: 'xterm-256color',
|
|
||||||
cols: 120,
|
|
||||||
rows: 30,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TERM: 'xterm-256color',
|
|
||||||
COLUMNS: '120',
|
|
||||||
LINES: '30',
|
|
||||||
SHELL: '/bin/bash',
|
|
||||||
COLORTERM: 'truecolor',
|
|
||||||
FORCE_COLOR: '1',
|
|
||||||
NO_COLOR: '0',
|
|
||||||
CLICOLOR: '1',
|
|
||||||
CLICOLOR_FORCE: '1'
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Build SSH command based on authentication type
|
||||||
|
const { command, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
|
||||||
|
// Use ptySpawn for proper terminal emulation and color support
|
||||||
|
const sshCommand = ptySpawn(command, args, {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols: 120,
|
||||||
|
rows: 30,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLUMNS: '120',
|
||||||
|
LINES: '30',
|
||||||
|
SHELL: '/bin/bash',
|
||||||
|
COLORTERM: 'truecolor',
|
||||||
|
FORCE_COLOR: '1',
|
||||||
|
NO_COLOR: '0',
|
||||||
|
CLICOLOR: '1',
|
||||||
|
CLICOLOR_FORCE: '1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Use pty's onData method which handles both stdout and stderr combined
|
// Use pty's onData method which handles both stdout and stderr combined
|
||||||
sshCommand.onData((data) => {
|
sshCommand.onData((data) => {
|
||||||
@@ -82,8 +189,34 @@ class SSHExecutionService {
|
|||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
process: sshCommand,
|
process: sshCommand,
|
||||||
kill: () => sshCommand.kill('SIGTERM')
|
kill: () => {
|
||||||
|
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) {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
@@ -100,20 +233,49 @@ class SSHExecutionService {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async transferScriptsFolder(server, onData, onError) {
|
async transferScriptsFolder(server, onData, onError) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||||
|
/** @type {string|null} */
|
||||||
|
let tempKeyPath = null;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const rsyncCommand = spawn('rsync', [
|
try {
|
||||||
'-avz',
|
// Create temporary key file if using key authentication
|
||||||
'--delete',
|
if (auth_type === 'key' || auth_type === 'both') {
|
||||||
'--exclude=*.log',
|
if (ssh_key) {
|
||||||
'--exclude=*.tmp',
|
tempKeyPath = this.createTempKeyFile(server);
|
||||||
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
|
}
|
||||||
'scripts/',
|
}
|
||||||
`${user}@${ip}:/tmp/scripts/`
|
|
||||||
], {
|
// Build rsync command based on authentication type
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
let rshCommand;
|
||||||
});
|
if (auth_type === 'key' && tempKeyPath) {
|
||||||
|
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`;
|
||||||
|
} 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) {
|
||||||
|
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
} else {
|
||||||
|
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to password authentication
|
||||||
|
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsyncCommand = spawn('rsync', [
|
||||||
|
'-avz',
|
||||||
|
'--delete',
|
||||||
|
'--exclude=*.log',
|
||||||
|
'--exclude=*.tmp',
|
||||||
|
`--rsh=${rshCommand}`,
|
||||||
|
'scripts/',
|
||||||
|
`${user}@${ip}:/tmp/scripts/`
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
|
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
|
||||||
// Ensure proper UTF-8 encoding for ANSI colors
|
// Ensure proper UTF-8 encoding for ANSI colors
|
||||||
@@ -128,6 +290,17 @@ 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('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
@@ -136,8 +309,32 @@ 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('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temporary key file on error
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,47 +348,79 @@ 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) {
|
||||||
const { ip, user, password } = server;
|
/** @type {string|null} */
|
||||||
|
let tempKeyPath = null;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Use ptySpawn for proper terminal emulation and color support
|
try {
|
||||||
const sshCommand = ptySpawn('sshpass', [
|
// Create temporary key file if using key authentication
|
||||||
'-p', password,
|
if (server.auth_type === 'key' || server.auth_type === 'both') {
|
||||||
'ssh',
|
tempKeyPath = this.createTempKeyFile(server);
|
||||||
'-t',
|
}
|
||||||
'-o', 'ConnectTimeout=10',
|
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
// Build SSH command based on authentication type
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||||
'-o', 'LogLevel=ERROR',
|
|
||||||
'-o', 'PasswordAuthentication=yes',
|
// Add the command to execute to the args
|
||||||
'-o', 'PubkeyAuthentication=no',
|
args.push(command);
|
||||||
'-o', 'RequestTTY=yes',
|
|
||||||
'-o', 'SetEnv=TERM=xterm-256color',
|
// Use ptySpawn for proper terminal emulation and color support
|
||||||
'-o', 'SetEnv=COLUMNS=120',
|
const sshCommand = ptySpawn(sshCommandName, args, {
|
||||||
'-o', 'SetEnv=LINES=30',
|
name: 'xterm-color',
|
||||||
'-o', 'SetEnv=COLORTERM=truecolor',
|
cols: 120,
|
||||||
'-o', 'SetEnv=FORCE_COLOR=1',
|
rows: 30,
|
||||||
'-o', 'SetEnv=NO_COLOR=0',
|
cwd: process.cwd(),
|
||||||
'-o', 'SetEnv=CLICOLOR=1',
|
env: process.env
|
||||||
`${user}@${ip}`,
|
});
|
||||||
command
|
|
||||||
], {
|
|
||||||
name: 'xterm-color',
|
|
||||||
cols: 120,
|
|
||||||
rows: 30,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
env: process.env
|
|
||||||
});
|
|
||||||
|
|
||||||
sshCommand.onData((data) => {
|
sshCommand.onData((data) => {
|
||||||
onData(data);
|
onData(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
sshCommand.onExit((e) => {
|
sshCommand.onExit((e) => {
|
||||||
|
// Clean up temporary key file
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
onExit(e.exitCode);
|
onExit(e.exitCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve({ process: sshCommand });
|
resolve({
|
||||||
|
process: sshCommand,
|
||||||
|
kill: () => {
|
||||||
|
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) {
|
||||||
|
// Clean up temporary key file on error
|
||||||
|
if (tempKeyPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(tempKeyPath);
|
||||||
|
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||||
|
unlinkSync(tempDir);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { writeFileSync, unlinkSync, chmodSync } from 'fs';
|
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
class SSHService {
|
class SSHService {
|
||||||
/**
|
/**
|
||||||
@@ -10,38 +11,42 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testConnection(server) {
|
async testConnection(server) {
|
||||||
const { ip, user, password } = server;
|
const { auth_type = 'password' } = server;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = 15000; // 15 seconds timeout for login test
|
const timeout = 15000; // 15 seconds timeout for login test
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
// Try sshpass first if available
|
// Choose authentication method based on auth_type
|
||||||
this.testWithSshpass(server).then(result => {
|
let authPromise;
|
||||||
|
if (auth_type === 'key') {
|
||||||
|
authPromise = this.testWithSSHKey(server);
|
||||||
|
} else if (auth_type === 'both') {
|
||||||
|
// Try SSH key first, then password
|
||||||
|
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
|
||||||
|
} else {
|
||||||
|
// Default to password authentication
|
||||||
|
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
authPromise.then(result => {
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
resolve(result);
|
resolve(result);
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// If sshpass fails, try expect
|
// If primary method fails, return error
|
||||||
this.testWithExpect(server).then(result => {
|
if (!resolved) {
|
||||||
if (!resolved) {
|
resolved = true;
|
||||||
resolved = true;
|
resolve({
|
||||||
resolve(result);
|
success: false,
|
||||||
}
|
message: `SSH login test failed for ${auth_type} authentication`,
|
||||||
}).catch(() => {
|
details: {
|
||||||
// If both fail, return error
|
method: 'auth_failed',
|
||||||
if (!resolved) {
|
auth_type: auth_type
|
||||||
resolved = true;
|
}
|
||||||
resolve({
|
});
|
||||||
success: false,
|
}
|
||||||
message: 'SSH login test requires sshpass or expect - neither available or working',
|
|
||||||
details: {
|
|
||||||
method: 'no_auth_tools'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up overall timeout
|
// Set up overall timeout
|
||||||
@@ -64,7 +69,11 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testWithSshpass(server) {
|
async testWithSshpass(server) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new Error('Password is required for password authentication');
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = 10000;
|
const timeout = 10000;
|
||||||
@@ -73,6 +82,7 @@ class SSHService {
|
|||||||
const sshCommand = spawn('sshpass', [
|
const sshCommand = spawn('sshpass', [
|
||||||
'-p', password,
|
'-p', password,
|
||||||
'ssh',
|
'ssh',
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
'-o', 'ConnectTimeout=10',
|
'-o', 'ConnectTimeout=10',
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
@@ -156,7 +166,7 @@ class SSHService {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testWithExpect(server) {
|
async testWithExpect(server) {
|
||||||
const { ip, user, password } = server;
|
const { ip, user, password, ssh_port = 22 } = server;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = 10000;
|
const timeout = 10000;
|
||||||
@@ -164,7 +174,7 @@ class SSHService {
|
|||||||
|
|
||||||
const expectScript = `#!/usr/bin/expect -f
|
const expectScript = `#!/usr/bin/expect -f
|
||||||
set timeout 10
|
set timeout 10
|
||||||
spawn ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
||||||
expect {
|
expect {
|
||||||
"password:" {
|
"password:" {
|
||||||
send "${password}\r"
|
send "${password}\r"
|
||||||
@@ -428,13 +438,14 @@ expect {
|
|||||||
* @returns {Promise<Object>} Connection test result
|
* @returns {Promise<Object>} Connection test result
|
||||||
*/
|
*/
|
||||||
async testSSHConnection(server) {
|
async testSSHConnection(server) {
|
||||||
const { ip, user } = server;
|
const { ip, user, ssh_port = 22 } = server;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = 5000;
|
const timeout = 5000;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
const sshCommand = spawn('ssh', [
|
const sshCommand = spawn('ssh', [
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
'-o', 'ConnectTimeout=5',
|
'-o', 'ConnectTimeout=5',
|
||||||
'-o', 'StrictHostKeyChecking=no',
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
@@ -523,6 +534,148 @@ expect {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SSH connection using SSH key authentication
|
||||||
|
* @param {import('../types/server').Server} server - Server configuration
|
||||||
|
* @returns {Promise<Object>} Connection test result
|
||||||
|
*/
|
||||||
|
async testWithSSHKey(server) {
|
||||||
|
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
|
||||||
|
|
||||||
|
if (!ssh_key) {
|
||||||
|
throw new Error('SSH key not provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = 10000;
|
||||||
|
let resolved = false;
|
||||||
|
let tempKeyPath = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create temporary key file
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
||||||
|
tempKeyPath = join(tempDir, 'private_key');
|
||||||
|
|
||||||
|
// Write the private key to temporary file
|
||||||
|
writeFileSync(tempKeyPath, ssh_key);
|
||||||
|
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||||
|
|
||||||
|
// Build SSH command
|
||||||
|
const sshArgs = [
|
||||||
|
'-i', tempKeyPath,
|
||||||
|
'-p', ssh_port.toString(),
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null',
|
||||||
|
'-o', 'LogLevel=ERROR',
|
||||||
|
'-o', 'PasswordAuthentication=no',
|
||||||
|
'-o', 'PubkeyAuthentication=yes',
|
||||||
|
`${user}@${ip}`,
|
||||||
|
'echo "SSH_LOGIN_SUCCESS"'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use sshpass if passphrase is provided
|
||||||
|
let command, args;
|
||||||
|
if (ssh_key_passphrase) {
|
||||||
|
command = 'sshpass';
|
||||||
|
args = ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...sshArgs];
|
||||||
|
} else {
|
||||||
|
command = 'ssh';
|
||||||
|
args = sshArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshCommand = spawn(command, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
sshCommand.kill('SIGTERM');
|
||||||
|
reject(new Error('SSH key login timeout'));
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
sshCommand.stdout.on('data', (data) => {
|
||||||
|
output += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.stderr.on('data', (data) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.on('close', (code) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
message: 'SSH key authentication successful - credentials verified',
|
||||||
|
details: {
|
||||||
|
server: server.name || 'Unknown',
|
||||||
|
ip: ip,
|
||||||
|
user: user,
|
||||||
|
method: 'ssh_key_verified'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let errorMessage = 'SSH key authentication failed';
|
||||||
|
|
||||||
|
if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) {
|
||||||
|
errorMessage = 'SSH key authentication failed - check key and permissions';
|
||||||
|
} else if (errorOutput.includes('Connection refused')) {
|
||||||
|
errorMessage = 'Connection refused - server may be down or SSH not running';
|
||||||
|
} else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) {
|
||||||
|
errorMessage = 'Host not found - check IP address';
|
||||||
|
} else if (errorOutput.includes('Connection timed out')) {
|
||||||
|
errorMessage = 'Connection timeout - server may be unreachable';
|
||||||
|
} else if (errorOutput.includes('Load key') || errorOutput.includes('invalid format')) {
|
||||||
|
errorMessage = 'Invalid SSH key format';
|
||||||
|
} else if (errorOutput.includes('Enter passphrase')) {
|
||||||
|
errorMessage = 'SSH key passphrase required but not provided';
|
||||||
|
} else {
|
||||||
|
errorMessage = `SSH key authentication failed: ${errorOutput.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sshCommand.on('error', (error) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ export interface Server {
|
|||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password?: string;
|
||||||
|
auth_type?: 'password' | 'key' | 'both';
|
||||||
|
ssh_key?: string;
|
||||||
|
ssh_key_passphrase?: string;
|
||||||
|
ssh_port?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -12,7 +16,11 @@ export interface CreateServerData {
|
|||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password?: string;
|
||||||
|
auth_type?: 'password' | 'key' | 'both';
|
||||||
|
ssh_key?: string;
|
||||||
|
ssh_key_passphrase?: string;
|
||||||
|
ssh_port?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServerData extends CreateServerData {
|
export interface UpdateServerData extends CreateServerData {
|
||||||
|
|||||||
@@ -704,7 +704,7 @@ main() {
|
|||||||
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||||
app_dir="$(pwd)"
|
app_dir="$(pwd)"
|
||||||
else
|
else
|
||||||
# Try multiple common locations
|
# Try multiple common locations:
|
||||||
for search_path in /opt /root /home /usr/local; do
|
for search_path in /opt /root /home /usr/local; do
|
||||||
if [ -d "$search_path" ]; then
|
if [ -d "$search_path" ]; then
|
||||||
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
|
app_dir=$(find "$search_path" -name "package.json" -path "*/ProxmoxVE-Local*" -exec dirname {} \; 2>/dev/null | head -1)
|
||||||
|
|||||||
Reference in New Issue
Block a user