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:
Michel Roegl-Brunner
2025-10-10 11:54:15 +02:00
committed by GitHub
parent e8be9e7214
commit ff1ab35b46
9 changed files with 984 additions and 141 deletions

View 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-----&#10;b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABFwAAAAdzc2gtcn...&#10;-----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>
);
}

View File

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

View File

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

View File

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

View File

@@ -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);
} }
/** /**

View File

@@ -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);
}
}); });
} }

View File

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

View File

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

View File

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