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 type { CreateServerData } from '../../types/server';
|
||||
import { Button } from './ui/button';
|
||||
import { SSHKeyInput } from './SSHKeyInput';
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (data: CreateServerData) => void;
|
||||
@@ -18,13 +19,18 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
ip: '',
|
||||
user: '',
|
||||
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 newErrors: Partial<CreateServerData> = {};
|
||||
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Server name is required';
|
||||
@@ -44,12 +50,36 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
newErrors.user = 'Username is required';
|
||||
}
|
||||
|
||||
if (!formData.password.trim()) {
|
||||
newErrors.password = 'Password is required';
|
||||
// Validate SSH port
|
||||
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);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
return Object.keys(newErrors).length === 0 && !sshKeyError;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -57,13 +87,22 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
if (validateForm()) {
|
||||
onSubmit(formData);
|
||||
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) => (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
// 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 (
|
||||
<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>
|
||||
<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>}
|
||||
</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>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Password *
|
||||
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={formData.password}
|
||||
value={formData.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 ${
|
||||
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>}
|
||||
</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">
|
||||
{isEditing && onCancel && (
|
||||
|
||||
@@ -52,16 +52,55 @@ export async function PUT(
|
||||
}
|
||||
|
||||
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
|
||||
if (!name || !ip || !user || !password) {
|
||||
if (!name || !ip || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||
{ 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();
|
||||
|
||||
// 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(
|
||||
{
|
||||
|
||||
@@ -20,18 +20,66 @@ export async function GET() {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
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
|
||||
if (!name || !ip || !user || !password) {
|
||||
if (!name || !ip || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ error: 'Missing required fields: name, ip, and user are required' },
|
||||
{ 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 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(
|
||||
{
|
||||
|
||||
@@ -16,12 +16,59 @@ class DatabaseService {
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
ip 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,
|
||||
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
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
||||
@@ -53,12 +100,12 @@ class DatabaseService {
|
||||
* @param {import('../types/server').CreateServerData} 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(`
|
||||
INSERT INTO servers (name, ip, user, password)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port)
|
||||
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() {
|
||||
@@ -79,13 +126,13 @@ class DatabaseService {
|
||||
* @param {import('../types/server').CreateServerData} 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(`
|
||||
UPDATE servers
|
||||
SET name = ?, ip = ?, user = ?, password = ?
|
||||
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?
|
||||
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 as ptySpawn } from 'node-pty';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} Server
|
||||
* @property {string} ip - Server IP address
|
||||
* @property {string} user - Username
|
||||
* @property {string} password - Password
|
||||
* @property {string} [password] - Password (optional)
|
||||
* @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 {
|
||||
/**
|
||||
* 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
|
||||
* @param {Server} server - Server configuration
|
||||
@@ -21,7 +136,8 @@ class SSHExecutionService {
|
||||
* @returns {Promise<Object>} Process information
|
||||
*/
|
||||
async executeScript(server, scriptPath, onData, onError, onExit) {
|
||||
const { ip, user, password } = server;
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
try {
|
||||
await this.transferScriptsFolder(server, onData, onError);
|
||||
@@ -29,46 +145,37 @@ class SSHExecutionService {
|
||||
return new Promise((resolve, reject) => {
|
||||
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
|
||||
|
||||
// Use ptySpawn for proper terminal emulation and color support
|
||||
const sshCommand = ptySpawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-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'
|
||||
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
|
||||
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
|
||||
sshCommand.onData((data) => {
|
||||
@@ -82,8 +189,34 @@ class SSHExecutionService {
|
||||
|
||||
resolve({
|
||||
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) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -100,20 +233,49 @@ class SSHExecutionService {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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) => {
|
||||
const rsyncCommand = spawn('rsync', [
|
||||
'-avz',
|
||||
'--delete',
|
||||
'--exclude=*.log',
|
||||
'--exclude=*.tmp',
|
||||
'--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
|
||||
'scripts/',
|
||||
`${user}@${ip}:/tmp/scripts/`
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
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
|
||||
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) => {
|
||||
// Ensure proper UTF-8 encoding for ANSI colors
|
||||
@@ -128,6 +290,17 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
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) {
|
||||
resolve();
|
||||
} else {
|
||||
@@ -136,8 +309,32 @@ class SSHExecutionService {
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
} 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
|
||||
*/
|
||||
async executeCommand(server, command, onData, onError, onExit) {
|
||||
const { ip, user, password } = server;
|
||||
/** @type {string|null} */
|
||||
let tempKeyPath = null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use ptySpawn for proper terminal emulation and color support
|
||||
const sshCommand = ptySpawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-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',
|
||||
`${user}@${ip}`,
|
||||
command
|
||||
], {
|
||||
name: 'xterm-color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: process.cwd(),
|
||||
env: process.env
|
||||
});
|
||||
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
|
||||
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
|
||||
|
||||
// Add the command to execute to the args
|
||||
args.push(command);
|
||||
|
||||
// Use ptySpawn for proper terminal emulation and color support
|
||||
const sshCommand = ptySpawn(sshCommandName, args, {
|
||||
name: 'xterm-color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: process.cwd(),
|
||||
env: process.env
|
||||
});
|
||||
|
||||
sshCommand.onData((data) => {
|
||||
onData(data);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 { writeFileSync, unlinkSync, chmodSync } from 'fs';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
class SSHService {
|
||||
/**
|
||||
@@ -10,38 +11,42 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testConnection(server) {
|
||||
const { ip, user, password } = server;
|
||||
const { auth_type = 'password' } = server;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = 15000; // 15 seconds timeout for login test
|
||||
let resolved = false;
|
||||
|
||||
// Try sshpass first if available
|
||||
this.testWithSshpass(server).then(result => {
|
||||
// Choose authentication method based on auth_type
|
||||
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) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
}).catch(() => {
|
||||
// If sshpass fails, try expect
|
||||
this.testWithExpect(server).then(result => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
}).catch(() => {
|
||||
// If both fail, return error
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'SSH login test requires sshpass or expect - neither available or working',
|
||||
details: {
|
||||
method: 'no_auth_tools'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// If primary method fails, return error
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve({
|
||||
success: false,
|
||||
message: `SSH login test failed for ${auth_type} authentication`,
|
||||
details: {
|
||||
method: 'auth_failed',
|
||||
auth_type: auth_type
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Set up overall timeout
|
||||
@@ -64,7 +69,11 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
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) => {
|
||||
const timeout = 10000;
|
||||
@@ -73,6 +82,7 @@ class SSHService {
|
||||
const sshCommand = spawn('sshpass', [
|
||||
'-p', password,
|
||||
'ssh',
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
@@ -156,7 +166,7 @@ class SSHService {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testWithExpect(server) {
|
||||
const { ip, user, password } = server;
|
||||
const { ip, user, password, ssh_port = 22 } = server;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 10000;
|
||||
@@ -164,7 +174,7 @@ class SSHService {
|
||||
|
||||
const expectScript = `#!/usr/bin/expect -f
|
||||
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 {
|
||||
"password:" {
|
||||
send "${password}\r"
|
||||
@@ -428,13 +438,14 @@ expect {
|
||||
* @returns {Promise<Object>} Connection test result
|
||||
*/
|
||||
async testSSHConnection(server) {
|
||||
const { ip, user } = server;
|
||||
const { ip, user, ssh_port = 22 } = server;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = 5000;
|
||||
let resolved = false;
|
||||
|
||||
const sshCommand = spawn('ssh', [
|
||||
'-p', ssh_port.toString(),
|
||||
'-o', 'ConnectTimeout=5',
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-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
|
||||
|
||||
@@ -3,7 +3,11 @@ export interface Server {
|
||||
name: string;
|
||||
ip: 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;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -12,7 +16,11 @@ export interface CreateServerData {
|
||||
name: string;
|
||||
ip: 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 {
|
||||
|
||||
@@ -704,7 +704,7 @@ main() {
|
||||
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||
app_dir="$(pwd)"
|
||||
else
|
||||
# Try multiple common locations
|
||||
# Try multiple common locations:
|
||||
for search_path in /opt /root /home /usr/local; do
|
||||
if [ -d "$search_path" ]; then
|
||||
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